Compare commits
169
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
170b020e17 | ||
|
|
8260af3a92 | ||
|
|
b9154cc9e7 | ||
|
|
08955f1588 | ||
|
|
756295241a | ||
|
|
249679481e | ||
|
|
a491de4d7c | ||
|
|
898efc56a9 | ||
|
|
c0e7ae95de | ||
|
|
1e8190a1f9 | ||
|
|
aac187a76c | ||
|
|
03390c1e25 | ||
|
|
8d3b9c6a33 | ||
|
|
ddef88866f | ||
|
|
8133fab9e4 | ||
|
|
37414eb54a | ||
|
|
22bf68a6f0 | ||
|
|
68dd4dc17e | ||
|
|
ecf6dbf2aa | ||
|
|
01daaceb21 | ||
|
|
a5345808ec | ||
|
|
bfa0a42219 | ||
|
|
426bb8bbb2 | ||
|
|
823af013ae | ||
|
|
98aea1e0b9 | ||
|
|
620e294f8c | ||
|
|
84eaded152 | ||
|
|
31d7173432 | ||
|
|
a48329b697 | ||
|
|
6e62a3aa5c | ||
|
|
746b0c9d8b | ||
|
|
0f38a1287a | ||
|
|
61bfebf5d8 | ||
|
|
170c377445 | ||
|
|
4cbf67181d | ||
|
|
6bf6108beb | ||
|
|
ce85be3346 | ||
|
|
e125e743a3 | ||
|
|
05da842b7a | ||
|
|
216b70475e | ||
|
|
9d259d0836 | ||
|
|
ca52cdd3bf | ||
|
|
cf68002922 | ||
|
|
495c7f925d | ||
|
|
32add5f602 | ||
|
|
5f74b3ca3e | ||
|
|
761cde6a32 | ||
|
|
9546b927d9 | ||
|
|
f82f885dad | ||
|
|
34d2fddf49 | ||
|
|
a7544f75ce | ||
|
|
bd3d645be6 | ||
|
|
5fedc2c040 | ||
|
|
6c8a7cf2ba | ||
|
|
4f02f6eeb7 | ||
|
|
72f75ea40b | ||
|
|
8b6a953933 | ||
|
|
33b0b6dcb4 | ||
|
|
cdc7d95db2 | ||
|
|
592bf6f1fd | ||
|
|
8973174ad5 | ||
|
|
c160e91c08 | ||
|
|
f33e31ad6e | ||
|
|
4111d0e638 | ||
|
|
ffb7b6487b | ||
|
|
77063e5c85 | ||
|
|
585eac2a6f | ||
|
|
c54db522b3 | ||
|
|
d62e9971ee | ||
|
|
7d796f5aa5 | ||
|
|
a5bb36141d | ||
|
|
f3234c3c41 | ||
|
|
91e3781e83 | ||
|
|
86e3aa8c04 | ||
|
|
aa1035d16c | ||
|
|
bd395a3cff | ||
|
|
446a69b1d1 | ||
|
|
826d9c80a8 | ||
|
|
772eb0cc53 | ||
|
|
5c6dd874e0 | ||
|
|
a9e47e4759 | ||
|
|
bfd728a9d1 | ||
|
|
381a2823d7 | ||
|
|
2111b8b12c | ||
|
|
1665e4d534 | ||
|
|
692cedf921 | ||
|
|
e1f4901b80 | ||
|
|
028f871d40 | ||
|
|
2821b4eeb7 | ||
|
|
adfa56b2a2 | ||
|
|
415ad90986 | ||
|
|
afcb7bba84 | ||
|
|
10c3b6f27f | ||
|
|
c64446306e | ||
|
|
2136c7ba4a | ||
|
|
67c3d861fe | ||
|
|
d2c12cc6ef | ||
|
|
c1b2e10f34 | ||
|
|
f48d8006bc | ||
|
|
20e323d666 | ||
|
|
0ec6142bf8 | ||
|
|
0ea25bb9ae | ||
|
|
67293fc269 | ||
|
|
cb77544a46 | ||
|
|
770e7b24da | ||
|
|
ff9df497c9 | ||
|
|
2d6c245b5e | ||
|
|
37b3d7fa07 | ||
|
|
8def4f33f6 | ||
|
|
d92ab6fabe | ||
|
|
198cfd0f95 | ||
|
|
9fba016cff | ||
|
|
4b8badba2e | ||
|
|
341325f844 | ||
|
|
7500d8723f | ||
|
|
fa45dea07d | ||
|
|
f94226ad7e | ||
|
|
0e159f776c | ||
|
|
f1a965b046 | ||
|
|
9f88b5c2be | ||
|
|
7e73c344f5 | ||
|
|
4bd4efce4b | ||
|
|
84f80437c1 | ||
|
|
ac48b2409b | ||
|
|
bde53fcf34 | ||
|
|
7437289140 | ||
|
|
ca1fb84eba | ||
|
|
087c29efdd | ||
|
|
9c2b0f7111 | ||
|
|
fd967ce8e3 | ||
|
|
7ab8808b50 | ||
|
|
ec75d9502c | ||
|
|
85c4ef10ce | ||
|
|
50e01ce067 | ||
|
|
f15f62feec | ||
|
|
2d1549bb8b | ||
|
|
effe60b48f | ||
|
|
2029869d9c | ||
|
|
02f6f87d08 | ||
|
|
1305527822 | ||
|
|
b882ad7807 | ||
|
|
030c522816 | ||
|
|
c356813852 | ||
|
|
1c638edd80 | ||
|
|
2fd5b84505 | ||
|
|
2bfc9b2eb2 | ||
|
|
e9dc2190a1 | ||
|
|
d0fa1b1e66 | ||
|
|
85a5037f50 | ||
|
|
fd67c5db58 | ||
|
|
ca65b43566 | ||
|
|
1f72396d7f | ||
|
|
08834f6385 | ||
|
|
143ab6d127 | ||
|
|
bc512551ab | ||
|
|
8e29d48426 | ||
|
|
e7e6446e7d | ||
|
|
7113f7c669 | ||
|
|
f9d7935d84 | ||
|
|
acd5656d4d | ||
|
|
6d1d344c48 | ||
|
|
26b8c320ae | ||
|
|
c7f7f48222 | ||
|
|
e814248d9c | ||
|
|
d7a0613067 | ||
|
|
dbc031664d | ||
|
|
5640d48854 | ||
|
|
9d6739ea59 | ||
|
|
dc49683357 |
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1
|
||||
with:
|
||||
version: 2.0.4
|
||||
version: 2.0.6
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
|
||||
|
||||
# posthog API key
|
||||
# - not required for prod/staging
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY="ignored"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
|
||||
# Stripe Customer portal
|
||||
NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||
"extends": "//"
|
||||
}
|
||||
|
||||
@@ -9,17 +9,20 @@ module.exports = [
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/payments",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
// -- end build category
|
||||
// -- storage
|
||||
|
||||
// -- scale category
|
||||
"/rpc",
|
||||
"/insight",
|
||||
"/storage",
|
||||
// -- end scale category
|
||||
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
"/insight",
|
||||
// -- contracts
|
||||
"/contracts",
|
||||
"/contracts/modular-contracts",
|
||||
@@ -59,4 +62,6 @@ module.exports = [
|
||||
// -- universal bridge landing pages --
|
||||
"/universal-bridge-regions/:region_slug",
|
||||
"/enterprise",
|
||||
"/token",
|
||||
"/vault",
|
||||
];
|
||||
|
||||
@@ -208,35 +208,29 @@ function getConfig(): NextConfig {
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { withPlausibleProxy } = require("next-plausible");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
return withBundleAnalyzer(
|
||||
withPlausibleProxy({
|
||||
customDomain: "https://pl.thirdweb.com",
|
||||
scriptName: "pl",
|
||||
})(
|
||||
withSentryConfig(
|
||||
{
|
||||
...baseNextConfig,
|
||||
// @ts-expect-error - this is a valid option
|
||||
webpack: (config) => {
|
||||
if (config.cache) {
|
||||
config.cache = Object.freeze({
|
||||
type: "memory",
|
||||
});
|
||||
}
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
};
|
||||
// Important: return the modified config
|
||||
return config;
|
||||
},
|
||||
withSentryConfig(
|
||||
{
|
||||
...baseNextConfig,
|
||||
// @ts-expect-error - this is a valid option
|
||||
webpack: (config) => {
|
||||
if (config.cache) {
|
||||
config.cache = Object.freeze({
|
||||
type: "memory",
|
||||
});
|
||||
}
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
};
|
||||
// Important: return the modified config
|
||||
return config;
|
||||
},
|
||||
SENTRY_OPTIONS,
|
||||
),
|
||||
},
|
||||
SENTRY_OPTIONS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+28
-27
@@ -4,13 +4,14 @@
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@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",
|
||||
@@ -23,14 +24,14 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@scalar/api-reference-react": "^0.6.19",
|
||||
"@sentry/nextjs": "9.29.0",
|
||||
"@scalar/api-reference-react": "0.7.25",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.1.0",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
@@ -40,29 +41,29 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"framer-motion": "12.17.0",
|
||||
"framer-motion": "12.23.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.514.0",
|
||||
"next": "15.3.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.252.0",
|
||||
"prettier": "3.5.3",
|
||||
"posthog-js": "1.256.1",
|
||||
"posthog-node": "^5.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-table": "^7.8.0",
|
||||
@@ -71,7 +72,7 @@
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "2.0.5",
|
||||
"sonner": "2.0.6",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
@@ -81,19 +82,19 @@
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.67"
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.4",
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "4.0.0",
|
||||
"@next/bundle-analyzer": "15.3.3",
|
||||
"@next/eslint-plugin-next": "15.3.3",
|
||||
"@playwright/test": "1.53.0",
|
||||
"@storybook/addon-docs": "9.0.8",
|
||||
"@storybook/addon-links": "9.0.8",
|
||||
"@storybook/addon-onboarding": "9.0.8",
|
||||
"@storybook/nextjs": "9.0.8",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
"@playwright/test": "1.53.2",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
@@ -107,15 +108,15 @@
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"checkly": "5.6.0",
|
||||
"checkly": "6.0.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.8",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.5",
|
||||
"storybook": "9.0.8",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
|
||||
@@ -106,9 +106,27 @@ const projectPageRedirects = [
|
||||
source: `${projectRoute}/nebula/:path*`,
|
||||
},
|
||||
{
|
||||
source: `${projectRoute}/connect/analytics`,
|
||||
destination: `${projectRoute}`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/analytics`,
|
||||
},
|
||||
];
|
||||
|
||||
const teamPageRedirects = [
|
||||
{
|
||||
destination: "/team/:team_slug/~/billing/:path*",
|
||||
permanent: false,
|
||||
source: "/team/:team_slug/~/settings/billing/:path*",
|
||||
},
|
||||
{
|
||||
destination: "/team/:team_slug/~/billing/invoices/:path*",
|
||||
permanent: false,
|
||||
source: "/team/:team_slug/~/settings/invoices/:path*",
|
||||
},
|
||||
{
|
||||
destination: "/team/:team_slug/~/billing",
|
||||
permanent: false,
|
||||
source: "/team/:team_slug/~/settings/credits",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -309,13 +327,6 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/solutions/chains",
|
||||
},
|
||||
// redirect /rpc to portal
|
||||
{
|
||||
destination:
|
||||
"https://portal.thirdweb.com/infrastructure/rpc-edge/overview",
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
// redirect /sdk to portal
|
||||
{
|
||||
destination: "https://portal.thirdweb.com/connect/blockchain-api",
|
||||
@@ -405,7 +416,7 @@ async function redirects() {
|
||||
source: "/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/connect/universal-bridge",
|
||||
},
|
||||
@@ -424,8 +435,24 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/engine",
|
||||
},
|
||||
{
|
||||
destination: "/rpc",
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
{
|
||||
source: "/support/:path*",
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { isVercel } from "@/utils/vercel";
|
||||
import { verifyTurnstileToken } from "./verifyTurnstileToken";
|
||||
import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken";
|
||||
|
||||
export async function getLoginPayload(
|
||||
params: GenerateLoginPayloadParams,
|
||||
@@ -1,7 +1,10 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ChainInfraSKU } from "@/types/billing";
|
||||
import { getAbsoluteUrl } from "@/utils/vercel";
|
||||
|
||||
export async function reSubscribePlan(options: {
|
||||
teamId: string;
|
||||
@@ -14,7 +17,10 @@ export async function reSubscribePlan(options: {
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
|
||||
new URL(
|
||||
`/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
@@ -35,3 +41,83 @@ export async function reSubscribePlan(options: {
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChainInfraCheckoutURL(options: {
|
||||
teamSlug: string;
|
||||
skus: ChainInfraSKU[];
|
||||
chainId: number;
|
||||
annual: boolean;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
error: "You are not logged in",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
new URL(
|
||||
`/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
body: JSON.stringify({
|
||||
annual: options.annual,
|
||||
baseUrl: getAbsoluteUrl(),
|
||||
chainId: options.chainId,
|
||||
skus: options.skus,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error("Failed to create checkout link", text, res.status);
|
||||
switch (res.status) {
|
||||
case 402: {
|
||||
return {
|
||||
error:
|
||||
"You have outstanding invoices, please pay these first before re-subscribing.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 429: {
|
||||
return {
|
||||
error: "Too many requests, please try again later.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 403: {
|
||||
return {
|
||||
error: "You are not authorized to deploy infrastructure.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!json.result) {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
data: json.result as string,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
let _posthogClient: PostHog | null = null;
|
||||
|
||||
function getPostHogServer(): PostHog | null {
|
||||
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
host: "https://us.i.posthog.com",
|
||||
});
|
||||
}
|
||||
return _posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled for a specific user
|
||||
* @param flagKey - The feature flag key
|
||||
* @param userEmail - The user's email address for filtering
|
||||
*/
|
||||
export const isFeatureFlagEnabled = unstable_cache(
|
||||
async (params: {
|
||||
flagKey: string;
|
||||
accountId: string;
|
||||
email: string | undefined;
|
||||
}): Promise<boolean> => {
|
||||
const posthogClient = getPostHogServer();
|
||||
if (!posthogClient) {
|
||||
console.warn("Posthog client not set");
|
||||
return true;
|
||||
}
|
||||
|
||||
const { flagKey, accountId, email } = params;
|
||||
|
||||
try {
|
||||
if (posthogClient && accountId) {
|
||||
const isEnabled = await posthogClient.isFeatureEnabled(
|
||||
flagKey,
|
||||
accountId,
|
||||
{
|
||||
personProperties: email ? { email } : undefined,
|
||||
},
|
||||
);
|
||||
if (isEnabled !== undefined) {
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagKey}:`, error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
["is-feature-flag-enabled"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { ProductSKU } from "../types/billing";
|
||||
|
||||
// ----------------------------
|
||||
// CONTRACTS
|
||||
@@ -361,7 +362,11 @@ export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step: "deploy-contract" | "mint-nfts" | "set-claim-conditions";
|
||||
step:
|
||||
| "deploy-contract"
|
||||
| "mint-nfts"
|
||||
| "set-claim-conditions"
|
||||
| "set-admins";
|
||||
}
|
||||
| {
|
||||
assetType: "coin";
|
||||
@@ -380,3 +385,31 @@ export function reportAssetCreationFailed(
|
||||
step: properties.step,
|
||||
});
|
||||
}
|
||||
|
||||
type UpsellParams = {
|
||||
content: "storage-limit";
|
||||
campaign: "create-coin" | "create-nft";
|
||||
sku: Exclude<ProductSKU, null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track how effective the upsells are in driving users to upgrade
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportUpsellShown(properties: UpsellParams) {
|
||||
posthog.capture("upsell shown", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track how effective the upsells are in driving users to upgrade
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportUpsellClicked(properties: UpsellParams) {
|
||||
posthog.capture("upsell clicked", properties);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,54 @@ import type {
|
||||
EcosystemWalletStats,
|
||||
EngineCloudStats,
|
||||
InAppWalletStats,
|
||||
RpcMethodStats,
|
||||
TransactionStats,
|
||||
UniversalBridgeStats,
|
||||
UniversalBridgeWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
WebhookLatencyStats,
|
||||
WebhookRequestStats,
|
||||
WebhookSummaryStats,
|
||||
} from "@/types/analytics";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getChains } from "./chain";
|
||||
|
||||
export interface InsightChainStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface InsightStatusCodeStats {
|
||||
date: string;
|
||||
httpStatusCode: number;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface InsightEndpointStats {
|
||||
date: string;
|
||||
endpoint: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
interface InsightUsageStats {
|
||||
date: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface RpcMethodStats {
|
||||
date: string;
|
||||
evmMethod: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RpcUsageTypeStats {
|
||||
date: string;
|
||||
usageType: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
async function fetchAnalytics(
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
@@ -76,6 +113,9 @@ function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams {
|
||||
if (params.period) {
|
||||
searchParams.append("period", params.period);
|
||||
}
|
||||
if (params.limit) {
|
||||
searchParams.append("limit", params.limit.toString());
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
@@ -225,6 +265,26 @@ export async function getRpcMethodUsage(
|
||||
return json.data as RpcMethodStats[];
|
||||
}
|
||||
|
||||
export async function getRpcUsageByType(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<RpcUsageTypeStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/rpc/usage-types?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch RPC usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as RpcUsageTypeStats[];
|
||||
}
|
||||
|
||||
export async function getWalletUsers(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<WalletUserStats[]> {
|
||||
@@ -424,3 +484,145 @@ export async function getEngineCloudMethodUsage(
|
||||
const json = await res.json();
|
||||
return json.data as EngineCloudStats[];
|
||||
}
|
||||
|
||||
export async function getWebhookSummary(
|
||||
params: AnalyticsQueryParams & { webhookId: string },
|
||||
): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/summary?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookSummaryStats[] };
|
||||
}
|
||||
|
||||
export async function getWebhookRequests(
|
||||
params: AnalyticsQueryParams & { webhookId?: string },
|
||||
): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
if (params.webhookId) {
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
}
|
||||
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/requests?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookRequestStats[] };
|
||||
}
|
||||
|
||||
export async function getWebhookLatency(
|
||||
params: AnalyticsQueryParams & { webhookId?: string },
|
||||
): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
if (params.webhookId) {
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/latency?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookLatencyStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightChainUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage/by-chain?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight chain usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightChainStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightStatusCodeUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightStatusCodeStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage/by-status-code?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight status code usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightStatusCodeStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightEndpointUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightEndpointStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage/by-endpoint?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight endpoint usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightEndpointStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightUsageStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightUsageStats[] };
|
||||
}
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import "server-only";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import type { ChainService } from "@/types/chain";
|
||||
|
||||
export async function getGasSponsoredChains() {
|
||||
if (!API_SERVER_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/gas-sponsored`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": API_SERVER_SECRET,
|
||||
},
|
||||
next: {
|
||||
revalidate: 15 * 60, //15 minutes
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to fetch gas sponsored chains",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
res.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()).data as number[];
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gas sponsored chains", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getChains() {
|
||||
return fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`,
|
||||
|
||||
+72
-20
@@ -1,22 +1,26 @@
|
||||
export const authOptions = [
|
||||
"email",
|
||||
"phone",
|
||||
"passkey",
|
||||
"siwe",
|
||||
"guest",
|
||||
"google",
|
||||
"facebook",
|
||||
"x",
|
||||
"discord",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
"github",
|
||||
"twitch",
|
||||
"steam",
|
||||
"apple",
|
||||
"coinbase",
|
||||
"line",
|
||||
] as const;
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuthOption =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "passkey"
|
||||
| "siwe"
|
||||
| "guest"
|
||||
| "google"
|
||||
| "facebook"
|
||||
| "x"
|
||||
| "discord"
|
||||
| "farcaster"
|
||||
| "telegram"
|
||||
| "github"
|
||||
| "twitch"
|
||||
| "steam"
|
||||
| "apple"
|
||||
| "coinbase"
|
||||
| "line";
|
||||
|
||||
export type Ecosystem = {
|
||||
name: string;
|
||||
@@ -24,7 +28,7 @@ export type Ecosystem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
permission: "PARTNER_WHITELIST" | "ANYONE";
|
||||
authOptions: (typeof authOptions)[number][];
|
||||
authOptions: AuthOption[];
|
||||
customAuthOptions?: {
|
||||
authEndpoint?: {
|
||||
url: string;
|
||||
@@ -47,6 +51,54 @@ export type Ecosystem = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function fetchEcosystemList(teamIdOrSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await res.json()).result as Ecosystem[];
|
||||
}
|
||||
|
||||
export async function fetchEcosystem(slug: string, teamIdOrSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem };
|
||||
return data.result;
|
||||
}
|
||||
|
||||
type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
export type Partner = {
|
||||
id: string;
|
||||
@@ -0,0 +1,143 @@
|
||||
"use server";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { SupportTicket } from "../../app/(app)/team/[team_slug]/(team)/~/support/types/tickets";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";
|
||||
|
||||
const ESCALATION_FEEDBACK_RATING = 9999;
|
||||
|
||||
export async function createSupportTicket(params: {
|
||||
message: string;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
conversationId?: string;
|
||||
}): Promise<{ data: SupportTicket } | { error: string }> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No auth token available" };
|
||||
}
|
||||
|
||||
try {
|
||||
const walletAddress = await getAuthTokenWalletAddress();
|
||||
|
||||
const encodedTeamSlug = encodeURIComponent(params.teamSlug);
|
||||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`;
|
||||
|
||||
// Build the payload for creating a conversation
|
||||
// If the message does not already include wallet address, prepend it
|
||||
let message = params.message;
|
||||
if (!message.includes("Wallet address:")) {
|
||||
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
markdown: message.trim(),
|
||||
title: params.title,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
body,
|
||||
headers,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: `API Server error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
const createdConversation: SupportTicket = await response.json();
|
||||
|
||||
// Escalate to SIWA feedback endpoint if conversationId is provided
|
||||
if (params.conversationId) {
|
||||
try {
|
||||
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
|
||||
if (siwaUrl) {
|
||||
await fetch(`${siwaUrl}/v1/chat/feedback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(params.teamId ? { "x-team-id": params.teamId } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: params.conversationId,
|
||||
feedbackRating: ESCALATION_FEEDBACK_RATING,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the ticket creation
|
||||
console.error("Failed to escalate to SIWA feedback:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: createdConversation };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to create support ticket: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessageToTicket(request: {
|
||||
ticketId: string;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
message: string;
|
||||
}): Promise<{ success: true } | { error: string }> {
|
||||
if (!request.ticketId || !request.teamSlug) {
|
||||
return { error: "Ticket ID and team slug are required" };
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No auth token available" };
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
|
||||
const encodedTicketId = encodeURIComponent(request.ticketId);
|
||||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;
|
||||
|
||||
// Append /unthread send for customer messages to ensure proper routing
|
||||
const messageWithUnthread = `${request.message.trim()}\n/unthread send`;
|
||||
const payload = {
|
||||
markdown: messageWithUnthread,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
...(request.teamId ? { "x-team-id": request.teamId } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
body,
|
||||
headers,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: `API Server error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ProductSKU } from "@/types/billing";
|
||||
import type { ChainInfraSKU, ProductSKU } from "@/types/billing";
|
||||
|
||||
type InvoiceLine = {
|
||||
// amount for this line item
|
||||
@@ -22,7 +22,7 @@ type Invoice = {
|
||||
|
||||
export type TeamSubscription = {
|
||||
id: string;
|
||||
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
|
||||
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT" | "CHAIN";
|
||||
status:
|
||||
| "incomplete"
|
||||
| "incomplete_expired"
|
||||
@@ -37,6 +37,13 @@ export type TeamSubscription = {
|
||||
trialStart: string | null;
|
||||
trialEnd: string | null;
|
||||
upcomingInvoice: Invoice;
|
||||
skus: (ProductSKU | ChainInfraSKU)[];
|
||||
};
|
||||
|
||||
type ChainTeamSubscription = Omit<TeamSubscription, "skus"> & {
|
||||
chainId: string;
|
||||
skus: ChainInfraSKU[];
|
||||
isLegacy: boolean;
|
||||
};
|
||||
|
||||
export async function getTeamSubscriptions(slug: string) {
|
||||
@@ -60,3 +67,61 @@ export async function getTeamSubscriptions(slug: string) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const CHAIN_PLAN_TO_INFRA = {
|
||||
"chain:plan:gold": ["chain:infra:rpc", "chain:infra:account_abstraction"],
|
||||
"chain:plan:platinum": [
|
||||
"chain:infra:rpc",
|
||||
"chain:infra:insight",
|
||||
"chain:infra:account_abstraction",
|
||||
],
|
||||
"chain:plan:ultimate": [
|
||||
"chain:infra:rpc",
|
||||
"chain:infra:insight",
|
||||
"chain:infra:account_abstraction",
|
||||
],
|
||||
};
|
||||
|
||||
export async function getChainSubscriptions(slug: string) {
|
||||
const allSubscriptions = await getTeamSubscriptions(slug);
|
||||
if (!allSubscriptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// first replace any sku that MIGHT match a chain plan
|
||||
const updatedSubscriptions = allSubscriptions
|
||||
.filter((s) => s.type === "CHAIN")
|
||||
.map((s) => {
|
||||
const skus = s.skus;
|
||||
const updatedSkus = skus.flatMap((sku) => {
|
||||
const plan =
|
||||
CHAIN_PLAN_TO_INFRA[sku as keyof typeof CHAIN_PLAN_TO_INFRA];
|
||||
return plan ? plan : sku;
|
||||
});
|
||||
return {
|
||||
...s,
|
||||
isLegacy: updatedSkus.length !== skus.length,
|
||||
skus: updatedSkus,
|
||||
};
|
||||
});
|
||||
|
||||
return updatedSubscriptions.filter(
|
||||
(s): s is ChainTeamSubscription =>
|
||||
"chainId" in s && typeof s.chainId === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getChainSubscriptionForChain(
|
||||
slug: string,
|
||||
chainId: number,
|
||||
) {
|
||||
const chainSubscriptions = await getChainSubscriptions(slug);
|
||||
|
||||
if (!chainSubscriptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
chainSubscriptions.find((s) => s.chainId === chainId.toString()) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function service_getTeamBySlug(slug: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTeamById(id: string) {
|
||||
function getTeamById(id: string) {
|
||||
return getTeamBySlug(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export interface WebhookConfig {
|
||||
id: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
pausedAt: string | null;
|
||||
webhookSecret: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
topics: {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
filters: object | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
type WebhookConfigsResponse =
|
||||
| {
|
||||
data: WebhookConfig[];
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
interface CreateWebhookConfigRequest {
|
||||
topicIdsWithFilters: { id: string; filters: object | null }[];
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
type CreateWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
type TopicsResponse =
|
||||
| {
|
||||
data: Topic[];
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
interface UpdateWebhookConfigRequest {
|
||||
destinationUrl?: string;
|
||||
topicIdsWithFilters?: { id: string; filters: object | null }[];
|
||||
description?: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
type UpdateWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
type DeleteWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function getWebhookConfigs(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
}): Promise<WebhookConfigsResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
config: CreateWebhookConfigRequest;
|
||||
}): Promise<CreateWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
|
||||
{
|
||||
body: JSON.stringify(props.config),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAvailableTopics(): Promise<TopicsResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
webhookConfigId: string;
|
||||
config: UpdateWebhookConfigRequest;
|
||||
}): Promise<UpdateWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
|
||||
{
|
||||
body: JSON.stringify(props.config),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
webhookConfigId: string;
|
||||
}): Promise<DeleteWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
type TestDestinationUrlResponse =
|
||||
| {
|
||||
result: {
|
||||
httpStatusCode: number;
|
||||
httpResponseBody: string;
|
||||
};
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function testDestinationUrl(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
destinationUrl: string;
|
||||
}): Promise<TestDestinationUrlResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/test-destination-url`,
|
||||
{
|
||||
body: JSON.stringify({ destinationUrl: props.destinationUrl }),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
body: await resp.text(),
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { normalizeTime } from "@/lib/time";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DateRangeSelector(props: {
|
||||
range: Range;
|
||||
setRange: (range: Range) => void;
|
||||
popoverAlign?: "start" | "end" | "center";
|
||||
className?: string;
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
const daysDiff = differenceInCalendarDays(range.to, range.from);
|
||||
@@ -26,7 +28,7 @@ export function DateRangeSelector(props: {
|
||||
|
||||
return (
|
||||
<DatePickerWithRange
|
||||
className="w-auto bg-card"
|
||||
className={cn("w-auto bg-card", props.className)}
|
||||
from={range.from}
|
||||
header={
|
||||
<div className="mb-2 border-border border-b p-4">
|
||||
|
||||
@@ -76,8 +76,9 @@ export function RangeSelector({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-4">
|
||||
<div className="flex justify-end gap-3 flex-col lg:flex-row">
|
||||
<DateRangeSelector
|
||||
className="rounded-full"
|
||||
range={localRange}
|
||||
setRange={(newRange) => {
|
||||
setRange(newRange);
|
||||
@@ -89,7 +90,7 @@ export function RangeSelector({
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
className="bg-card"
|
||||
className="bg-card rounded-full"
|
||||
intervalType={localInterval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setInterval(newInterval);
|
||||
|
||||
@@ -82,7 +82,7 @@ function UnpaidInvoicesWarning({ teamSlug }: { teamSlug: string }) {
|
||||
|
||||
<Button asChild className="w-full gap-2 bg-card" variant="outline">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
href={`/team/${teamSlug}/~/billing/invoices?status=open`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account, BillingCredit } from "@/hooks/useApi";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import type { BillingCredit } from "@/hooks/useApi";
|
||||
import { formatToDollars } from "./formatToDollars";
|
||||
|
||||
interface CreditsItemProps {
|
||||
credit?: BillingCredit;
|
||||
isOpCreditDefault?: boolean;
|
||||
onClickApply?: () => void;
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
credit,
|
||||
isOpCreditDefault,
|
||||
onClickApply,
|
||||
twAccount,
|
||||
client,
|
||||
teamSlug,
|
||||
}) => {
|
||||
const [hasAppliedForOpGrant] = useLocalStorage(
|
||||
`appliedForOpGrant-${twAccount.id}`,
|
||||
false,
|
||||
);
|
||||
|
||||
const isOpCredit = credit?.name.startsWith("OP -") || isOpCreditDefault;
|
||||
export const CreditsItem: React.FC<CreditsItemProps> = ({ credit }) => {
|
||||
const isTwCredit = credit?.name.startsWith("TW -");
|
||||
const isStartupCredit = credit?.name.startsWith("SU -");
|
||||
|
||||
let creditTitle = credit?.name ?? "thirdweb credits";
|
||||
if (isOpCredit) {
|
||||
creditTitle = "Optimism sponsorship credits";
|
||||
} else if (isTwCredit) {
|
||||
if (isTwCredit) {
|
||||
creditTitle = "thirdweb credits";
|
||||
} else if (isStartupCredit) {
|
||||
creditTitle = "Startup grant credits";
|
||||
@@ -50,13 +23,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
<div className="flex flex-col gap-3 p-4 lg:p-6">
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
{isOpCredit ? (
|
||||
<ChainIconClient
|
||||
className="size-6"
|
||||
client={client}
|
||||
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
|
||||
/>
|
||||
) : isTwCredit ? (
|
||||
{isTwCredit ? (
|
||||
<Image
|
||||
alt="tw-credit"
|
||||
className="size-6"
|
||||
@@ -112,35 +79,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAppliedForOpGrant && !credit && isOpCredit && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Grant application pending approval</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will receive an email once your application's status
|
||||
changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAppliedForOpGrant && isOpCredit && (
|
||||
<div className="mt-2 flex justify-end border-t px-4 py-4 lg:px-6">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/credits`}
|
||||
onClick={() => {
|
||||
if (onClickApply) {
|
||||
onClickApply();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply Now
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,41 +2,26 @@
|
||||
|
||||
// TODO - convert to RSC
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { type Account, useAccountCredits } from "@/hooks/useApi";
|
||||
import { useAccountCredits } from "@/hooks/useApi";
|
||||
import { CreditsItem } from "./CreditsItem";
|
||||
|
||||
export const CreditsInfoCard = (props: {
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
}) => {
|
||||
export const CreditsInfoCard = () => {
|
||||
const { data: credits } = useAccountCredits();
|
||||
|
||||
if (!credits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opCredit = credits.find((crd) => crd.name.startsWith("OP -"));
|
||||
const restCredits = credits.filter((crd) => !crd.name.startsWith("OP -"));
|
||||
|
||||
if (restCredits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={opCredit}
|
||||
isOpCreditDefault={true}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits?.map((credit) => (
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={credit}
|
||||
key={credit.couponId}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits.map((credit) => (
|
||||
<CreditsItem credit={credit} key={credit.couponId} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ function BillingWarning({ teamSlug }: { teamSlug: string }) {
|
||||
You have outstanding invoices. Please{" "}
|
||||
<Link
|
||||
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
href={`/team/${teamSlug}/~/billing/invoices?status=open`}
|
||||
>
|
||||
pay them
|
||||
</Link>{" "}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button asChild className="justify-start gap-2" size="sm">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function MobileSidebar(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function useActiveSidebarLink(links: SidebarLink[]) {
|
||||
function useActiveSidebarLink(links: SidebarLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeLink = useMemo(() => {
|
||||
|
||||
@@ -152,6 +152,7 @@ export function SingleNetworkSelector(props: {
|
||||
disableChainId?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
disableTestnets?: boolean;
|
||||
disableDeprecated?: boolean;
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
@@ -169,8 +170,17 @@ export function SingleNetworkSelector(props: {
|
||||
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
|
||||
}
|
||||
|
||||
if (props.disableDeprecated) {
|
||||
chains = chains.filter((chain) => chain.status !== "deprecated");
|
||||
}
|
||||
|
||||
return chains;
|
||||
}, [allChains, props.chainIds, props.disableTestnets]);
|
||||
}, [
|
||||
allChains,
|
||||
props.chainIds,
|
||||
props.disableTestnets,
|
||||
props.disableDeprecated,
|
||||
]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return chainsToShow.map((chain) => {
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { AppFooter } from "../footers/app-footer";
|
||||
import { NavLink } from "../ui/NavLink";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
import { CustomSidebar, type SidebarLink } from "./Sidebar";
|
||||
|
||||
export function SidebarLayout(props: {
|
||||
@@ -51,129 +34,3 @@ export function SidebarLayout(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullWidthSidebarLayout(props: {
|
||||
contentSidebarLinks: SidebarLink[];
|
||||
footerSidebarLinks?: SidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 overflow-y-hidden",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar className="pt-2" collapsible="icon" side="left">
|
||||
<SidebarContent className="p-2">
|
||||
<RenderSidebarMenu links={contentSidebarLinks} />
|
||||
</SidebarContent>
|
||||
|
||||
{footerSidebarLinks && (
|
||||
<SidebarFooter className="pb-3">
|
||||
<RenderSidebarMenu links={footerSidebarLinks} />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
{/* right - content */}
|
||||
<div className="flex h-full flex-grow flex-col overflow-y-auto">
|
||||
<MobileSidebarTrigger
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarGroup(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
groupName: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarMenuItem>
|
||||
<SidebarGroupLabel> {props.groupName}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<RenderSidebarMenu links={props.sidebarLinks} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarMenu(props: { links: SidebarLink[] }) {
|
||||
const sidebar = useSidebar();
|
||||
return (
|
||||
<SidebarMenu className="gap-1.5">
|
||||
{props.links.map((link, idx) => {
|
||||
// link
|
||||
if ("href" in link) {
|
||||
return (
|
||||
<SidebarMenuSubItem key={link.href}>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
activeClassName="text-foreground bg-accent"
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
href={link.href}
|
||||
isActive={link.isActive}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span>{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
// separator
|
||||
if ("separator" in link) {
|
||||
return (
|
||||
<SidebarSeparator
|
||||
className="my-1"
|
||||
key={`separator-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
|
||||
idx
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// group
|
||||
return (
|
||||
<RenderSidebarGroup
|
||||
groupName={link.group}
|
||||
key={link.group}
|
||||
sidebarLinks={link.links}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
|
||||
const activeLink = useActiveSidebarLink(props.links);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<SidebarTrigger className="size-4" />
|
||||
<Separator className="h-4" orientation="vertical" />
|
||||
{activeLink && <span className="text-sm">{activeLink.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -164,7 +165,6 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="group"
|
||||
{...(tableProps.onRowClick
|
||||
? {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function TeamPlanBadge(props: {
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
router.push(`/team/${props.teamSlug}/~/settings/billing?showPlans=true`);
|
||||
router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,12 +38,18 @@ const ACCENT = {
|
||||
type UpsellBannerCardProps = {
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
cta: {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
target?: "_blank";
|
||||
link: string;
|
||||
};
|
||||
cta?:
|
||||
| {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
target?: "_blank";
|
||||
link: string;
|
||||
}
|
||||
| {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
accentColor?: keyof typeof ACCENT;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
@@ -93,25 +99,41 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
<Link
|
||||
href={props.cta.link}
|
||||
rel={
|
||||
props.cta.target === "_blank" ? "noopener noreferrer" : undefined
|
||||
}
|
||||
target={props.cta.target}
|
||||
{props.cta && "target" in props.cta ? (
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
<Link
|
||||
href={props.cta.link}
|
||||
rel={
|
||||
props.cta.target === "_blank"
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
target={props.cta.target}
|
||||
>
|
||||
{props.cta.text}
|
||||
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
|
||||
</Link>
|
||||
</Button>
|
||||
) : props.cta && "onClick" in props.cta ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
onClick={props.cta.onClick}
|
||||
size="sm"
|
||||
>
|
||||
{props.cta.text}
|
||||
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
emptyChartState?: React.ReactElement;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
return (
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
right: props.margin?.right ?? 0,
|
||||
left: props.margin?.left ?? 0,
|
||||
bottom: props.margin?.bottom ?? 10,
|
||||
top: props.margin?.top ?? 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
|
||||
<XAxis
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { NavLink } from "@/components/ui/NavLink";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ShadcnSidebarBaseLink = {
|
||||
href: string;
|
||||
label: React.ReactNode;
|
||||
exactMatch?: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
isActive?: (pathname: string) => boolean;
|
||||
};
|
||||
|
||||
type ShadcnSidebarLink =
|
||||
| ShadcnSidebarBaseLink
|
||||
| {
|
||||
group: string;
|
||||
links: ShadcnSidebarBaseLink[];
|
||||
}
|
||||
| {
|
||||
separator: true;
|
||||
}
|
||||
| {
|
||||
subMenu: Omit<ShadcnSidebarBaseLink, "href" | "exactMatch">;
|
||||
links: Omit<ShadcnSidebarBaseLink, "icon">[];
|
||||
};
|
||||
|
||||
export function FullWidthSidebarLayout(props: {
|
||||
contentSidebarLinks: ShadcnSidebarLink[];
|
||||
footerSidebarLinks?: ShadcnSidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 overflow-y-hidden",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar className="pt-2" collapsible="icon" side="left">
|
||||
<SidebarContent className="p-2">
|
||||
<RenderSidebarMenu links={contentSidebarLinks} />
|
||||
</SidebarContent>
|
||||
|
||||
{footerSidebarLinks && (
|
||||
<SidebarFooter className="pb-3">
|
||||
<RenderSidebarMenu links={footerSidebarLinks} />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
{/* right - content */}
|
||||
<div className="flex h-full flex-grow flex-col overflow-y-auto">
|
||||
<MobileSidebarTrigger
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
|
||||
const activeLink = useActiveShadcnSidebarLink(props.links);
|
||||
const parentSubNav = props.links.find(
|
||||
(link) =>
|
||||
"subMenu" in link && link.links.some((l) => l.href === activeLink?.href),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<SidebarTrigger className="size-4" />
|
||||
<Separator
|
||||
className="h-4 bg-muted-foreground/50"
|
||||
orientation="vertical"
|
||||
/>
|
||||
{parentSubNav && "subMenu" in parentSubNav && (
|
||||
<>
|
||||
<span className="text-sm">{parentSubNav.subMenu.label}</span>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/50 -mx-1.5" />
|
||||
</>
|
||||
)}
|
||||
{activeLink && <span className="text-sm">{activeLink.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeLink = useMemo(() => {
|
||||
function isActive(link: ShadcnSidebarBaseLink) {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
if ("links" in link) {
|
||||
for (const subLink of link.links) {
|
||||
if (isActive(subLink)) {
|
||||
return subLink;
|
||||
}
|
||||
}
|
||||
} else if ("href" in link) {
|
||||
if (isActive(link)) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [links, pathname]);
|
||||
|
||||
return activeLink;
|
||||
}
|
||||
|
||||
function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isSubnavActive = useMemo(() => {
|
||||
function isActive(link: ShadcnSidebarBaseLink) {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
}
|
||||
|
||||
return links.some(isActive);
|
||||
}, [links, pathname]);
|
||||
|
||||
return isSubnavActive;
|
||||
}
|
||||
|
||||
function RenderSidebarGroup(props: {
|
||||
sidebarLinks: ShadcnSidebarLink[];
|
||||
groupName: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarMenuItem>
|
||||
<SidebarGroupLabel> {props.groupName}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<RenderSidebarMenu links={props.sidebarLinks} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarSubmenu(props: {
|
||||
links: ShadcnSidebarBaseLink[];
|
||||
subMenu: Omit<ShadcnSidebarBaseLink, "href" | "exactMatch">;
|
||||
}) {
|
||||
const sidebar = useSidebar();
|
||||
const isSubnavActive = useIsSubnavActive(props.links);
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<DynamicHeight transition="height 200ms ease">
|
||||
<Collapsible className="group/collapsible" defaultOpen={isSubnavActive}>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{props.subMenu.icon && (
|
||||
<props.subMenu.icon className="size-4" />
|
||||
)}
|
||||
<span>{props.subMenu.label}</span>
|
||||
<ChevronDownIcon className="ml-auto group-[[data-state=open]]/collapsible:rotate-180 transition-transform" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="mt-0.5">
|
||||
<SidebarMenuSub className="pr-0 pl-2">
|
||||
{props.links.map((link) => {
|
||||
return (
|
||||
<SidebarMenuSubItem key={link.href}>
|
||||
<NavLink
|
||||
activeClassName="text-foreground bg-accent "
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent hover:text-foreground px-2 py-1.5 rounded-lg w-full"
|
||||
exactMatch={link.exactMatch}
|
||||
href={link.href}
|
||||
isActive={link.isActive}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span className="whitespace-nowrap">{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</DynamicHeight>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) {
|
||||
const sidebar = useSidebar();
|
||||
return (
|
||||
<SidebarMenu className="gap-1.5">
|
||||
{props.links.map((link, idx) => {
|
||||
// link
|
||||
if ("href" in link) {
|
||||
return (
|
||||
<SidebarMenuSubItem key={link.href}>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
activeClassName="text-foreground bg-accent"
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
href={link.href}
|
||||
isActive={link.isActive}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span>{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
// separator
|
||||
if ("separator" in link) {
|
||||
return (
|
||||
<SidebarSeparator
|
||||
className="my-1"
|
||||
key={`separator-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
|
||||
idx
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// subnav
|
||||
if ("subMenu" in link) {
|
||||
return (
|
||||
<RenderSidebarSubmenu
|
||||
key={`submenu_$${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
|
||||
idx
|
||||
}`}
|
||||
links={link.links}
|
||||
subMenu={link.subMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// group
|
||||
return (
|
||||
<RenderSidebarGroup
|
||||
groupName={link.group}
|
||||
key={link.group}
|
||||
sidebarLinks={link.links}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export type MultiStepState<T extends string> = {
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: React.ReactNode;
|
||||
message: string;
|
||||
};
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -27,6 +27,10 @@ export type MultiStepState<T extends string> = {
|
||||
export function MultiStepStatus<T extends string>(props: {
|
||||
steps: MultiStepState<T>[];
|
||||
onRetry: (step: MultiStepState<T>) => void;
|
||||
renderError?: (
|
||||
step: MultiStepState<T>,
|
||||
errorMessage: string,
|
||||
) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DynamicHeight>
|
||||
@@ -66,22 +70,24 @@ export function MultiStepStatus<T extends string>(props: {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step.status.type === "error" && (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">
|
||||
{step.status.message}
|
||||
</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => props.onRetry(step)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{step.status.type === "error"
|
||||
? props.renderError?.(step, step.status.message) || (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">
|
||||
{step.status.message}
|
||||
</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => props.onRetry(step)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const PaginationButtons = (props: {
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === 1}
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
@@ -57,7 +57,7 @@ export const PaginationButtons = (props: {
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === totalPages}
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
@@ -78,7 +78,7 @@ export const PaginationButtons = (props: {
|
||||
{pages.map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
isActive={activePage === page}
|
||||
onClick={() => {
|
||||
setPage(page);
|
||||
@@ -98,7 +98,7 @@ export const PaginationButtons = (props: {
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === 1}
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
@@ -111,7 +111,7 @@ export const PaginationButtons = (props: {
|
||||
<>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
}}
|
||||
@@ -129,7 +129,7 @@ export const PaginationButtons = (props: {
|
||||
{activePage - 1 > 0 && (
|
||||
<PaginationItem className="max-sm:hidden">
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
}}
|
||||
@@ -140,7 +140,7 @@ export const PaginationButtons = (props: {
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationLink className="rounded-full" isActive>
|
||||
<PaginationLink className="rounded-full lg:min-w-[38px]" isActive>
|
||||
{activePage}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
@@ -148,7 +148,7 @@ export const PaginationButtons = (props: {
|
||||
{activePage + 1 <= totalPages && (
|
||||
<PaginationItem className="max-sm:hidden">
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
}}
|
||||
@@ -167,7 +167,7 @@ export const PaginationButtons = (props: {
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(totalPages);
|
||||
}}
|
||||
@@ -180,7 +180,7 @@ export const PaginationButtons = (props: {
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === totalPages}
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
@@ -190,7 +191,6 @@ export const SelectWithSearch = React.forwardRef<
|
||||
ref={
|
||||
i === optionsToShow.length - 1 ? lastItemRef : undefined
|
||||
}
|
||||
// biome-ignore lint/a11y/useSemanticElements: TDOO
|
||||
role="option"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
|
||||
import { CrownIcon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
@@ -111,18 +111,15 @@ export function UpsellContent(props: {
|
||||
<CardContent className="space-y-6">
|
||||
{props.benefits && props.benefits.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
|
||||
What you'll get:
|
||||
<h4 className="font-semibold text-foreground text-sm capitalize text-center">
|
||||
What you'll get
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
{props.benefits.map((benefit) => (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
className="flex items-center justify-center gap-3 text-center text-balance"
|
||||
key={benefit.description}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
<SparklesIcon className="h-3 w-3 text-success-text" />
|
||||
</div>
|
||||
<span className="text-sm">{benefit.description}</span>
|
||||
{benefit.status === "soon" && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
@@ -138,7 +135,7 @@ export function UpsellContent(props: {
|
||||
<div className="flex flex-col gap-3 pt-4 sm:flex-row">
|
||||
<Button asChild className="flex-1 py-3 font-semibold" size="lg">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
>
|
||||
<CrownIcon className="mr-2 h-4 w-4" />
|
||||
Upgrade to{" "}
|
||||
@@ -146,9 +143,7 @@ export function UpsellContent(props: {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="md:flex-1" size="lg" variant="outline">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true`}
|
||||
>
|
||||
<Link href={`/team/${props.teamSlug}/~/billing?showPlans=true`}>
|
||||
View All Plans
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +59,11 @@ export function WalletAddress(props: {
|
||||
|
||||
// special case for zero address
|
||||
if (address === ZERO_ADDRESS) {
|
||||
return <span className="cursor-pointer font-mono">{shortenedAddress}</span>;
|
||||
return (
|
||||
<span className={cn("cursor-pointer font-mono", props.className)}>
|
||||
{shortenedAddress}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -116,13 +116,12 @@ export function ChatBar(props: {
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Send"
|
||||
className="!h-auto w-auto border border-nebula-pink-foreground p-2 disabled:opacity-100"
|
||||
className="!h-auto w-auto p-2 disabled:opacity-100"
|
||||
disabled={message.trim() === "" || props.isConnectingWallet}
|
||||
onClick={() => {
|
||||
if (message.trim() === "") return;
|
||||
handleSubmit(message);
|
||||
}}
|
||||
variant="pink"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -14,22 +16,22 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
isLoggedIn: boolean;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
isFloating: boolean;
|
||||
pageType: "chain" | "contract" | "support";
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
authToken: string;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
const layoutSegments = useSelectedLayoutSegments();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasBeenOpened, setHasBeenOpened] = useState(false);
|
||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
@@ -54,14 +56,14 @@ export function CustomChatButton(props: {
|
||||
ref={ref}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
className="h-auto w-auto p-1 text-muted-foreground"
|
||||
className="h-auto w-auto p-1 text-muted-foreground rounded-full"
|
||||
onClick={closeModal}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -80,9 +82,7 @@ export function CustomChatButton(props: {
|
||||
message: prompt,
|
||||
title: prompt,
|
||||
}))}
|
||||
networks={props.networks}
|
||||
requireLogin={props.requireLogin}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
"use client";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import type { UserMessage, UserMessageContent } from "./CustomChats";
|
||||
import { type CustomChatMessage, CustomChats } from "./CustomChats";
|
||||
import type { ExamplePrompt, NebulaContext } from "./types";
|
||||
import type { ExamplePrompt } from "./types";
|
||||
|
||||
export default function CustomChatContent(props: {
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
networks: NebulaContext["networks"];
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
if (props.requireLogin !== false && !props.authToken) {
|
||||
return <LoggedOutStateChatContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomChatContentLoggedIn
|
||||
authToken={props.authToken || ""}
|
||||
client={props.client}
|
||||
clientId={props.clientId}
|
||||
examplePrompts={props.examplePrompts}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomChatContentLoggedIn(props: {
|
||||
authToken: string;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
@@ -55,6 +47,9 @@ function CustomChatContentLoggedIn(props: {
|
||||
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
|
||||
const connectionStatus = useActiveWalletConnectionStatus();
|
||||
|
||||
const [showSupportForm, setShowSupportForm] = useState(false);
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (userMessage: UserMessage) => {
|
||||
const abortController = new AbortController();
|
||||
@@ -96,7 +91,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
...(props.clientId ? { "x-client-id": props.clientId } : {}),
|
||||
},
|
||||
method: "POST",
|
||||
@@ -132,7 +127,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[props.authToken, props.clientId, props.teamId, sessionId],
|
||||
[props.authToken, props.clientId, props.team.id, sessionId],
|
||||
);
|
||||
|
||||
const handleFeedback = useCallback(
|
||||
@@ -165,7 +160,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -188,9 +183,20 @@ function CustomChatContentLoggedIn(props: {
|
||||
// Consider implementing retry logic here
|
||||
}
|
||||
},
|
||||
[sessionId, props.authToken, props.teamId, messages],
|
||||
[sessionId, props.authToken, props.team.id, messages],
|
||||
);
|
||||
|
||||
const handleAddSuccessMessage = useCallback((message: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "assistant" as const,
|
||||
text: message,
|
||||
request_id: undefined,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
@@ -212,6 +218,12 @@ function CustomChatContentLoggedIn(props: {
|
||||
sessionId={sessionId}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
useSmallText
|
||||
showSupportForm={showSupportForm}
|
||||
setShowSupportForm={setShowSupportForm}
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
team={props.team}
|
||||
addSuccessMessage={handleAddSuccessMessage}
|
||||
/>
|
||||
)}
|
||||
<ChatBar
|
||||
@@ -245,42 +257,6 @@ function CustomChatContentLoggedIn(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedOutStateChatContent() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center p-4">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
today?
|
||||
</h1>
|
||||
|
||||
<div className="h-3" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
Sign in to use AI Assistant
|
||||
</p>
|
||||
<div className="h-5" />
|
||||
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="w-full max-w-96 gap-2"
|
||||
href={`/login?next=${encodeURIComponent(pathname)}`}
|
||||
>
|
||||
Sign in
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
examplePrompts: { title: string; message: string }[];
|
||||
@@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/20 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-1">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/40 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-2">
|
||||
<NebulaIcon className="size-7 text-nebula-pink-foreground" />
|
||||
<div className="rounded-full border p-1 bg-muted/20">
|
||||
<div className="rounded-full border p-2 bg-inverted">
|
||||
<ThirdwebMiniLogo
|
||||
isMonoChrome
|
||||
className="size-7 text-inverted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
|
||||
</h1>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
|
||||
<div className="flex flex-col items-center justify-center gap-2.5 overflow-hidden">
|
||||
{props.examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
disabled={false}
|
||||
key={prompt.title}
|
||||
className="rounded-full text-xs sm:text-sm truncate bg-card w-fit h-auto py-1.5 whitespace-pre-wrap"
|
||||
onClick={() =>
|
||||
props.sendMessage({
|
||||
content: [
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
MessageCircleIcon,
|
||||
ArrowRightIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Reasoning } from "./Reasoning";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm";
|
||||
import { Button } from "../ui/button";
|
||||
import { TextShimmer } from "../ui/text-shimmer";
|
||||
|
||||
// Define local types
|
||||
export type UserMessageContent = { type: "text"; text: string };
|
||||
@@ -46,11 +57,18 @@ export function CustomChats(props: {
|
||||
enableAutoScroll: boolean;
|
||||
useSmallText?: boolean;
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
showSupportForm: boolean;
|
||||
setShowSupportForm: (v: boolean) => void;
|
||||
productLabel: string;
|
||||
setProductLabel: (v: string) => void;
|
||||
team: Team;
|
||||
addSuccessMessage?: (message: string) => void;
|
||||
}) {
|
||||
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [supportTicketCreated, setSupportTicketCreated] = useState(false);
|
||||
|
||||
// auto scroll to bottom when messages change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@@ -86,6 +104,8 @@ export function CustomChats(props: {
|
||||
chatScrollContainer.addEventListener("wheel", disableScroll);
|
||||
}, [setEnableAutoScroll, enableAutoScroll]);
|
||||
|
||||
const [showSupportFormDialog, setShowSupportFormDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
|
||||
@@ -123,6 +143,66 @@ export function CustomChats(props: {
|
||||
sendMessage={props.sendMessage}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
{/* Support Case Button/Form in last assistant message */}
|
||||
{message.type === "assistant" &&
|
||||
index === props.messages.length - 1 && (
|
||||
<>
|
||||
{/* Only show button/form if ticket not created */}
|
||||
{!supportTicketCreated && (
|
||||
<div className="mt-3 pl-12">
|
||||
<Dialog
|
||||
open={showSupportFormDialog}
|
||||
onOpenChange={setShowSupportFormDialog}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowSupportFormDialog(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full bg-card"
|
||||
>
|
||||
Create Support Case
|
||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<DialogContent className="p-0 bg-card">
|
||||
<DynamicHeight>
|
||||
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
|
||||
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
|
||||
Create Support Case
|
||||
</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Let's create a detailed support case for
|
||||
our technical team.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<SupportTicketForm
|
||||
team={props.team}
|
||||
productLabel={props.productLabel}
|
||||
setProductLabel={props.setProductLabel}
|
||||
conversationId={props.sessionId}
|
||||
closeForm={() => {
|
||||
setShowSupportFormDialog(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
props.setShowSupportForm(false);
|
||||
props.setProductLabel("");
|
||||
setSupportTicketCreated(true);
|
||||
// Add success message as a regular assistant message
|
||||
if (props.addSuccessMessage) {
|
||||
const supportPortalUrl = `/team/${props.team.slug}/~/support`;
|
||||
props.addSuccessMessage(
|
||||
`Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DynamicHeight>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -143,7 +223,7 @@ function RenderMessage(props: {
|
||||
nextMessage: CustomChatMessage | undefined;
|
||||
authToken: string;
|
||||
sessionId: string | undefined;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
}) {
|
||||
const { message } = props;
|
||||
|
||||
@@ -179,13 +259,16 @@ function RenderMessage(props: {
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full",
|
||||
message.type === "assistant" && "border bg-card",
|
||||
message.type === "assistant" && "border bg-inverted",
|
||||
message.type === "error" && "border",
|
||||
message.type === "presence" && "border bg-card",
|
||||
message.type === "presence" && "border bg-inverted",
|
||||
)}
|
||||
>
|
||||
{(message.type === "presence" || message.type === "assistant") && (
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
<ThirdwebMiniLogo
|
||||
className="size-4 text-inverted-foreground"
|
||||
isMonoChrome
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "error" && (
|
||||
@@ -227,7 +310,7 @@ function RenderMessage(props: {
|
||||
function CustomFeedbackButtons(props: {
|
||||
message: CustomChatMessage & { type: "assistant" };
|
||||
messageIndex: number;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -240,9 +323,9 @@ function CustomFeedbackButtons(props: {
|
||||
await props.onFeedback(props.messageIndex, feedback);
|
||||
} catch (_e) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
// Don't show buttons if feedback already given
|
||||
@@ -251,25 +334,26 @@ function CustomFeedbackButtons(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", props.className)}>
|
||||
<button
|
||||
aria-label="Thumbs up"
|
||||
className="text-muted-foreground transition-colors hover:text-green-500 disabled:opacity-50"
|
||||
<div className={cn("flex gap-1.5", props.className)}>
|
||||
<Button
|
||||
aria-label="Upvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsUpIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Thumbs down"
|
||||
className="text-muted-foreground transition-colors hover:text-red-500 disabled:opacity-50"
|
||||
<ThumbsUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Downvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(-1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsDownIcon className="size-5" />
|
||||
</button>
|
||||
<ThumbsDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,7 +380,11 @@ function RenderResponse(props: {
|
||||
);
|
||||
|
||||
case "presence":
|
||||
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
|
||||
return (
|
||||
<div className="h-8 flex items-center">
|
||||
<TextShimmer text="Reasoning..." />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
@@ -325,19 +413,15 @@ function StyledMarkdownRenderer(props: {
|
||||
}) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
code={{
|
||||
className: "bg-transparent",
|
||||
className: "bg-card",
|
||||
ignoreFormattingErrors: true,
|
||||
}}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
li={{ className: "text-foreground" }}
|
||||
markdownText={props.text}
|
||||
p={{
|
||||
className:
|
||||
props.type === "assistant"
|
||||
? "text-foreground"
|
||||
: "text-foreground leading-normal",
|
||||
className: props.type === "assistant" ? "" : "leading-normal",
|
||||
}}
|
||||
skipHtml
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { TextShimmer } from "@/components/ui/text-shimmer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Reasoning(props: { isPending: boolean; texts: string[] }) {
|
||||
const [_isOpen, setIsOpen] = useState(false);
|
||||
const isOpen = props.isPending ? true : _isOpen;
|
||||
const showAll = !props.isPending;
|
||||
const lastText = props.texts[props.texts.length - 1];
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<Button
|
||||
className="h-auto w-auto items-center gap-1 px-0 py-1 pb-2 font-normal text-base text-muted-foreground hover:text-foreground hover:no-underline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<TextShimmer text="Reasoning..." />
|
||||
) : (
|
||||
<span className="text-foreground">Reasoning</span>
|
||||
)}
|
||||
|
||||
{!props.isPending && (
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{showAll && props.texts.length > 0 && (
|
||||
<ul className="list-none space-y-1.5">
|
||||
{props.texts.map((text) => (
|
||||
<li
|
||||
className="fade-in-0 animate-in text-muted-foreground text-sm leading-relaxed duration-300"
|
||||
key={text}
|
||||
>
|
||||
{text.trim()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAll && lastText && (
|
||||
<div className="text-muted-foreground text-sm leading-relaxed">
|
||||
{lastText.trim()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,6 @@ export type ExamplePrompt = {
|
||||
|
||||
// TODO - remove "Nebula" wording and simplify types
|
||||
|
||||
export type NebulaContext = {
|
||||
chainIds: string[] | null;
|
||||
walletAddress: string | null;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
};
|
||||
|
||||
type NebulaUserMessageContentItem =
|
||||
| {
|
||||
type: "image";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { doLogout } from "@app/login/auth-actions";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
useActiveAccount,
|
||||
useConnectModal,
|
||||
} from "thirdweb/react";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
|
||||
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
|
||||
@@ -120,20 +120,18 @@ export const CustomConnectWallet = (props: {
|
||||
|
||||
if ((!isLoggedIn || !account) && loginRequired) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
className={props.signInLinkButtonClassName}
|
||||
size="lg"
|
||||
variant="default"
|
||||
<Button
|
||||
asChild
|
||||
className={props.signInLinkButtonClassName}
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<Link
|
||||
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
|
||||
>
|
||||
<Link
|
||||
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
|
||||
>
|
||||
Connect Wallet
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
Connect Wallet
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import type { StaticImageData } from "next/image";
|
||||
import Image from "next/image";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import generalContractIcon from "../../../../../public/assets/tw-icons/general.png";
|
||||
|
||||
@@ -26,7 +27,17 @@ export const ContractIdImage: React.FC<ContractIdImageProps> = ({
|
||||
<img
|
||||
alt=""
|
||||
className="size-8 rounded-full"
|
||||
src={replaceIpfsUrl(logo, serverThirdwebClient)}
|
||||
src={
|
||||
DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? replaceIpfsUrl(
|
||||
logo,
|
||||
getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
)
|
||||
: logo
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ const NetworkFilterCell = React.memo(function NetworkFilterCell({
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
if (chainIds.length < 2) {
|
||||
return <> NETWORK </>;
|
||||
return "NETWORK";
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -661,7 +661,7 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Connect SDK
|
||||
thirdweb SDK
|
||||
</UnderlineLink>{" "}
|
||||
in your applications. This will ensure account contracts are
|
||||
deployed for your users only when they need it.
|
||||
|
||||
@@ -46,9 +46,9 @@ function AnnouncementBannerUI(props: {
|
||||
export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://nebula.thirdweb.com/"
|
||||
label="Announcing Nebula App — the most powerful AI for blockchains! Try it now!"
|
||||
trackingLabel="nebula-beta-launch"
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,12 +38,10 @@ import { createProjectClient } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
|
||||
import { toArrFromList } from "@/utils/string";
|
||||
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) =>
|
||||
srv.name !== "relayer" &&
|
||||
srv.name !== "chainsaw" &&
|
||||
srv.name !== "engineCloud", // TODO enable once API server is out
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
);
|
||||
|
||||
export type CreateProjectPrefillOptions = {
|
||||
@@ -66,6 +64,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
|
||||
address: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltip?: string;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "default"
|
||||
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
|
||||
copyIconPosition={props.copyIconPosition}
|
||||
textToCopy={props.address}
|
||||
textToShow={shortenedAddress}
|
||||
tooltip="Copy Address"
|
||||
tooltip={props.tooltip || "Copy Address"}
|
||||
variant={props.variant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
squares?: Array<[x: number, y: number]>;
|
||||
strokeDasharray?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = "0",
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible" role="presentation">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const buttonVariants = cva(
|
||||
link: "underline-offset-4 hover:underline",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
|
||||
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
|
||||
secondary:
|
||||
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -50,9 +50,10 @@ const PaginationLink = ({
|
||||
<Button
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"h-auto px-3 py-2",
|
||||
"h-auto px-3 py-2 border border-transparent",
|
||||
className,
|
||||
props.disabled && "cursor-not-allowed",
|
||||
!isActive && "border-border",
|
||||
)}
|
||||
variant={!isActive ? "outline" : "default"}
|
||||
{...props}
|
||||
|
||||
@@ -3,7 +3,9 @@ import "server-only";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "./server-envs";
|
||||
import { getConfiguredThirdwebClient } from "./thirdweb.server";
|
||||
|
||||
// During build time, the secret key might not be available
|
||||
// Create a client that will work for build but may fail at runtime if secret key is needed
|
||||
export const serverThirdwebClient = getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY || "dummy-build-time-secret",
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
@@ -76,14 +76,18 @@ export function getConfiguredThirdwebClient(options: {
|
||||
});
|
||||
}
|
||||
|
||||
// During build time, provide fallbacks if credentials are missing
|
||||
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
|
||||
const secretKey = options.secretKey || undefined;
|
||||
|
||||
return createThirdwebClient({
|
||||
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
clientId: clientId,
|
||||
config: {
|
||||
storage: {
|
||||
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
|
||||
},
|
||||
},
|
||||
secretKey: options.secretKey,
|
||||
secretKey: secretKey,
|
||||
teamId: options.teamId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button asChild className="py-2 text-sm" variant="primary">
|
||||
<Link href="/support" rel="noopener noreferrer" target="_blank">
|
||||
Visit support site
|
||||
<Button asChild className="py-2 text-sm" variant="default">
|
||||
<Link
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Contact Support
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
|
||||
export const accountPlan = {
|
||||
enterprise: "enterprise",
|
||||
free: "free",
|
||||
growth: "growth",
|
||||
pro: "pro",
|
||||
} as const;
|
||||
|
||||
export type AuthorizedWallet = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
@@ -318,26 +312,40 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function rotateSecretKeyClient(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
|
||||
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// if the project has an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rotate vault admin key", error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys";
|
||||
const fetchAccountList = ({
|
||||
jwt,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
pageNumber,
|
||||
}: {
|
||||
jwt: string;
|
||||
clientId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
pageNumber: number;
|
||||
}) => {
|
||||
return async () => {
|
||||
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
|
||||
// Add clientId or ecosystemSlug parameter
|
||||
if (ecosystemSlug) {
|
||||
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
|
||||
} else if (clientId) {
|
||||
url.searchParams.append("clientId", clientId);
|
||||
}
|
||||
|
||||
url.searchParams.append("page", pageNumber.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-thirdweb-team-id": teamId,
|
||||
...(clientId && { "x-client-id": clientId }),
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -37,23 +50,27 @@ const fetchAccountList = ({
|
||||
};
|
||||
|
||||
export function useEmbeddedWallets(params: {
|
||||
clientId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
page: number;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { clientId, page, authToken } = params;
|
||||
const { clientId, ecosystemSlug, teamId, page, authToken } = params;
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useQuery({
|
||||
enabled: !!address && !!clientId,
|
||||
enabled: !!address && !!(clientId || ecosystemSlug),
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId,
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
@@ -66,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ clientId }: { clientId: string }) => {
|
||||
mutationFn: async ({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
}: {
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
}) => {
|
||||
const responses: WalletUser[] = [];
|
||||
let page = 1;
|
||||
|
||||
@@ -76,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId,
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ChainIconClient = ({
|
||||
fallback={<img alt="" src={fallbackChainIcon} />}
|
||||
key={resolvedSrc}
|
||||
loading={restProps.loading || "lazy"}
|
||||
skeleton={<div className="animate-pulse rounded-full bg-border" />}
|
||||
skeleton={<span className="animate-pulse rounded-full bg-border" />}
|
||||
src={resolvedSrc}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
enable-background="new 0 0 100 100"
|
||||
enableBackground="new 0 0 100 100"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
|
||||
@@ -57,7 +57,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
},
|
||||
nebula: {
|
||||
enabled: true,
|
||||
rateLimit: 1000,
|
||||
rateLimit: {
|
||||
perSecond: 1000,
|
||||
perMinute: 1000,
|
||||
},
|
||||
},
|
||||
pay: {
|
||||
enabled: true,
|
||||
@@ -82,6 +85,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
totalFileSizeBytesLimit: 1_000_000_000,
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
enabled: true,
|
||||
rateLimit: 10,
|
||||
},
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
dedicatedSupportChannel: null,
|
||||
@@ -192,6 +199,7 @@ export function teamSubscriptionsStub(
|
||||
currentPeriodEnd: "2024-12-15T20:56:06.000Z",
|
||||
currentPeriodStart: "2024-11-15T20:56:06.000Z",
|
||||
id: "sub-1",
|
||||
skus: [],
|
||||
status: "active",
|
||||
trialEnd: overrides?.trialEnd || null,
|
||||
trialStart: null,
|
||||
@@ -212,6 +220,7 @@ export function teamSubscriptionsStub(
|
||||
currentPeriodEnd: "2024-12-15T20:56:06.000Z",
|
||||
currentPeriodStart: "2024-11-15T20:56:15.000Z",
|
||||
id: "sub-2",
|
||||
skus: [],
|
||||
status: "active",
|
||||
trialEnd: null,
|
||||
trialStart: null,
|
||||
@@ -229,25 +238,19 @@ export function teamSubscriptionsStub(
|
||||
// In-App Wallets
|
||||
{
|
||||
amount: usage.inAppWalletAmount?.amount || 0,
|
||||
description: `${
|
||||
usage.inAppWalletAmount?.quantity || 0
|
||||
} x In-App Wallets (Tier 1 at $0.00 / month)`,
|
||||
description: `${usage.inAppWalletAmount?.quantity || 0} x In-App Wallets (Tier 1 at $0.00 / month)`,
|
||||
thirdwebSku: "usage:in_app_wallet",
|
||||
},
|
||||
// AA Sponsorship
|
||||
{
|
||||
amount: usage.aaSponsorshipAmount?.amount || 0,
|
||||
description: `${
|
||||
usage.aaSponsorshipAmount?.quantity || 0
|
||||
} x AA Gas Sponsorship (at $0.011 / month)`,
|
||||
description: `${usage.aaSponsorshipAmount?.quantity || 0} x AA Gas Sponsorship (at $0.011 / month)`,
|
||||
thirdwebSku: "usage:aa_sponsorship",
|
||||
},
|
||||
// OP Grant
|
||||
{
|
||||
amount: usage.aaSponsorshipOpGrantAmount?.amount || 0,
|
||||
description: `${
|
||||
usage.aaSponsorshipOpGrantAmount?.quantity || 0
|
||||
} x AA Gas Sponsorship (OP) (at $0.011 / month)`,
|
||||
description: `${usage.aaSponsorshipOpGrantAmount?.quantity || 0} x AA Gas Sponsorship (OP) (at $0.011 / month)`,
|
||||
thirdwebSku: "usage:aa_sponsorship_op_grant",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -39,12 +39,6 @@ export interface TransactionStats {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RpcMethodStats {
|
||||
date: string;
|
||||
evmMethod: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface EngineCloudStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
@@ -72,10 +66,36 @@ export interface UniversalBridgeWalletStats {
|
||||
developerFeeUsdCents: number;
|
||||
}
|
||||
|
||||
export interface WebhookRequestStats {
|
||||
date: string;
|
||||
webhookId: string;
|
||||
httpStatusCode: number;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface WebhookLatencyStats {
|
||||
date: string;
|
||||
webhookId: string;
|
||||
p50LatencyMs: number;
|
||||
p90LatencyMs: number;
|
||||
p99LatencyMs: number;
|
||||
}
|
||||
|
||||
export interface WebhookSummaryStats {
|
||||
webhookId: string;
|
||||
totalRequests: number;
|
||||
successRequests: number;
|
||||
errorRequests: number;
|
||||
successRate: number;
|
||||
avgLatencyMs: number;
|
||||
errorBreakdown: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AnalyticsQueryParams {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,8 @@ export type ProductSKU =
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
| null;
|
||||
|
||||
export type ChainInfraSKU =
|
||||
| "chain:infra:rpc"
|
||||
| "chain:infra:insight"
|
||||
| "chain:infra:account_abstraction";
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { JSX } from "react";
|
||||
|
||||
const PLEASE_REACH_OUT_MESSAGE = (
|
||||
<span>
|
||||
If you believe this is incorrect or the error persists, please visit our{" "}
|
||||
If you believe this is incorrect or the error persists, please{" "}
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href="/support"
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
support site
|
||||
contact support
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
|
||||
@@ -23,7 +23,6 @@ export const TEAM_PLANS: Record<
|
||||
features: [
|
||||
"Email Support",
|
||||
"48hr Guaranteed Response",
|
||||
"Invite Team Members",
|
||||
"Custom In-App Wallet Auth",
|
||||
],
|
||||
price: 99,
|
||||
|
||||
+57
-27
@@ -1,5 +1,7 @@
|
||||
import { defineChain } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
@@ -53,7 +55,7 @@ export async function RouteListCard({
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full transition-colors hover:border-active-border">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
@@ -80,32 +82,60 @@ export async function RouteListCard({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<table className="w-full">
|
||||
<tbody className="text-sm [&_td>*]:min-h-[25px]">
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{originTokenName === "ETH"
|
||||
? originChain.nativeCurrency.name
|
||||
: originTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{destinationTokenName === "ETH"
|
||||
? destinationChain.nativeCurrency.name
|
||||
: destinationTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenName={originTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenName={destinationTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenName(props: {
|
||||
tokenAddress: string;
|
||||
tokenName: string;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
if (isERC20) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenName}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left font-normal text-base">
|
||||
{props.chainMetadata.nativeCurrency.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+68
-60
@@ -1,5 +1,14 @@
|
||||
import { defineChain, getChainMetadata } from "thirdweb/chains";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
type ChainMetadata,
|
||||
defineChain,
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
@@ -52,37 +61,14 @@ export async function RouteListRow({
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow className="group transition-colors hover:bg-accent/50" linkBox>
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={originTokenAddress}
|
||||
className="size-7 rounded-full border border-border/50 shadow-sm transition-transform group-hover:scale-105"
|
||||
src={resolvedOriginTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{originTokenSymbol && (
|
||||
<CopyTextButton
|
||||
className="relative z-10 font-medium text-base"
|
||||
copyIconPosition="right"
|
||||
textToCopy={originTokenAddress}
|
||||
textToShow={
|
||||
originTokenSymbol === "ETH"
|
||||
? originChain.nativeCurrency.symbol
|
||||
: originTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TokenInfo
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenIconUri={resolvedOriginTokenIconUri}
|
||||
tokenSymbol={originTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
@@ -90,34 +76,12 @@ export async function RouteListRow({
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={destinationTokenAddress}
|
||||
className="size-7 rounded-full border border-border/50 shadow-sm transition-transform group-hover:scale-105"
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{destinationTokenSymbol && (
|
||||
<CopyTextButton
|
||||
className="relative z-10 font-medium text-base"
|
||||
copyIconPosition="right"
|
||||
textToCopy={destinationTokenAddress}
|
||||
textToShow={
|
||||
destinationTokenSymbol === "ETH"
|
||||
? destinationChain.nativeCurrency.symbol
|
||||
: destinationTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TokenInfo
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenIconUri={resolvedDestinationTokenIconUri}
|
||||
tokenSymbol={destinationTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
@@ -126,3 +90,47 @@ export async function RouteListRow({
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenInfo(props: {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string | undefined;
|
||||
chainMetadata: ChainMetadata;
|
||||
tokenIconUri: string | undefined;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{props.tokenIconUri ? (
|
||||
<Img
|
||||
alt={props.tokenAddress}
|
||||
className="size-7 rounded-full border border-border/50"
|
||||
src={props.tokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{isERC20 ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-auto w-auto py-1 px-1.5 gap-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-medium text-sm px-1.5 py-1">
|
||||
{props.chainMetadata.nativeCurrency.symbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-15
@@ -86,22 +86,14 @@ export async function RoutesData(props: {
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer className="overflow-hidden rounded-xl border border-border/50 bg-card/50 shadow-sm transition-all">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader className="z-0">
|
||||
<TableRow className="border-border/50 border-b bg-muted/50">
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Origin Token
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Origin Chain
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Destination Token
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Destination Chain
|
||||
</TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Universal Bridge Routes: Swap, Bridge, and Onramp";
|
||||
const title = "Payments Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
@@ -66,7 +66,7 @@ export default async function RoutesPage(props: {
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with Universal Bridge
|
||||
Get Started with thirdweb Payments
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Address } from "thirdweb";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { Route } from "./types/route";
|
||||
@@ -10,21 +9,13 @@ export async function getRoutes({
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
originChainId,
|
||||
destinationChainId,
|
||||
originTokenAddress,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
destinationChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes`);
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
@@ -37,20 +28,7 @@ export async function getRoutes({
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
|
||||
+4
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { defineChain, type ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<section className="flex flex-col gap-4 items-center justify-center">
|
||||
<BuyWidget
|
||||
@@ -14,6 +17,7 @@ export function BuyFundsSection(props: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(props.chain.chainId)}
|
||||
client={props.client}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { SectionTitle } from "../server/SectionTitle";
|
||||
|
||||
export default function NextSteps(props: { chain: ChainMetadata }) {
|
||||
const { chain } = props;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Next Steps" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border">
|
||||
<FileTextIcon className="mt-0.5 size-5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="mb-1.5 font-medium">
|
||||
<Link
|
||||
className="before:absolute before:inset-0"
|
||||
href={
|
||||
"https://blog.thirdweb.com/supercharge-user-adoption-integrate-embedded-wallets-in-minutes/"
|
||||
}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create a login for {chain.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Supercharge User Adoption—Integrate In-App Wallets in Minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SectionTitle } from "./SectionTitle";
|
||||
|
||||
export function ClaimChainSection() {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Claim Chain" />
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="mb-1"> This chain page is unclaimed </p>
|
||||
<p className="mb-5 text-muted-foreground text-sm">
|
||||
Are you the owner of this ecosystem? Claim this page to add additional
|
||||
information on this page to help your developers get started!
|
||||
</p>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
className="gap-2"
|
||||
href="https://share.hsforms.com/1qAViW3U5SoKbabNhRZy4Kgea58c"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span>Claim Chain</span>
|
||||
<ExternalLinkIcon className="-mt-[3px] size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../../../@/utils/map-chains";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { ChainIcon } from "../../../../components/server/chain-icon";
|
||||
import { AddChainToWallet } from "../client/add-chain-to-wallet";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { mapV4ChainToV5Chain } from "../../../../../../@/utils/map-chains";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
|
||||
+23
-9
@@ -1,7 +1,8 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { useId } from "react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchChain } from "@/utils/fetchChain";
|
||||
|
||||
// Route segment config
|
||||
@@ -81,16 +82,29 @@ export default async function Image({
|
||||
fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
// download the chain icon if there is one
|
||||
chain.icon?.url && hasWorkingChainIcon
|
||||
? download({
|
||||
client: serverThirdwebClient,
|
||||
uri: chain.icon.url,
|
||||
}).then((res) => res.arrayBuffer())
|
||||
// download the chain icon if there is one and secret key is available
|
||||
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? (async () => {
|
||||
try {
|
||||
const client = getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
});
|
||||
const response = await download({
|
||||
client,
|
||||
uri: chain.icon?.url || "",
|
||||
});
|
||||
return response.arrayBuffer();
|
||||
} catch (error) {
|
||||
// If download fails, return undefined to fallback to no icon
|
||||
console.warn("Failed to download chain icon:", error);
|
||||
return undefined;
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
// download the background image (based on chain)
|
||||
fetch(
|
||||
chain.icon?.url && hasWorkingChainIcon
|
||||
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? new URL(
|
||||
"og-lib/assets/chain/bg-with-icon.png",
|
||||
|
||||
@@ -118,7 +132,7 @@ export default async function Image({
|
||||
/>
|
||||
{/* the actual component starts here */}
|
||||
|
||||
{hasWorkingChainIcon && (
|
||||
{hasWorkingChainIcon && chainIcon && (
|
||||
<img
|
||||
alt=""
|
||||
// @ts-expect-error - TS doesn't know about the ImageResponse component
|
||||
|
||||
@@ -3,9 +3,7 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { BuyFundsSection } from "./components/client/BuyFundsSection";
|
||||
import NextSteps from "./components/client/NextSteps";
|
||||
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
|
||||
import { ClaimChainSection } from "./components/server/ClaimChainSection";
|
||||
import { ChainCTA } from "./components/server/cta-card";
|
||||
import { ExplorersSection } from "./components/server/explorer-section";
|
||||
import { FaucetSection } from "./components/server/FaucetSection";
|
||||
@@ -60,11 +58,6 @@ export default async function Page(props: {
|
||||
{chain.services.filter((s) => s.enabled).length > 0 && (
|
||||
<SupportedProductsSection services={chain.services} />
|
||||
)}
|
||||
{/*Next Steps */}
|
||||
<NextSteps chain={chain} />
|
||||
|
||||
{/* Claim Chain */}
|
||||
{!chainMetadata && <ClaimChainSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
// biome-ignore-all lint/nursery/noNestedComponentDefinitions: TODO
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
@@ -256,7 +257,6 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
onClick={() => setTokenRow(row.original)}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="group"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
|
||||
+182
-187
@@ -130,6 +130,7 @@ const getClaimConditionTypeFromPhase = (
|
||||
|
||||
if (phase.snapshot) {
|
||||
if (
|
||||
phase.maxClaimablePerWallet?.toString() === "0" &&
|
||||
phase.price === "0" &&
|
||||
typeof phase.snapshot !== "string" &&
|
||||
phase.snapshot.length === 1 &&
|
||||
@@ -464,204 +465,198 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
|
||||
<Flex direction="column" gap={6}>
|
||||
{/* Show the reason why the form is disabled */}
|
||||
{!isAdmin && (
|
||||
<Text>Connect with admin wallet to edit claim conditions.</Text>
|
||||
)}
|
||||
{controlledFields.map((field, index) => {
|
||||
const dropType: DropType = field.snapshot
|
||||
? field.maxClaimablePerWallet?.toString() === "0"
|
||||
? "specific"
|
||||
: "overrides"
|
||||
: "any";
|
||||
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
|
||||
<Flex direction="column" gap={6}>
|
||||
{/* Show the reason why the form is disabled */}
|
||||
{!isAdmin && (
|
||||
<Text>Connect with admin wallet to edit claim conditions.</Text>
|
||||
)}
|
||||
{controlledFields.map((field, index) => {
|
||||
const dropType: DropType = field.snapshot
|
||||
? field.maxClaimablePerWallet?.toString() === "0"
|
||||
? "specific"
|
||||
: "overrides"
|
||||
: "any";
|
||||
|
||||
const claimConditionType = getClaimConditionTypeFromPhase(field);
|
||||
const claimConditionType = getClaimConditionTypeFromPhase(field);
|
||||
|
||||
const isActive = activePhaseId === field.id;
|
||||
const isActive = activePhaseId === field.id;
|
||||
|
||||
const snapshotValue = field.snapshot?.map((v) =>
|
||||
typeof v === "string"
|
||||
? {
|
||||
address: v,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
maxClaimable: "unlimited",
|
||||
price: "unlimited",
|
||||
}
|
||||
: {
|
||||
...v,
|
||||
currencyAddress: v?.currencyAddress || ZERO_ADDRESS,
|
||||
maxClaimable: v?.maxClaimable?.toString() || "unlimited",
|
||||
price: v?.price?.toString() || "unlimited",
|
||||
},
|
||||
);
|
||||
const snapshotValue = field.snapshot?.map((v) =>
|
||||
typeof v === "string"
|
||||
? {
|
||||
address: v,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
maxClaimable: "unlimited",
|
||||
price: "unlimited",
|
||||
}
|
||||
: {
|
||||
...v,
|
||||
currencyAddress: v?.currencyAddress || ZERO_ADDRESS,
|
||||
maxClaimable: v?.maxClaimable?.toString() || "unlimited",
|
||||
price: v?.price?.toString() || "unlimited",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={`snapshot_${field.id}_${index}`}>
|
||||
<SnapshotViewerSheet
|
||||
client={contract.client}
|
||||
dropType={dropType}
|
||||
isDisabled={!canEditForm}
|
||||
isOpen={openSnapshotIndex === index}
|
||||
onClose={() => {
|
||||
setOpenSnapshotIndex(-1);
|
||||
}}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
}
|
||||
value={snapshotValue}
|
||||
/>
|
||||
return (
|
||||
<Fragment key={`snapshot_${field.id}_${index}`}>
|
||||
<SnapshotViewerSheet
|
||||
client={contract.client}
|
||||
dropType={dropType}
|
||||
isDisabled={!canEditForm}
|
||||
isOpen={openSnapshotIndex === index}
|
||||
onClose={() => {
|
||||
setOpenSnapshotIndex(-1);
|
||||
}}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
}
|
||||
value={snapshotValue}
|
||||
/>
|
||||
|
||||
<ClaimsConditionFormContext.Provider
|
||||
value={{
|
||||
claimConditionType,
|
||||
dropType,
|
||||
field,
|
||||
form,
|
||||
formDisabled: !canEditForm,
|
||||
isActive,
|
||||
isAdmin,
|
||||
isColumn,
|
||||
isErc20,
|
||||
isMultiPhase,
|
||||
phaseIndex: index,
|
||||
setOpenSnapshotIndex,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
}}
|
||||
>
|
||||
<ClaimConditionsPhase
|
||||
contract={contract}
|
||||
isPending={sendTx.isPending}
|
||||
onRemove={() => {
|
||||
removePhase(index);
|
||||
}}
|
||||
/>
|
||||
</ClaimsConditionFormContext.Provider>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{phases?.length === 0 && (
|
||||
<Alert borderRadius="md" status="warning">
|
||||
<AlertIcon />
|
||||
<div className="flex flex-col">
|
||||
<AlertTitle as={Heading} size="label.lg">
|
||||
{isMultiPhase
|
||||
? "Missing Claim Phases"
|
||||
: "Missing Claim Conditions"}
|
||||
</AlertTitle>
|
||||
<AlertDescription as={Text}>
|
||||
{isMultiPhase
|
||||
? "You need to set at least one claim phase for people to claim this drop."
|
||||
: "You need to set claim conditions for people to claim this drop."}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<AdminOnly contract={contract}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
isDisabled={
|
||||
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
|
||||
}
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
size="sm"
|
||||
variant={phases?.length > 0 ? "outline" : "solid"}
|
||||
>
|
||||
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
zIndex="overlay"
|
||||
>
|
||||
{Object.keys(ClaimConditionTypeData).map((key) => {
|
||||
const type = key as ClaimConditionType;
|
||||
|
||||
if (type === "custom") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={type}
|
||||
onClick={() => {
|
||||
addPhase(type);
|
||||
// TODO: Automatically start editing the new phase after adding it
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{ClaimConditionTypeData[type].name}
|
||||
<TooltipBox
|
||||
content={
|
||||
<Text size="body.md">
|
||||
{ClaimConditionTypeData[type].description}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</AdminOnly>
|
||||
|
||||
{controlledFields.some((field) => field.fromSdk) && (
|
||||
<ResetClaimEligibility
|
||||
<ClaimsConditionFormContext.Provider
|
||||
value={{
|
||||
claimConditionType,
|
||||
dropType,
|
||||
field,
|
||||
form,
|
||||
formDisabled: !canEditForm,
|
||||
isActive,
|
||||
isAdmin,
|
||||
isColumn,
|
||||
isErc20,
|
||||
isMultiPhase,
|
||||
phaseIndex: index,
|
||||
setOpenSnapshotIndex,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
}}
|
||||
>
|
||||
<ClaimConditionsPhase
|
||||
contract={contract}
|
||||
isErc20={isErc20}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isMultiphase={isMultiPhase}
|
||||
tokenId={tokenId}
|
||||
isPending={sendTx.isPending}
|
||||
onRemove={() => {
|
||||
removePhase(index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ClaimsConditionFormContext.Provider>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-row">
|
||||
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
|
||||
<Flex alignItems="center" gap={3} justifyContent="center">
|
||||
{(hasRemovedPhases || hasAddedPhases) && (
|
||||
<Text color="red.500" fontWeight="bold">
|
||||
You have unsaved changes
|
||||
</Text>
|
||||
)}
|
||||
{controlledFields.length > 0 ||
|
||||
hasRemovedPhases ||
|
||||
!isMultiPhase ? (
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={claimConditionsQuery.isPending}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{claimConditionsQuery.isPending
|
||||
? "Saving Phases"
|
||||
: "Save Phases"}
|
||||
</TransactionButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</AdminOnly>
|
||||
{phases?.length === 0 && (
|
||||
<Alert borderRadius="md" status="warning">
|
||||
<AlertIcon />
|
||||
<div className="flex flex-col">
|
||||
<AlertTitle as={Heading} size="label.lg">
|
||||
{isMultiPhase
|
||||
? "Missing Claim Phases"
|
||||
: "Missing Claim Conditions"}
|
||||
</AlertTitle>
|
||||
<AlertDescription as={Text}>
|
||||
{isMultiPhase
|
||||
? "You need to set at least one claim phase for people to claim this drop."
|
||||
: "You need to set claim conditions for people to claim this drop."}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Flex>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<AdminOnly contract={contract}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
isDisabled={
|
||||
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
|
||||
}
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
size="sm"
|
||||
variant={phases?.length > 0 ? "outline" : "solid"}
|
||||
>
|
||||
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
|
||||
</MenuButton>
|
||||
<MenuList borderRadius="lg" overflow="hidden" zIndex="overlay">
|
||||
{Object.keys(ClaimConditionTypeData).map((key) => {
|
||||
const type = key as ClaimConditionType;
|
||||
|
||||
if (type === "custom") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={type}
|
||||
onClick={() => {
|
||||
addPhase(type);
|
||||
// TODO: Automatically start editing the new phase after adding it
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{ClaimConditionTypeData[type].name}
|
||||
<TooltipBox
|
||||
content={
|
||||
<Text size="body.md">
|
||||
{ClaimConditionTypeData[type].description}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</AdminOnly>
|
||||
|
||||
{controlledFields.some((field) => field.fromSdk) && (
|
||||
<ResetClaimEligibility
|
||||
contract={contract}
|
||||
isErc20={isErc20}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isMultiphase={isMultiPhase}
|
||||
tokenId={tokenId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
|
||||
<Flex alignItems="center" gap={3} justifyContent="center">
|
||||
{(hasRemovedPhases || hasAddedPhases) && (
|
||||
<Text color="red.500" fontWeight="bold">
|
||||
You have unsaved changes
|
||||
</Text>
|
||||
)}
|
||||
{controlledFields.length > 0 ||
|
||||
hasRemovedPhases ||
|
||||
!isMultiPhase ? (
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={claimConditionsQuery.isPending}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{claimConditionsQuery.isPending
|
||||
? "Saving Phases"
|
||||
: "Save Phases"}
|
||||
</TransactionButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</AdminOnly>
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+14
-5
@@ -1,9 +1,10 @@
|
||||
import { getAddress, getContract, isAddress } from "thirdweb";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
|
||||
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getContractPageParamsInfo(params: {
|
||||
contractAddress: string;
|
||||
@@ -18,13 +19,21 @@ export async function getContractPageParamsInfo(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// attempt to get the auth token
|
||||
// Create server client only if secret key is available
|
||||
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serverClient = getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
const serverContract = getContract({
|
||||
address: contractAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: mapV4ChainToV5Chain(chainMetadata),
|
||||
client: serverThirdwebClient,
|
||||
client: serverClient,
|
||||
});
|
||||
|
||||
const clientContract = getContract({
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
|
||||
import { getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import type { ProjectMeta } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types";
|
||||
import { redirectToContractLandingPage } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
|
||||
import { buildContractPagePath } from "../../_utils/contract-page-path";
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -320,7 +321,6 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
|
||||
}}
|
||||
opacity={failedToLoad ? 0.3 : 1}
|
||||
pointerEvents={failedToLoad ? "none" : "auto"}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="group"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
|
||||
import { getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getPublishedByCardProps, PublishedByUI } from "./published-by-ui";
|
||||
|
||||
interface PublishedByProps {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<div className="border-b bg-card">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { isOwnerSupported, owner } from "thirdweb/extensions/common";
|
||||
import {
|
||||
getRoleMember,
|
||||
isGetRoleAdminSupported,
|
||||
} from "thirdweb/extensions/permissions";
|
||||
|
||||
export async function getContractCreator(
|
||||
contract: ThirdwebContract,
|
||||
functionSelectors: string[],
|
||||
) {
|
||||
try {
|
||||
if (isOwnerSupported(functionSelectors)) {
|
||||
return await owner({
|
||||
contract,
|
||||
});
|
||||
}
|
||||
|
||||
if (isGetRoleAdminSupported(functionSelectors)) {
|
||||
return await getRoleMember({
|
||||
contract,
|
||||
index: BigInt(0),
|
||||
role: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+36
-8
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { BadgeContainer } from "../../../../../../../../@/storybook/utils";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import { SupplyClaimedProgress } from "./supply-claimed-progress";
|
||||
|
||||
const meta = {
|
||||
@@ -18,28 +17,57 @@ export const Variants: Story = {
|
||||
function StoryVariants() {
|
||||
return (
|
||||
<div className="container max-w-md space-y-10 py-10">
|
||||
<BadgeContainer label="5.5 / Unlimited Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={5.5}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 / Unlimited Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={maxUint256} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="500/1000 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={500}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="123.45/1000 Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={123.45}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={0}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/10 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={10}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 Supply - don't show anything">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
|
||||
<SupplyClaimedProgress claimedSupplyTokens={0} totalSupplyTokens={0} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
+12
-13
@@ -1,19 +1,18 @@
|
||||
import { InfinityIcon } from "lucide-react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { supplyFormatter } from "../nft/format";
|
||||
|
||||
export function SupplyClaimedProgress(props: {
|
||||
claimedSupply: bigint;
|
||||
totalSupply: bigint;
|
||||
claimedSupplyTokens: number;
|
||||
totalSupplyTokens: number | "unlimited";
|
||||
}) {
|
||||
// if total supply is unlimited
|
||||
if (props.totalSupply === maxUint256) {
|
||||
if (props.totalSupplyTokens === "unlimited") {
|
||||
return (
|
||||
<p className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">Claimed Supply </span>
|
||||
<span className="flex items-center gap-1 font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
<InfinityIcon aria-label="Unlimited" className="size-4" />
|
||||
</span>
|
||||
</p>
|
||||
@@ -21,29 +20,29 @@ export function SupplyClaimedProgress(props: {
|
||||
}
|
||||
|
||||
// if total supply is 0 - don't show anything
|
||||
if (props.totalSupply === 0n) {
|
||||
if (props.totalSupplyTokens === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// multiply by 10k to have precision up to 2 decimal places in percentage value
|
||||
const soldFractionTimes10KBigInt =
|
||||
(props.claimedSupply * 10000n) / props.totalSupply;
|
||||
|
||||
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
|
||||
const soldPercentage =
|
||||
(props.claimedSupplyTokens / props.totalSupplyTokens) * 100;
|
||||
|
||||
const percentToShow = soldPercentage.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Supply Claimed</span>
|
||||
<span className="font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupply)}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupplyTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress className="h-2.5" value={soldPercentage} />
|
||||
<p className="font-medium text-muted-foreground text-xs">
|
||||
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
|
||||
{soldPercentage.toFixed(2)}% Sold
|
||||
{percentToShow === "0.00" ? "~0.00%" : `${percentToShow}%`} Sold
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
try {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch token price data", await res.text());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
|
||||
return priceData;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+25
@@ -88,6 +88,25 @@ export const WithImageAndMultipleSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: mockTokenImage,
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
discord: mockSocialUrls.discord,
|
||||
github: mockSocialUrls.github,
|
||||
telegram: mockSocialUrls.telegram,
|
||||
twitter: mockSocialUrls.twitter,
|
||||
website: mockSocialUrls.website,
|
||||
},
|
||||
symbol: "SMPL",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithContractCreator: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: "0x1234567890123456789012345678901234567890",
|
||||
image: mockTokenImage,
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -105,6 +124,7 @@ export const WithBrokenImageAndSingleSocialUrl: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "broken-image.png",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -118,6 +138,7 @@ export const WithoutImageAndNoSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: undefined,
|
||||
name: "Sample Token",
|
||||
socialUrls: {},
|
||||
@@ -129,6 +150,7 @@ export const LongNameAndLotsOfSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "This is a very long token name that should wrap to multiple lines",
|
||||
socialUrls: {
|
||||
@@ -148,6 +170,7 @@ export const AllSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -171,6 +194,7 @@ export const InvalidSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -188,6 +212,7 @@ export const SomeSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
|
||||
+68
-43
@@ -1,7 +1,12 @@
|
||||
import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
Settings2Icon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -42,6 +47,8 @@ export function ContractHeaderUI(props: {
|
||||
clientContract: ThirdwebContract;
|
||||
socialUrls: object;
|
||||
imageClassName?: string;
|
||||
contractCreator: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const socialUrls = useMemo(() => {
|
||||
const socialUrlsValue: { name: string; href: string }[] = [];
|
||||
@@ -62,41 +69,45 @@ export function ContractHeaderUI(props: {
|
||||
?.replace("Mainnet", "")
|
||||
.trim();
|
||||
|
||||
const explorersToShow = getExplorersToShow(props.chainMetadata);
|
||||
const validBlockExplorer = getExplorerToShow(props.chainMetadata);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
|
||||
{props.image && (
|
||||
<Img
|
||||
className={cn(
|
||||
"size-20 shrink-0 rounded-full border bg-muted",
|
||||
props.imageClassName,
|
||||
)}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-3xl text-muted-foreground/80">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="border p-1 rounded-full bg-card">
|
||||
<Img
|
||||
className={cn("size-28 shrink-0 rounded-full bg-muted")}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-5xl text-muted-foreground/50 capitalize">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2.5 flex-1">
|
||||
{/* top row */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center">
|
||||
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tighter lg:text-5xl">
|
||||
{props.name}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 ">
|
||||
<Link
|
||||
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
|
||||
href={`/${props.chainMetadata.slug}`}
|
||||
@@ -111,12 +122,15 @@ export function ContractHeaderUI(props: {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card w-[30px] h-[30px] p-0 [&>span]:hidden [&>svg]:text-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="outline"
|
||||
/>
|
||||
{props.contractCreator &&
|
||||
validBlockExplorer &&
|
||||
props.contractCreator !== ZERO_ADDRESS && (
|
||||
<SocialLink
|
||||
href={`${validBlockExplorer.url}/address/${props.contractCreator}`}
|
||||
name="Contract Owner"
|
||||
icon={UserIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{socialUrls
|
||||
.toSorted((a, b) => {
|
||||
@@ -146,7 +160,15 @@ export function ContractHeaderUI(props: {
|
||||
</div>
|
||||
|
||||
{/* bottom row */}
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<div className="flex flex-col items-end sm:flex-row flex-wrap sm:items-center gap-2 absolute top-6 right-0 sm:static w-1/2 sm:w-full">
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card px-2.5 py-1.5 text-xs"
|
||||
copyIconPosition="left"
|
||||
tooltip="Copy contract address"
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[300px]"
|
||||
label={
|
||||
@@ -171,13 +193,13 @@ export function ContractHeaderUI(props: {
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
{explorersToShow?.map((validBlockExplorer) => (
|
||||
{validBlockExplorer && (
|
||||
<BadgeLink
|
||||
href={`${validBlockExplorer.url.endsWith("/") ? validBlockExplorer.url : `${validBlockExplorer.url}/`}address/${props.clientContract.address}`}
|
||||
key={validBlockExplorer.url}
|
||||
name={validBlockExplorer.name}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* TODO - render social links here */}
|
||||
</div>
|
||||
@@ -195,19 +217,22 @@ function isValidUrl(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getExplorersToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers
|
||||
?.filter((e) => e.standard === "EIP3091")
|
||||
?.slice(0, 2);
|
||||
function getExplorerToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers?.filter(
|
||||
(e) => e.standard === "EIP3091",
|
||||
);
|
||||
|
||||
return validBlockExplorers?.slice(0, 1);
|
||||
return validBlockExplorers?.[0];
|
||||
}
|
||||
|
||||
function BadgeLink(props: { name: string; href: string }) {
|
||||
function BadgeLink(props: { name: string; href: string; className?: string }) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize"
|
||||
className={cn(
|
||||
"!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize",
|
||||
props.className,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<Link href={props.href} rel="noopener noreferrer" target="_blank">
|
||||
|
||||
+26
-24
@@ -2,45 +2,47 @@
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
import { getSDKTheme } from "../../../../../../../../../@/utils/sdk-component-theme";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyTokenEmbed(props: {
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
tokenAddress: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<PayEmbed
|
||||
<BuyWidget
|
||||
amount="1"
|
||||
chain={props.chain}
|
||||
className="!rounded-xl !w-full"
|
||||
client={props.client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
}}
|
||||
payOptions={{
|
||||
metadata: {
|
||||
name: `Buy ${props.tokenSymbol}`,
|
||||
},
|
||||
mode: "fund_wallet",
|
||||
prefillBuy: {
|
||||
allowEdits: {
|
||||
amount: true,
|
||||
chain: false,
|
||||
token: false,
|
||||
},
|
||||
amount: "1",
|
||||
chain: props.chain,
|
||||
token: {
|
||||
address: props.tokenAddress,
|
||||
name: props.tokenName,
|
||||
symbol: props.tokenSymbol,
|
||||
},
|
||||
},
|
||||
onError={(e) => {
|
||||
const errorMessage = parseError(e);
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
});
|
||||
}}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
tokenAddress={props.tokenAddress as `0x${string}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+63
-112
@@ -6,13 +6,11 @@ import { useMemo, useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTokenPriceData } from "../_hooks/useTokenPriceData";
|
||||
import type { TokenPriceData } from "../_apis/token-price-data";
|
||||
|
||||
function PriceChartUI(props: {
|
||||
isPending: boolean;
|
||||
showTimeOfDay: boolean;
|
||||
data: Array<{
|
||||
date: string;
|
||||
@@ -29,7 +27,7 @@ function PriceChartUI(props: {
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-background p-0"
|
||||
className="border-none bg-transparent p-0"
|
||||
config={{
|
||||
price: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
@@ -38,7 +36,7 @@ function PriceChartUI(props: {
|
||||
}}
|
||||
data={data}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
isPending={false}
|
||||
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return tokenPriceUSDFormatter.format(value as number);
|
||||
@@ -49,7 +47,7 @@ function PriceChartUI(props: {
|
||||
|
||||
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 10,
|
||||
maximumFractionDigits: 8,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
@@ -92,20 +90,14 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
|
||||
export function TokenStats(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
tokenPriceData: TokenPriceData;
|
||||
}) {
|
||||
const tokenPriceQuery = useTokenPriceData(params);
|
||||
const [interval, setInterval] = useState<Interval>("max");
|
||||
|
||||
const tokenPriceData = tokenPriceQuery.data;
|
||||
|
||||
const filteredHistoricalPrices = useMemo(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (tokenPriceData?.type === "no-data") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tokenPriceData?.data?.historical_prices.filter((item) => {
|
||||
return params.tokenPriceData.historical_prices.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const maxDiff =
|
||||
interval === "24h"
|
||||
@@ -120,106 +112,75 @@ export function TokenStats(params: {
|
||||
|
||||
return differenceInCalendarDays(currentDate, date) <= maxDiff;
|
||||
});
|
||||
}, [tokenPriceData, interval]);
|
||||
}, [params.tokenPriceData, interval]);
|
||||
|
||||
const priceUsd =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.price_usd;
|
||||
const priceUsd = params.tokenPriceData.price_usd;
|
||||
const percentChange24h = params.tokenPriceData.percent_change_24h;
|
||||
const marketCap = params.tokenPriceData.market_cap_usd;
|
||||
const holders = params.tokenPriceData.holders;
|
||||
|
||||
const percentChange24h =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.percent_change_24h;
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(percentChange24h),
|
||||
);
|
||||
|
||||
const marketCap =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.market_cap_usd;
|
||||
const isAlmostZeroChange =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
const holders =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.holders;
|
||||
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-card rounded-lg border">
|
||||
{/* price and change */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonContainer
|
||||
loadedData={priceUsd}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-bold text-4xl tracking-tight">
|
||||
{typeof v === "number"
|
||||
? tokenPriceUSDFormatter.format(v)
|
||||
: v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={10.0001}
|
||||
/>
|
||||
<SkeletonContainer
|
||||
loadedData={percentChange24h}
|
||||
render={(data) => {
|
||||
if (typeof data === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(data),
|
||||
);
|
||||
|
||||
const isAlmostZero =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="gap-2 text-sm"
|
||||
variant={
|
||||
isAlmostZero
|
||||
? "default"
|
||||
: data > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZero ? null : data > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZero ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
);
|
||||
}}
|
||||
skeletonData={0.001}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold text-3xl lg:text-4xl tracking-tight",
|
||||
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
|
||||
)}
|
||||
>
|
||||
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
|
||||
</p>
|
||||
<Badge
|
||||
className="gap-2 lg:text-sm text-xs px-1.5"
|
||||
variant={
|
||||
isAlmostZeroChange
|
||||
? "default"
|
||||
: percentChange24h > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalSelector interval={interval} setInterval={setInterval} />
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
isPending={tokenPriceQuery.isPending}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
<div className="px-0">
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex gap-6 border-y border-dashed py-4 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:border-dashed [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<TokenStat
|
||||
label="Market Cap"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
|
||||
value={
|
||||
typeof marketCap === "number"
|
||||
@@ -230,7 +191,6 @@ export function TokenStats(params: {
|
||||
|
||||
<TokenStat
|
||||
label="Number of Holders"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
|
||||
value={
|
||||
// show 0 value as N/A
|
||||
@@ -246,9 +206,8 @@ export function TokenStats(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenStat<T extends string | number>(props: {
|
||||
value: T | undefined;
|
||||
skeletonData: T;
|
||||
function TokenStat(props: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}) {
|
||||
@@ -261,17 +220,9 @@ function TokenStat<T extends string | number>(props: {
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<SkeletonContainer
|
||||
loadedData={props.value}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={props.skeletonData}
|
||||
/>
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{props.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -296,7 +247,7 @@ function IntervalSelector(props: {
|
||||
{Object.entries(intervals).map(([key, value]) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
|
||||
props.interval === key && "border-active-border bg-accent",
|
||||
)}
|
||||
key={key}
|
||||
|
||||
+21
-23
@@ -47,8 +47,8 @@ function RecentTransfersUI(props: {
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
Recent Transfers
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -57,7 +57,7 @@ function RecentTransfersUI(props: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-b-none">
|
||||
<TableContainer className="rounded-b-none rounded-t-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -159,7 +159,7 @@ function RecentTransfersUI(props: {
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.page === 0 || props.isPending}
|
||||
onClick={() => props.setPage(props.page - 1)}
|
||||
size="sm"
|
||||
@@ -169,7 +169,7 @@ function RecentTransfersUI(props: {
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.isPending || props.data.length < props.rowsPerPage}
|
||||
onClick={() => props.setPage(props.page + 1)}
|
||||
size="sm"
|
||||
@@ -222,23 +222,21 @@ export function RecentTransfers(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-6
@@ -26,7 +26,7 @@ import {
|
||||
getApprovalForTransaction,
|
||||
} from "thirdweb/extensions/erc20";
|
||||
import { useActiveAccount, useSendTransaction } from "thirdweb/react";
|
||||
import { getClaimParams } from "thirdweb/utils";
|
||||
import { getClaimParams, maxUint256 } from "thirdweb/utils";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
@@ -39,8 +39,8 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
import { getSDKTheme } from "../../../../../../../../../../@/utils/sdk-component-theme";
|
||||
import { PublicPageConnectButton } from "../../../_components/PublicPageConnectButton";
|
||||
import { SupplyClaimedProgress } from "../../../_components/supply-claimed-progress";
|
||||
import { TokenPrice } from "../../../_components/token-price";
|
||||
@@ -325,12 +325,19 @@ export function TokenDropClaim(props: {
|
||||
<div className="h-4" />
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(
|
||||
claimedSupplyTokens={Number(
|
||||
toTokens(props.claimCondition.supplyClaimed, props.decimals),
|
||||
)}
|
||||
totalSupply={BigInt(
|
||||
toTokens(props.claimCondition.maxClaimableSupply, props.decimals),
|
||||
)}
|
||||
totalSupplyTokens={
|
||||
props.claimCondition.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: Number(
|
||||
toTokens(
|
||||
props.claimCondition.maxClaimableSupply,
|
||||
props.decimals,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
+33
-33
@@ -29,38 +29,38 @@ export function ContractAnalyticsOverview(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
View trends in unique wallets, transactions, and events over time for
|
||||
this contract
|
||||
</p>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-background p-0"
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-3))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-2))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.2] lg:aspect-[3]"
|
||||
cardContentClassName="px-0 pb-0"
|
||||
margin={{
|
||||
bottom: 20,
|
||||
}}
|
||||
header={{
|
||||
headerClassName: "p-4 lg:p-6",
|
||||
title: "Analytics",
|
||||
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
|
||||
description:
|
||||
"View trends of transactions, events and unique wallets interacting with this contract over time",
|
||||
}}
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-5))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-8))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user