Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
|
||||
const CHECKOUT_WIDGET_IFRAME_BASE_URL =
|
||||
"https://thirdweb.com/bridge/checkout-widget";
|
||||
|
||||
export function buildCheckoutIframeUrl(
|
||||
options: BridgeComponentsPlaygroundOptions,
|
||||
) {
|
||||
const url = new URL(CHECKOUT_WIDGET_IFRAME_BASE_URL);
|
||||
|
||||
// Required params
|
||||
url.searchParams.set("chain", String(options.payOptions.buyTokenChain.id));
|
||||
url.searchParams.set("amount", options.payOptions.buyTokenAmount);
|
||||
url.searchParams.set("seller", options.payOptions.sellerAddress);
|
||||
|
||||
// Token address (optional - if not set, native token is used)
|
||||
if (options.payOptions.buyTokenAddress) {
|
||||
url.searchParams.set("tokenAddress", options.payOptions.buyTokenAddress);
|
||||
}
|
||||
|
||||
// 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],
|
||||
);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const CURRENCIES = [
|
||||
type SupportedFiatCurrency = (typeof CURRENCIES)[number];
|
||||
|
||||
export type BridgeComponentsPlaygroundOptions = {
|
||||
integrationType?: "iframe" | "react";
|
||||
theme: {
|
||||
type: "dark" | "light";
|
||||
darkColorOverrides: ThemeOverrides["colors"];
|
||||
@@ -58,5 +59,9 @@ export type BridgeComponentsPlaygroundOptions = {
|
||||
currency?: SupportedFiatCurrency;
|
||||
|
||||
showThirdwebBranding: boolean;
|
||||
|
||||
// Editability options
|
||||
amountEditable: boolean;
|
||||
tokenEditable: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import { OverviewPage } from "@/components/blocks/OverviewPage";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { bridgeFeatureCards } from "../data/pages-metadata";
|
||||
|
||||
export default function Page() {
|
||||
@@ -8,7 +8,7 @@ export default function Page() {
|
||||
title="Bridge"
|
||||
description="thirdweb Bridge lets developers swap and transfer any token across any chain instantly"
|
||||
featureCards={bridgeFeatureCards}
|
||||
icon={PayIcon}
|
||||
icon={BringToFrontIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const SWAP_WIDGET_IFRAME_BASE_URL = "https://thirdweb.com/bridge/swap-widget";
|
||||
|
||||
export function buildSwapIframeUrl(
|
||||
options: SwapWidgetPlaygroundOptions,
|
||||
type: "code" | "preview" = "preview",
|
||||
) {
|
||||
const url = new URL(SWAP_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");
|
||||
}
|
||||
|
||||
// Buy token params
|
||||
if (options.prefill?.buyToken?.chainId) {
|
||||
url.searchParams.set("buyChain", String(options.prefill.buyToken.chainId));
|
||||
|
||||
if (options.prefill.buyToken.tokenAddress) {
|
||||
url.searchParams.set(
|
||||
"buyTokenAddress",
|
||||
options.prefill.buyToken.tokenAddress,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.prefill.buyToken.amount) {
|
||||
url.searchParams.set("buyAmount", options.prefill.buyToken.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Sell token params
|
||||
if (options.prefill?.sellToken?.chainId) {
|
||||
url.searchParams.set(
|
||||
"sellChain",
|
||||
String(options.prefill.sellToken.chainId),
|
||||
);
|
||||
|
||||
if (options.prefill.sellToken.tokenAddress) {
|
||||
url.searchParams.set(
|
||||
"sellTokenAddress",
|
||||
options.prefill.sellToken.tokenAddress,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.prefill.sellToken.amount) {
|
||||
url.searchParams.set("sellAmount", options.prefill.sellToken.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
|
||||
import { buildSwapIframeUrl } from "./buildSwapIframeUrl";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
@@ -18,16 +19,35 @@ function CodeLoading() {
|
||||
}
|
||||
|
||||
export function CodeGen(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
const code =
|
||||
props.options.integrationType === "iframe"
|
||||
? getIframeCode(props.options)
|
||||
: getReactCode(props.options);
|
||||
|
||||
const lang = props.options.integrationType === "iframe" ? "html" : "ts";
|
||||
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient className="grow" code={getCode(props.options)} lang="ts" />
|
||||
<CodeClient className="grow" code={code} lang={lang} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCode(options: SwapWidgetPlaygroundOptions) {
|
||||
function getIframeCode(options: SwapWidgetPlaygroundOptions) {
|
||||
// Use "code" type to exclude persistTokenSelections from the generated code
|
||||
const iframeUrl = buildSwapIframeUrl(options, "code");
|
||||
|
||||
return `<iframe
|
||||
src="${iframeUrl}"
|
||||
height="700px"
|
||||
width="100%"
|
||||
style="border: 0;"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function getReactCode(options: SwapWidgetPlaygroundOptions) {
|
||||
const imports = {
|
||||
thirdweb: ["createThirdwebClient"] as string[],
|
||||
"thirdweb/react": ["SwapWidget"] as string[],
|
||||
|
||||
@@ -85,16 +85,18 @@ export function LeftSection(props: {
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Colors */}
|
||||
<ColorFormGroup
|
||||
onChange={(newTheme) => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
theme: newTheme,
|
||||
}));
|
||||
}}
|
||||
theme={options.theme}
|
||||
/>
|
||||
{/* 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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { darkTheme, lightTheme, SwapWidget } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildSwapIframeUrl } from "./buildSwapIframeUrl";
|
||||
import { CodeGen } from "./code";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
@@ -50,21 +51,31 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
previewTab !== "code" && "items-center",
|
||||
)}
|
||||
>
|
||||
<BackgroundPattern />
|
||||
|
||||
{previewTab === "ui" && (
|
||||
<SwapWidget
|
||||
client={THIRDWEB_CLIENT}
|
||||
theme={themeObj}
|
||||
prefill={props.options.prefill}
|
||||
currency={props.options.currency}
|
||||
showThirdwebBranding={props.options.showThirdwebBranding}
|
||||
key={JSON.stringify({
|
||||
prefill: props.options.prefill,
|
||||
})}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
)}
|
||||
{previewTab === "ui" &&
|
||||
(props.options.integrationType === "iframe" ? (
|
||||
<iframe
|
||||
src={buildSwapIframeUrl(props.options, "preview")}
|
||||
height="700px"
|
||||
width="100%"
|
||||
title="Swap Widget"
|
||||
className="fade-in-0 animate-in rounded-xl duration-500"
|
||||
style={{
|
||||
border: "0",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SwapWidget
|
||||
client={THIRDWEB_CLIENT}
|
||||
theme={themeObj}
|
||||
prefill={props.options.prefill}
|
||||
currency={props.options.currency}
|
||||
showThirdwebBranding={props.options.showThirdwebBranding}
|
||||
key={JSON.stringify({
|
||||
prefill: props.options.prefill,
|
||||
})}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{previewTab === "code" && <CodeGen options={props.options} />}
|
||||
</div>
|
||||
@@ -72,21 +83,6 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundPattern() {
|
||||
const color = "hsl(var(--foreground)/15%)";
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-[-1]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(${color} 1px, transparent 1px)`,
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButtons(props: {
|
||||
tabs: Array<{
|
||||
name: string;
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
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 { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const defaultOptions: SwapWidgetPlaygroundOptions = {
|
||||
integrationType: "react",
|
||||
prefill: undefined,
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
@@ -17,11 +19,26 @@ const defaultOptions: SwapWidgetPlaygroundOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export function SwapWidgetPlayground() {
|
||||
function updatePageUrl(tab: SwapWidgetPlaygroundOptions["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 SwapWidgetPlayground(props: {
|
||||
defaultTab?: "iframe" | "react";
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [options, setOptions] =
|
||||
useState<SwapWidgetPlaygroundOptions>(defaultOptions);
|
||||
const [options, setOptions] = useState<SwapWidgetPlaygroundOptions>(() => ({
|
||||
...defaultOptions,
|
||||
integrationType: props.defaultTab || defaultOptions.integrationType,
|
||||
}));
|
||||
|
||||
// change theme on global theme change
|
||||
useEffect(() => {
|
||||
@@ -34,12 +51,36 @@ export function SwapWidgetPlayground() {
|
||||
}));
|
||||
}, [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 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 options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
<RightSection options={options} />
|
||||
</div>
|
||||
<RightSection options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||