Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a20cb7d5 | ||
|
|
e13b9a784a | ||
|
|
da4a4a540a | ||
|
|
5e5c82fb2e | ||
|
|
1d28988253 | ||
|
|
5352152835 | ||
|
|
decbbb8f7a | ||
|
|
ecd5ae1feb | ||
|
|
1caf6dd460 | ||
|
|
19204e4654 | ||
|
|
674c8d5e6c | ||
|
|
1eb0cd9314 | ||
|
|
b7dfde2d68 | ||
|
|
b50e304e0c | ||
|
|
9baa251bb1 | ||
|
|
7f36303b0b | ||
|
|
13a46b7ff6 | ||
|
|
b9d93e44ca | ||
|
|
fc9e81f99a | ||
|
|
281759f06a | ||
|
|
2d98fe2799 | ||
|
|
93bcdc557c | ||
|
|
d38deecaa1 | ||
|
|
a3ea002afd | ||
|
|
6390052f8b | ||
|
|
621f187a8a | ||
|
|
36ff327744 | ||
|
|
f68c2c62a5 | ||
|
|
5766c90fb4 | ||
|
|
8d4b6a1fea | ||
|
|
49b80fe87f | ||
|
|
5ca4d7497b | ||
|
|
0382d42a99 | ||
|
|
d35cad8655 | ||
|
|
c1275a5e38 | ||
|
|
277874594b | ||
|
|
4fc02b3618 | ||
|
|
26a91537d2 | ||
|
|
a3ff7572d6 | ||
|
|
01004e8408 | ||
|
|
f497b614cb | ||
|
|
f344fb5fbd | ||
|
|
9f265bf734 | ||
|
|
e5519eaef9 | ||
|
|
805b838fd3 | ||
|
|
ec73f1ff83 | ||
|
|
245e9a688c | ||
|
|
8bdc0c0c27 | ||
|
|
9c04b3f180 | ||
|
|
800503da68 | ||
|
|
0ff8b9ff60 | ||
|
|
304e4e5be8 | ||
|
|
fdd2557663 | ||
|
|
56a1c0a467 | ||
|
|
6e87b9205f | ||
|
|
9383ccdf4e | ||
|
|
ac326e24f1 | ||
|
|
7ebc32a0c4 | ||
|
|
5f0e25b5a8 | ||
|
|
aad3e6477e | ||
|
|
4bab6f1ae8 | ||
|
|
f9a435051d | ||
|
|
27d72e5d3e | ||
|
|
151127d668 | ||
|
|
759f41b11e | ||
|
|
5ec84bd3b6 | ||
|
|
38f4a89076 | ||
|
|
31e24f1c58 | ||
|
|
2bb6a4f9a3 | ||
|
|
e281ed185e | ||
|
|
116975286f | ||
|
|
ff8f4130e9 | ||
|
|
ffd8c402f4 | ||
|
|
d365efe997 | ||
|
|
1558b48056 | ||
|
|
3fba7e4e88 | ||
|
|
57ccadbef2 | ||
|
|
20e41ee164 | ||
|
|
f5ab57f365 | ||
|
|
01dce0e581 | ||
|
|
18425f9d74 | ||
|
|
bd2761af15 | ||
|
|
f3d2739df1 | ||
|
|
1aa4912b17 | ||
|
|
7967d7f17e | ||
|
|
1487de6808 | ||
|
|
18917b632a | ||
|
|
41919024c3 |
@@ -67,5 +67,5 @@ STRIPE_SECRET_KEY=""
|
||||
GROWTH_PLAN_SKU=""
|
||||
PAYMENT_METHOD_CONFIGURATION=""
|
||||
|
||||
# required for bridge iframe (/bridge/embed) page
|
||||
# required for bridge iframe (/bridge/widget) page
|
||||
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID=""
|
||||
@@ -150,7 +150,7 @@ const baseNextConfig: NextConfig = {
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/embed",
|
||||
source: "/bridge/widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
@@ -159,7 +159,61 @@ const baseNextConfig: NextConfig = {
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/embed/:path*",
|
||||
source: "/bridge/widget/:path*",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/checkout-widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/checkout-widget/:path*",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/swap-widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/swap-widget/:path*",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/buy-widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/buy-widget/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -1,132 +1,132 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@stripe/react-stripe-js": "4.0.2",
|
||||
"@stripe/stripe-js": "7.9.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/api": "workspace:*",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"abitype": "1.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^7.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.3",
|
||||
"remark-gfm": "4.0.1",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.6",
|
||||
"@next/eslint-plugin-next": "15.3.6",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "thirdweb-dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@stripe/react-stripe-js": "4.0.2",
|
||||
"@stripe/stripe-js": "7.9.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/api": "workspace:*",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"abitype": "1.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^7.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.3",
|
||||
"remark-gfm": "4.0.1",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.8",
|
||||
"@next/eslint-plugin-next": "15.3.8",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "thirdweb-dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -109,6 +109,18 @@ export async function getChainInfraCheckoutURL(options: {
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (
|
||||
"error" in json &&
|
||||
"message" in json.error &&
|
||||
typeof json.error.message === "string"
|
||||
) {
|
||||
return {
|
||||
error: json.error.message,
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
|
||||
@@ -683,7 +683,7 @@ export function reportProductFeedback(properties: {
|
||||
*
|
||||
*/
|
||||
export function reportBridgePageLinkClick(params: {
|
||||
linkType: "bridge-docs" | "trending-tokens";
|
||||
linkType: "bridge-docs" | "trending-tokens" | "integrate-bridge-widget";
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
export type Partner = {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
permissions: [PartnerPermission];
|
||||
|
||||
@@ -6,13 +6,16 @@ export const StatCard: React.FC<{
|
||||
icon: React.FC<{ className?: string }>;
|
||||
formatter?: (value: number) => string;
|
||||
isPending: boolean;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
|
||||
emptyText?: string;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending, emptyText }) => {
|
||||
return (
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
|
||||
<div>
|
||||
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
|
||||
{isPending ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : emptyText ? (
|
||||
<span className="text-muted-foreground">{emptyText}</span>
|
||||
) : value !== undefined && formatter ? (
|
||||
formatter(value)
|
||||
) : (
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { BuyWidget, SwapWidget } from "thirdweb/react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { BridgeWidget, type SupportedFiatCurrency } from "thirdweb/react";
|
||||
import type { Wallet } from "thirdweb/wallets";
|
||||
import {
|
||||
reportAssetBuyCancelled,
|
||||
@@ -18,14 +17,12 @@ import {
|
||||
reportTokenSwapFailed,
|
||||
reportTokenSwapSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
|
||||
} from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { appMetadata } from "../../constants/connect";
|
||||
@@ -34,6 +31,7 @@ import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
|
||||
type PageType = "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
|
||||
export type BuyAndSwapEmbedProps = {
|
||||
persistTokenSelections?: boolean;
|
||||
buyTab:
|
||||
| {
|
||||
buyToken:
|
||||
@@ -65,11 +63,12 @@ export type BuyAndSwapEmbedProps = {
|
||||
| undefined;
|
||||
pageType: PageType;
|
||||
wallets?: Wallet[];
|
||||
currency?: SupportedFiatCurrency;
|
||||
showThirdwebBranding?: boolean;
|
||||
};
|
||||
|
||||
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
|
||||
const isMounted = useRef(false);
|
||||
|
||||
@@ -102,208 +101,161 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
}, [props.pageType]);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl min-w-0">
|
||||
<div className="flex gap-2.5 p-4 border-b border-dashed">
|
||||
<TabButton
|
||||
label="Swap"
|
||||
onClick={() => setTab("swap")}
|
||||
isActive={tab === "swap"}
|
||||
/>
|
||||
<TabButton
|
||||
label="Buy"
|
||||
onClick={() => setTab("buy")}
|
||||
isActive={tab === "buy"}
|
||||
/>
|
||||
</div>
|
||||
<BridgeWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="z-10 min-w-0 shadow-xl"
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
buy={
|
||||
props.buyTab
|
||||
? {
|
||||
amount: props.buyTab.buyToken?.amount,
|
||||
chainId: props.buyTab.buyToken?.chainId,
|
||||
tokenAddress: props.buyTab.buyToken?.tokenAddress,
|
||||
onError: (e, quote) => {
|
||||
const errorMessage = parseError(e);
|
||||
|
||||
{tab === "buy" && (
|
||||
<BuyWidget
|
||||
amount={props.buyTab?.buyToken?.amount || "1"}
|
||||
chain={
|
||||
props.buyTab?.buyToken?.chainId
|
||||
? defineChain(props.buyTab.buyToken.chainId)
|
||||
: undefined
|
||||
}
|
||||
tokenAddress={
|
||||
props.buyTab?.buyToken?.tokenAddress as `0x${string}` | undefined
|
||||
}
|
||||
className="!rounded-2xl !border-none"
|
||||
title=""
|
||||
client={client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
onError={(e, quote) => {
|
||||
const errorMessage = parseError(e);
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCancel: (quote) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuyCancelled({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: ({ quote }) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={(quote) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuyCancelled({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSuccess={({ quote }) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
theme={themeObj}
|
||||
paymentMethods={["card"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "swap" && (
|
||||
<SwapWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none"
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
prefill={{
|
||||
buyToken: props.swapTab?.buyToken,
|
||||
sellToken: props.swapTab?.sellToken,
|
||||
}}
|
||||
onError={(error, quote) => {
|
||||
const errorMessage = parseError(error);
|
||||
reportTokenSwapFailed({
|
||||
errorMessage: errorMessage,
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onCancel={(quote) => {
|
||||
reportTokenSwapCancelled({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={props.onClick}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-full text-muted-foreground px-4 bg-accent",
|
||||
props.isActive && "text-foreground border-foreground/50",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
: undefined
|
||||
}
|
||||
swap={{
|
||||
persistTokenSelections: props.persistTokenSelections,
|
||||
prefill: {
|
||||
buyToken: props.swapTab?.buyToken,
|
||||
sellToken: props.swapTab?.sellToken,
|
||||
},
|
||||
onError: (error, quote) => {
|
||||
const errorMessage = parseError(error);
|
||||
reportTokenSwapFailed({
|
||||
errorMessage: errorMessage,
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
},
|
||||
onCancel: (quote) => {
|
||||
reportTokenSwapCancelled({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
},
|
||||
}}
|
||||
currency={props.currency}
|
||||
showThirdwebBranding={props.showThirdwebBranding}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export const SelectWithSearch = React.forwardRef<
|
||||
? renderOption(selectedOption)
|
||||
: selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 cursor-pointer text-muted-foreground" />
|
||||
<ChevronDownIcon className="size-3.5 cursor-pointer text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
DollarSignIcon,
|
||||
RefreshCwIcon,
|
||||
UserIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { WalletUser } from "thirdweb/wallets";
|
||||
import { StatCard } from "@/components/analytics/stat";
|
||||
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { TWTable } from "@/components/blocks/TWTable";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
ToolTipLabel,
|
||||
@@ -22,6 +33,10 @@ import {
|
||||
useAllEmbeddedWallets,
|
||||
useEmbeddedWallets,
|
||||
} from "@/hooks/useEmbeddedWallets";
|
||||
import {
|
||||
useFetchAllPortfolios,
|
||||
type WalletPortfolioData,
|
||||
} from "@/hooks/useWalletPortfolio";
|
||||
import { CopyTextButton } from "../ui/CopyTextButton";
|
||||
import { AdvancedSearchInput } from "./AdvancedSearchInput";
|
||||
import { SearchResults } from "./SearchResults";
|
||||
@@ -74,6 +89,101 @@ export function UserWalletsTable(
|
||||
| { ecosystemSlug: string; projectClientId?: never }
|
||||
),
|
||||
) {
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearchResults, setHasSearchResults] = useState(false);
|
||||
|
||||
// Portfolio state
|
||||
const [selectedChains, setSelectedChains] = useState<number[]>([1]); // Default to Ethereum
|
||||
const [portfolioMap, setPortfolioMap] = useState<
|
||||
Map<string, WalletPortfolioData>
|
||||
>(new Map());
|
||||
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
|
||||
const [fetchProgress, setFetchProgress] = useState({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const walletsQuery = useEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
teamId: props.teamId,
|
||||
page: activePage,
|
||||
});
|
||||
const wallets = walletsQuery?.data?.users || [];
|
||||
const { mutateAsync: getAllEmbeddedWallets, isPending: isLoadingAllWallets } =
|
||||
useAllEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
const fetchPortfoliosMutation = useFetchAllPortfolios();
|
||||
|
||||
const handleFetchBalances = useCallback(async () => {
|
||||
if (selectedChains.length === 0) return;
|
||||
|
||||
try {
|
||||
// First get all wallets
|
||||
const allWallets = await getAllEmbeddedWallets({
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
const allAddresses = allWallets
|
||||
.map((w) => w.wallets[0]?.address)
|
||||
.filter((a): a is string => !!a);
|
||||
|
||||
if (allAddresses.length === 0) {
|
||||
setPortfolioLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchProgress({ completed: 0, total: allAddresses.length });
|
||||
|
||||
const results = await fetchPortfoliosMutation.mutateAsync({
|
||||
addresses: allAddresses,
|
||||
chainIds: selectedChains,
|
||||
authToken: props.authToken,
|
||||
teamId: props.teamId,
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
onProgress: (completed, total) => {
|
||||
setFetchProgress({ completed, total });
|
||||
},
|
||||
});
|
||||
|
||||
setPortfolioMap(results);
|
||||
setPortfolioLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balances:", error);
|
||||
}
|
||||
}, [
|
||||
selectedChains,
|
||||
getAllEmbeddedWallets,
|
||||
props.projectClientId,
|
||||
props.ecosystemSlug,
|
||||
props.teamId,
|
||||
props.authToken,
|
||||
fetchPortfoliosMutation,
|
||||
]);
|
||||
|
||||
const isFetchingBalances =
|
||||
isLoadingAllWallets || fetchPortfoliosMutation.isPending;
|
||||
|
||||
const aggregatedStats = useMemo(() => {
|
||||
let fundedWallets = 0;
|
||||
let totalValue = 0;
|
||||
portfolioMap.forEach((data) => {
|
||||
if (data.totalUsdValue > 0) {
|
||||
fundedWallets++;
|
||||
totalValue += data.totalUsdValue;
|
||||
}
|
||||
});
|
||||
return { fundedWallets, totalValue };
|
||||
}, [portfolioMap]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
columnHelper.accessor("id", {
|
||||
@@ -129,6 +239,79 @@ export function UserWalletsTable(
|
||||
header: "Address",
|
||||
id: "address",
|
||||
}),
|
||||
columnHelper.accessor("wallets", {
|
||||
id: "total_balance",
|
||||
header: "Total Balance",
|
||||
cell: (cell) => {
|
||||
const address = cell.getValue()[0]?.address;
|
||||
if (!address) return "N/A";
|
||||
if (!portfolioLoaded) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const data = portfolioMap.get(address);
|
||||
if (!data) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(data.totalUsdValue)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("wallets", {
|
||||
id: "tokens",
|
||||
header: "Tokens",
|
||||
cell: (cell) => {
|
||||
const address = cell.getValue()[0]?.address;
|
||||
if (!address) return "N/A";
|
||||
if (!portfolioLoaded) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const data = portfolioMap.get(address);
|
||||
if (!data || data.tokens.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">None</span>;
|
||||
}
|
||||
|
||||
const topTokens = data.tokens
|
||||
.sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0))
|
||||
.slice(0, 3)
|
||||
.map((t) => t.symbol)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="text-sm">
|
||||
{topTokens}
|
||||
{data.tokens.length > 3 ? "..." : ""}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.tokens.map((t) => (
|
||||
<div
|
||||
key={`${t.tokenAddress}-${t.chainId}`}
|
||||
className="flex justify-between gap-4 text-xs"
|
||||
>
|
||||
<span>{t.symbol}</span>
|
||||
<span>
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(t.usdValue || 0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("linkedAccounts", {
|
||||
cell: (cell) => {
|
||||
const email = getPrimaryEmail(cell.getValue());
|
||||
@@ -201,24 +384,7 @@ export function UserWalletsTable(
|
||||
id: "login_methods",
|
||||
}),
|
||||
];
|
||||
}, [props.client]);
|
||||
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearchResults, setHasSearchResults] = useState(false);
|
||||
const walletsQuery = useEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
teamId: props.teamId,
|
||||
page: activePage,
|
||||
});
|
||||
const wallets = walletsQuery?.data?.users || [];
|
||||
const { mutateAsync: getAllEmbeddedWallets, isPending } =
|
||||
useAllEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
});
|
||||
}, [props.client, portfolioMap, portfolioLoaded]);
|
||||
|
||||
const handleSearch = async (searchType: SearchType, query: string) => {
|
||||
setIsSearching(true);
|
||||
@@ -315,11 +481,11 @@ export function UserWalletsTable(
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
disabled={wallets.length === 0 || isLoadingAllWallets}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
{isLoadingAllWallets && <Spinner className="size-4" />}
|
||||
Download as .csv
|
||||
</Button>
|
||||
</div>
|
||||
@@ -330,6 +496,83 @@ export function UserWalletsTable(
|
||||
<SearchResults results={searchResults} client={props.client} />
|
||||
) : (
|
||||
<>
|
||||
{/* Chain Selector and Fetch Button */}
|
||||
<div className="flex items-center gap-3 px-4 lg:px-6 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<MultiNetworkSelector
|
||||
client={props.client}
|
||||
selectedChainIds={selectedChains}
|
||||
onChange={setSelectedChains}
|
||||
disableChainId
|
||||
hideTestnets
|
||||
popoverContentClassName="max-h-[300px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleFetchBalances()}
|
||||
disabled={
|
||||
isFetchingBalances ||
|
||||
selectedChains.length === 0 ||
|
||||
walletsQuery.isPending
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{isFetchingBalances ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
{isFetchingBalances
|
||||
? `Fetching... ${fetchProgress.total > 0 ? Math.round((fetchProgress.completed / fetchProgress.total) * 100) : 0}%`
|
||||
: "Fetch All Balances"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetchingBalances && (
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-[150px]">
|
||||
{fetchProgress.total > 0 && (
|
||||
<Progress
|
||||
value={
|
||||
(fetchProgress.completed / fetchProgress.total) * 100
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This may take a few minutes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portfolioLoaded && !isFetchingBalances && (
|
||||
<Badge variant="success" className="ml-auto">
|
||||
Balances loaded for {portfolioMap.size} wallets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="grid grid-cols-2 gap-4 px-4 lg:px-6 py-4">
|
||||
<StatCard
|
||||
label="Funded Wallets"
|
||||
value={portfolioLoaded ? aggregatedStats.fundedWallets : 0}
|
||||
icon={WalletIcon}
|
||||
isPending={isFetchingBalances}
|
||||
emptyText={!portfolioLoaded ? "—" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Value"
|
||||
value={portfolioLoaded ? aggregatedStats.totalValue : 0}
|
||||
icon={DollarSignIcon}
|
||||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(value)
|
||||
}
|
||||
isPending={isFetchingBalances}
|
||||
emptyText={!portfolioLoaded ? "—" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<TWTable
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
|
||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogOverlay className={dialogOverlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-xl",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
|
||||
@@ -44,3 +44,12 @@ export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
|
||||
|
||||
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID;
|
||||
|
||||
@@ -330,20 +330,16 @@ export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
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);
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
return res.data;
|
||||
|
||||
@@ -177,33 +177,63 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
}) => {
|
||||
const responses: WalletUser[] = [];
|
||||
let page = 1;
|
||||
let consecutiveFailures = 0;
|
||||
const maxConsecutiveFailures = 3;
|
||||
|
||||
while (true) {
|
||||
const res = await queryClient.fetchQuery<{
|
||||
users: WalletUser[];
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
while (consecutiveFailures < maxConsecutiveFailures) {
|
||||
try {
|
||||
const res = await queryClient.fetchQuery<{
|
||||
users: WalletUser[];
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) =>
|
||||
Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||
});
|
||||
|
||||
responses.push(...res.users);
|
||||
responses.push(...res.users);
|
||||
consecutiveFailures = 0; // Reset on success
|
||||
|
||||
if (!res.hasMore) {
|
||||
break;
|
||||
if (!res.hasMore) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} catch (error) {
|
||||
consecutiveFailures++;
|
||||
console.warn(
|
||||
`Failed to fetch page ${page}, attempt ${consecutiveFailures}/${maxConsecutiveFailures}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
if (consecutiveFailures >= maxConsecutiveFailures) {
|
||||
// If we have some data, return it instead of throwing
|
||||
if (responses.length > 0) {
|
||||
console.warn(
|
||||
`Returning partial results (${responses.length} users) after ${maxConsecutiveFailures} consecutive failures`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retrying the same page
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1000 * consecutiveFailures),
|
||||
);
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return responses;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { GetBalanceResult } from "thirdweb/extensions/erc20";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
type WalletPortfolioToken = GetBalanceResult & {
|
||||
usdValue?: number;
|
||||
priceUsd?: number;
|
||||
};
|
||||
|
||||
export type WalletPortfolioData = {
|
||||
totalUsdValue: number;
|
||||
tokens: WalletPortfolioToken[];
|
||||
};
|
||||
|
||||
// Retry helper with exponential backoff - returns null on failure instead of throwing
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
maxRetries = 3,
|
||||
): Promise<Response | null> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Retry on rate limit (429) or server errors (5xx)
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
const delay = Math.min(1000 * 2 ** attempt, 10000); // Max 10s delay
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (_e) {
|
||||
// Network error - retry with backoff
|
||||
const delay = Math.min(1000 * 2 ** attempt, 10000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed - return null instead of throwing
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch tokens for a single address on a single chain
|
||||
async function fetchTokensForChain(
|
||||
address: string,
|
||||
chainId: number,
|
||||
authToken: string,
|
||||
teamId: string,
|
||||
clientId?: string,
|
||||
ecosystemSlug?: string,
|
||||
): Promise<WalletPortfolioToken[]> {
|
||||
try {
|
||||
const url = new URL(`${THIRDWEB_API_HOST}/v1/wallets/${address}/tokens`);
|
||||
url.searchParams.set("chainId", chainId.toString());
|
||||
url.searchParams.set("limit", "50");
|
||||
url.searchParams.set("metadata", "true");
|
||||
url.searchParams.set("includeSpam", "false");
|
||||
url.searchParams.set("includeNative", "true");
|
||||
url.searchParams.set("sortBy", "usd_value");
|
||||
url.searchParams.set("sortOrder", "desc");
|
||||
|
||||
const response = await fetchWithRetry(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-thirdweb-team-id": teamId,
|
||||
...(clientId && { "x-client-id": clientId }),
|
||||
...(ecosystemSlug && {
|
||||
"x-ecosystem-id": `ecosystem.${ecosystemSlug}`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// If all retries failed or response not ok, return empty (balance = 0)
|
||||
if (!response || !response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawTokens = data.result?.tokens || [];
|
||||
|
||||
return rawTokens.map(
|
||||
(t: {
|
||||
balance: string;
|
||||
decimals: number;
|
||||
name: string;
|
||||
symbol: string;
|
||||
token_address: string;
|
||||
chain_id: number;
|
||||
price_data?: { usd_value?: number; price_usd?: number };
|
||||
}): WalletPortfolioToken => ({
|
||||
value: BigInt(t.balance),
|
||||
decimals: t.decimals,
|
||||
displayValue: (Number(t.balance) / 10 ** t.decimals).toString(),
|
||||
name: t.name,
|
||||
symbol: t.symbol,
|
||||
tokenAddress: t.token_address,
|
||||
chainId: t.chain_id,
|
||||
usdValue: t.price_data?.usd_value,
|
||||
priceUsd: t.price_data?.price_usd,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
// Silent fail for individual chain - continue with others
|
||||
console.warn(
|
||||
`Failed to fetch tokens for ${address} on chain ${chainId}:`,
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch tokens for a single address on selected chains
|
||||
async function fetchWalletPortfolio(
|
||||
address: string,
|
||||
chainIds: number[],
|
||||
authToken: string,
|
||||
teamId: string,
|
||||
clientId?: string,
|
||||
ecosystemSlug?: string,
|
||||
): Promise<WalletPortfolioData> {
|
||||
// Fetch all chains concurrently for this address
|
||||
const results = await Promise.all(
|
||||
chainIds.map((chainId) =>
|
||||
fetchTokensForChain(
|
||||
address,
|
||||
chainId,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const allTokens = results.flat();
|
||||
const totalUsdValue = allTokens.reduce(
|
||||
(acc, token) => acc + (token.usdValue || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalUsdValue,
|
||||
tokens: allTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Batch fetch portfolios for all addresses with progress callback
|
||||
async function fetchAllPortfolios(
|
||||
addresses: string[],
|
||||
chainIds: number[],
|
||||
authToken: string,
|
||||
teamId: string,
|
||||
clientId?: string,
|
||||
ecosystemSlug?: string,
|
||||
onProgress?: (completed: number, total: number) => void,
|
||||
): Promise<Map<string, WalletPortfolioData>> {
|
||||
const results = new Map<string, WalletPortfolioData>();
|
||||
const concurrency = 50; // Process up to 50 addresses concurrently
|
||||
let completed = 0;
|
||||
let index = 0;
|
||||
|
||||
// Worker function that processes one address at a time
|
||||
async function worker() {
|
||||
while (index < addresses.length) {
|
||||
const currentIndex = index++;
|
||||
const address = addresses[currentIndex];
|
||||
if (!address) continue;
|
||||
|
||||
try {
|
||||
const data = await fetchWalletPortfolio(
|
||||
address,
|
||||
chainIds,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
);
|
||||
results.set(address, data);
|
||||
} catch (e) {
|
||||
// Silent fail - continue with others
|
||||
console.warn(`Failed to fetch portfolio for ${address}:`, e);
|
||||
}
|
||||
|
||||
completed++;
|
||||
onProgress?.(completed, addresses.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Start concurrent workers
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, addresses.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function useFetchAllPortfolios() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
addresses,
|
||||
chainIds,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
onProgress,
|
||||
}: {
|
||||
addresses: string[];
|
||||
chainIds: number[];
|
||||
authToken: string;
|
||||
teamId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
onProgress?: (completed: number, total: number) => void;
|
||||
}) => {
|
||||
return fetchAllPortfolios(
|
||||
addresses,
|
||||
chainIds,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
onProgress,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,16 @@ export type ProductSKU =
|
||||
| "usage:in_app_wallet"
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
// dedicated relayer SKUs
|
||||
| DedicatedRelayerSKU
|
||||
| null;
|
||||
|
||||
export type ChainInfraSKU =
|
||||
| "chain:infra:rpc"
|
||||
| "chain:infra:insight"
|
||||
| "chain:infra:account_abstraction";
|
||||
|
||||
export type DedicatedRelayerSKU =
|
||||
| "product:dedicated_relayer_standard"
|
||||
| "product:dedicated_relayer_premium"
|
||||
| "product:dedicated_relayer_enterprise";
|
||||
|
||||
@@ -283,7 +283,7 @@ function TokenInfoSection(props: {
|
||||
<dt className="text-xs font-medium text-muted-foreground tracking-wider uppercase">
|
||||
{props.label}
|
||||
</dt>
|
||||
<dd className="text-3xl font-bold text-foreground tracking-tight">
|
||||
<dd className="text-3xl font-semibold text-foreground tracking-tight">
|
||||
{props.value}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -285,6 +285,11 @@ const chainMetaRecord = {
|
||||
"Lisk is focused on serving builders in high-growth markets like Africa and Southeast Asia. When you build on Lisk, you become part of the strongest Ethereum collective, alongside Coinbase, Sony, World, Kraken and Uniswap -- the OP Superchain.",
|
||||
headerImgUrl: liskBanner.src,
|
||||
},
|
||||
//Sei Mainnet
|
||||
1329: {
|
||||
about:
|
||||
"Sei is a blockchain designed for fast, cheap financial transactions, combining the network effects of Ethereum with the performance of Solana. Sei has processed more than four billion transactions across more than 80 million wallets and has become the #1 EVM chain by number of active users. The team is backed by top investors such as Multicoin, Jump, Coinbase Ventures, and Circle Ventures – and includes talent from companies like Robinhood, Google, Coinbase, Databricks, Uber, and Goldman Sachs.",
|
||||
},
|
||||
//Reactive Mainnet
|
||||
1597: {
|
||||
about:
|
||||
|
||||
@@ -72,7 +72,6 @@ export const DecodedInputSet: React.FC<DecodedInputSetProps> = ({
|
||||
<div className="mt-6 flex justify-start">
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={param.type === "bytes" && fields.length >= 1}
|
||||
onClick={() =>
|
||||
append({
|
||||
defaultValue: "",
|
||||
|
||||
@@ -17,15 +17,21 @@ export default async function CheckoutPage(props: {
|
||||
searchParams: Promise<{
|
||||
amount?: string;
|
||||
invoice_id?: string;
|
||||
project_id?: string;
|
||||
chain_id?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
console.log("params", params);
|
||||
console.log("searchParams", searchParams);
|
||||
|
||||
switch (params.sku) {
|
||||
case "topup": {
|
||||
const amountUSD = Number.parseInt(
|
||||
(await props.searchParams).amount || "10",
|
||||
);
|
||||
const amountUSD = Number.parseInt(searchParams.amount || "10");
|
||||
if (Number.isNaN(amountUSD)) {
|
||||
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
|
||||
}
|
||||
@@ -43,7 +49,7 @@ export default async function CheckoutPage(props: {
|
||||
break;
|
||||
}
|
||||
case "invoice": {
|
||||
const invoiceId = (await props.searchParams).invoice_id;
|
||||
const invoiceId = searchParams.invoice_id;
|
||||
if (!invoiceId) {
|
||||
return <StripeRedirectErrorPage errorMessage="Invalid invoice ID" />;
|
||||
}
|
||||
@@ -59,10 +65,12 @@ export default async function CheckoutPage(props: {
|
||||
redirect(invoice);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const response = await getBillingCheckoutUrl({
|
||||
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
|
||||
teamSlug: params.team_slug,
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
if (response.status === "error") {
|
||||
|
||||
@@ -7,6 +7,10 @@ import { getAbsoluteUrl } from "@/utils/vercel";
|
||||
export async function getBillingCheckoutUrl(options: {
|
||||
teamSlug: string;
|
||||
sku: Exclude<ProductSKU, null>;
|
||||
params: {
|
||||
project_id?: string;
|
||||
chain_id?: string | string[];
|
||||
};
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -17,13 +21,26 @@ export async function getBillingCheckoutUrl(options: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const chainIds = options.params.chain_id
|
||||
? (Array.isArray(options.params.chain_id)
|
||||
? options.params.chain_id
|
||||
: [options.params.chain_id]
|
||||
)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
: undefined;
|
||||
|
||||
const body = {
|
||||
redirectTo: getAbsoluteStripeRedirectUrl(),
|
||||
sku: options.sku,
|
||||
projectId: options.params.project_id,
|
||||
chainIds: chainIds,
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
redirectTo: getAbsoluteStripeRedirectUrl(),
|
||||
sku: options.sku,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -58,6 +75,18 @@ export async function getBillingCheckoutUrl(options: {
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (
|
||||
"error" in json &&
|
||||
"message" in json.error &&
|
||||
typeof json.error.message === "string"
|
||||
) {
|
||||
return {
|
||||
error: json.error.message,
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
|
||||
@@ -312,7 +312,7 @@ function CreateProjectForm(props: {
|
||||
type="submit"
|
||||
>
|
||||
{createProject.isPending && <Spinner className="size-4" />}
|
||||
Create Projecct
|
||||
Create Project
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useAddPartner } from "../../hooks/use-add-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
@@ -23,6 +24,8 @@ export function AddPartnerForm({
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const storageUpload = useDashboardStorageUpload({ client });
|
||||
|
||||
const { mutateAsync: addPartner, isPending } = useAddPartner(
|
||||
{
|
||||
authToken,
|
||||
@@ -48,10 +51,23 @@ export function AddPartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
const isUploading = storageUpload.isPending;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
let imageUrl: string | undefined;
|
||||
if (values.logo) {
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([values.logo]);
|
||||
imageUrl = uri;
|
||||
} catch {
|
||||
toast.error("Failed to upload logo");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addPartner({
|
||||
accessControl: finalAccessControl,
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
@@ -61,6 +77,7 @@ export function AddPartnerForm({
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
ecosystem,
|
||||
imageUrl,
|
||||
name: values.name,
|
||||
});
|
||||
};
|
||||
@@ -68,7 +85,7 @@ export function AddPartnerForm({
|
||||
return (
|
||||
<PartnerForm
|
||||
client={client}
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isUploading}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Add"
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useId, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { z } from "zod";
|
||||
import type { Partner } from "@/api/team/ecosystems";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import { AllowedOperationsSection } from "./allowed-operations-section";
|
||||
|
||||
@@ -118,6 +121,7 @@ export function PartnerForm({
|
||||
|
||||
const accessControlId = useId();
|
||||
const serverVerifierId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -150,6 +154,101 @@ export function PartnerForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={() => {
|
||||
const removeLogo = form.watch("removeLogo");
|
||||
const logoFile = form.watch("logo");
|
||||
const newFilePreview = logoFile
|
||||
? URL.createObjectURL(logoFile)
|
||||
: undefined;
|
||||
const existingImageUrl =
|
||||
partner?.imageUrl && !removeLogo
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: partner.imageUrl,
|
||||
})
|
||||
: undefined;
|
||||
const displayUrl = newFilePreview || existingImageUrl;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Partner Logo</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
form.setValue("logo", file, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("removeLogo", false);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
{displayUrl ? (
|
||||
<div className="relative inline-block">
|
||||
<Img
|
||||
alt={partner?.name ?? "Partner logo"}
|
||||
className="size-20 rounded-md border object-contain object-center"
|
||||
src={displayUrl}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Remove logo"
|
||||
className="absolute -top-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
|
||||
onClick={() => {
|
||||
form.setValue("logo", undefined);
|
||||
form.setValue("removeLogo", true);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Change logo"
|
||||
className="absolute -bottom-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PencilIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ImageUpload
|
||||
accept="image/png, image/jpeg, image/webp"
|
||||
className="bg-background"
|
||||
onUpload={(files) => {
|
||||
if (files[0]) {
|
||||
form.setValue("logo", files[0], {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("removeLogo", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional logo for this partner. Used in OTP emails sent to
|
||||
users authenticating through this partner.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useUpdatePartner } from "../../hooks/use-update-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
@@ -25,6 +26,8 @@ export function UpdatePartnerForm({
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const storageUpload = useDashboardStorageUpload({ client });
|
||||
|
||||
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
|
||||
{
|
||||
authToken,
|
||||
@@ -50,10 +53,31 @@ export function UpdatePartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
const isUploading = storageUpload.isPending;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
// Determine imageUrl based on three states:
|
||||
// 1. New file uploaded → upload and use new URI
|
||||
// 2. Explicit removal → send null to clear
|
||||
// 3. No change → preserve existing partner imageUrl
|
||||
let imageUrl: string | null | undefined;
|
||||
if (values.logo) {
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([values.logo]);
|
||||
imageUrl = uri;
|
||||
} catch {
|
||||
toast.error("Failed to upload logo");
|
||||
return;
|
||||
}
|
||||
} else if (values.removeLogo) {
|
||||
imageUrl = null;
|
||||
} else {
|
||||
imageUrl = partner.imageUrl;
|
||||
}
|
||||
|
||||
updatePartner({
|
||||
accessControl: finalAccessControl,
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
@@ -63,6 +87,7 @@ export function UpdatePartnerForm({
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
ecosystem,
|
||||
imageUrl,
|
||||
name: values.name,
|
||||
partnerId: partner.id,
|
||||
});
|
||||
@@ -71,7 +96,7 @@ export function UpdatePartnerForm({
|
||||
return (
|
||||
<PartnerForm
|
||||
client={client}
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isUploading}
|
||||
onSubmit={handleSubmit}
|
||||
partner={partner}
|
||||
submitLabel="Update"
|
||||
|
||||
@@ -154,6 +154,21 @@ const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
|
||||
|
||||
export const partnerFormSchema = z
|
||||
.object({
|
||||
logo: z
|
||||
.instanceof(File, {
|
||||
message: "Please select an image file",
|
||||
})
|
||||
.refine(
|
||||
(file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type),
|
||||
{
|
||||
message: "Only PNG, JPG or WEBP images are allowed",
|
||||
},
|
||||
)
|
||||
.refine((file) => file.size <= 500 * 1024, {
|
||||
message: "Logo size must be less than 500KB",
|
||||
})
|
||||
.optional(),
|
||||
removeLogo: z.boolean().default(false),
|
||||
accessControl: z
|
||||
.object({
|
||||
allowedOperations: z
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
type AddPartnerParams = {
|
||||
ecosystem: Ecosystem;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: Partner["accessControl"] | null;
|
||||
@@ -37,6 +38,7 @@ export function useAddPartner(
|
||||
accessControl: params.accessControl ?? undefined,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
imageUrl: params.imageUrl,
|
||||
name: params.name,
|
||||
// TODO - remove the requirement for permissions in API endpoint
|
||||
permissions: ["FULL_CONTROL_V1"],
|
||||
|
||||
@@ -9,6 +9,7 @@ type UpdatePartnerParams = {
|
||||
partnerId: string;
|
||||
ecosystem: Ecosystem;
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: {
|
||||
@@ -43,6 +44,7 @@ export function useUpdatePartner(
|
||||
accessControl: params.accessControl,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
imageUrl: params.imageUrl,
|
||||
name: params.name,
|
||||
}),
|
||||
|
||||
|
||||
@@ -20,35 +20,17 @@ export function QuickStartSection(props: {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<FeatureCard
|
||||
title="Buy Widget Component"
|
||||
icon={CodeIcon}
|
||||
id="swap_tokens"
|
||||
features={[
|
||||
"React component",
|
||||
"Cross-chain token swaps across 85+ blockchains",
|
||||
"Fiat onramp support to buy tokens with credit/debit cards",
|
||||
"Customizable UI",
|
||||
]}
|
||||
description={undefined}
|
||||
link={{
|
||||
href: `https://portal.thirdweb.com/bridge/swap`,
|
||||
label: "Get Started",
|
||||
}}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Bridge Widget Script"
|
||||
description={undefined}
|
||||
title="Bridge Widget"
|
||||
description="Integrate cross-chain swaps and fiat onramp into your app using bridge widget"
|
||||
icon={CodeIcon}
|
||||
id="fees"
|
||||
features={[
|
||||
"Integrate with a script tag",
|
||||
"Integrate with iframe, script, or React component",
|
||||
"Cross-chain token swaps across 85+ blockchains",
|
||||
"Fiat onramp support to buy tokens with credit/debit cards",
|
||||
"Customizable UI",
|
||||
]}
|
||||
link={{
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
|
||||
href: `https://portal.thirdweb.com/bridge/bridge-widget`,
|
||||
label: "Get Started",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -51,6 +51,14 @@ export function ProjectSidebarLayout(props: {
|
||||
href: `${props.layoutPath}/wallets/sponsored-gas`,
|
||||
label: "Gas Sponsorship",
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/wallets/dedicated-relayer`,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Dedicated Relayer <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(props.showContracts
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ListCollapseIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
ShuffleIcon,
|
||||
WalletIcon,
|
||||
Settings2Icon,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -228,9 +230,12 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="p-5 flex flex-col lg:flex-row gap-5">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground mb-1">Wallet Address</p>
|
||||
<div className="px-5 py-4 border-b border-dashed flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground mb-0.5 tracking-tight">
|
||||
{projectWallet.label || "Project Wallet"}
|
||||
</h2>
|
||||
|
||||
<CopyTextButton
|
||||
textToShow={shortenAddress(projectWallet.address, 6)}
|
||||
textToCopy={projectWallet.address}
|
||||
@@ -241,14 +246,66 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground mb-1">Wallet Label</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
|
||||
{projectWallet.label || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-auto h-auto p-2 rounded-full"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 rounded-xl"
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<ArrowUpFromLineIcon className="size-4 text-muted-foreground" />
|
||||
Deposit Funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSendOpen(true)}
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-4 text-muted-foreground" />
|
||||
Withdraw Funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSwapOpen(true)}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
Swap Tokens
|
||||
</DropdownMenuItem>
|
||||
{/* add line */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListCollapseIcon className="size-4 text-muted-foreground" />
|
||||
View Transactions
|
||||
</DropdownMenuItem>
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<Settings2Icon className="size-4 text-muted-foreground" />
|
||||
Change Project Wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
@@ -280,10 +337,10 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50 border-r-0 rounded-r-none"
|
||||
client={props.client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
@@ -302,76 +359,16 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
chainId={selectedChainId}
|
||||
client={props.client}
|
||||
enabled={true}
|
||||
disableAddress={true}
|
||||
showCheck={true}
|
||||
addNativeTokenIfMissing={true}
|
||||
placeholder="Native token"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50 rounded-l-none"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background hover:bg-accent/50"
|
||||
onClick={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
Withdraw
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background hover:bg-accent/50"
|
||||
onClick={() => setIsSwapOpen(true)}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4" />
|
||||
Swap
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-2 bg-background hover:bg-accent/50"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Deposit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
Transactions
|
||||
</DropdownMenuItem>
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
Change Wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendProjectWalletModal
|
||||
|
||||
@@ -5,7 +5,7 @@ import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { ProjectWalletSectionUI } from "./project-wallet";
|
||||
|
||||
const meta = {
|
||||
component: ProjectWalletSectionUI,
|
||||
component: Variant,
|
||||
title: "Project/ProjectWalletSection",
|
||||
parameters: {
|
||||
nextjs: {
|
||||
@@ -35,16 +35,16 @@ projectWithManagedAccessToken.services = [
|
||||
|
||||
const projectWithoutManagedAccessToken = projectStub("foo", "bar");
|
||||
|
||||
const projectWallet1: ProjectWalletSummary = {
|
||||
const serverWallet1: ProjectWalletSummary = {
|
||||
id: "server-wallet-id",
|
||||
address: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
label: "Project Wallet 1",
|
||||
label: "XYZ",
|
||||
};
|
||||
|
||||
const projectWallet2: ProjectWalletSummary = {
|
||||
const serverWallet2: ProjectWalletSummary = {
|
||||
id: "server-wallet-id-2",
|
||||
address: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
|
||||
label: "Project Wallet 2",
|
||||
label: "FooBar",
|
||||
};
|
||||
|
||||
export const NoProjectWalletSetNoManagedAccessToken: Story = {
|
||||
@@ -84,7 +84,7 @@ export const NoProjectWalletSetWithManagedAccessTokenAndServerWallets: Story = {
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
return [serverWallet1, serverWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -98,7 +98,7 @@ export const NoProjectWalletSetLoading: Story = {
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
return [serverWallet1, serverWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -109,10 +109,10 @@ export const ProjectWalletSetMultipleServerWallets: Story = {
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
client: storybookThirdwebClient,
|
||||
projectWallet: projectWallet1,
|
||||
projectWallet: serverWallet1,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
return [serverWallet1, serverWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -122,11 +122,23 @@ export const ProjectWalletSetSingleServerWallet: Story = {
|
||||
layout: "column",
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
projectWallet: projectWallet1,
|
||||
projectWallet: serverWallet1,
|
||||
client: storybookThirdwebClient,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1];
|
||||
return [serverWallet1];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function Variant(props: Parameters<typeof ProjectWalletSectionUI>[0]) {
|
||||
return (
|
||||
<div className="space-y-20">
|
||||
<ProjectWalletSectionUI {...props} />
|
||||
|
||||
<div className="max-w-[660px]">
|
||||
<ProjectWalletSectionUI {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
|
||||
import { ArrowUpRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronDownIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -134,11 +134,8 @@ function CreateProjectWalletSection(props: {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-5 bg-card">
|
||||
<div className="rounded-full p-2 border bg-background inline-flex mb-4">
|
||||
<XIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium text-foreground mb-0.5">
|
||||
No Project Wallet set
|
||||
<h2 className="text-sm font-medium text-foreground mb-0.5 text-yellow-700 dark:text-yellow-500">
|
||||
No project wallet set
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set a project wallet to set the default sender in thirdweb API.
|
||||
|
||||
@@ -102,7 +102,7 @@ export default async function Page(props: {
|
||||
},
|
||||
{
|
||||
type: "webhooks",
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Img } from "@workspace/ui/components/img";
|
||||
import { ArrowDownToLineIcon, CoinsIcon, ImagesIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
@@ -63,19 +65,27 @@ export function Cards(props: {
|
||||
projectId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<CardLink
|
||||
description="Launch your own ERC-20 coin"
|
||||
images={{
|
||||
light: "/assets/tokens/coin-light.png",
|
||||
dark: "/assets/tokens/coin-dark.png",
|
||||
}}
|
||||
description="Launch an ERC-20 coin with various pricing strategies"
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens/create/token`}
|
||||
icon={CoinsIcon}
|
||||
title="Create Coin"
|
||||
title="Coin"
|
||||
/>
|
||||
|
||||
<CardLink
|
||||
description="Launch your own NFT collection"
|
||||
images={{
|
||||
light: "/assets/tokens/nft-light.png",
|
||||
dark: "/assets/tokens/nft-dark.png",
|
||||
}}
|
||||
description="Launch an ERC-721 or ERC-1155 NFT collection"
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens/create/nft`}
|
||||
icon={ImagesIcon}
|
||||
title="Create NFT Collection"
|
||||
title="NFT Collection"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -87,15 +97,20 @@ function CardLink(props: {
|
||||
href: string | undefined;
|
||||
onClick?: () => void;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
images: {
|
||||
dark: string;
|
||||
light: string;
|
||||
};
|
||||
}) {
|
||||
const { onClick } = props;
|
||||
const isClickable = !!onClick || !!props.href;
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: FIXME
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border bg-card p-4",
|
||||
"relative flex flex-col rounded-xl border bg-card p-4",
|
||||
isClickable && "cursor-pointer hover:border-active-border ",
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -107,13 +122,23 @@ function CardLink(props: {
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
<div className="mb-4 flex">
|
||||
<div className="flex items-center justify-center rounded-full border p-2">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
<div className="mb-4">
|
||||
<div className="flex lg:hidden">
|
||||
<div className="flex items-center justify-center rounded-full border p-2">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Img
|
||||
src={
|
||||
resolvedTheme === "light" ? props.images.light : props.images.dark
|
||||
}
|
||||
alt={props.title}
|
||||
className="hidden lg:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.href ? (
|
||||
<Link className="before:absolute before:inset-0" href={props.href}>
|
||||
{props.title}
|
||||
@@ -8,6 +8,7 @@ import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TokenIcon } from "@/icons/TokenIcon";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { Cards, ImportTokenButton } from "../cards";
|
||||
import { Cards, ImportTokenButton } from "./cards";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -63,9 +64,12 @@ export default async function Layout(props: {
|
||||
Create Token
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="!max-w-md">
|
||||
<DialogContent className="!max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Token</DialogTitle>
|
||||
<DialogDescription>
|
||||
Launch your own ERC-20 coin or NFT collection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Cards
|
||||
client={client}
|
||||
|
||||
@@ -21,6 +21,41 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
|
||||
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
|
||||
"Management Token for Dashboard";
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff.
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: { maxAttempts?: number; baseDelayMs?: number } = {},
|
||||
): Promise<T> {
|
||||
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
||||
throw new Error("maxAttempts must be at least 1");
|
||||
}
|
||||
if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) {
|
||||
throw new Error("baseDelayMs must be a non-negative number");
|
||||
}
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt < maxAttempts) {
|
||||
// Exponential backoff with cap at 30s and jitter to prevent thundering herd
|
||||
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 30_000);
|
||||
const jitter = Math.floor(Math.random() * Math.min(250, delay));
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, delay + jitter),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("withRetry failed without capturing an error");
|
||||
}
|
||||
|
||||
let vc: VaultClient | null = null;
|
||||
|
||||
export async function initVaultClient() {
|
||||
@@ -47,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: {
|
||||
throw new Error("No rotation code found");
|
||||
}
|
||||
|
||||
// IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project.
|
||||
// If we rotate first and then secret key validation fails, the old rotation code
|
||||
// is consumed but we can't save the new one, leaving the project unrecoverable.
|
||||
if (props.projectSecretKey) {
|
||||
const projectSecretKeyHash = await hashSecretKey(props.projectSecretKey);
|
||||
const secretKeysHashed = [
|
||||
...props.project.secretKeys,
|
||||
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
|
||||
];
|
||||
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
throw new Error("Invalid project secret key");
|
||||
}
|
||||
}
|
||||
|
||||
const rotateServiceAccountRes = await rotateServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
@@ -69,6 +118,9 @@ export async function rotateVaultAccountAndAccessToken(props: {
|
||||
vaultClient,
|
||||
adminKey,
|
||||
rotationCode,
|
||||
// Skip wallet creation on rotation - preserve the existing project wallet
|
||||
skipWalletCreation: true,
|
||||
existingProjectWalletAddress: service?.projectWalletAddress ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -222,9 +274,19 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
projectSecretHash?: string;
|
||||
adminKey: string;
|
||||
rotationCode: string;
|
||||
skipWalletCreation?: boolean;
|
||||
existingProjectWalletAddress?: string;
|
||||
}) {
|
||||
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
|
||||
props;
|
||||
const {
|
||||
project,
|
||||
projectSecretKey,
|
||||
projectSecretHash,
|
||||
vaultClient,
|
||||
adminKey,
|
||||
rotationCode,
|
||||
skipWalletCreation,
|
||||
existingProjectWalletAddress,
|
||||
} = props;
|
||||
|
||||
const [managementTokenResult, walletTokenResult] = await Promise.all([
|
||||
createManagementAccessToken({ project, adminKey, vaultClient }),
|
||||
@@ -246,12 +308,12 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
// create a default project server wallet
|
||||
const defaultProjectServerWallet = await createProjectServerWallet({
|
||||
project,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
label: getProjectWalletLabel(project.name),
|
||||
});
|
||||
// CRITICAL: Save credentials IMMEDIATELY after creating tokens.
|
||||
// This prevents a broken state if wallet creation or other operations fail.
|
||||
// The rotationCode is consumed when rotating, so if we don't save the new one,
|
||||
// the project becomes unrecoverable.
|
||||
let encryptedAdminKey: string | null = null;
|
||||
let encryptedWalletAccessToken: string | null = null;
|
||||
|
||||
if (projectSecretKey) {
|
||||
// verify that the project secret key is valid
|
||||
@@ -259,68 +321,65 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
const secretKeysHashed = [
|
||||
...project.secretKeys,
|
||||
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
|
||||
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
|
||||
...(projectSecretHash ? [{ hash: projectSecretHash }] : []),
|
||||
];
|
||||
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
throw new Error("Invalid project secret key");
|
||||
}
|
||||
|
||||
// encrypt admin key and wallet token with project secret key
|
||||
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
[encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
encrypt(adminKey, projectSecretKey),
|
||||
encrypt(walletToken.accessToken, projectSecretKey),
|
||||
]);
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// no secret key, only store the management token, remove any encrypted keys
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey: null,
|
||||
encryptedWalletAccessToken: null,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// For rotation, preserve existing wallet address. For new creation, create a default wallet.
|
||||
let projectWalletAddress: string | null | undefined =
|
||||
existingProjectWalletAddress ??
|
||||
project.services.find((s) => s.name === "engineCloud")
|
||||
?.projectWalletAddress;
|
||||
|
||||
// Only create a new wallet if we don't have one (initial setup, not rotation)
|
||||
if (!skipWalletCreation && !projectWalletAddress) {
|
||||
const defaultProjectServerWallet = await createProjectServerWallet({
|
||||
project,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
label: getProjectWalletLabel(project.name),
|
||||
});
|
||||
projectWalletAddress = defaultProjectServerWallet.address;
|
||||
}
|
||||
|
||||
// Save credentials with retry - this is critical because if rotation succeeded
|
||||
// but this save fails, the new rotation code is lost and project becomes unrecoverable
|
||||
await withRetry(
|
||||
() =>
|
||||
updateProjectClient(
|
||||
{
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: projectWalletAddress ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
{ maxAttempts: 3, baseDelayMs: 1000 },
|
||||
);
|
||||
|
||||
return {
|
||||
managementToken,
|
||||
walletToken,
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { ExternalLinkIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
type Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useFleetTransactions,
|
||||
useFleetTransactionsSummary,
|
||||
} from "../lib/hooks";
|
||||
import type { Fleet, FleetTransaction } from "../types";
|
||||
|
||||
type DedicatedRelayerActiveStateProps = {
|
||||
fleet: Fleet;
|
||||
teamId: string;
|
||||
fleetId: string;
|
||||
client: ThirdwebClient;
|
||||
range: Range;
|
||||
setRange: (range: Range) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DedicatedRelayerActiveState(
|
||||
props: DedicatedRelayerActiveStateProps,
|
||||
) {
|
||||
const { fleet, teamId, fleetId, client, range, setRange } = props;
|
||||
|
||||
const pageSize = 10;
|
||||
const [page, setPage] = useState(1);
|
||||
const [chainIdFilter, setChainIdFilter] = useState<number | undefined>();
|
||||
|
||||
const summaryQuery = useFleetTransactionsSummary({
|
||||
teamId,
|
||||
fleetId,
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
});
|
||||
|
||||
const transactionsQuery = useFleetTransactions({
|
||||
teamId,
|
||||
fleetId,
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
chainId: chainIdFilter,
|
||||
});
|
||||
|
||||
const totalPages = transactionsQuery.data
|
||||
? Math.ceil(transactionsQuery.data.meta.total / pageSize)
|
||||
: 0;
|
||||
|
||||
// Filter active chains to only include those in the fleet
|
||||
const activeChainsCount =
|
||||
summaryQuery.data?.data.transactionsByChain.filter((c) =>
|
||||
fleet.chainIds.includes(Number(c.chainId)),
|
||||
).length ?? 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", props.className)}>
|
||||
<div className="flex justify-end">
|
||||
<DateRangeSelector range={range} setRange={setRange} />
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
title="Total Transactions"
|
||||
value={
|
||||
summaryQuery.data?.data.totalTransactions.toLocaleString() ?? "—"
|
||||
}
|
||||
isLoading={summaryQuery.isPending}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Gas Spent"
|
||||
value={
|
||||
summaryQuery.data?.data.totalGasSpentUsd !== undefined
|
||||
? `$${summaryQuery.data.data.totalGasSpentUsd.toFixed(2)}`
|
||||
: "—"
|
||||
}
|
||||
isLoading={summaryQuery.isPending}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Chains"
|
||||
value={summaryQuery.data ? activeChainsCount.toString() : "—"}
|
||||
isLoading={summaryQuery.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Configuration Info */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Executors */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Executors</h2>
|
||||
</div>
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fleet.executors.map((address) => (
|
||||
<div
|
||||
key={address}
|
||||
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
|
||||
>
|
||||
<WalletAddress address={address} client={client} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chains */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Chains</h2>
|
||||
</div>
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fleet.chainIds.map((chainId) => (
|
||||
<div
|
||||
key={chainId}
|
||||
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
|
||||
>
|
||||
<ChainCell chainId={chainId.toString()} client={client} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions Table */}
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
|
||||
<h2 className="font-semibold text-xl">Transaction History</h2>
|
||||
<div className="flex gap-2">
|
||||
<ChainFilter
|
||||
chainId={chainIdFilter}
|
||||
setChainId={(chainId) => {
|
||||
setChainIdFilter(chainId);
|
||||
setPage(1);
|
||||
}}
|
||||
client={client}
|
||||
chainIds={fleet.chainIds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Transaction Hash</TableHead>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead>Wallet</TableHead>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Fee</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!transactionsQuery.isPending
|
||||
? transactionsQuery.data?.data.map((tx) => (
|
||||
<TransactionRow
|
||||
key={tx.transactionHash}
|
||||
transaction={tx}
|
||||
client={client}
|
||||
/>
|
||||
))
|
||||
: Array.from({ length: pageSize }).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no unique id
|
||||
<SkeletonRow key={`skeleton-${index}`} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{!transactionsQuery.isPending &&
|
||||
(transactionsQuery.isError ? (
|
||||
<div className="px-6 py-24">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-2">
|
||||
<XIcon className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-destructive-text text-sm">
|
||||
Failed to load transactions
|
||||
</p>
|
||||
</div>
|
||||
) : transactionsQuery.data?.data.length === 0 ? (
|
||||
<div className="px-6 py-24">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-2">
|
||||
<XIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
No transactions yet
|
||||
</p>
|
||||
</div>
|
||||
) : null)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-end gap-3 rounded-b-lg border-t p-4">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard(props: {
|
||||
title: string;
|
||||
value: string;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-muted-foreground text-sm">{props.title}</p>
|
||||
{props.isLoading ? (
|
||||
<Skeleton className="mt-1 h-8 w-24" />
|
||||
) : (
|
||||
<p className="font-semibold text-2xl">{props.value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionRow(props: {
|
||||
transaction: FleetTransaction;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { transaction, client } = props;
|
||||
const utcTimestamp = transaction.timestamp.endsWith("Z")
|
||||
? transaction.timestamp
|
||||
: `${transaction.timestamp}Z`;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TransactionHashCell
|
||||
chainId={transaction.chainId}
|
||||
hash={transaction.transactionHash}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChainCell chainId={transaction.chainId} client={client} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WalletAddress address={transaction.walletAddress} client={client} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ToolTipLabel hoverable label={format(new Date(utcTimestamp), "PPpp")}>
|
||||
<span>
|
||||
{formatDistance(new Date(utcTimestamp), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TransactionFeeCell usdValue={transaction.transactionFeeUsd} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="h-[72.5px]">
|
||||
<TableCell>
|
||||
<Skeleton className="h-7 w-[160px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-7 w-[130px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-7 w-[130px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-7 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-7 w-[40px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionHashCell(props: { hash: string; chainId: string }) {
|
||||
const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`;
|
||||
|
||||
return (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
href={`https://thirdweb.com/${props.chainId}/tx/${props.hash}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{txHashToShow}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainCell(props: { chainId: string; client: ThirdwebClient }) {
|
||||
const { idToChain, allChains } = useAllChainsData();
|
||||
const chain = idToChain.get(Number(props.chainId));
|
||||
|
||||
if (allChains.length === 0) {
|
||||
return <Skeleton className="w-[100px]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex w-max items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
src={chain?.icon?.url}
|
||||
/>
|
||||
<Link
|
||||
className="before:absolute before:inset-0 hover:underline hover:underline-offset-4"
|
||||
href={`/${chain ? chain.slug : props.chainId}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{chain ? chain.name : `Chain #${props.chainId}`}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionFeeCell(props: { usdValue: number | null }) {
|
||||
if (props.usdValue === null) {
|
||||
return <span className="font-mono text-sm text-muted-foreground">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-sm">
|
||||
{props.usdValue < 0.001 ? "< $0.001" : `$${props.usdValue.toFixed(3)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainFilter(props: {
|
||||
chainId: number | undefined;
|
||||
setChainId: (chainId: number | undefined) => void;
|
||||
client: ThirdwebClient;
|
||||
chainIds: number[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-background">
|
||||
<SingleNetworkSelector
|
||||
client={props.client}
|
||||
chainId={props.chainId}
|
||||
onChange={(chainId) => props.setChainId(chainId)}
|
||||
popoverContentClassName="z-[10001] !w-[280px]"
|
||||
align="end"
|
||||
placeholder="All Chains"
|
||||
className="min-w-[150px]"
|
||||
chainIds={props.chainIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { Img } from "@workspace/ui/components/img";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DedicatedRelayerSKU } from "@/types/billing";
|
||||
import { RELAYER_SUPPORTED_CHAINS } from "../constants";
|
||||
import { PlanSection } from "./tier-selection";
|
||||
|
||||
type DedicatedRelayerEmptyStateProps = {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
onPurchaseTier: (
|
||||
tier: DedicatedRelayerSKU,
|
||||
chainIds: number[],
|
||||
) => Promise<void>;
|
||||
client: ThirdwebClient;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Empty state shown when user hasn't purchased a dedicated relayer fleet.
|
||||
* Shows tier selection for purchasing.
|
||||
*/
|
||||
export function DedicatedRelayerEmptyState(
|
||||
props: DedicatedRelayerEmptyStateProps,
|
||||
) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedTier, setSelectedTier] = useState<DedicatedRelayerSKU | null>(
|
||||
null,
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedChainIds, setSelectedChainIds] = useState<number[]>([]);
|
||||
|
||||
const handleSelectTier = async (tier: DedicatedRelayerSKU) => {
|
||||
if (tier === "product:dedicated_relayer_enterprise") {
|
||||
window.open("https://thirdweb.com/contact-us", "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTier(tier);
|
||||
setSelectedChainIds([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedTier) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await props.onPurchaseTier(selectedTier, selectedChainIds);
|
||||
setIsModalOpen(false);
|
||||
setSelectedTier(null);
|
||||
setSelectedChainIds([]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to purchase dedicated relayer");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const maxChains =
|
||||
selectedTier === "product:dedicated_relayer_standard" ? 1 : 2;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-8 pt-2", props.className)}>
|
||||
<FeatureSection />
|
||||
<PlanSection
|
||||
onSelectTier={handleSelectTier}
|
||||
isLoading={isLoading}
|
||||
selectedTier={selectedTier}
|
||||
/>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Chains</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select up to {maxChains} chains for your dedicated relayer.
|
||||
</p>
|
||||
<MultiNetworkSelector
|
||||
selectedChainIds={selectedChainIds}
|
||||
onChange={setSelectedChainIds}
|
||||
client={props.client}
|
||||
chainIds={RELAYER_SUPPORTED_CHAINS}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handlePurchase}
|
||||
disabled={
|
||||
selectedChainIds.length === 0 ||
|
||||
selectedChainIds.length > maxChains ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{isLoading ? "Processing..." : "Continue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureSection() {
|
||||
return (
|
||||
<div className="grid lg:grid-cols-3 border rounded-xl bg-card">
|
||||
<FeatureCard
|
||||
title="Prioritized Queueing"
|
||||
description="Dedicated infrastructure to avoid sharing resources and competing with other wallets"
|
||||
images={{
|
||||
darkSrc: "/assets/dedicated-relayer/server-wallet-dark.png",
|
||||
lightSrc: "/assets/dedicated-relayer/server-wallet-light.png",
|
||||
}}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Zero Configuration"
|
||||
className="border-t lg:border-t-0 lg:border-l border-dashed"
|
||||
description="No code changes required, gas fees continue to be sponsored without need to top up funds"
|
||||
images={{
|
||||
darkSrc: "/assets/dedicated-relayer/no-config-dark.png",
|
||||
lightSrc: "/assets/dedicated-relayer/no-config-light.png",
|
||||
}}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Monitoring"
|
||||
className="border-t lg:border-t-0 lg:border-l border-dashed"
|
||||
description="All your transactions are initiated from the same onchain relayer address, making monitoring and dashboard building easy"
|
||||
images={{
|
||||
darkSrc: "/assets/dedicated-relayer/monitoring-dark.png",
|
||||
lightSrc: "/assets/dedicated-relayer/monitoring-light.png",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
images: {
|
||||
darkSrc: string;
|
||||
lightSrc: string;
|
||||
};
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const imageSrc =
|
||||
resolvedTheme === "light" ? props.images.lightSrc : props.images.darkSrc;
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<Img src={imageSrc} alt="" className="object-cover" key={imageSrc} />
|
||||
<div className="p-6">
|
||||
<h3 className="font-semibold text-xl mb-1 tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm text-pretty">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { subDays } from "date-fns";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Range } from "@/components/analytics/date-range-selector";
|
||||
import type { DedicatedRelayerSKU } from "@/types/billing";
|
||||
import { getAbsoluteUrl } from "@/utils/vercel";
|
||||
import { useFleetStatus, useFleetTransactionsSummary } from "../lib/hooks";
|
||||
import type { Fleet, FleetStatus } from "../types";
|
||||
import { DedicatedRelayerActiveState } from "./active-state";
|
||||
import { DedicatedRelayerEmptyState } from "./empty-state";
|
||||
import { DedicatedRelayerPendingState } from "./pending-state";
|
||||
|
||||
type DedicatedRelayerPageClientProps = {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
client: ThirdwebClient;
|
||||
fleetId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
rangeType: Range["type"];
|
||||
initialFleet: Fleet | null;
|
||||
};
|
||||
|
||||
export function DedicatedRelayerPageClient(
|
||||
props: DedicatedRelayerPageClientProps,
|
||||
) {
|
||||
const [fleet, _setFleet] = useState<Fleet | null>(props.initialFleet);
|
||||
const [fleetStatus, _setFleetStatus] = useState<FleetStatus>(() =>
|
||||
getInitialStatus(props.initialFleet),
|
||||
);
|
||||
|
||||
const [dateRange, setDateRange] = useState<Range>({
|
||||
from: new Date(props.from),
|
||||
to: new Date(props.to),
|
||||
type: props.rangeType,
|
||||
});
|
||||
|
||||
// Poll for fleet status when not purchased or pending setup
|
||||
const fleetStatusQuery = useFleetStatus(
|
||||
props.teamSlug,
|
||||
props.projectSlug,
|
||||
fleetStatus === "not-purchased" || fleetStatus === "pending-setup",
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (fleetStatusQuery.data) {
|
||||
_setFleet(fleetStatusQuery.data);
|
||||
_setFleetStatus(getInitialStatus(fleetStatusQuery.data));
|
||||
}
|
||||
}, [fleetStatusQuery.data]);
|
||||
|
||||
const activityWindowDates = useMemo(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
from: subDays(now, 120).toISOString(),
|
||||
to: now.toISOString(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check for any activity in the last 120 days to determine if we should show the dashboard
|
||||
// This prevents switching back to "pending" state if the user selects a date range with no transactions
|
||||
const hasActivityQuery = useFleetTransactionsSummary({
|
||||
teamId: props.teamId,
|
||||
fleetId: props.fleetId,
|
||||
from: activityWindowDates.from,
|
||||
to: activityWindowDates.to,
|
||||
enabled: fleetStatus === "active",
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const isCheckingActivity = hasActivityQuery.isPending;
|
||||
const totalTransactions = hasActivityQuery.data?.data.totalTransactions ?? 0;
|
||||
const hasTransactions = totalTransactions > 0;
|
||||
|
||||
// TODO-FLEET: Implement purchase flow
|
||||
// 1. Call Stripe checkout API to create a checkout session for the selected tier
|
||||
// 2. Redirect user to Stripe checkout
|
||||
// 3. On success callback, call API server to provision fleet
|
||||
// 4. API server should:
|
||||
// - Create fleet record in DB
|
||||
// - Call bundler service to provision executor wallets
|
||||
// - Update ProjectBundlerService with fleet config
|
||||
// 5. Refetch fleet status and update UI
|
||||
const handlePurchaseTier = async (
|
||||
sku: DedicatedRelayerSKU,
|
||||
chainIds: number[],
|
||||
) => {
|
||||
const search = new URLSearchParams();
|
||||
search.set("project_id", props.projectId);
|
||||
for (const chainId of chainIds) {
|
||||
search.append("chain_id", chainId.toString());
|
||||
}
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.open(
|
||||
`${getAbsoluteUrl()}/checkout/${props.teamSlug}/${sku}?${search.toString()}`,
|
||||
"_blank",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{fleetStatus === "not-purchased" && (
|
||||
<DedicatedRelayerEmptyState
|
||||
projectSlug={props.projectSlug}
|
||||
teamSlug={props.teamSlug}
|
||||
onPurchaseTier={handlePurchaseTier}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fleetStatus === "pending-setup" && fleet && (
|
||||
<DedicatedRelayerPendingState fleet={fleet} />
|
||||
)}
|
||||
|
||||
{fleetStatus === "active" &&
|
||||
fleet &&
|
||||
!hasTransactions &&
|
||||
!isCheckingActivity && (
|
||||
<DedicatedRelayerPendingState
|
||||
fleet={fleet}
|
||||
hasTransactions={hasTransactions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fleetStatus === "active" &&
|
||||
fleet &&
|
||||
(hasTransactions || isCheckingActivity) && (
|
||||
<DedicatedRelayerActiveState
|
||||
fleet={fleet}
|
||||
teamId={props.teamId}
|
||||
fleetId={props.fleetId}
|
||||
client={props.client}
|
||||
range={dateRange}
|
||||
setRange={setDateRange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialStatus(fleet: Fleet | null): FleetStatus {
|
||||
if (!fleet) {
|
||||
return "not-purchased";
|
||||
}
|
||||
if (fleet.executors.length === 0) {
|
||||
return "pending-setup";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { ClockIcon, Loader2Icon } from "lucide-react";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Fleet } from "../types";
|
||||
|
||||
type DedicatedRelayerPendingStateProps = {
|
||||
fleet: Fleet;
|
||||
hasTransactions?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pending state shown when fleet is purchased but executors are not yet set up.
|
||||
* Shows the requested chain IDs and a "setting up" status.
|
||||
*/
|
||||
export function DedicatedRelayerPendingState(
|
||||
props: DedicatedRelayerPendingStateProps,
|
||||
) {
|
||||
const { fleet, hasTransactions } = props;
|
||||
const hasExecutors = fleet.executors.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", props.className)}>
|
||||
{/* Status Banner */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-warning/50 bg-warning/10 px-4 py-3">
|
||||
<Loader2Icon className="size-5 animate-spin text-warning" />
|
||||
<div>
|
||||
<p className="font-medium text-warning">
|
||||
{hasExecutors
|
||||
? "Waiting for first transaction"
|
||||
: "Setting up your dedicated relayer"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{hasExecutors
|
||||
? "Your dedicated relayer is ready. Waiting for the first transaction to appear."
|
||||
: "Your relayer is being provisioned. This usually takes 48 to 72 hours."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fleet Details Preview */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Fleet Configuration
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Requested Chains */}
|
||||
<div>
|
||||
<h3 className="mb-3 font-medium text-muted-foreground text-sm">
|
||||
Requested Chains
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fleet.chainIds.map((chainId) => (
|
||||
<ChainBadge key={chainId} chainId={chainId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<h3 className="mb-3 font-medium text-muted-foreground text-sm">
|
||||
Status
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-4" />
|
||||
<span>
|
||||
{hasExecutors
|
||||
? "Active, waiting for transactions"
|
||||
: "Awaiting executor deployment"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What to Expect */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
What to Expect
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-6">
|
||||
<ol className="flex flex-col gap-4">
|
||||
<SetupStep
|
||||
completed={true}
|
||||
number={1}
|
||||
title="Subscription activated"
|
||||
/>
|
||||
<SetupStep
|
||||
completed={hasExecutors}
|
||||
inProgress={!hasExecutors}
|
||||
number={2}
|
||||
title="Executor wallets deployed"
|
||||
/>
|
||||
<SetupStep
|
||||
completed={!!hasTransactions}
|
||||
inProgress={hasExecutors && !hasTransactions}
|
||||
number={3}
|
||||
title="Waiting for first transaction"
|
||||
/>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Transaction Snippet */}
|
||||
{hasExecutors && (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Send a Test Transaction
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 lg:p-6">
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
You can send a test transaction using the thirdweb API. Replace{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
|
||||
YOUR_SECRET_KEY
|
||||
</code>{" "}
|
||||
with your actual secret key.
|
||||
</p>
|
||||
<CodeClient
|
||||
code={`curl https://api.thirdweb.com/v1/transactions \\
|
||||
--request POST \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--header 'x-secret-key: YOUR_SECRET_KEY' \\
|
||||
--data '{
|
||||
"chainId": ${fleet.chainIds[0]},
|
||||
"transactions": [
|
||||
{
|
||||
"data": "0x",
|
||||
"to": "vitalik.eth",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}'`}
|
||||
lang="bash"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainBadge(props: { chainId: number }) {
|
||||
const chain = useV5DashboardChain(props.chainId);
|
||||
const label = chain?.name ?? `Chain ${props.chainId}`;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border bg-background px-3 py-1 text-sm">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStep(props: {
|
||||
number: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
inProgress?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex size-6 items-center justify-center rounded-full text-xs font-medium ${
|
||||
props.completed
|
||||
? "bg-success text-success-foreground"
|
||||
: props.inProgress
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{props.inProgress ? (
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
) : (
|
||||
props.number
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
props.completed
|
||||
? "text-success"
|
||||
: props.inProgress
|
||||
? "text-warning"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BoxesIcon,
|
||||
FolderCogIcon,
|
||||
FolderIcon,
|
||||
FoldersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DedicatedRelayerSKU } from "@/types/billing";
|
||||
|
||||
type TierConfig = {
|
||||
id: DedicatedRelayerSKU;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
isPerMonth?: boolean;
|
||||
features: Array<{ icon: React.FC<{ className?: string }>; label: string }>;
|
||||
cta: string;
|
||||
isRecommended?: boolean;
|
||||
};
|
||||
|
||||
const TIERS: TierConfig[] = [
|
||||
{
|
||||
id: "product:dedicated_relayer_standard",
|
||||
icon: FolderIcon,
|
||||
name: "Standard",
|
||||
description:
|
||||
"Most suitable for startups and applications with moderate transaction volume",
|
||||
price: "$99",
|
||||
isPerMonth: true,
|
||||
features: [
|
||||
{ icon: WalletProductIcon, label: "Single executor wallet" },
|
||||
{ icon: BoxesIcon, label: "Support for 1 chain" },
|
||||
],
|
||||
cta: "Select",
|
||||
},
|
||||
{
|
||||
id: "product:dedicated_relayer_premium",
|
||||
icon: FoldersIcon,
|
||||
name: "Premium",
|
||||
description:
|
||||
"Best for enterprise companies and applications with high transaction volume",
|
||||
price: "$299",
|
||||
isPerMonth: true,
|
||||
features: [
|
||||
{
|
||||
icon: WalletProductIcon,
|
||||
label: "10 executor wallets (10x throughput)",
|
||||
},
|
||||
{ icon: BoxesIcon, label: "Support for up to 2 chains" },
|
||||
],
|
||||
cta: "Select",
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
id: "product:dedicated_relayer_enterprise",
|
||||
icon: FolderCogIcon,
|
||||
name: "Custom",
|
||||
description:
|
||||
"Contact us for applications operating at a global scale with custom requirements",
|
||||
price: "Custom",
|
||||
features: [
|
||||
{ icon: WalletProductIcon, label: "Unlimited executor wallets" },
|
||||
{ icon: BoxesIcon, label: "Unlimited chains" },
|
||||
],
|
||||
cta: "Contact Sales",
|
||||
},
|
||||
];
|
||||
|
||||
export function PlanSection(props: {
|
||||
onSelectTier: (tier: DedicatedRelayerSKU) => void;
|
||||
isLoading?: boolean;
|
||||
selectedTier?: DedicatedRelayerSKU | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("border rounded-xl bg-card", props.className)}>
|
||||
<div className="p-6 border-b border-dashed">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Select a plan</h2>
|
||||
</div>
|
||||
<div className="grid lg:grid-cols-3">
|
||||
{TIERS.map((tier, index) => (
|
||||
<PlanCard
|
||||
key={tier.id}
|
||||
tier={tier}
|
||||
isLoading={props.isLoading && props.selectedTier === tier.id}
|
||||
isDisabled={props.isLoading && props.selectedTier !== tier.id}
|
||||
onSelect={() => props.onSelectTier(tier.id)}
|
||||
className={
|
||||
index !== 0
|
||||
? "border-t lg:border-t-0 lg:border-l border-dashed"
|
||||
: ""
|
||||
}
|
||||
isRecommended={tier.isRecommended}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard(props: {
|
||||
tier: TierConfig;
|
||||
onSelect: () => void;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
isRecommended?: boolean;
|
||||
}) {
|
||||
const { tier } = props;
|
||||
const isEnterprise = tier.id === "product:dedicated_relayer_enterprise";
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex flex-col px-6 py-8", props.className)}>
|
||||
<div className="mb-5">
|
||||
<div className="p-2.5 mb-5 inline-flex rounded-full border bg-background">
|
||||
<tier.icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="h-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-2xl tracking-tight">
|
||||
{tier.name}
|
||||
</h3>
|
||||
{props.isRecommended && (
|
||||
<span className="text-xs border border-blue-500 bg-blue-500/10 text-blue-800 dark:border-blue-600 dark:text-blue-200 dark:bg-blue-600/10 rounded-full px-2.5 py-1 font-medium">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm text-pretty max-w-[320px]">
|
||||
{tier.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex items-end gap-1">
|
||||
<span className="font-semibold text-3xl tracking-tight text-foreground">
|
||||
{tier.price}
|
||||
</span>
|
||||
{tier.isPerMonth && (
|
||||
<span className="text-muted-foreground font-medium"> / month</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-3">Includes:</p>
|
||||
|
||||
<ul className="mb-8 flex flex-1 flex-col gap-2">
|
||||
{tier.features.map((feature) => (
|
||||
<li
|
||||
key={feature.label}
|
||||
className="flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<feature.icon className="size-4 shrink-0 mt-0.5" />
|
||||
<span>{feature.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isEnterprise ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full rounded-full py-3 h-auto text-base bg-background gap-2"
|
||||
>
|
||||
<Link
|
||||
href="https://ea58c.share.hsforms.com/2o8qbJVozSX2Q4oukYfYGuA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tier.cta}
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={props.onSelect}
|
||||
disabled={props.isLoading || props.isDisabled}
|
||||
variant="outline"
|
||||
className="w-full gap-2 rounded-full py-3 h-auto text-base bg-background"
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-4" />}
|
||||
{tier.cta}
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const RELAYER_SUPPORTED_CHAINS = [
|
||||
// MAINNETS
|
||||
1, // ethereum mainnet
|
||||
100, // gnosis mainnet
|
||||
56, // bsc mainnet
|
||||
10, // optimism mainnet
|
||||
8453, // base mainnet
|
||||
57073, // ink mainnet
|
||||
130, // unichain mainnet
|
||||
7777777, // zora mainnet
|
||||
1868, // soneium mainnet
|
||||
34443, // mode mainnet
|
||||
534352, // scroll mainnet
|
||||
42161, // arbitrum mainnet
|
||||
80094, // berachain mainnet
|
||||
137, // polygon mainnet
|
||||
185, // Mint Mainnet mainnet
|
||||
747, // EVM on Flow mainnet
|
||||
1135, // Lisk mainnet
|
||||
2020, // Ronin mainnet
|
||||
5330, // Superseed mainnet
|
||||
7897, // Arena-Z mainnet
|
||||
42170, // Arbitrum Nova mainnet
|
||||
60808, // BOB mainnet
|
||||
747474, // Katana mainnet
|
||||
1628, // T-REX mainnet
|
||||
466, // Appchain mainnet
|
||||
80888, // Onyx mainnet
|
||||
42220, // Celo mainnet
|
||||
1329, // Sei mainnet
|
||||
42793, // Etherlink mainnet
|
||||
143, // Monad mainnet
|
||||
252, // Fraxtal mainnet
|
||||
// TESTNETS
|
||||
11155111, // sepolia testnet
|
||||
10200, // gnosis chiado testnet
|
||||
97, // bsc testnet
|
||||
11155420, // op sepolia testnet
|
||||
84532, // base sepolia testnet
|
||||
763373, // ink sepolia testnet
|
||||
1301, // unichain sepolia testnet
|
||||
999999999, // zora sepolia testnet
|
||||
1946, // soneium minato testnet
|
||||
919, // mode sepolia testnet
|
||||
534351, // scroll sepolia testnet
|
||||
421614, // arbitrum sepolia testnet
|
||||
80069, // berachain bepolia testnet
|
||||
9899, // Arena-Z Testnet testnet testnet
|
||||
1962, // T-Rex testnet testnet
|
||||
545, // EVM on Flow Testnet testnet
|
||||
1001, // Kaia Testnet Kairos testnet
|
||||
2021, // Saigon testnet testnet
|
||||
4202, // Lisk Sepolia Testnet testnet
|
||||
14853, // Humanode Testnet 5 Israfel testnet
|
||||
53302, // Superseed Sepolia Testnet testnet
|
||||
59141, // Linea Sepolia testnet
|
||||
129399, // Tatara testnet
|
||||
808813, // BOB Sepolia testnet
|
||||
1992, // Sanko Testnet testnet
|
||||
2030232745, // Lumia Beam Testnet testnet
|
||||
11142220, // Celo Sepolia Testnet testnet
|
||||
4661, // Appchain Testnet
|
||||
1328, // Sei Testnet
|
||||
128123, // Etherlink Testnet
|
||||
123420001114, // Basecamp testnet
|
||||
5042002, // Arc Testnet (Circle USDC Native)
|
||||
127823, // Etherlink Shadownet Testnet
|
||||
10143, // Monad Testnet (MON)
|
||||
1417429182, // Zephyr Testnet
|
||||
42429, // Tempo Testnet
|
||||
2523, // Fraxtal Testnet testnet
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/dedicated-relayer`;
|
||||
|
||||
const [authToken, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(basePath);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ProjectPage
|
||||
header={{
|
||||
icon: WalletProductIcon,
|
||||
title: "Dedicated Relayer",
|
||||
description:
|
||||
"Executor wallets which automatically relay transactions with higher throughput and dedicated infrastructure",
|
||||
actions: null,
|
||||
client,
|
||||
links: [
|
||||
{
|
||||
type: "docs",
|
||||
href: "https://portal.thirdweb.com/wallets/server",
|
||||
},
|
||||
],
|
||||
}}
|
||||
tabs={[]}
|
||||
>
|
||||
{props.children}
|
||||
</ProjectPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use server";
|
||||
|
||||
import { analyticsServerProxy } from "@/actions/proxies";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import {
|
||||
buildFleetId,
|
||||
type Fleet,
|
||||
type FleetTransaction,
|
||||
type FleetTransactionsSummary,
|
||||
} from "../types";
|
||||
|
||||
type GetFleetTransactionsParams = {
|
||||
teamId: string;
|
||||
fleetId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
chainId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches paginated fleet transactions from the analytics service.
|
||||
*/
|
||||
export async function getFleetTransactions(params: GetFleetTransactionsParams) {
|
||||
const res = await analyticsServerProxy<{
|
||||
data: FleetTransaction[];
|
||||
meta: { total: number };
|
||||
}>({
|
||||
method: "GET",
|
||||
pathname: "/v2/bundler/fleet-transactions",
|
||||
searchParams: {
|
||||
teamId: params.teamId,
|
||||
fleetId: params.fleetId,
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
limit: params.limit.toString(),
|
||||
offset: params.offset.toString(),
|
||||
...(params.chainId && { chainId: params.chainId.toString() }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
type GetFleetTransactionsSummaryParams = {
|
||||
teamId: string;
|
||||
fleetId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches fleet transactions summary from the analytics service.
|
||||
*/
|
||||
export async function getFleetTransactionsSummary(
|
||||
params: GetFleetTransactionsSummaryParams,
|
||||
) {
|
||||
const res = await analyticsServerProxy<{
|
||||
data: FleetTransactionsSummary;
|
||||
}>({
|
||||
method: "GET",
|
||||
pathname: "/v2/bundler/fleet-transactions/summary",
|
||||
searchParams: {
|
||||
teamId: params.teamId,
|
||||
fleetId: params.fleetId,
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current fleet status for a project.
|
||||
*/
|
||||
export async function getFleetStatus(
|
||||
teamSlug: string,
|
||||
projectSlug: string,
|
||||
): Promise<Fleet | null> {
|
||||
const project = await getProject(teamSlug, projectSlug);
|
||||
if (!project) return null;
|
||||
|
||||
const bundlerService = project.services.find((s) => s.name === "bundler");
|
||||
const fleetConfig =
|
||||
bundlerService && "dedicatedRelayer" in bundlerService
|
||||
? (bundlerService.dedicatedRelayer as {
|
||||
sku: string;
|
||||
chainIds: number[];
|
||||
executors: string[];
|
||||
} | null)
|
||||
: null;
|
||||
|
||||
if (fleetConfig) {
|
||||
return {
|
||||
id: buildFleetId(project.teamId, project.id),
|
||||
chainIds: fleetConfig.chainIds,
|
||||
executors: fleetConfig.executors,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getFleetStatus,
|
||||
getFleetTransactions,
|
||||
getFleetTransactionsSummary,
|
||||
} from "./api";
|
||||
|
||||
type UseFleetTransactionsParams = {
|
||||
teamId: string;
|
||||
fleetId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
chainId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query hook for fetching paginated fleet transactions.
|
||||
*/
|
||||
export function useFleetTransactions(params: UseFleetTransactionsParams) {
|
||||
return useQuery({
|
||||
queryKey: ["fleet-transactions", params],
|
||||
queryFn: () => getFleetTransactions(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
type UseFleetTransactionsSummaryParams = {
|
||||
teamId: string;
|
||||
fleetId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
enabled?: boolean;
|
||||
refetchInterval?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query hook for fetching fleet transactions summary.
|
||||
*/
|
||||
export function useFleetTransactionsSummary(
|
||||
params: UseFleetTransactionsSummaryParams,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["fleet-transactions-summary", params],
|
||||
queryFn: () => getFleetTransactionsSummary(params),
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: params.enabled !== false,
|
||||
refetchInterval: params.refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query hook for fetching fleet status.
|
||||
* Polls every 5 seconds if enabled.
|
||||
*/
|
||||
export function useFleetStatus(
|
||||
teamSlug: string,
|
||||
projectSlug: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["fleet-status", teamSlug, projectSlug],
|
||||
queryFn: () => getFleetStatus(teamSlug, projectSlug),
|
||||
enabled,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { getLastNDaysRange } from "@/components/analytics/date-range-selector";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { DedicatedRelayerPageClient } from "./components/page-client";
|
||||
import { buildFleetId, type Fleet } from "./types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DedicatedRelayerPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const [params, authToken] = await Promise.all([props.params, getAuthToken()]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/wallets/dedicated-relayer`,
|
||||
);
|
||||
}
|
||||
|
||||
const [team, project] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
// Build fleet ID from team and project
|
||||
const fleetId = buildFleetId(team.id, project.id);
|
||||
|
||||
// Default date range: last 7 days
|
||||
const range = getLastNDaysRange("last-7");
|
||||
|
||||
// Extract fleet configuration from bundler service
|
||||
const bundlerService = project.services.find((s) => s.name === "bundler");
|
||||
const fleetConfig =
|
||||
bundlerService && "dedicatedRelayer" in bundlerService
|
||||
? (bundlerService.dedicatedRelayer as {
|
||||
sku: string;
|
||||
chainIds: number[];
|
||||
executors: string[];
|
||||
} | null)
|
||||
: null;
|
||||
|
||||
// Convert fleet config to Fleet type
|
||||
// If fleet is undefined/null, show empty state (not-purchased)
|
||||
// If fleet.executors is empty, show pending state
|
||||
// If fleet.executors has addresses, show active state
|
||||
let initialFleet: Fleet | null = null;
|
||||
if (fleetConfig) {
|
||||
initialFleet = {
|
||||
id: fleetId,
|
||||
chainIds: fleetConfig.chainIds,
|
||||
executors: fleetConfig.executors,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<DedicatedRelayerPageClient
|
||||
teamId={team.id}
|
||||
projectId={project.id}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
client={client}
|
||||
fleetId={fleetId}
|
||||
from={range.from.toISOString()}
|
||||
to={range.to.toISOString()}
|
||||
rangeType={range.type}
|
||||
initialFleet={initialFleet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Represents a dedicated relayer fleet configuration.
|
||||
* Fleet config is fetched from ProjectBundlerService.fleet (service-utils).
|
||||
*/
|
||||
export type Fleet = {
|
||||
id: string;
|
||||
chainIds: number[];
|
||||
executors: string[]; // executor wallet addresses
|
||||
};
|
||||
|
||||
/**
|
||||
* Fleet status based on its state.
|
||||
*/
|
||||
export type FleetStatus = "not-purchased" | "pending-setup" | "active";
|
||||
|
||||
/**
|
||||
* Derives the fleet status from the fleet object.
|
||||
*/
|
||||
function _getFleetStatus(fleet: Fleet | null): FleetStatus {
|
||||
if (!fleet) {
|
||||
return "not-purchased";
|
||||
}
|
||||
if (fleet.executors.length === 0) {
|
||||
return "pending-setup";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
/**
|
||||
* A single transaction from the fleet.
|
||||
*/
|
||||
export type FleetTransaction = {
|
||||
timestamp: string;
|
||||
chainId: string;
|
||||
transactionFee: number;
|
||||
transactionFeeUsd: number | null;
|
||||
walletAddress: string;
|
||||
transactionHash: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Summary statistics for fleet transactions.
|
||||
*/
|
||||
export type FleetTransactionsSummary = {
|
||||
totalTransactions: number;
|
||||
totalGasSpentUsd: number;
|
||||
transactionsByChain: {
|
||||
chainId: string;
|
||||
count: number;
|
||||
gasSpentUsd: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the fleet ID from team and project.
|
||||
*/
|
||||
export function buildFleetId(teamId: string, projectId: string): string {
|
||||
return `fleet-${teamId}-${projectId}`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ServerWalletsTable } from "../../../transactions/components/server-wall
|
||||
import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types";
|
||||
import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types";
|
||||
import { VaultRecoveryCard } from "./vault-recovery-card.client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -107,12 +108,10 @@ export default async function Page(props: {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{eoas.error ? (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
|
||||
</div>
|
||||
<VaultRecoveryCard
|
||||
errorMessage={eoas.error.message}
|
||||
project={project}
|
||||
/>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
RotateCcwIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
createVaultAccountAndAccessToken,
|
||||
initVaultClient,
|
||||
maskSecret,
|
||||
} from "../../../transactions/lib/vault.client";
|
||||
|
||||
interface VaultRecoveryCardProps {
|
||||
errorMessage: string;
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function VaultRecoveryCard({
|
||||
errorMessage,
|
||||
project,
|
||||
}: VaultRecoveryCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [secretKeyInput, setSecretKeyInput] = useState("");
|
||||
// Option for managed vault users who lost their secret key
|
||||
const [manageKeysSelf, setManageKeysSelf] = useState(false);
|
||||
// For ejected vault key download flow
|
||||
const [keysConfirmed, setKeysConfirmed] = useState(false);
|
||||
const [keysDownloaded, setKeysDownloaded] = useState(false);
|
||||
|
||||
// Check if this was a managed vault (had encryptedAdminKey)
|
||||
const engineCloudService = project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
);
|
||||
const wasManagedVault = !!engineCloudService?.encryptedAdminKey;
|
||||
|
||||
// Will create ejected vault if: wasn't managed, OR user chose to manage keys themselves
|
||||
const willCreateEjectedVault = !wasManagedVault || manageKeysSelf;
|
||||
|
||||
const isInsufficientScopeError =
|
||||
errorMessage.includes("AUTH_INSUFFICIENT_SCOPE") ||
|
||||
errorMessage.toLowerCase().includes("insufficient scope");
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await initVaultClient();
|
||||
|
||||
const result = await createVaultAccountAndAccessToken({
|
||||
project,
|
||||
// Only pass secret key if creating managed vault (not ejected)
|
||||
projectSecretKey: willCreateEjectedVault ? undefined : secretKeyInput,
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// For managed vaults (with secret key), reload immediately
|
||||
// For ejected vaults, show the key download dialog first
|
||||
if (!willCreateEjectedVault) {
|
||||
window.location.reload();
|
||||
}
|
||||
// For ejected vaults, we stay in the dialog to show the admin key
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadKeys = () => {
|
||||
if (!regenerateMutation.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = `Project:\n${project.name} (${project.publishableKey})\n\nVault Admin Key:\n${regenerateMutation.data.adminKey}\n\nVault Access Token:\n${regenerateMutation.data.walletToken.accessToken}\n`;
|
||||
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const filename = `${project.name}-vault-keys.txt`;
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Keys downloaded as ${filename}`);
|
||||
setKeysDownloaded(true);
|
||||
};
|
||||
|
||||
const handleCloseAfterKeysSaved = () => {
|
||||
if (!keysConfirmed) {
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// For managed vaults creating managed vault, require secret key input
|
||||
// For ejected vaults (or managed choosing to manage keys), just need confirmation
|
||||
const canProceed = willCreateEjectedVault
|
||||
? confirmed
|
||||
: confirmed && secretKeyInput.trim().length > 0;
|
||||
|
||||
if (!isInsufficientScopeError) {
|
||||
// Show standard error for non-scope errors
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="mb-2 font-semibold text-destructive">EVM Wallet Error</p>
|
||||
<p className="text-muted-foreground text-sm">{errorMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="border-destructive/50">
|
||||
<AlertTriangleIcon className="size-4" />
|
||||
<AlertTitle>Server Wallet Access Lost</AlertTitle>
|
||||
<AlertDescription className="mt-3 space-y-4">
|
||||
<p>
|
||||
Your server wallet credentials have become invalid. This can happen if
|
||||
the secret key was rotated but the new credentials weren't saved
|
||||
properly.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p className="font-medium text-destructive-text text-sm">
|
||||
⚠️ Important: Existing wallets cannot be recovered
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
If you have existing wallets that are important to your project,
|
||||
please{" "}
|
||||
<a
|
||||
href="https://thirdweb.com/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link-foreground underline"
|
||||
>
|
||||
contact support
|
||||
</a>{" "}
|
||||
before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2">
|
||||
<RotateCcwIcon className="size-4" />
|
||||
Create New Server Wallet
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
{/* Show key download UI for ejected vaults after success */}
|
||||
{willCreateEjectedVault && regenerateMutation.data ? (
|
||||
<>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Save your Vault Admin Key</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
You'll need this key to create server wallets and
|
||||
access tokens.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<CopyTextButton
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
copyIconPosition="right"
|
||||
textToCopy={regenerateMutation.data.adminKey}
|
||||
textToShow={maskSecret(
|
||||
regenerateMutation.data.adminKey,
|
||||
)}
|
||||
tooltip="Copy Admin Key"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Download this key to your local machine or a password
|
||||
manager.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Secure your admin key</AlertTitle>
|
||||
<AlertDescription>
|
||||
This key will not be displayed again. Store it
|
||||
securely as it provides access to your server wallets.
|
||||
</AlertDescription>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Button
|
||||
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
|
||||
onClick={handleDownloadKeys}
|
||||
variant="link"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{keysDownloaded
|
||||
? "Key Downloaded"
|
||||
: "Download Admin Key"}
|
||||
</Button>
|
||||
{keysDownloaded && (
|
||||
<span className="text-success-text text-xs">
|
||||
<CheckIcon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={keysConfirmed}
|
||||
onCheckedChange={(v) => setKeysConfirmed(!!v)}
|
||||
/>
|
||||
I confirm that I've securely stored my admin
|
||||
key
|
||||
</CheckboxWithLabel>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
disabled={!keysConfirmed}
|
||||
onClick={handleCloseAfterKeysSaved}
|
||||
variant="primary"
|
||||
>
|
||||
Close
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Create New Server Wallet Configuration?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
This will create a completely new server wallet
|
||||
configuration for your project.
|
||||
</p>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p className="font-semibold text-destructive-text text-sm">
|
||||
Warning: This action cannot be undone
|
||||
</p>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 text-muted-foreground text-sm">
|
||||
<li>
|
||||
Any existing server wallets will be permanently
|
||||
inaccessible
|
||||
</li>
|
||||
<li>
|
||||
You will need to create new wallets after this
|
||||
process
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{wasManagedVault && (
|
||||
<div className="space-y-3">
|
||||
{!manageKeysSelf && (
|
||||
<>
|
||||
<label
|
||||
htmlFor="secret-key-input"
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
Enter your project Secret Key
|
||||
</label>
|
||||
<Input
|
||||
id="secret-key-input"
|
||||
type="password"
|
||||
placeholder="sk_..."
|
||||
value={secretKeyInput}
|
||||
onChange={(e) =>
|
||||
setSecretKeyInput(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your secret key is required to create a managed
|
||||
vault.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 border-t pt-3">
|
||||
<Checkbox
|
||||
id="manage-keys-self"
|
||||
checked={manageKeysSelf}
|
||||
onCheckedChange={(checked) => {
|
||||
setManageKeysSelf(checked === true);
|
||||
if (checked) {
|
||||
setSecretKeyInput("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="manage-keys-self"
|
||||
className="cursor-pointer text-sm leading-tight"
|
||||
>
|
||||
I lost my secret key and want to manage vault keys
|
||||
myself (advanced)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 pt-2">
|
||||
<Checkbox
|
||||
id="confirm-recovery"
|
||||
checked={confirmed}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfirmed(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="confirm-recovery"
|
||||
className="cursor-pointer text-sm leading-tight"
|
||||
>
|
||||
I understand that existing server wallets will not be
|
||||
recoverable and I want to proceed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setConfirmed(false);
|
||||
setSecretKeyInput("");
|
||||
setManageKeysSelf(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={!canProceed || regenerateMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
regenerateMutation.mutate();
|
||||
}}
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
>
|
||||
{regenerateMutation.isPending && (
|
||||
<Spinner className="size-4" />
|
||||
)}
|
||||
Create New Wallet Configuration
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{regenerateMutation.error && (
|
||||
<p className="text-destructive-text text-sm">
|
||||
Failed to create new configuration:{" "}
|
||||
{regenerateMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Error details: {errorMessage}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import RoutesImage from "../assets/routes.png";
|
||||
import TokensImage from "../assets/tokens.png";
|
||||
import { bridgeStats, bridgeStatsNumbers } from "../data";
|
||||
import { AnimatedNumbers } from "./client/animated-numbers";
|
||||
import { AddBridgeWidgetLink } from "./client/badge-link";
|
||||
import { UniversalBridgeEmbed } from "./client/UniversalBridgeEmbed";
|
||||
import { BridgePageHeader } from "./header";
|
||||
|
||||
@@ -29,6 +30,12 @@ export function BridgePageUI(props: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container max-w-2xl flex justify-center">
|
||||
<AddBridgeWidgetLink />
|
||||
</div>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
<HeadingSection title={props.title} />
|
||||
|
||||
<div className="h-20 lg:h-40" />
|
||||
@@ -43,7 +50,7 @@ export function BridgePageUI(props: {
|
||||
function HeadingSection(props: { title: React.ReactNode }) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="mb-3 lg:mb-6">{props.title}</div>
|
||||
<div className="mb-3 lg:mb-5">{props.title}</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm text-pretty text-center lg:text-lg mb-6 lg:mb-8">
|
||||
Seamlessly move your assets across {bridgeStats.supportedChains} chains
|
||||
@@ -81,9 +88,9 @@ function DataSquare(props: {
|
||||
imageClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-2 lg:py-0 size-full lg:size-[220px] rounded-xl border hover:bg-card bg-card/50 relative shrink-0 overflow-hidden">
|
||||
<div className="py-2 lg:py-0 size-full lg:size-[200px] rounded-2xl border hover:bg-card bg-card/50 relative shrink-0 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-1 text-3xl lg:text-5xl font-medium tracking-tight font-mono mb-1 h-[45px] lg:h-[56px]">
|
||||
<div className="flex items-center gap-1 text-3xl lg:text-4xl font-semibold tracking-tight mb-1 h-[45px]">
|
||||
<AnimatedNumbers
|
||||
value={props.data}
|
||||
format={props.format}
|
||||
|
||||
@@ -40,3 +40,25 @@ export function BridgeProviders({
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BridgeProvidersLite({
|
||||
children,
|
||||
forcedTheme,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
forcedTheme?: string;
|
||||
}) {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { SupportedFiatCurrency } from "thirdweb/react";
|
||||
import { createWallet } from "thirdweb/wallets";
|
||||
import {
|
||||
BuyAndSwapEmbed,
|
||||
@@ -19,16 +20,22 @@ export const bridgeWallets = [
|
||||
];
|
||||
|
||||
export function UniversalBridgeEmbed(props: {
|
||||
persistTokenSelections?: boolean;
|
||||
buyTab: BuyAndSwapEmbedProps["buyTab"];
|
||||
swapTab: BuyAndSwapEmbedProps["swapTab"];
|
||||
pageType: "bridge" | "bridge-iframe";
|
||||
currency?: SupportedFiatCurrency;
|
||||
showThirdwebBranding?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BuyAndSwapEmbed
|
||||
persistTokenSelections={props.persistTokenSelections}
|
||||
currency={props.currency}
|
||||
buyTab={props.buyTab}
|
||||
swapTab={props.swapTab}
|
||||
pageType={props.pageType}
|
||||
wallets={bridgeWallets}
|
||||
showThirdwebBranding={props.showThirdwebBranding}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { reportBridgePageLinkClick } from "@/analytics/report";
|
||||
|
||||
export function AddBridgeWidgetLink() {
|
||||
return (
|
||||
<BadgeLink
|
||||
href="https://portal.thirdweb.com/bridge/bridge-widget"
|
||||
label="Add Bridge widget in your app"
|
||||
onClick={() => {
|
||||
reportBridgePageLinkClick({ linkType: "integrate-bridge-widget" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeLink(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={props.href}
|
||||
className="text-sm text-foreground bg-accent/50 rounded-full px-4 py-2 border hover:bg-accent flex items-center gap-2"
|
||||
target="_blank"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.label}
|
||||
<ArrowUpRightIcon className="size-4 text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import { bridgeWallets } from "./client/UniversalBridgeEmbed";
|
||||
|
||||
export function BridgePageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-border/70">
|
||||
<div>
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3 lg:py-4",
|
||||
"container flex max-w-5xl justify-between py-3 lg:py-5",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export const bridgeStats = {
|
||||
supportedChains: "95+",
|
||||
supportedTokens: "14,000+",
|
||||
supportedTokens: "17,000+",
|
||||
supportedRoutes: "15M+",
|
||||
};
|
||||
|
||||
export const bridgeStatsNumbers = {
|
||||
supportedChains: 95,
|
||||
supportedTokens: 14000,
|
||||
supportedTokens: 17000,
|
||||
supportedRoutes: 15000000,
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ export default async function Page(props: {
|
||||
: undefined,
|
||||
}}
|
||||
title={
|
||||
<h1 className="text-3xl md:text-6xl font-semibold tracking-tighter text-balance text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-semibold tracking-tighter text-balance text-center">
|
||||
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
|
||||
chain, instantly
|
||||
</h1>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SupportedFiatCurrency } from "thirdweb/react";
|
||||
|
||||
export function isValidCurrency(
|
||||
currency: string,
|
||||
): currency is SupportedFiatCurrency {
|
||||
if (currency in VALID_CURRENCIES) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const VALID_CURRENCIES: Record<SupportedFiatCurrency, true> = {
|
||||
USD: true,
|
||||
EUR: true,
|
||||
GBP: true,
|
||||
JPY: true,
|
||||
KRW: true,
|
||||
CNY: true,
|
||||
INR: true,
|
||||
NOK: true,
|
||||
SEK: true,
|
||||
CHF: true,
|
||||
AUD: true,
|
||||
CAD: true,
|
||||
NZD: true,
|
||||
MXN: true,
|
||||
BRL: true,
|
||||
CLP: true,
|
||||
CZK: true,
|
||||
DKK: true,
|
||||
HKD: true,
|
||||
HUF: true,
|
||||
IDR: true,
|
||||
ILS: true,
|
||||
ISK: true,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isAddress } from "thirdweb";
|
||||
|
||||
export function parseQueryParams<T>(
|
||||
value: string | string[] | undefined,
|
||||
fn: (value: string) => T | undefined,
|
||||
): T | undefined {
|
||||
if (typeof value === "string") {
|
||||
return fn(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
|
||||
export const onlyNumber = (v: string) =>
|
||||
Number.isNaN(Number(v)) ? undefined : Number(v);
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { type Address, defineChain } from "thirdweb";
|
||||
import { BuyWidget, type SupportedFiatCurrency } from "thirdweb/react";
|
||||
import { NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
|
||||
export function BuyWidgetEmbed({
|
||||
chainId,
|
||||
tokenAddress,
|
||||
amount,
|
||||
showThirdwebBranding,
|
||||
theme,
|
||||
currency,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
paymentMethods,
|
||||
buttonLabel,
|
||||
receiverAddress,
|
||||
country,
|
||||
amountEditable,
|
||||
tokenEditable,
|
||||
}: {
|
||||
chainId?: number;
|
||||
tokenAddress?: Address;
|
||||
amount?: string;
|
||||
showThirdwebBranding?: boolean;
|
||||
theme: "light" | "dark";
|
||||
currency?: SupportedFiatCurrency;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
paymentMethods?: ("crypto" | "card")[];
|
||||
buttonLabel?: string;
|
||||
receiverAddress?: Address;
|
||||
country?: string;
|
||||
amountEditable?: boolean;
|
||||
tokenEditable?: boolean;
|
||||
}) {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
getConfiguredThirdwebClient({
|
||||
clientId: NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const chain = useMemo(() => {
|
||||
if (!chainId) return undefined;
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return defineChain(chainId);
|
||||
}, [chainId]);
|
||||
|
||||
return (
|
||||
<BuyWidget
|
||||
className="shadow-xl"
|
||||
client={client}
|
||||
chain={chain}
|
||||
tokenAddress={tokenAddress}
|
||||
amount={amount}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
theme={theme}
|
||||
currency={currency}
|
||||
title={title}
|
||||
description={description}
|
||||
image={image}
|
||||
paymentMethods={paymentMethods}
|
||||
buttonLabel={buttonLabel}
|
||||
receiverAddress={receiverAddress}
|
||||
country={country}
|
||||
amountEditable={amountEditable}
|
||||
tokenEditable={tokenEditable}
|
||||
onSuccess={() => {
|
||||
sendMessageToParent({
|
||||
source: "buy-widget",
|
||||
type: "success",
|
||||
});
|
||||
}}
|
||||
onError={(error) => {
|
||||
sendMessageToParent({
|
||||
source: "buy-widget",
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessageToParent(content: object) {
|
||||
try {
|
||||
window.parent.postMessage(content, "*");
|
||||
} catch (error) {
|
||||
console.error("Failed to send post message to parent window");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const fontSans = Inter({
|
||||
display: "swap",
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function BuyWidgetLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-dvh bg-background font-sans antialiased flex flex-col",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Metadata } from "next";
|
||||
import "@workspace/ui/global.css";
|
||||
import type { SupportedFiatCurrency } from "thirdweb/react";
|
||||
import { isValidCurrency } from "../_common/isValidCurrency";
|
||||
import {
|
||||
onlyAddress,
|
||||
onlyNumber,
|
||||
parseQueryParams,
|
||||
} from "../_common/parseQueryParams";
|
||||
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
|
||||
import { BuyWidgetEmbed } from "./BuyWidgetEmbed.client";
|
||||
|
||||
const title = "thirdweb Buy: Purchase Crypto with Ease";
|
||||
const description =
|
||||
"Buy crypto tokens with credit card or other payment methods. Simple and secure purchases with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
type SearchParams = {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
// Token params
|
||||
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
|
||||
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
|
||||
const amount = parseQueryParams(searchParams.amount, (v) => v);
|
||||
|
||||
// Optional params
|
||||
const showThirdwebBranding = parseQueryParams(
|
||||
searchParams.showThirdwebBranding,
|
||||
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
|
||||
(v) => (v === "false" ? false : true),
|
||||
);
|
||||
|
||||
const theme =
|
||||
parseQueryParams(searchParams.theme, (v) =>
|
||||
v === "light" ? "light" : "dark",
|
||||
) || "dark";
|
||||
|
||||
const currency = parseQueryParams(searchParams.currency, (v) =>
|
||||
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
|
||||
);
|
||||
|
||||
// Metadata params
|
||||
const widgetTitle = parseQueryParams(searchParams.title, (v) => v);
|
||||
const widgetDescription = parseQueryParams(
|
||||
searchParams.description,
|
||||
(v) => v,
|
||||
);
|
||||
const image = parseQueryParams(searchParams.image, (v) => v);
|
||||
|
||||
// Payment params
|
||||
const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => {
|
||||
if (v === "crypto" || v === "card") {
|
||||
return [v] as ("crypto" | "card")[];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
|
||||
const receiverAddress = parseQueryParams(searchParams.receiver, onlyAddress);
|
||||
const country = parseQueryParams(searchParams.country, (v) => v);
|
||||
|
||||
// Editable params
|
||||
const amountEditable = parseQueryParams(searchParams.amountEditable, (v) =>
|
||||
v === "false" ? false : undefined,
|
||||
);
|
||||
const tokenEditable = parseQueryParams(searchParams.tokenEditable, (v) =>
|
||||
v === "false" ? false : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<BridgeProvidersLite forcedTheme={theme}>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||||
<BuyWidgetEmbed
|
||||
chainId={chainId}
|
||||
tokenAddress={tokenAddress}
|
||||
amount={amount}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
theme={theme}
|
||||
currency={currency}
|
||||
title={widgetTitle}
|
||||
description={widgetDescription}
|
||||
image={image}
|
||||
paymentMethods={paymentMethods}
|
||||
buttonLabel={buttonLabel}
|
||||
receiverAddress={receiverAddress}
|
||||
country={country}
|
||||
amountEditable={amountEditable}
|
||||
tokenEditable={tokenEditable}
|
||||
/>
|
||||
</div>
|
||||
</BridgeProvidersLite>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { CheckoutWidget, type SupportedFiatCurrency } from "thirdweb/react";
|
||||
import { NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
|
||||
export function CheckoutWidgetEmbed({
|
||||
chainId,
|
||||
amount,
|
||||
seller,
|
||||
tokenAddress,
|
||||
name,
|
||||
description,
|
||||
image,
|
||||
buttonLabel,
|
||||
feePayer,
|
||||
country,
|
||||
showThirdwebBranding,
|
||||
theme,
|
||||
currency,
|
||||
paymentMethods,
|
||||
}: {
|
||||
chainId: number;
|
||||
amount: string;
|
||||
seller: Address;
|
||||
tokenAddress?: Address;
|
||||
name?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
buttonLabel?: string;
|
||||
feePayer?: "user" | "seller";
|
||||
country?: string;
|
||||
showThirdwebBranding?: boolean;
|
||||
theme: "light" | "dark";
|
||||
currency?: SupportedFiatCurrency;
|
||||
paymentMethods?: ("crypto" | "card")[];
|
||||
}) {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
getConfiguredThirdwebClient({
|
||||
clientId: NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = useMemo(() => defineChain(chainId), [chainId]);
|
||||
|
||||
return (
|
||||
<CheckoutWidget
|
||||
className="shadow-xl"
|
||||
client={client}
|
||||
chain={chain}
|
||||
amount={amount}
|
||||
seller={seller}
|
||||
tokenAddress={tokenAddress}
|
||||
name={name}
|
||||
description={description}
|
||||
image={image}
|
||||
buttonLabel={buttonLabel}
|
||||
feePayer={feePayer}
|
||||
country={country}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
theme={theme}
|
||||
currency={currency}
|
||||
paymentMethods={paymentMethods}
|
||||
onSuccess={() => {
|
||||
sendMessageToParent({
|
||||
source: "checkout-widget",
|
||||
type: "success",
|
||||
});
|
||||
}}
|
||||
onError={(error) => {
|
||||
sendMessageToParent({
|
||||
source: "checkout-widget",
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessageToParent(content: object) {
|
||||
try {
|
||||
window.parent.postMessage(content, "*");
|
||||
} catch (error) {
|
||||
console.error("Failed to send post message to parent window");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Metadata } from "next";
|
||||
import "@workspace/ui/global.css";
|
||||
import { InlineCode } from "@workspace/ui/components/code/inline-code";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import type { SupportedFiatCurrency } from "thirdweb/react";
|
||||
import { isValidCurrency } from "../_common/isValidCurrency";
|
||||
import {
|
||||
onlyAddress,
|
||||
onlyNumber,
|
||||
parseQueryParams,
|
||||
} from "../_common/parseQueryParams";
|
||||
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
|
||||
import { CheckoutWidgetEmbed } from "./CheckoutWidgetEmbed.client";
|
||||
|
||||
const title = "thirdweb Checkout: Accept Crypto & Fiat Payments";
|
||||
const description =
|
||||
"Accept fiat or crypto payments on any chain—direct to your wallet. Instant checkout, webhook support, and full control over post-sale actions.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
type SearchParams = {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
// Required params
|
||||
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
|
||||
const amount = parseQueryParams(searchParams.amount, (v) => v);
|
||||
const seller = parseQueryParams(searchParams.seller, onlyAddress);
|
||||
|
||||
// Optional params
|
||||
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
|
||||
const title = parseQueryParams(searchParams.title, (v) => v);
|
||||
const productDescription = parseQueryParams(
|
||||
searchParams.description,
|
||||
(v) => v,
|
||||
);
|
||||
const image = parseQueryParams(searchParams.image, (v) => v);
|
||||
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
|
||||
const feePayer = parseQueryParams(searchParams.feePayer, (v) =>
|
||||
v === "seller" || v === "user" ? v : undefined,
|
||||
);
|
||||
const country = parseQueryParams(searchParams.country, (v) => v);
|
||||
|
||||
const showThirdwebBranding = parseQueryParams(
|
||||
searchParams.showThirdwebBranding,
|
||||
(v) => v !== "false",
|
||||
);
|
||||
|
||||
const theme =
|
||||
parseQueryParams(searchParams.theme, (v) =>
|
||||
v === "light" ? "light" : "dark",
|
||||
) || "dark";
|
||||
|
||||
const currency = parseQueryParams(searchParams.currency, (v) =>
|
||||
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
|
||||
);
|
||||
|
||||
const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => {
|
||||
if (v === "crypto" || v === "card") {
|
||||
return [v] as ("crypto" | "card")[];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Validate required params
|
||||
if (!chainId || !amount || !seller) {
|
||||
return (
|
||||
<BridgeProvidersLite forcedTheme={theme}>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-xl">
|
||||
<div className="p-2.5 inline-flex rounded-full bg-background mb-4 border">
|
||||
<AlertTriangleIcon className="size-5 text-destructive-text" />
|
||||
</div>
|
||||
<h2 className="mb-2 font-semibold text-destructive-text text-lg">
|
||||
Invalid Configuration
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
The following query parameters are required but are missing:
|
||||
</p>
|
||||
<ul className="mt-2 text-left text-muted-foreground text-sm space-y-2">
|
||||
{!chainId && (
|
||||
<li>
|
||||
• <InlineCode code="chain" /> - Chain ID (e.g., 1, 8453,
|
||||
42161)
|
||||
</li>
|
||||
)}
|
||||
{!amount && (
|
||||
<li>
|
||||
• <InlineCode code="amount" /> - Amount to charge (e.g.,
|
||||
"0.01")
|
||||
</li>
|
||||
)}
|
||||
{!seller && (
|
||||
<li>
|
||||
• <InlineCode code="seller" /> - Seller wallet address
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BridgeProvidersLite>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BridgeProvidersLite forcedTheme={theme}>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||||
<CheckoutWidgetEmbed
|
||||
chainId={chainId}
|
||||
amount={amount}
|
||||
seller={seller}
|
||||
tokenAddress={tokenAddress}
|
||||
name={title}
|
||||
description={productDescription}
|
||||
image={image}
|
||||
buttonLabel={buttonLabel}
|
||||
feePayer={feePayer}
|
||||
country={country}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
theme={theme}
|
||||
currency={currency}
|
||||
paymentMethods={paymentMethods}
|
||||
/>
|
||||
</div>
|
||||
</BridgeProvidersLite>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { Address } from "thirdweb";
|
||||
import { type SupportedFiatCurrency, SwapWidget } from "thirdweb/react";
|
||||
import { NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
|
||||
export function SwapWidgetEmbed({
|
||||
buyChainId,
|
||||
buyTokenAddress,
|
||||
buyAmount,
|
||||
sellChainId,
|
||||
sellTokenAddress,
|
||||
sellAmount,
|
||||
showThirdwebBranding,
|
||||
theme,
|
||||
currency,
|
||||
persistTokenSelections,
|
||||
}: {
|
||||
buyChainId?: number;
|
||||
buyTokenAddress?: Address;
|
||||
buyAmount?: string;
|
||||
sellChainId?: number;
|
||||
sellTokenAddress?: Address;
|
||||
sellAmount?: string;
|
||||
showThirdwebBranding?: boolean;
|
||||
theme: "light" | "dark";
|
||||
currency?: SupportedFiatCurrency;
|
||||
persistTokenSelections?: boolean;
|
||||
}) {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
getConfiguredThirdwebClient({
|
||||
clientId: NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const prefill = useMemo(() => {
|
||||
const result: {
|
||||
buyToken?: { chainId: number; tokenAddress?: string; amount?: string };
|
||||
sellToken?: { chainId: number; tokenAddress?: string; amount?: string };
|
||||
} = {};
|
||||
|
||||
if (buyChainId) {
|
||||
result.buyToken = {
|
||||
chainId: buyChainId,
|
||||
tokenAddress: buyTokenAddress,
|
||||
amount: buyAmount,
|
||||
};
|
||||
}
|
||||
|
||||
if (sellChainId) {
|
||||
result.sellToken = {
|
||||
chainId: sellChainId,
|
||||
tokenAddress: sellTokenAddress,
|
||||
amount: sellAmount,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}, [
|
||||
buyChainId,
|
||||
buyTokenAddress,
|
||||
buyAmount,
|
||||
sellChainId,
|
||||
sellTokenAddress,
|
||||
sellAmount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SwapWidget
|
||||
className="shadow-xl"
|
||||
client={client}
|
||||
prefill={prefill}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
theme={theme}
|
||||
currency={currency}
|
||||
persistTokenSelections={persistTokenSelections}
|
||||
onSuccess={() => {
|
||||
sendMessageToParent({
|
||||
source: "swap-widget",
|
||||
type: "success",
|
||||
});
|
||||
}}
|
||||
onError={(error) => {
|
||||
sendMessageToParent({
|
||||
source: "swap-widget",
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessageToParent(content: object) {
|
||||
try {
|
||||
window.parent.postMessage(content, "*");
|
||||
} catch (error) {
|
||||
console.error("Failed to send post message to parent window");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const fontSans = Inter({
|
||||
display: "swap",
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function SwapWidgetLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-dvh bg-background font-sans antialiased flex flex-col",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Metadata } from "next";
|
||||
import "@workspace/ui/global.css";
|
||||
import type { SupportedFiatCurrency } from "thirdweb/react";
|
||||
import { isValidCurrency } from "../_common/isValidCurrency";
|
||||
import {
|
||||
onlyAddress,
|
||||
onlyNumber,
|
||||
parseQueryParams,
|
||||
} from "../_common/parseQueryParams";
|
||||
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
|
||||
import { SwapWidgetEmbed } from "./SwapWidgetEmbed.client";
|
||||
|
||||
const title = "thirdweb Swap: Cross-Chain Token Swaps";
|
||||
const description =
|
||||
"Swap tokens across any chain with the best rates. Cross-chain swaps made simple with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
type SearchParams = {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
// Buy token params
|
||||
const buyChainId = parseQueryParams(searchParams.buyChain, onlyNumber);
|
||||
const buyTokenAddress = parseQueryParams(
|
||||
searchParams.buyTokenAddress,
|
||||
onlyAddress,
|
||||
);
|
||||
const buyAmount = parseQueryParams(searchParams.buyAmount, (v) => v);
|
||||
|
||||
// Sell token params
|
||||
const sellChainId = parseQueryParams(searchParams.sellChain, onlyNumber);
|
||||
const sellTokenAddress = parseQueryParams(
|
||||
searchParams.sellTokenAddress,
|
||||
onlyAddress,
|
||||
);
|
||||
const sellAmount = parseQueryParams(searchParams.sellAmount, (v) => v);
|
||||
|
||||
// Optional params
|
||||
const showThirdwebBranding = parseQueryParams(
|
||||
searchParams.showThirdwebBranding,
|
||||
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
|
||||
(v) => (v === "false" ? false : true),
|
||||
);
|
||||
|
||||
const persistTokenSelections = parseQueryParams(
|
||||
searchParams.persistTokenSelections,
|
||||
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
|
||||
(v) => (v === "false" ? false : true),
|
||||
);
|
||||
|
||||
const theme =
|
||||
parseQueryParams(searchParams.theme, (v) =>
|
||||
v === "light" ? "light" : "dark",
|
||||
) || "dark";
|
||||
|
||||
const currency = parseQueryParams(searchParams.currency, (v) =>
|
||||
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<BridgeProvidersLite forcedTheme={theme}>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||||
<SwapWidgetEmbed
|
||||
buyChainId={buyChainId}
|
||||
buyTokenAddress={buyTokenAddress}
|
||||
buyAmount={buyAmount}
|
||||
sellChainId={sellChainId}
|
||||
sellTokenAddress={sellTokenAddress}
|
||||
sellAmount={sellAmount}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
theme={theme}
|
||||
currency={currency}
|
||||
persistTokenSelections={persistTokenSelections}
|
||||
/>
|
||||
</div>
|
||||
</BridgeProvidersLite>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const fontSans = Inter({
|
||||
display: "swap",
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function BridgeEmbedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-dvh bg-background font-sans antialiased flex flex-col",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
@@ -1,9 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { isAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { UniversalBridgeEmbed } from "../(general)/components/client/UniversalBridgeEmbed";
|
||||
import { bridgeStats } from "../(general)/data";
|
||||
import "@workspace/ui/global.css";
|
||||
import { NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { isValidCurrency } from "../_common/isValidCurrency";
|
||||
import {
|
||||
onlyAddress,
|
||||
onlyNumber,
|
||||
parseQueryParams,
|
||||
} from "../_common/parseQueryParams";
|
||||
import { BridgeProviders } from "../(general)/components/client/Providers.client";
|
||||
|
||||
const title = `thirdweb Bridge: Buy, Bridge & Swap Crypto on ${bridgeStats.supportedChains} Chains`;
|
||||
@@ -27,31 +33,61 @@ export default async function Page(props: {
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
|
||||
const onlyNumber = (v: string) =>
|
||||
Number.isNaN(Number(v)) ? undefined : Number(v);
|
||||
|
||||
// output is buy, input is sell
|
||||
const sellChain = parse(searchParams.inputChain, onlyNumber);
|
||||
const sellCurrency = parse(searchParams.inputCurrency, onlyAddress);
|
||||
const sellChain = parseQueryParams(searchParams.inputChain, onlyNumber);
|
||||
const sellCurrency = parseQueryParams(
|
||||
searchParams.inputCurrency,
|
||||
onlyAddress,
|
||||
);
|
||||
const sellAmount = parseQueryParams(
|
||||
searchParams.inputCurrencyAmount,
|
||||
onlyNumber,
|
||||
);
|
||||
|
||||
const buyChain = parse(searchParams.outputChain, onlyNumber);
|
||||
const buyCurrency = parse(searchParams.outputCurrency, onlyAddress);
|
||||
const buyChain = parseQueryParams(searchParams.outputChain, onlyNumber);
|
||||
const buyCurrency = parseQueryParams(
|
||||
searchParams.outputCurrency,
|
||||
onlyAddress,
|
||||
);
|
||||
const buyAmount = parseQueryParams(
|
||||
searchParams.outputCurrencyAmount,
|
||||
onlyNumber,
|
||||
);
|
||||
|
||||
const showThirdwebBranding = parseQueryParams(
|
||||
searchParams.showThirdwebBranding,
|
||||
(v) => v !== "false",
|
||||
);
|
||||
|
||||
const persistTokenSelections =
|
||||
parseQueryParams(searchParams.persistTokenSelections, (v) =>
|
||||
v === "false" ? "false" : "true",
|
||||
) || "true";
|
||||
|
||||
const theme =
|
||||
parse(searchParams.theme, (v) => (v === "light" ? "light" : "dark")) ||
|
||||
"dark";
|
||||
parseQueryParams(searchParams.theme, (v) =>
|
||||
v === "light" ? "light" : "dark",
|
||||
) || "dark";
|
||||
|
||||
const currency = parseQueryParams(searchParams.currency, (v) =>
|
||||
isValidCurrency(v) ? v : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Providers theme={theme}>
|
||||
<div className="flex items-center justify-center min-h-screen py-6 bg-background">
|
||||
<UniversalBridgeEmbed
|
||||
persistTokenSelections={persistTokenSelections === "true"}
|
||||
pageType="bridge-iframe"
|
||||
currency={currency}
|
||||
showThirdwebBranding={showThirdwebBranding}
|
||||
buyTab={{
|
||||
buyToken: buyChain
|
||||
? {
|
||||
chainId: buyChain,
|
||||
tokenAddress: buyCurrency || NATIVE_TOKEN_ADDRESS,
|
||||
amount:
|
||||
buyAmount === undefined ? undefined : buyAmount.toString(),
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
@@ -60,12 +96,18 @@ export default async function Page(props: {
|
||||
? {
|
||||
chainId: sellChain,
|
||||
tokenAddress: sellCurrency || NATIVE_TOKEN_ADDRESS,
|
||||
amount:
|
||||
sellAmount === undefined
|
||||
? undefined
|
||||
: sellAmount.toString(),
|
||||
}
|
||||
: undefined,
|
||||
buyToken: buyChain
|
||||
? {
|
||||
chainId: buyChain,
|
||||
tokenAddress: buyCurrency || NATIVE_TOKEN_ADDRESS,
|
||||
amount:
|
||||
buyAmount === undefined ? undefined : buyAmount.toString(),
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
@@ -75,16 +117,6 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function parse<T>(
|
||||
value: string | string[] | undefined,
|
||||
fn: (value: string) => T | undefined,
|
||||
): T | undefined {
|
||||
if (typeof value === "string") {
|
||||
return fn(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function Providers({
|
||||
children,
|
||||
theme,
|
||||
@@ -1,85 +1,85 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fuse.js": "7.1.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/eslint-plugin-next": "15.3.6",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "nebula-app",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "0.0.2"
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"fuse.js": "7.1.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/eslint-plugin-next": "15.3.8",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "nebula-app",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "0.0.2"
|
||||
}
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@abstract-foundation/agw-react": "^1.10.0",
|
||||
"@ai-sdk/react": "^2.0.25",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@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-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@thirdweb-dev/ai-sdk-provider": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"ai": "^5.0.25",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "12.1.3",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-pick-color": "^2.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"streamdown": "^1.1.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"thirdweb": "workspace:*",
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-config-next": "15.3.6",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"knip": "5.60.2",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "playground-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "rm -rf .next && next dev --turbopack",
|
||||
"fix": "eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"prefix": "biome check ./src --fix",
|
||||
"prelint": "biome check ./src",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "0.1.1"
|
||||
"dependencies": {
|
||||
"@abstract-foundation/agw-react": "^1.10.0",
|
||||
"@ai-sdk/react": "^2.0.25",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@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-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@thirdweb-dev/ai-sdk-provider": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"ai": "^5.0.25",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "12.1.3",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-pick-color": "^2.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "3.12.0",
|
||||
"sonner": "2.0.6",
|
||||
"streamdown": "^1.1.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"thirdweb": "workspace:*",
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-config-next": "15.3.8",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"knip": "5.60.2",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"name": "playground-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "rm -rf .next && next dev --turbopack",
|
||||
"fix": "eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"prefix": "biome check ./src --fix",
|
||||
"prelint": "biome check ./src",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "0.1.1"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ChatPageContent(props: {
|
||||
const userChainIdArray = userChainIds
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== "" && !isNaN(Number(id)));
|
||||
.filter((id) => id !== "" && !Number.isNaN(Number(id)));
|
||||
|
||||
return {
|
||||
chainIds:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createThirdwebClient, defineChain } from "thirdweb";
|
||||
import { toUnits } from "thirdweb/utils";
|
||||
import { facilitator, settlePayment } from "thirdweb/x402";
|
||||
import { facilitator, settlePayment, verifyPayment } from "thirdweb/x402";
|
||||
import { token } from "../../x402/components/constants";
|
||||
|
||||
// Allow streaming responses up to 5 minutes
|
||||
@@ -27,7 +27,9 @@ export async function GET(request: NextRequest) {
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const paymentData = request.headers.get("X-PAYMENT");
|
||||
const paymentData =
|
||||
request.headers.get("PAYMENT-SIGNATURE") ||
|
||||
request.headers.get("X-PAYMENT");
|
||||
const queryParams = request.nextUrl.searchParams;
|
||||
|
||||
const chainId = queryParams.get("chainId");
|
||||
@@ -46,27 +48,78 @@ export async function GET(request: NextRequest) {
|
||||
const waitUntil =
|
||||
(queryParams.get("waitUntil") as "simulated" | "submitted" | "confirmed") ||
|
||||
"simulated";
|
||||
const scheme = (queryParams.get("scheme") as "exact" | "upto") || "exact";
|
||||
const minPriceAmount = queryParams.get("minPrice");
|
||||
const settlementAmount = queryParams.get("settlementAmount");
|
||||
|
||||
const result = await settlePayment({
|
||||
const priceConfig = tokenAddress
|
||||
? {
|
||||
amount: toUnits(amount, parseInt(decimals)).toString(),
|
||||
asset: {
|
||||
address: tokenAddress as `0x${string}`,
|
||||
decimals: decimals ? parseInt(decimals) : token.decimals,
|
||||
},
|
||||
}
|
||||
: amount;
|
||||
|
||||
const minPriceConfig =
|
||||
scheme === "upto" && minPriceAmount
|
||||
? tokenAddress
|
||||
? {
|
||||
amount: toUnits(minPriceAmount, parseInt(decimals)).toString(),
|
||||
asset: {
|
||||
address: tokenAddress as `0x${string}`,
|
||||
decimals: decimals ? parseInt(decimals) : token.decimals,
|
||||
},
|
||||
}
|
||||
: minPriceAmount
|
||||
: undefined;
|
||||
|
||||
let finalPriceConfig = priceConfig;
|
||||
|
||||
const paymentArgs = {
|
||||
resourceUrl: "https://playground-web.thirdweb.com/api/paywall",
|
||||
method: "GET",
|
||||
paymentData,
|
||||
network: defineChain(Number(chainId)),
|
||||
payTo,
|
||||
price: tokenAddress
|
||||
? {
|
||||
amount: toUnits(amount, parseInt(decimals)).toString(),
|
||||
asset: {
|
||||
address: tokenAddress as `0x${string}`,
|
||||
decimals: decimals ? parseInt(decimals) : token.decimals,
|
||||
},
|
||||
}
|
||||
: amount,
|
||||
scheme,
|
||||
price: priceConfig,
|
||||
minPrice: minPriceConfig,
|
||||
routeConfig: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
waitUntil,
|
||||
facilitator: twFacilitator,
|
||||
};
|
||||
|
||||
if (minPriceConfig) {
|
||||
const verifyResult = await verifyPayment(paymentArgs);
|
||||
|
||||
if (verifyResult.status !== 200) {
|
||||
return NextResponse.json(verifyResult.responseBody, {
|
||||
status: verifyResult.status,
|
||||
headers: verifyResult.responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// If settlementAmount is provided, override the price for settlement
|
||||
if (settlementAmount) {
|
||||
finalPriceConfig = tokenAddress
|
||||
? {
|
||||
amount: toUnits(settlementAmount, parseInt(decimals)).toString(),
|
||||
asset: {
|
||||
address: tokenAddress as `0x${string}`,
|
||||
decimals: decimals ? parseInt(decimals) : token.decimals,
|
||||
},
|
||||
}
|
||||
: settlementAmount;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await settlePayment({
|
||||
...paymentArgs,
|
||||
price: finalPriceConfig,
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
@@ -76,7 +129,7 @@ export async function GET(request: NextRequest) {
|
||||
success: true,
|
||||
message: "Payment successful. You have accessed the protected route.",
|
||||
payment: {
|
||||
amount,
|
||||
amount: settlementAmount || amount,
|
||||
tokenAddress,
|
||||
},
|
||||
receipt: result.paymentReceipt,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TabButtons } from "../../../../components/ui/tab-buttons";
|
||||
import { LeftSection } from "./left-section";
|
||||
import { RightSection } from "./right-section";
|
||||
import type { BridgeWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const defaultOptions: BridgeWidgetPlaygroundOptions = {
|
||||
integrationType: "iframe",
|
||||
prefill: undefined,
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
theme: {
|
||||
darkColorOverrides: {},
|
||||
lightColorOverrides: {},
|
||||
type: "dark",
|
||||
},
|
||||
};
|
||||
|
||||
function updatePageUrl(tab: BridgeWidgetPlaygroundOptions["integrationType"]) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === defaultOptions.integrationType) {
|
||||
url.searchParams.delete("tab");
|
||||
} else {
|
||||
url.searchParams.set("tab", tab);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export function BridgeWidgetPlayground(props: {
|
||||
defaultTab: "iframe" | "script" | "react" | undefined;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [options, setOptions] = useState<BridgeWidgetPlaygroundOptions>(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
integrationType: props.defaultTab || defaultOptions.integrationType,
|
||||
};
|
||||
});
|
||||
|
||||
// change theme on global theme change
|
||||
useEffect(() => {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
theme: {
|
||||
...prev.theme,
|
||||
type: theme === "dark" ? "dark" : "light",
|
||||
},
|
||||
}));
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePageUrl(options.integrationType);
|
||||
}, [options.integrationType]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "Iframe",
|
||||
onClick: () =>
|
||||
setOptions({ ...options, integrationType: "iframe" }),
|
||||
isActive: options.integrationType === "iframe",
|
||||
},
|
||||
{
|
||||
name: "Script",
|
||||
onClick: () =>
|
||||
setOptions({ ...options, integrationType: "script" }),
|
||||
isActive: options.integrationType === "script",
|
||||
},
|
||||
{
|
||||
name: "React",
|
||||
onClick: () => setOptions({ ...options, integrationType: "react" }),
|
||||
isActive: options.integrationType === "react",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
<RightSection options={options} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { BridgeWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const BRIDGE_WIDGET_IFRAME_BASE_URL = "https://thirdweb.com/bridge/widget";
|
||||
|
||||
export function buildIframeUrl(
|
||||
options: BridgeWidgetPlaygroundOptions,
|
||||
type: "code" | "preview",
|
||||
) {
|
||||
const url = new URL(BRIDGE_WIDGET_IFRAME_BASE_URL);
|
||||
|
||||
if (type === "preview") {
|
||||
// always set it to false so playground doesn't show last used tokens
|
||||
url.searchParams.set("persistTokenSelections", "false");
|
||||
}
|
||||
|
||||
// Theme (only add if light, dark is default)
|
||||
if (options.theme.type === "light") {
|
||||
url.searchParams.set("theme", "light");
|
||||
}
|
||||
|
||||
// Currency (only add if not USD, USD is default)
|
||||
if (options.currency && options.currency !== "USD") {
|
||||
url.searchParams.set("currency", options.currency);
|
||||
}
|
||||
|
||||
// Branding
|
||||
if (options.showThirdwebBranding === false) {
|
||||
url.searchParams.set("showThirdwebBranding", "false");
|
||||
}
|
||||
|
||||
// Sell token (input)
|
||||
if (options.prefill?.sellToken) {
|
||||
url.searchParams.set(
|
||||
"inputChain",
|
||||
String(options.prefill.sellToken.chainId),
|
||||
);
|
||||
if (options.prefill.sellToken.tokenAddress) {
|
||||
url.searchParams.set(
|
||||
"inputCurrency",
|
||||
options.prefill.sellToken.tokenAddress,
|
||||
);
|
||||
}
|
||||
if (options.prefill.sellToken.amount) {
|
||||
url.searchParams.set(
|
||||
"inputCurrencyAmount",
|
||||
options.prefill.sellToken.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Buy token (output)
|
||||
if (options.prefill?.buyToken) {
|
||||
url.searchParams.set(
|
||||
"outputChain",
|
||||
String(options.prefill.buyToken.chainId),
|
||||
);
|
||||
if (options.prefill.buyToken.tokenAddress) {
|
||||
url.searchParams.set(
|
||||
"outputCurrency",
|
||||
options.prefill.buyToken.tokenAddress,
|
||||
);
|
||||
}
|
||||
if (options.prefill.buyToken.amount) {
|
||||
url.searchParams.set(
|
||||
"outputCurrencyAmount",
|
||||
options.prefill.buyToken.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
|
||||
import { buildIframeUrl } from "./buildIframeUrl";
|
||||
import type { BridgeWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
import("../../../../components/code/code.client").then((m) => ({
|
||||
default: m.CodeClient,
|
||||
})),
|
||||
);
|
||||
|
||||
function CodeLoading() {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center bg-card border rounded-lg">
|
||||
<LoadingDots />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeGen(props: { options: BridgeWidgetPlaygroundOptions }) {
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient
|
||||
className="grow"
|
||||
code={getCode(props.options)}
|
||||
lang={props.options.integrationType === "react" ? "tsx" : "html"}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCode(options: BridgeWidgetPlaygroundOptions) {
|
||||
if (options.integrationType === "script") {
|
||||
return getCode_Script(options);
|
||||
}
|
||||
if (options.integrationType === "react") {
|
||||
return getCode_ReactComponent(options);
|
||||
}
|
||||
if (options.integrationType === "iframe") {
|
||||
return getCode_Iframe(options);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getCode_Script(options: BridgeWidgetPlaygroundOptions) {
|
||||
const widgetOptions: Record<string, unknown> = {
|
||||
clientId: "your-thirdweb-client-id",
|
||||
};
|
||||
|
||||
// Theme configuration
|
||||
if (options.theme.type === "light") {
|
||||
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
|
||||
widgetOptions.theme = {
|
||||
type: "light",
|
||||
...options.theme.lightColorOverrides,
|
||||
};
|
||||
} else {
|
||||
widgetOptions.theme = "light";
|
||||
}
|
||||
} else {
|
||||
// dark theme
|
||||
if (Object.keys(options.theme.darkColorOverrides || {}).length > 0) {
|
||||
widgetOptions.theme = {
|
||||
type: "dark",
|
||||
...options.theme.darkColorOverrides,
|
||||
};
|
||||
}
|
||||
// default is dark, so no need to set if no overrides
|
||||
}
|
||||
|
||||
// Currency
|
||||
if (options.currency && options.currency !== "USD") {
|
||||
widgetOptions.currency = options.currency;
|
||||
}
|
||||
|
||||
// Branding
|
||||
if (options.showThirdwebBranding === false) {
|
||||
widgetOptions.showThirdwebBranding = false;
|
||||
}
|
||||
|
||||
// Buy tab options (for buyToken)
|
||||
if (options.prefill?.buyToken) {
|
||||
widgetOptions.buy = {
|
||||
chainId: options.prefill.buyToken.chainId,
|
||||
tokenAddress: options.prefill.buyToken.tokenAddress,
|
||||
amount: options.prefill.buyToken.amount,
|
||||
};
|
||||
}
|
||||
|
||||
// Swap prefill options
|
||||
if (options.prefill?.buyToken || options.prefill?.sellToken) {
|
||||
widgetOptions.swap = {
|
||||
prefill: options.prefill,
|
||||
};
|
||||
}
|
||||
|
||||
const optionsString = JSON.stringify(widgetOptions, null, 2)
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
||||
.join("\n");
|
||||
|
||||
return `\
|
||||
<!-- Add this script in <head> -->
|
||||
<script src="https://unpkg.com/thirdweb/dist/scripts/bridge-widget.js"></script>
|
||||
|
||||
<!-- Add a container element where you want to render the UI -->
|
||||
<div id="bridge-widget-container"></div>
|
||||
|
||||
<!-- Initialize the widget at the end of <body> -->
|
||||
<script>
|
||||
const container = document.querySelector("#bridge-widget-container");
|
||||
|
||||
BridgeWidget.render(container, ${optionsString});
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function getCode_ReactComponent(options: BridgeWidgetPlaygroundOptions) {
|
||||
const imports = {
|
||||
thirdweb: ["createThirdwebClient"] as string[],
|
||||
"thirdweb/react": ["BridgeWidget"] as string[],
|
||||
};
|
||||
|
||||
let themeProp: string | undefined;
|
||||
if (
|
||||
options.theme.type === "dark" &&
|
||||
Object.keys(options.theme.darkColorOverrides || {}).length > 0
|
||||
) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
if (options.theme.type === "light") {
|
||||
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
}
|
||||
|
||||
// Build buy prop for buyToken
|
||||
let buyProp: string | undefined;
|
||||
if (options.prefill?.buyToken) {
|
||||
buyProp = JSON.stringify(
|
||||
{
|
||||
chainId: options.prefill.buyToken.chainId,
|
||||
tokenAddress: options.prefill.buyToken.tokenAddress,
|
||||
amount: options.prefill.buyToken.amount,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// Build swap prop with prefill
|
||||
let swapProp: string | undefined;
|
||||
if (options.prefill?.buyToken || options.prefill?.sellToken) {
|
||||
swapProp = JSON.stringify(
|
||||
{
|
||||
prefill: {
|
||||
buyToken: options.prefill?.buyToken,
|
||||
sellToken: options.prefill?.sellToken,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
const props: Record<string, string | undefined | boolean> = {
|
||||
client: "client",
|
||||
theme: themeProp,
|
||||
buy: buyProp,
|
||||
swap: swapProp,
|
||||
currency:
|
||||
options.currency !== "USD" && options.currency
|
||||
? quotes(options.currency)
|
||||
: undefined,
|
||||
showThirdwebBranding:
|
||||
options.showThirdwebBranding === false ? false : undefined,
|
||||
};
|
||||
|
||||
return `\
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "your-thirdweb-client-id",
|
||||
});
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<BridgeWidget ${stringifyProps(props)} />
|
||||
);
|
||||
}`;
|
||||
}
|
||||
|
||||
function getCode_Iframe(options: BridgeWidgetPlaygroundOptions) {
|
||||
const src = buildIframeUrl(options, "code");
|
||||
|
||||
return `\
|
||||
<iframe
|
||||
src="${src}"
|
||||
height="750px"
|
||||
width="100%"
|
||||
style="border: 0;"
|
||||
/>`;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { CoinsIcon, PaletteIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useId } from "react";
|
||||
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { CollapsibleSection } from "@/app/wallets/sign-in/components/CollapsibleSection";
|
||||
import { ColorFormGroup } from "@/app/wallets/sign-in/components/ColorFormGroup";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import { TokenSelector } from "@/components/ui/TokenSelector";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
import { CurrencySelector } from "../../../../components/blocks/CurrencySelector";
|
||||
import type { BridgeWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
export function LeftSection(props: {
|
||||
options: BridgeWidgetPlaygroundOptions;
|
||||
setOptions: React.Dispatch<
|
||||
React.SetStateAction<BridgeWidgetPlaygroundOptions>
|
||||
>;
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
const setThemeType = (themeType: "dark" | "light") => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: {
|
||||
...v.theme,
|
||||
type: themeType,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const themeId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CollapsibleSection defaultOpen icon={CoinsIcon} title="Token Selection">
|
||||
<div className="flex flex-col gap-6 pt-5">
|
||||
<section className="flex flex-col gap-3">
|
||||
<Label htmlFor="currency">Display Currency</Label>
|
||||
<CurrencySelector
|
||||
value={options.currency}
|
||||
onChange={(currency) => {
|
||||
setOptions((v) => ({ ...v, currency }));
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<TokenFieldset
|
||||
title="Sell Token"
|
||||
type="sellToken"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<TokenFieldset
|
||||
title="Buy Token"
|
||||
type="buyToken"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection icon={PaletteIcon} title="Appearance">
|
||||
{/* Theme */}
|
||||
<section className="flex flex-col gap-3 pt-6">
|
||||
<Label htmlFor="theme"> Theme </Label>
|
||||
<CustomRadioGroup
|
||||
id={themeId}
|
||||
onValueChange={setThemeType}
|
||||
options={[
|
||||
{ label: "Dark", value: "dark" },
|
||||
{ label: "Light", value: "light" },
|
||||
]}
|
||||
value={options.theme.type}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Colors - disabled for iframe */}
|
||||
{options.integrationType !== "iframe" && (
|
||||
<ColorFormGroup
|
||||
onChange={(newTheme) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
theme={options.theme}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={options.showThirdwebBranding}
|
||||
id={"branding"}
|
||||
onCheckedChange={(checked) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
showThirdwebBranding: checked === true,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={"branding"}>Show Branding</Label>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenFieldset(props: {
|
||||
type: "buyToken" | "sellToken";
|
||||
title: string;
|
||||
options: BridgeWidgetPlaygroundOptions;
|
||||
setOptions: React.Dispatch<
|
||||
React.SetStateAction<BridgeWidgetPlaygroundOptions>
|
||||
>;
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
|
||||
const chainId = options.prefill?.[props.type]?.chainId;
|
||||
const tokenAddress = options.prefill?.[props.type]?.tokenAddress;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 font-medium">{props.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Sets the default token and amount to{" "}
|
||||
{props.type === "buyToken" ? "buy" : "sell"} in the widget, <br />
|
||||
User can change this default selection in the widget
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Chain selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Chain</Label>
|
||||
<BridgeNetworkSelector
|
||||
chainId={chainId}
|
||||
onChange={(chainId) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
prefill: {
|
||||
...v.prefill,
|
||||
[props.type]: {
|
||||
...v.prefill?.[props.type],
|
||||
chainId,
|
||||
tokenAddress: undefined, // clear token selection
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
placeholder="Select a chain"
|
||||
className="bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token selection - only show if chain is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label>Token</Label>
|
||||
<TokenSelector
|
||||
chainId={chainId}
|
||||
client={THIRDWEB_CLIENT}
|
||||
disableAddress
|
||||
enabled={true}
|
||||
onChange={(token) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
prefill: {
|
||||
...v.prefill,
|
||||
[props.type]: {
|
||||
chainId: token.chainId,
|
||||
tokenAddress: token.address,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
placeholder="Select a token"
|
||||
selectedToken={
|
||||
tokenAddress && chainId
|
||||
? {
|
||||
address: tokenAddress,
|
||||
chainId: chainId,
|
||||
}
|
||||
: chainId
|
||||
? {
|
||||
address: getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: chainId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chainId && (
|
||||
<div className="space-y-2">
|
||||
<Label> Token Amount</Label>
|
||||
<Input
|
||||
className="bg-card"
|
||||
value={options.prefill?.[props.type]?.amount || ""}
|
||||
onChange={(e) => {
|
||||
setOptions((v) => {
|
||||
return {
|
||||
...v,
|
||||
prefill: {
|
||||
...v.prefill,
|
||||
[props.type]: {
|
||||
...v.prefill?.[props.type],
|
||||
amount: e.target.value,
|
||||
chainId: chainId,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { BridgeWidget, darkTheme, lightTheme } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildIframeUrl } from "./buildIframeUrl";
|
||||
import { CodeGen } from "./code";
|
||||
import type { BridgeWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
type Tab = "ui" | "code";
|
||||
|
||||
export function RightSection(props: {
|
||||
options: BridgeWidgetPlaygroundOptions;
|
||||
}) {
|
||||
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
|
||||
return "ui";
|
||||
});
|
||||
|
||||
function setPreviewTab(tab: "ui" | "code") {
|
||||
_setPreviewTab(tab);
|
||||
}
|
||||
|
||||
const themeObj =
|
||||
props.options.theme.type === "dark"
|
||||
? darkTheme({
|
||||
colors: props.options.theme.darkColorOverrides,
|
||||
})
|
||||
: lightTheme({
|
||||
colors: props.options.theme.lightColorOverrides,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-4 xl:sticky xl:top-0 xl:max-h-[100vh] xl:w-[764px]">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
isActive: previewTab === "ui",
|
||||
name: "Preview",
|
||||
onClick: () => setPreviewTab("ui"),
|
||||
},
|
||||
{
|
||||
isActive: previewTab === "code",
|
||||
name: "Code",
|
||||
onClick: () => setPreviewTab("code"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-[300px] grow justify-center rounded-lg",
|
||||
previewTab !== "code" && "items-center",
|
||||
)}
|
||||
>
|
||||
{previewTab === "ui" && props.options.integrationType !== "iframe" && (
|
||||
<BridgeWidget
|
||||
className="shadow-xl"
|
||||
client={THIRDWEB_CLIENT}
|
||||
theme={themeObj}
|
||||
buy={
|
||||
props.options.prefill?.buyToken
|
||||
? {
|
||||
chainId: props.options.prefill.buyToken.chainId,
|
||||
tokenAddress: props.options.prefill.buyToken.tokenAddress,
|
||||
amount: props.options.prefill.buyToken.amount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
swap={{
|
||||
persistTokenSelections: false,
|
||||
prefill: {
|
||||
buyToken: props.options.prefill?.buyToken
|
||||
? {
|
||||
chainId: props.options.prefill.buyToken.chainId,
|
||||
tokenAddress: props.options.prefill.buyToken.tokenAddress,
|
||||
amount: props.options.prefill.buyToken.amount,
|
||||
}
|
||||
: undefined,
|
||||
sellToken: props.options.prefill?.sellToken
|
||||
? {
|
||||
chainId: props.options.prefill.sellToken.chainId,
|
||||
tokenAddress:
|
||||
props.options.prefill.sellToken.tokenAddress,
|
||||
amount: props.options.prefill.sellToken.amount,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
currency={props.options.currency}
|
||||
showThirdwebBranding={props.options.showThirdwebBranding}
|
||||
key={JSON.stringify(props.options.prefill)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewTab === "ui" && props.options.integrationType === "iframe" && (
|
||||
<iframe
|
||||
src={buildIframeUrl(props.options, "preview")}
|
||||
height="750px"
|
||||
width="100%"
|
||||
title="Bridge Widget"
|
||||
className="fade-in-0 duration-500 animate-in rounded-xl"
|
||||
style={{
|
||||
border: "0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewTab === "code" && <CodeGen options={props.options} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButtons(props: {
|
||||
tabs: Array<{
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="inline-flex justify-start gap-1 rounded-lg border bg-card p-1.5">
|
||||
{props.tabs.map((tab) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"gap-2 px-4",
|
||||
tab.isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "bg-transparent text-muted-foreground",
|
||||
)}
|
||||
key={tab.name}
|
||||
onClick={tab.onClick}
|
||||
variant="ghost"
|
||||
>
|
||||
{tab.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SupportedFiatCurrency,
|
||||
SwapWidgetProps,
|
||||
ThemeOverrides,
|
||||
} from "thirdweb/react";
|
||||
|
||||
export type BridgeWidgetPlaygroundOptions = {
|
||||
integrationType: "iframe" | "script" | "react";
|
||||
theme: {
|
||||
type: "dark" | "light";
|
||||
darkColorOverrides: ThemeOverrides["colors"];
|
||||
lightColorOverrides: ThemeOverrides["colors"];
|
||||
};
|
||||
currency?: SupportedFiatCurrency;
|
||||
prefill?: {
|
||||
buyToken?: {
|
||||
tokenAddress?: string;
|
||||
chainId: number;
|
||||
amount?: string;
|
||||
};
|
||||
sellToken?: {
|
||||
tokenAddress?: string;
|
||||
chainId: number;
|
||||
amount?: string;
|
||||
};
|
||||
};
|
||||
showThirdwebBranding?: SwapWidgetProps["showThirdwebBranding"];
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import { PageLayout } from "@/components/blocks/APIHeader";
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { BridgeWidgetPlayground } from "./components/bridge-playground";
|
||||
|
||||
const title = "Bridge Widget";
|
||||
const description =
|
||||
"Easily embed cross-chain token swap and fiat onramp UI into your app";
|
||||
|
||||
const validTabs = ["iframe", "script", "react"] as const;
|
||||
type ValidTabs = (typeof validTabs)[number];
|
||||
|
||||
export const metadata = createMetadata({
|
||||
description: description,
|
||||
title,
|
||||
image: {
|
||||
icon: "payments",
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
tab?: string | undefined | string[];
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const tab =
|
||||
typeof searchParams.tab === "string" ? searchParams.tab : undefined;
|
||||
|
||||
const validTab = validTabs.includes(tab as ValidTabs)
|
||||
? (tab as ValidTabs)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
icon={BringToFrontIcon}
|
||||
title={title}
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/bridge/bridge-widget?utm_source=playground"
|
||||
>
|
||||
<BridgeWidgetPlayground defaultTab={validTab} />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { arbitrum } from "thirdweb/chains";
|
||||
import { TabButtons } from "@/components/ui/tab-buttons";
|
||||
import { LeftSection } from "../components/LeftSection";
|
||||
import { RightSection } from "../components/RightSection";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
|
||||
|
||||
const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
integrationType: "react",
|
||||
payOptions: {
|
||||
buyTokenAddress: undefined,
|
||||
buyTokenAmount: "0.002",
|
||||
@@ -21,6 +24,8 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
transactionData: "",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
amountEditable: true,
|
||||
tokenEditable: true,
|
||||
},
|
||||
theme: {
|
||||
darkColorOverrides: {},
|
||||
@@ -29,16 +34,70 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export function BuyPlayground() {
|
||||
const [options, setOptions] =
|
||||
useState<BridgeComponentsPlaygroundOptions>(defaultOptions);
|
||||
function updatePageUrl(
|
||||
tab: BridgeComponentsPlaygroundOptions["integrationType"],
|
||||
) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === defaultOptions.integrationType) {
|
||||
url.searchParams.delete("tab");
|
||||
} else {
|
||||
url.searchParams.set("tab", tab || "");
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export function BuyPlayground(props: { defaultTab?: "iframe" | "react" }) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [options, setOptions] = useState<BridgeComponentsPlaygroundOptions>(
|
||||
() => ({
|
||||
...defaultOptions,
|
||||
integrationType: props.defaultTab || defaultOptions.integrationType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Change theme on global theme change
|
||||
useEffect(() => {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
theme: {
|
||||
...prev.theme,
|
||||
type: theme === "dark" ? "dark" : "light",
|
||||
},
|
||||
}));
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePageUrl(options.integrationType);
|
||||
}, [options.integrationType]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection widget="buy" options={options} setOptions={setOptions} />
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "React",
|
||||
onClick: () => setOptions({ ...options, integrationType: "react" }),
|
||||
isActive: options.integrationType === "react",
|
||||
},
|
||||
{
|
||||
name: "Iframe",
|
||||
onClick: () =>
|
||||
setOptions({ ...options, integrationType: "iframe" }),
|
||||
isActive: options.integrationType === "iframe",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection widget="buy" options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
<RightSection widget="buy" options={options} />
|
||||
</div>
|
||||
<RightSection widget="buy" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,16 +19,24 @@ export const metadata = createMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const defaultTab =
|
||||
searchParams.tab === "iframe" || searchParams.tab === "react"
|
||||
? searchParams.tab
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
icon={ShoppingBagIcon}
|
||||
title={title}
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
|
||||
docsLink="https://portal.thirdweb.com/references/typescript/v5/BuyWidget?utm_source=playground"
|
||||
>
|
||||
<BuyPlayground />
|
||||
<BuyPlayground defaultTab={defaultTab} />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { arbitrum } from "thirdweb/chains";
|
||||
import { TabButtons } from "@/components/ui/tab-buttons";
|
||||
import { LeftSection } from "../components/LeftSection";
|
||||
import { RightSection } from "../components/RightSection";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
|
||||
|
||||
const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
integrationType: "react",
|
||||
payOptions: {
|
||||
buyTokenAddress: undefined,
|
||||
buyTokenAmount: "0.01",
|
||||
@@ -21,6 +24,8 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
receiverAddress: undefined,
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
amountEditable: true,
|
||||
tokenEditable: true,
|
||||
},
|
||||
theme: {
|
||||
darkColorOverrides: {},
|
||||
@@ -29,20 +34,74 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export function CheckoutPlayground() {
|
||||
const [options, setOptions] =
|
||||
useState<BridgeComponentsPlaygroundOptions>(defaultOptions);
|
||||
function updatePageUrl(
|
||||
tab: BridgeComponentsPlaygroundOptions["integrationType"],
|
||||
) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === defaultOptions.integrationType) {
|
||||
url.searchParams.delete("tab");
|
||||
} else {
|
||||
url.searchParams.set("tab", tab || "");
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export function CheckoutPlayground(props: { defaultTab?: "iframe" | "react" }) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [options, setOptions] = useState<BridgeComponentsPlaygroundOptions>(
|
||||
() => ({
|
||||
...defaultOptions,
|
||||
integrationType: props.defaultTab || defaultOptions.integrationType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Change theme on global theme change
|
||||
useEffect(() => {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
theme: {
|
||||
...prev.theme,
|
||||
type: theme === "dark" ? "dark" : "light",
|
||||
},
|
||||
}));
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePageUrl(options.integrationType);
|
||||
}, [options.integrationType]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
widget="checkout"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "React",
|
||||
onClick: () => setOptions({ ...options, integrationType: "react" }),
|
||||
isActive: options.integrationType === "react",
|
||||
},
|
||||
{
|
||||
name: "Iframe",
|
||||
onClick: () =>
|
||||
setOptions({ ...options, integrationType: "iframe" }),
|
||||
isActive: options.integrationType === "iframe",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
widget="checkout"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
<RightSection widget="checkout" options={options} />
|
||||
</div>
|
||||
<RightSection widget="checkout" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ const description =
|
||||
const ogDescription =
|
||||
"Accept fiat or crypto payments on any chain—direct to your wallet. Instant checkout, webhook support, and full control over post-sale actions.";
|
||||
|
||||
const validTabs = ["iframe", "react"] as const;
|
||||
type ValidTabs = (typeof validTabs)[number];
|
||||
|
||||
export const metadata = createMetadata({
|
||||
description: ogDescription,
|
||||
title,
|
||||
@@ -19,16 +22,28 @@ export const metadata = createMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
tab?: string | undefined | string[];
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const tab =
|
||||
typeof searchParams.tab === "string" ? searchParams.tab : undefined;
|
||||
|
||||
const validTab = validTabs.includes(tab as ValidTabs)
|
||||
? (tab as ValidTabs)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
icon={CreditCardIcon}
|
||||
title={title}
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/payments?utm_source=playground"
|
||||
docsLink="https://portal.thirdweb.com/bridge/checkout-widget?utm_source=playground"
|
||||
>
|
||||
<CheckoutPlayground />
|
||||
<CheckoutPlayground defaultTab={validTab} />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
stringifyImports,
|
||||
stringifyProps,
|
||||
} from "../../../lib/code-gen";
|
||||
import { buildBuyIframeUrl } from "./buildBuyIframeUrl";
|
||||
import { buildCheckoutIframeUrl } from "./buildCheckoutIframeUrl";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
@@ -25,19 +27,48 @@ export function CodeGen(props: {
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
}) {
|
||||
const isIframe =
|
||||
(props.widget === "checkout" || props.widget === "buy") &&
|
||||
props.options.integrationType === "iframe";
|
||||
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient
|
||||
className="grow"
|
||||
code={getCode(props.widget, props.options)}
|
||||
lang="tsx"
|
||||
code={
|
||||
isIframe
|
||||
? getIframeCode(props.widget as "buy" | "checkout", props.options)
|
||||
: getCode(props.widget, props.options)
|
||||
}
|
||||
lang={isIframe ? "html" : "tsx"}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIframeCode(
|
||||
widget: "buy" | "checkout",
|
||||
options: BridgeComponentsPlaygroundOptions,
|
||||
) {
|
||||
const src =
|
||||
widget === "buy"
|
||||
? buildBuyIframeUrl(options)
|
||||
: buildCheckoutIframeUrl(options);
|
||||
|
||||
const hasImage = src.includes("image=");
|
||||
const height = hasImage ? "850px" : "700px";
|
||||
|
||||
return `\
|
||||
<iframe
|
||||
src="${src}"
|
||||
height="${height}"
|
||||
width="100%"
|
||||
style="border: 0;"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function getCode(
|
||||
widget: "buy" | "checkout" | "transaction",
|
||||
options: BridgeComponentsPlaygroundOptions,
|
||||
@@ -129,6 +160,14 @@ tokenId: 2n,
|
||||
? quotes(options.payOptions.buttonLabel)
|
||||
: undefined,
|
||||
transaction: transaction,
|
||||
amountEditable:
|
||||
widget === "buy" && options.payOptions.amountEditable === false
|
||||
? false
|
||||
: undefined,
|
||||
tokenEditable:
|
||||
widget === "buy" && options.payOptions.tokenEditable === false
|
||||
? false
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return `\
|
||||
|
||||
@@ -259,6 +259,46 @@ export function LeftSection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editability Options */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Editability Options</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={payOptions.tokenEditable}
|
||||
id="token-editable"
|
||||
onCheckedChange={(checked) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
tokenEditable: checked === true,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="token-editable">Token Editable</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={payOptions.amountEditable}
|
||||
id="amount-editable"
|
||||
onCheckedChange={(checked) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
payOptions: {
|
||||
...v.payOptions,
|
||||
amountEditable: checked === true,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="amount-editable">Amount Editable</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -484,16 +524,21 @@ export function LeftSection(props: {
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Colors */}
|
||||
<ColorFormGroup
|
||||
onChange={(newTheme) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
theme={options.theme}
|
||||
/>
|
||||
{/* Colors - disabled for iframe */}
|
||||
{!(
|
||||
(props.widget === "checkout" || props.widget === "buy") &&
|
||||
options.integrationType === "iframe"
|
||||
) && (
|
||||
<ColorFormGroup
|
||||
onChange={(newTheme) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
theme={options.theme}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { THIRDWEB_CLIENT } from "../../../lib/client";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { buildBuyIframeUrl } from "./buildBuyIframeUrl";
|
||||
import { buildCheckoutIframeUrl } from "./buildCheckoutIframeUrl";
|
||||
import { CodeGen } from "./CodeGen";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
|
||||
@@ -68,6 +70,8 @@ export function RightSection(props: {
|
||||
currency={props.options.payOptions.currency}
|
||||
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
|
||||
receiverAddress={props.options.payOptions.receiverAddress}
|
||||
amountEditable={props.options.payOptions.amountEditable}
|
||||
tokenEditable={props.options.payOptions.tokenEditable}
|
||||
key={JSON.stringify({
|
||||
amount: props.options.payOptions.buyTokenAmount,
|
||||
chain: props.options.payOptions.buyTokenChain,
|
||||
@@ -154,9 +158,18 @@ export function RightSection(props: {
|
||||
previewTab !== "code" && "items-center",
|
||||
)}
|
||||
>
|
||||
<BackgroundPattern />
|
||||
|
||||
{previewTab === "ui" && embed}
|
||||
{previewTab === "ui" &&
|
||||
(props.options.integrationType === "iframe" ? (
|
||||
props.widget === "checkout" ? (
|
||||
<CheckoutIframePreview options={props.options} />
|
||||
) : props.widget === "buy" ? (
|
||||
<BuyWidgetIframePreview options={props.options} />
|
||||
) : (
|
||||
embed
|
||||
)
|
||||
) : (
|
||||
embed
|
||||
))}
|
||||
|
||||
{previewTab === "code" && (
|
||||
<CodeGen widget={props.widget} options={props.options} />
|
||||
@@ -166,7 +179,46 @@ export function RightSection(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundPattern() {
|
||||
function CheckoutIframePreview(props: {
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
}) {
|
||||
const src = buildCheckoutIframeUrl(props.options);
|
||||
return (
|
||||
<iframe
|
||||
src={src}
|
||||
height="700px"
|
||||
width="100%"
|
||||
title="Checkout Widget"
|
||||
className="fade-in-0 animate-in rounded-xl duration-500"
|
||||
style={{
|
||||
border: "0",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BuyWidgetIframePreview(props: {
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
}) {
|
||||
const src = buildBuyIframeUrl(props.options);
|
||||
|
||||
const hasImage = src.includes("image=");
|
||||
const height = hasImage ? "850px" : "700px";
|
||||
return (
|
||||
<iframe
|
||||
src={src}
|
||||
height={height}
|
||||
width="100%"
|
||||
title="Buy Widget"
|
||||
className="fade-in-0 animate-in rounded-xl duration-500"
|
||||
style={{
|
||||
border: "0",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function _BackgroundPattern() {
|
||||
const color = "hsl(var(--foreground)/15%)";
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
|
||||
const BUY_WIDGET_IFRAME_BASE_URL = "https://thirdweb.com/bridge/buy-widget";
|
||||
|
||||
export function buildBuyIframeUrl(options: BridgeComponentsPlaygroundOptions) {
|
||||
const url = new URL(BUY_WIDGET_IFRAME_BASE_URL);
|
||||
|
||||
// Chain (optional)
|
||||
if (options.payOptions.buyTokenChain?.id) {
|
||||
url.searchParams.set("chain", String(options.payOptions.buyTokenChain.id));
|
||||
}
|
||||
|
||||
// Token address (optional - if not set, native token is used)
|
||||
if (options.payOptions.buyTokenAddress) {
|
||||
url.searchParams.set("tokenAddress", options.payOptions.buyTokenAddress);
|
||||
}
|
||||
|
||||
// Amount (optional)
|
||||
if (options.payOptions.buyTokenAmount) {
|
||||
url.searchParams.set("amount", options.payOptions.buyTokenAmount);
|
||||
}
|
||||
|
||||
// Receiver address (optional)
|
||||
if (options.payOptions.receiverAddress) {
|
||||
url.searchParams.set("receiver", options.payOptions.receiverAddress);
|
||||
}
|
||||
|
||||
// Theme (only add if light, dark is default)
|
||||
if (options.theme.type === "light") {
|
||||
url.searchParams.set("theme", "light");
|
||||
}
|
||||
|
||||
// Currency (only add if not USD, USD is default)
|
||||
if (options.payOptions.currency && options.payOptions.currency !== "USD") {
|
||||
url.searchParams.set("currency", options.payOptions.currency);
|
||||
}
|
||||
|
||||
// Branding
|
||||
if (options.payOptions.showThirdwebBranding === false) {
|
||||
url.searchParams.set("showThirdwebBranding", "false");
|
||||
}
|
||||
|
||||
// Product info
|
||||
if (options.payOptions.title) {
|
||||
url.searchParams.set("title", options.payOptions.title);
|
||||
}
|
||||
|
||||
if (options.payOptions.description) {
|
||||
url.searchParams.set("description", options.payOptions.description);
|
||||
}
|
||||
|
||||
if (options.payOptions.image) {
|
||||
url.searchParams.set("image", options.payOptions.image);
|
||||
}
|
||||
|
||||
if (options.payOptions.buttonLabel) {
|
||||
url.searchParams.set("buttonLabel", options.payOptions.buttonLabel);
|
||||
}
|
||||
|
||||
// Payment methods
|
||||
if (
|
||||
options.payOptions.paymentMethods &&
|
||||
options.payOptions.paymentMethods.length === 1
|
||||
) {
|
||||
url.searchParams.set(
|
||||
"paymentMethods",
|
||||
options.payOptions.paymentMethods[0],
|
||||
);
|
||||
}
|
||||
|
||||
// Editability options
|
||||
if (options.payOptions.amountEditable === false) {
|
||||
url.searchParams.set("amountEditable", "false");
|
||||
}
|
||||
|
||||
if (options.payOptions.tokenEditable === false) {
|
||||
url.searchParams.set("tokenEditable", "false");
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||