Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd5ae1feb | ||
|
|
1caf6dd460 | ||
|
|
19204e4654 | ||
|
|
674c8d5e6c | ||
|
|
1eb0cd9314 | ||
|
|
b7dfde2d68 | ||
|
|
b50e304e0c | ||
|
|
9baa251bb1 | ||
|
|
7f36303b0b | ||
|
|
13a46b7ff6 | ||
|
|
b9d93e44ca | ||
|
|
fc9e81f99a | ||
|
|
281759f06a | ||
|
|
2d98fe2799 | ||
|
|
93bcdc557c | ||
|
|
d38deecaa1 | ||
|
|
a3ea002afd | ||
|
|
6390052f8b | ||
|
|
621f187a8a | ||
|
|
36ff327744 | ||
|
|
f68c2c62a5 | ||
|
|
5766c90fb4 | ||
|
|
8d4b6a1fea | ||
|
|
49b80fe87f | ||
|
|
5ca4d7497b | ||
|
|
0382d42a99 | ||
|
|
d35cad8655 | ||
|
|
c1275a5e38 | ||
|
|
277874594b | ||
|
|
4fc02b3618 | ||
|
|
26a91537d2 | ||
|
|
a3ff7572d6 | ||
|
|
01004e8408 | ||
|
|
f497b614cb | ||
|
|
f344fb5fbd | ||
|
|
9f265bf734 | ||
|
|
e5519eaef9 | ||
|
|
805b838fd3 | ||
|
|
ec73f1ff83 | ||
|
|
245e9a688c | ||
|
|
8bdc0c0c27 | ||
|
|
9c04b3f180 | ||
|
|
800503da68 | ||
|
|
0ff8b9ff60 | ||
|
|
304e4e5be8 | ||
|
|
fdd2557663 | ||
|
|
56a1c0a467 | ||
|
|
6e87b9205f | ||
|
|
9383ccdf4e | ||
|
|
ac326e24f1 | ||
|
|
7ebc32a0c4 | ||
|
|
5f0e25b5a8 | ||
|
|
aad3e6477e | ||
|
|
4bab6f1ae8 | ||
|
|
f9a435051d | ||
|
|
27d72e5d3e | ||
|
|
151127d668 | ||
|
|
759f41b11e | ||
|
|
5ec84bd3b6 | ||
|
|
38f4a89076 | ||
|
|
31e24f1c58 | ||
|
|
2bb6a4f9a3 | ||
|
|
e281ed185e | ||
|
|
116975286f | ||
|
|
ff8f4130e9 | ||
|
|
ffd8c402f4 | ||
|
|
d365efe997 | ||
|
|
1558b48056 | ||
|
|
3fba7e4e88 |
@@ -161,6 +161,60 @@ const baseNextConfig: NextConfig = {
|
||||
],
|
||||
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*",
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
|
||||
+130
-130
@@ -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"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
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.",
|
||||
|
||||
@@ -105,6 +105,7 @@ type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
export type Partner = {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
permissions: [PartnerPermission];
|
||||
|
||||
@@ -6,13 +6,16 @@ export const StatCard: React.FC<{
|
||||
icon: React.FC<{ className?: string }>;
|
||||
formatter?: (value: number) => string;
|
||||
isPending: boolean;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
|
||||
emptyText?: string;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending, emptyText }) => {
|
||||
return (
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
|
||||
<div>
|
||||
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
|
||||
{isPending ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : emptyText ? (
|
||||
<span className="text-muted-foreground">{emptyText}</span>
|
||||
) : value !== undefined && formatter ? (
|
||||
formatter(value)
|
||||
) : (
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defineChain } from "thirdweb";
|
||||
import {
|
||||
BuyWidget,
|
||||
type SupportedFiatCurrency,
|
||||
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,
|
||||
@@ -22,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";
|
||||
@@ -71,11 +64,11 @@ export type BuyAndSwapEmbedProps = {
|
||||
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);
|
||||
|
||||
@@ -108,211 +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
|
||||
currency={props.currency || "USD"}
|
||||
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
|
||||
currency={props.currency || "USD"}
|
||||
persistTokenSelections={props.persistTokenSelections}
|
||||
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>
|
||||
|
||||
+264
-21
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
-1
@@ -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: "",
|
||||
|
||||
@@ -75,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.",
|
||||
|
||||
+19
-2
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useAddPartner } from "../../hooks/use-add-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
@@ -23,6 +24,8 @@ export function AddPartnerForm({
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const storageUpload = useDashboardStorageUpload({ client });
|
||||
|
||||
const { mutateAsync: addPartner, isPending } = useAddPartner(
|
||||
{
|
||||
authToken,
|
||||
@@ -48,10 +51,23 @@ export function AddPartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
const isUploading = storageUpload.isPending;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
let imageUrl: string | undefined;
|
||||
if (values.logo) {
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([values.logo]);
|
||||
imageUrl = uri;
|
||||
} catch {
|
||||
toast.error("Failed to upload logo");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addPartner({
|
||||
accessControl: finalAccessControl,
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
@@ -61,6 +77,7 @@ export function AddPartnerForm({
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
ecosystem,
|
||||
imageUrl,
|
||||
name: values.name,
|
||||
});
|
||||
};
|
||||
@@ -68,7 +85,7 @@ export function AddPartnerForm({
|
||||
return (
|
||||
<PartnerForm
|
||||
client={client}
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isUploading}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Add"
|
||||
/>
|
||||
|
||||
+101
-2
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useId, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { z } from "zod";
|
||||
import type { Partner } from "@/api/team/ecosystems";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import { AllowedOperationsSection } from "./allowed-operations-section";
|
||||
|
||||
@@ -118,6 +121,7 @@ export function PartnerForm({
|
||||
|
||||
const accessControlId = useId();
|
||||
const serverVerifierId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -150,6 +154,101 @@ export function PartnerForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={() => {
|
||||
const removeLogo = form.watch("removeLogo");
|
||||
const logoFile = form.watch("logo");
|
||||
const newFilePreview = logoFile
|
||||
? URL.createObjectURL(logoFile)
|
||||
: undefined;
|
||||
const existingImageUrl =
|
||||
partner?.imageUrl && !removeLogo
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: partner.imageUrl,
|
||||
})
|
||||
: undefined;
|
||||
const displayUrl = newFilePreview || existingImageUrl;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Partner Logo</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
form.setValue("logo", file, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("removeLogo", false);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
{displayUrl ? (
|
||||
<div className="relative inline-block">
|
||||
<Img
|
||||
alt={partner?.name ?? "Partner logo"}
|
||||
className="size-20 rounded-md border object-contain object-center"
|
||||
src={displayUrl}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Remove logo"
|
||||
className="absolute -top-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
|
||||
onClick={() => {
|
||||
form.setValue("logo", undefined);
|
||||
form.setValue("removeLogo", true);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Change logo"
|
||||
className="absolute -bottom-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PencilIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ImageUpload
|
||||
accept="image/png, image/jpeg, image/webp"
|
||||
className="bg-background"
|
||||
onUpload={(files) => {
|
||||
if (files[0]) {
|
||||
form.setValue("logo", files[0], {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("removeLogo", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional logo for this partner. Used in OTP emails sent to
|
||||
users authenticating through this partner.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
|
||||
+27
-2
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useUpdatePartner } from "../../hooks/use-update-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
@@ -25,6 +26,8 @@ export function UpdatePartnerForm({
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const storageUpload = useDashboardStorageUpload({ client });
|
||||
|
||||
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
|
||||
{
|
||||
authToken,
|
||||
@@ -50,10 +53,31 @@ export function UpdatePartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
const isUploading = storageUpload.isPending;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
// Determine imageUrl based on three states:
|
||||
// 1. New file uploaded → upload and use new URI
|
||||
// 2. Explicit removal → send null to clear
|
||||
// 3. No change → preserve existing partner imageUrl
|
||||
let imageUrl: string | null | undefined;
|
||||
if (values.logo) {
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([values.logo]);
|
||||
imageUrl = uri;
|
||||
} catch {
|
||||
toast.error("Failed to upload logo");
|
||||
return;
|
||||
}
|
||||
} else if (values.removeLogo) {
|
||||
imageUrl = null;
|
||||
} else {
|
||||
imageUrl = partner.imageUrl;
|
||||
}
|
||||
|
||||
updatePartner({
|
||||
accessControl: finalAccessControl,
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
@@ -63,6 +87,7 @@ export function UpdatePartnerForm({
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
ecosystem,
|
||||
imageUrl,
|
||||
name: values.name,
|
||||
partnerId: partner.id,
|
||||
});
|
||||
@@ -71,7 +96,7 @@ export function UpdatePartnerForm({
|
||||
return (
|
||||
<PartnerForm
|
||||
client={client}
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isUploading}
|
||||
onSubmit={handleSubmit}
|
||||
partner={partner}
|
||||
submitLabel="Update"
|
||||
|
||||
+15
@@ -154,6 +154,21 @@ const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
|
||||
|
||||
export const partnerFormSchema = z
|
||||
.object({
|
||||
logo: z
|
||||
.instanceof(File, {
|
||||
message: "Please select an image file",
|
||||
})
|
||||
.refine(
|
||||
(file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type),
|
||||
{
|
||||
message: "Only PNG, JPG or WEBP images are allowed",
|
||||
},
|
||||
)
|
||||
.refine((file) => file.size <= 500 * 1024, {
|
||||
message: "Logo size must be less than 500KB",
|
||||
})
|
||||
.optional(),
|
||||
removeLogo: z.boolean().default(false),
|
||||
accessControl: z
|
||||
.object({
|
||||
allowedOperations: z
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
type AddPartnerParams = {
|
||||
ecosystem: Ecosystem;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: Partner["accessControl"] | null;
|
||||
@@ -37,6 +38,7 @@ export function useAddPartner(
|
||||
accessControl: params.accessControl ?? undefined,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
imageUrl: params.imageUrl,
|
||||
name: params.name,
|
||||
// TODO - remove the requirement for permissions in API endpoint
|
||||
permissions: ["FULL_CONTROL_V1"],
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ type UpdatePartnerParams = {
|
||||
partnerId: string;
|
||||
ecosystem: Ecosystem;
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: {
|
||||
@@ -43,6 +44,7 @@ export function useUpdatePartner(
|
||||
accessControl: params.accessControl,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
imageUrl: params.imageUrl,
|
||||
name: params.name,
|
||||
}),
|
||||
|
||||
|
||||
+4
-22
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
+8
-9
@@ -22,7 +22,6 @@ export function ProjectSidebarLayout(props: {
|
||||
children: React.ReactNode;
|
||||
hasEngines: boolean;
|
||||
showContracts: boolean;
|
||||
teamId: string;
|
||||
}) {
|
||||
const contentSidebarLinks = [
|
||||
{
|
||||
@@ -52,14 +51,14 @@ export function ProjectSidebarLayout(props: {
|
||||
href: `${props.layoutPath}/wallets/sponsored-gas`,
|
||||
label: "Gas Sponsorship",
|
||||
},
|
||||
...(props.teamId === "team_clmb33q9w00gn1x0u2ri8z0k0"
|
||||
? [
|
||||
{
|
||||
href: `${props.layoutPath}/wallets/dedicated-relayer`,
|
||||
label: "Dedicated Relayer",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: `${props.layoutPath}/wallets/dedicated-relayer`,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Dedicated Relayer <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(props.showContracts
|
||||
|
||||
+75
-78
@@ -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
|
||||
|
||||
+23
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-6
@@ -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.
|
||||
|
||||
+1
-1
@@ -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`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -107,7 +107,6 @@ export default async function ProjectLayout(props: {
|
||||
layoutPath={layoutPath}
|
||||
hasEngines={hasLegacyDedicatedEngines}
|
||||
showContracts={showContracts}
|
||||
teamId={team.id}
|
||||
>
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
|
||||
+35
-10
@@ -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}
|
||||
+6
-2
@@ -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}
|
||||
|
||||
+118
-59
@@ -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,
|
||||
|
||||
+50
-48
@@ -13,7 +13,6 @@ 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 { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
@@ -111,23 +110,43 @@ export function DedicatedRelayerActiveState(
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Executors Info */}
|
||||
<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 Executors
|
||||
</h2>
|
||||
{/* 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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
@@ -135,7 +154,7 @@ export function DedicatedRelayerActiveState(
|
||||
{/* 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">Fleet Transactions</h2>
|
||||
<h2 className="font-semibold text-xl">Transaction History</h2>
|
||||
<div className="flex gap-2">
|
||||
<ChainFilter
|
||||
chainId={chainIdFilter}
|
||||
@@ -295,37 +314,20 @@ function SkeletonRow() {
|
||||
}
|
||||
|
||||
function TransactionHashCell(props: { hash: string; chainId: string }) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chain = idToChain.get(Number(props.chainId));
|
||||
|
||||
const explorerUrl = chain?.explorers?.[0]?.url;
|
||||
const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`;
|
||||
|
||||
if (explorerUrl) {
|
||||
return (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
href={`${explorerUrl}/tx/${props.hash}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{txHashToShow}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="-translate-x-2 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.hash}
|
||||
textToShow={txHashToShow}
|
||||
tooltip="Transaction Hash"
|
||||
variant="ghost"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,7 +364,7 @@ function TransactionFeeCell(props: { usdValue: number | null }) {
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-sm">
|
||||
${props.usdValue < 0.01 ? "<0.01" : props.usdValue.toFixed(2)}
|
||||
{props.usdValue < 0.001 ? "< $0.001" : `$${props.usdValue.toFixed(3)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -379,7 +381,7 @@ function ChainFilter(props: {
|
||||
client={props.client}
|
||||
chainId={props.chainId}
|
||||
onChange={(chainId) => props.setChainId(chainId)}
|
||||
popoverContentClassName="z-[10001]"
|
||||
popoverContentClassName="z-[10001] !w-[280px]"
|
||||
align="end"
|
||||
placeholder="All Chains"
|
||||
className="min-w-[150px]"
|
||||
|
||||
+8
-4
@@ -71,8 +71,8 @@ export function DedicatedRelayerEmptyState(
|
||||
}
|
||||
};
|
||||
|
||||
const requiredChains =
|
||||
selectedTier === "product:dedicated_relayer_standard" ? 2 : 4;
|
||||
const maxChains =
|
||||
selectedTier === "product:dedicated_relayer_standard" ? 1 : 2;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-8 pt-2", props.className)}>
|
||||
@@ -90,7 +90,7 @@ export function DedicatedRelayerEmptyState(
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select {requiredChains} chains for your dedicated relayer.
|
||||
Select up to {maxChains} chains for your dedicated relayer.
|
||||
</p>
|
||||
<MultiNetworkSelector
|
||||
selectedChainIds={selectedChainIds}
|
||||
@@ -102,7 +102,11 @@ export function DedicatedRelayerEmptyState(
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handlePurchase}
|
||||
disabled={selectedChainIds.length !== requiredChains || isLoading}
|
||||
disabled={
|
||||
selectedChainIds.length === 0 ||
|
||||
selectedChainIds.length > maxChains ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{isLoading ? "Processing..." : "Continue"}
|
||||
</Button>
|
||||
|
||||
+22
-16
@@ -73,6 +73,7 @@ export function DedicatedRelayerPageClient(
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const isCheckingActivity = hasActivityQuery.isPending;
|
||||
const totalTransactions = hasActivityQuery.data?.data.totalTransactions ?? 0;
|
||||
const hasTransactions = totalTransactions > 0;
|
||||
|
||||
@@ -117,23 +118,28 @@ export function DedicatedRelayerPageClient(
|
||||
<DedicatedRelayerPendingState fleet={fleet} />
|
||||
)}
|
||||
|
||||
{fleetStatus === "active" && fleet && !hasTransactions && (
|
||||
<DedicatedRelayerPendingState
|
||||
fleet={fleet}
|
||||
hasTransactions={hasTransactions}
|
||||
/>
|
||||
)}
|
||||
{fleetStatus === "active" &&
|
||||
fleet &&
|
||||
!hasTransactions &&
|
||||
!isCheckingActivity && (
|
||||
<DedicatedRelayerPendingState
|
||||
fleet={fleet}
|
||||
hasTransactions={hasTransactions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fleetStatus === "active" && fleet && hasTransactions && (
|
||||
<DedicatedRelayerActiveState
|
||||
fleet={fleet}
|
||||
teamId={props.teamId}
|
||||
fleetId={props.fleetId}
|
||||
client={props.client}
|
||||
range={dateRange}
|
||||
setRange={setDateRange}
|
||||
/>
|
||||
)}
|
||||
{fleetStatus === "active" &&
|
||||
fleet &&
|
||||
(hasTransactions || isCheckingActivity) && (
|
||||
<DedicatedRelayerActiveState
|
||||
fleet={fleet}
|
||||
teamId={props.teamId}
|
||||
fleetId={props.fleetId}
|
||||
client={props.client}
|
||||
range={dateRange}
|
||||
setRange={setDateRange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-6
@@ -33,12 +33,12 @@ const TIERS: TierConfig[] = [
|
||||
icon: FolderIcon,
|
||||
name: "Standard",
|
||||
description:
|
||||
"Most suitable for small startups and apps doing less than 10,000 transactions per day",
|
||||
"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 up to 2 chains" },
|
||||
{ icon: BoxesIcon, label: "Support for 1 chain" },
|
||||
],
|
||||
cta: "Select",
|
||||
},
|
||||
@@ -47,7 +47,7 @@ const TIERS: TierConfig[] = [
|
||||
icon: FoldersIcon,
|
||||
name: "Premium",
|
||||
description:
|
||||
"Best for large enterprise companies and apps doing 100,000+ transactions per day",
|
||||
"Best for enterprise companies and applications with high transaction volume",
|
||||
price: "$299",
|
||||
isPerMonth: true,
|
||||
features: [
|
||||
@@ -55,7 +55,7 @@ const TIERS: TierConfig[] = [
|
||||
icon: WalletProductIcon,
|
||||
label: "10 executor wallets (10x throughput)",
|
||||
},
|
||||
{ icon: BoxesIcon, label: "Support for up to 4 chains" },
|
||||
{ icon: BoxesIcon, label: "Support for up to 2 chains" },
|
||||
],
|
||||
cta: "Select",
|
||||
isRecommended: true,
|
||||
@@ -65,7 +65,7 @@ const TIERS: TierConfig[] = [
|
||||
icon: FolderCogIcon,
|
||||
name: "Custom",
|
||||
description:
|
||||
"Contact us for more throughput with custom number of chains and executor wallets",
|
||||
"Contact us for applications operating at a global scale with custom requirements",
|
||||
price: "Custom",
|
||||
features: [
|
||||
{ icon: WalletProductIcon, label: "Unlimited executor wallets" },
|
||||
@@ -171,7 +171,7 @@ function PlanCard(props: {
|
||||
className="w-full rounded-full py-3 h-auto text-base bg-background gap-2"
|
||||
>
|
||||
<Link
|
||||
href="https://thirdweb.com/contact-us"
|
||||
href="https://ea58c.share.hsforms.com/2o8qbJVozSX2Q4oukYfYGuA"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
+5
-6
@@ -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}
|
||||
|
||||
+385
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function UniversalBridgeEmbed(props: {
|
||||
swapTab: BuyAndSwapEmbedProps["swapTab"];
|
||||
pageType: "bridge" | "bridge-iframe";
|
||||
currency?: SupportedFiatCurrency;
|
||||
showThirdwebBranding?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BuyAndSwapEmbed
|
||||
@@ -34,6 +35,7 @@ export function UniversalBridgeEmbed(props: {
|
||||
swapTab={props.swapTab}
|
||||
pageType={props.pageType}
|
||||
wallets={bridgeWallets}
|
||||
showThirdwebBranding={props.showThirdwebBranding}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +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 type { SupportedFiatCurrency } from "thirdweb/react";
|
||||
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`;
|
||||
@@ -28,30 +33,44 @@ 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 =
|
||||
parse(searchParams.persistTokenSelections, (v) =>
|
||||
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 = parse(searchParams.currency, (v) =>
|
||||
VALID_CURRENCIES.includes(v as SupportedFiatCurrency)
|
||||
? (v as SupportedFiatCurrency)
|
||||
: undefined,
|
||||
const currency = parseQueryParams(searchParams.currency, (v) =>
|
||||
isValidCurrency(v) ? v : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -61,11 +80,14 @@ export default async function Page(props: {
|
||||
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,
|
||||
}}
|
||||
@@ -74,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,
|
||||
}}
|
||||
@@ -89,42 +117,6 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_CURRENCIES: SupportedFiatCurrency[] = [
|
||||
"USD",
|
||||
"EUR",
|
||||
"GBP",
|
||||
"JPY",
|
||||
"KRW",
|
||||
"CNY",
|
||||
"INR",
|
||||
"NOK",
|
||||
"SEK",
|
||||
"CHF",
|
||||
"AUD",
|
||||
"CAD",
|
||||
"NZD",
|
||||
"MXN",
|
||||
"BRL",
|
||||
"CLP",
|
||||
"CZK",
|
||||
"DKK",
|
||||
"HKD",
|
||||
"HUF",
|
||||
"IDR",
|
||||
"ILS",
|
||||
"ISK",
|
||||
];
|
||||
|
||||
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,
|
||||
|
||||
+83
-83
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
+48
-7
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SwapWidgetProps, ThemeOverrides } from "thirdweb/react";
|
||||
|
||||
export type SwapWidgetPlaygroundOptions = {
|
||||
integrationType: "iframe" | "react";
|
||||
theme: {
|
||||
type: "dark" | "light";
|
||||
darkColorOverrides: ThemeOverrides["colors"];
|
||||
|
||||
@@ -19,7 +19,9 @@ export const metadata = createMetadata({
|
||||
},
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
export default function Page(props: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
@@ -28,8 +30,20 @@ export default function Page() {
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/references/typescript/v5/SwapWidget?utm_source=playground"
|
||||
>
|
||||
<SwapWidgetPlayground />
|
||||
<SwapWidgetPlaygroundAsync searchParams={props.searchParams} />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function SwapWidgetPlaygroundAsync(props: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const defaultTab =
|
||||
searchParams.tab === "iframe" || searchParams.tab === "react"
|
||||
? searchParams.tab
|
||||
: undefined;
|
||||
|
||||
return <SwapWidgetPlayground defaultTab={defaultTab} />;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Page() {
|
||||
icon={ArrowLeftRightIcon}
|
||||
containerClassName="space-y-12"
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionButton"
|
||||
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionButton?utm_source=playground"
|
||||
title={title}
|
||||
>
|
||||
<Example />
|
||||
|
||||
@@ -21,6 +21,8 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
receiverAddress: undefined,
|
||||
amountEditable: true,
|
||||
tokenEditable: true,
|
||||
},
|
||||
theme: {
|
||||
darkColorOverrides: {},
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Page() {
|
||||
icon={ArrowLeftRightIcon}
|
||||
containerClassName="space-y-12"
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
|
||||
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionWidget?utm_source=playground"
|
||||
title={title}
|
||||
>
|
||||
<TransactionPlayground />
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
BotIcon,
|
||||
BoxIcon,
|
||||
CircleUserIcon,
|
||||
CreditCardIcon,
|
||||
DollarSignIcon,
|
||||
GlobeIcon,
|
||||
ImageIcon,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
RectangleHorizontalIcon,
|
||||
RssIcon,
|
||||
ShieldIcon,
|
||||
ShoppingBagIcon,
|
||||
SquareIcon,
|
||||
SquircleDashedIcon,
|
||||
StampIcon,
|
||||
UserIcon,
|
||||
@@ -118,27 +117,34 @@ export const headlessComponentsFeatureCards: FeatureCardMetadata[] = [
|
||||
|
||||
export const bridgeFeatureCards: FeatureCardMetadata[] = [
|
||||
{
|
||||
icon: ArrowLeftRightIcon,
|
||||
icon: SquareIcon,
|
||||
title: "Bridge Widget",
|
||||
link: "/bridge/bridge-widget",
|
||||
description:
|
||||
"A widget for bridging tokens with cross-chain support and fiat onramp",
|
||||
},
|
||||
{
|
||||
icon: SquareIcon,
|
||||
title: "Swap Widget",
|
||||
link: "/bridge/swap-widget",
|
||||
description: "A widget for swapping tokens with cross-chain support",
|
||||
},
|
||||
{
|
||||
icon: ShoppingBagIcon,
|
||||
icon: SquareIcon,
|
||||
title: "Buy Widget",
|
||||
link: "/bridge/buy-widget",
|
||||
description:
|
||||
"Buy any token with ability to customize theme, amounts, and more",
|
||||
"A widget to buy tokens with fiat onramp support or cross-chain swaps",
|
||||
},
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
icon: SquareIcon,
|
||||
title: "Checkout Widget",
|
||||
link: "/bridge/checkout-widget",
|
||||
description:
|
||||
"Enable crypto payments for services and get notified on each sale",
|
||||
},
|
||||
{
|
||||
icon: ArrowLeftRightIcon,
|
||||
icon: SquareIcon,
|
||||
title: "Transaction Widget",
|
||||
link: "/bridge/transaction-widget",
|
||||
description:
|
||||
|
||||
@@ -187,6 +187,10 @@ const bridge: ShadcnSidebarLink = {
|
||||
href: "/bridge",
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
href: "/bridge/bridge-widget",
|
||||
label: "Bridge Widget",
|
||||
},
|
||||
{
|
||||
href: "/bridge/swap-widget",
|
||||
label: "Swap Widget",
|
||||
|
||||
@@ -31,9 +31,8 @@ export default function Page() {
|
||||
|
||||
function UserTransactions() {
|
||||
return (
|
||||
<>
|
||||
<CodeExample
|
||||
code={`\
|
||||
<CodeExample
|
||||
code={`\
|
||||
import { inAppWallet } from "thirdweb/wallets/in-app";
|
||||
import { ConnectButton, useActiveWallet } from "thirdweb/react";
|
||||
|
||||
@@ -88,14 +87,13 @@ function App() {
|
||||
</>
|
||||
);
|
||||
}`}
|
||||
header={{
|
||||
description:
|
||||
"Queue, monitor, and retry transactions from your users in-app wallets. All transactions and analytics will be displayed in your developer dashboard.",
|
||||
title: "Transactions from User Wallets",
|
||||
}}
|
||||
lang="tsx"
|
||||
preview={<GatewayPreview />}
|
||||
/>
|
||||
</>
|
||||
header={{
|
||||
description:
|
||||
"Queue, monitor, and retry transactions from your users in-app wallets. All transactions and analytics will be displayed in your developer dashboard.",
|
||||
title: "Transactions from User Wallets",
|
||||
}}
|
||||
lang="tsx"
|
||||
preview={<GatewayPreview />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export function X402LeftSection(props: {
|
||||
const handleTokenChange = (token: TokenMetadata) => {
|
||||
setSelectedToken({
|
||||
address: token.address,
|
||||
// biome-ignore lint/style/noNonNullAssertion: ok
|
||||
chainId: selectedChain!,
|
||||
});
|
||||
|
||||
@@ -127,6 +128,7 @@ export function X402LeftSection(props: {
|
||||
onChange={handleChainChange}
|
||||
placeholder="Select a chain"
|
||||
className="bg-card"
|
||||
disableDeprecated
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function FeatureCard(props: {
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-medium mb-0.5 text-lg tracking-tight">
|
||||
<h3 className="font-semibold mb-0.5 text-lg tracking-tight">
|
||||
<Link className="before:absolute before:inset-0" href={props.href}>
|
||||
{props.title}
|
||||
</Link>
|
||||
|
||||
@@ -49,6 +49,14 @@ export function LinkAccount() {
|
||||
});
|
||||
};
|
||||
|
||||
const linkGithub = async () => {
|
||||
linkProfile({
|
||||
client: THIRDWEB_CLIENT,
|
||||
strategy: "github",
|
||||
mode: "redirect",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
{account ? (
|
||||
@@ -83,6 +91,14 @@ export function LinkAccount() {
|
||||
>
|
||||
Link Passkey
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full p-6"
|
||||
disabled={isPending}
|
||||
onClick={linkGithub}
|
||||
variant="default"
|
||||
>
|
||||
Link Github
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-red-500">Error: {error.message}</p>}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TabButtons(props: {
|
||||
tabs: {
|
||||
name: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
toolTip?: string;
|
||||
}[];
|
||||
tabClassName?: string;
|
||||
activeTabClassName?: string;
|
||||
tabContainerClassName?: string;
|
||||
containerClassName?: string;
|
||||
shadowColor?: string;
|
||||
tabIconClassName?: string;
|
||||
hideBottomLine?: boolean;
|
||||
bottomLineClassName?: string;
|
||||
}) {
|
||||
const { containerRef, lineRef, activeTabRef } =
|
||||
useUnderline<HTMLButtonElement>();
|
||||
|
||||
return (
|
||||
<div className={cn("relative", props.containerClassName)}>
|
||||
{/* Bottom line */}
|
||||
{!props.hideBottomLine && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
|
||||
props.bottomLineClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollShadow
|
||||
scrollableClassName="pb-[8px] relative"
|
||||
shadowColor={props.shadowColor}
|
||||
>
|
||||
<div
|
||||
className={cn("flex", props.tabContainerClassName)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{props.tabs.map((tab, index) => {
|
||||
return (
|
||||
<ToolTipLabel
|
||||
key={typeof tab.name === "string" ? tab.name : index}
|
||||
label={tab.toolTip}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"relative inline-flex h-auto items-center gap-1.5 rounded-lg font-medium hover:bg-accent !px-3",
|
||||
!tab.isActive &&
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
props.tabClassName,
|
||||
tab.isActive && props.activeTabClassName,
|
||||
)}
|
||||
onClick={tab.onClick}
|
||||
ref={tab.isActive ? activeTabRef : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={cn("size-6", props.tabIconClassName)}
|
||||
/>
|
||||
)}
|
||||
{tab.name}
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active line */}
|
||||
<div
|
||||
className="fade-in-0 absolute bottom-0 left-0 h-[2px] animate-in rounded-lg bg-foreground"
|
||||
ref={lineRef}
|
||||
/>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useUnderline<El extends HTMLElement>() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTabEl, setActiveTabEl] = useState<El | null>(null);
|
||||
|
||||
const activeTabRef = useCallback((el: El | null) => {
|
||||
setActiveTabEl(el);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function update() {
|
||||
if (activeTabEl && containerRef.current && lineRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const lineEl = lineRef.current;
|
||||
const rect = activeTabEl.getBoundingClientRect();
|
||||
const containerPaddingLeft =
|
||||
containerRect.left - containerRef.current.offsetLeft;
|
||||
lineEl.style.width = `${rect.width}px`;
|
||||
lineEl.style.transform = `translateX(${
|
||||
rect.left - containerPaddingLeft
|
||||
}px)`;
|
||||
setTimeout(() => {
|
||||
lineEl.style.transition = "transform 0.3s, width 0.3s";
|
||||
}, 0);
|
||||
} else if (lineRef.current) {
|
||||
lineRef.current.style.width = "0px";
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
setTimeout(() => {
|
||||
update();
|
||||
}, 100);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
// add event listener for resize
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [activeTabEl]);
|
||||
|
||||
return { activeTabRef, containerRef, lineRef };
|
||||
}
|
||||
@@ -30,7 +30,22 @@ async function fetchBridgeSupportedChainsFromApi() {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data as BridgeChain[];
|
||||
const data = json.data as BridgeChain[];
|
||||
|
||||
return data.sort((a, b) => {
|
||||
const aStartsWithNumber = a.name[0]?.match(/^\d/);
|
||||
const bStartsWithNumber = b.name[0]?.match(/^\d/);
|
||||
|
||||
if (aStartsWithNumber && !bStartsWithNumber) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!aStartsWithNumber && bStartsWithNumber) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function useBridgeSupportedChains() {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
export function PayIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={props.className}
|
||||
fill="none"
|
||||
height="24"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M22 10H2M2 8.2L2 15.8C2 16.9201 2 17.4802 2.21799 17.908C2.40973 18.2843 2.71569 18.5903 3.09202 18.782C3.51984 19 4.07989 19 5.2 19L18.8 19C19.9201 19 20.4802 19 20.908 18.782C21.2843 18.5903 21.5903 18.2843 21.782 17.908C22 17.4802 22 16.9201 22 15.8V8.2C22 7.0799 22 6.51984 21.782 6.09202C21.5903 5.7157 21.2843 5.40974 20.908 5.21799C20.4802 5 19.9201 5 18.8 5L5.2 5C4.0799 5 3.51984 5 3.09202 5.21799C2.7157 5.40973 2.40973 5.71569 2.21799 6.09202C2 6.51984 2 7.07989 2 8.2Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"@dirtycajunrice/klee": "^1.0.6",
|
||||
"@mdx-js/loader": "3.1.0",
|
||||
"@mdx-js/react": "3.1.0",
|
||||
"@next/mdx": "15.3.6",
|
||||
"@next/mdx": "15.3.8",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
@@ -21,7 +21,7 @@
|
||||
"github-slugger": "^2.0.0",
|
||||
"he": "^1.2.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.6",
|
||||
"next": "15.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"node-html-markdown": "^1.3.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@next/eslint-plugin-next": "15.3.6",
|
||||
"@next/eslint-plugin-next": "15.3.8",
|
||||
"@types/flexsearch": "^0.7.42",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/mdx": "^2.0.13",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { CodeBlock, Tabs, TabsContent, TabsList, TabsTrigger } from "@doc";
|
||||
|
||||
function getPreviewSrc(src: string) {
|
||||
const url = new URL(src);
|
||||
// Disable token persistence for docs previews
|
||||
url.searchParams.set("persistTokenSelections", "false");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function IframeCodePreview(props: { src: string }) {
|
||||
return (
|
||||
<Tabs defaultValue="code">
|
||||
@@ -22,7 +29,7 @@ export function IframeCodePreview(props: { src: string }) {
|
||||
<TabsContent value="preview">
|
||||
<iframe
|
||||
title="Bridge widget iframe"
|
||||
src={props.src}
|
||||
src={getPreviewSrc(props.src)}
|
||||
height="750px"
|
||||
width="100%"
|
||||
style={{ border: 0 }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user