Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37af4f191 | ||
|
|
f964b8aaff | ||
|
|
424e6b8f9b | ||
|
|
763d3de5a8 | ||
|
|
f6f138ccbc | ||
|
|
f5d5e73e57 | ||
|
|
673e51b11c | ||
|
|
282edf7ccb | ||
|
|
a87e91b99c | ||
|
|
0e630c4550 | ||
|
|
11c7bf0955 | ||
|
|
c9e430d092 | ||
|
|
1f440d0c97 | ||
|
|
3a0aac4393 | ||
|
|
7b13015053 | ||
|
|
7f081618e1 | ||
|
|
881c7a96f7 | ||
|
|
ad68969559 | ||
|
|
915d955da1 | ||
|
|
7da16cff2f | ||
|
|
f0edc0f253 | ||
|
|
0a2d269782 | ||
|
|
3d996d1d23 | ||
|
|
40781b8170 | ||
|
|
d907017217 | ||
|
|
cf53c2d7f1 | ||
|
|
207cb9dbeb | ||
|
|
b4d365bdc0 | ||
|
|
ea4e2fe376 | ||
|
|
1d54cff7b4 | ||
|
|
fd04eefea9 | ||
|
|
4cc68310dc | ||
|
|
ccdce496e0 | ||
|
|
2725f337f8 | ||
|
|
2c6ea3e6dd | ||
|
|
219fc69d72 | ||
|
|
8d17685683 | ||
|
|
8ef418c543 | ||
|
|
b88f75b494 | ||
|
|
c83f1c6f7a | ||
|
|
bc0295487b | ||
|
|
90b779107d | ||
|
|
afe187de95 | ||
|
|
759b93beff | ||
|
|
8e357b3cb3 | ||
|
|
6c318f83d6 | ||
|
|
f790c9fae9 | ||
|
|
bcbb3f540a | ||
|
|
931c730a08 | ||
|
|
e0c83121c2 | ||
|
|
a82a31e5ad | ||
|
|
3d235e218a | ||
|
|
c08fb1356e | ||
|
|
68cca6f992 | ||
|
|
e0abf2b273 | ||
|
|
abf0bbaf35 | ||
|
|
23cb3bd98c | ||
|
|
dcef3f4fe7 | ||
|
|
bf46520a6b | ||
|
|
7defce7264 | ||
|
|
ae3a122cf1 | ||
|
|
2412a6958c | ||
|
|
bfd3be764c | ||
|
|
46b9d2dd82 | ||
|
|
3e9eb49787 | ||
|
|
630edadcec | ||
|
|
f757e60846 | ||
|
|
d5d593956e | ||
|
|
9a4b1cb147 | ||
|
|
85fe9491cd | ||
|
|
899051f788 | ||
|
|
5828b9bd45 | ||
|
|
2b68a32eaa | ||
|
|
66ae33ee8b | ||
|
|
e9788096d6 | ||
|
|
706d696434 | ||
|
|
c36cfbb5c7 | ||
|
|
93a9ba9121 | ||
|
|
6a7b556d75 | ||
|
|
e0ace736a8 | ||
|
|
b30369f3cc | ||
|
|
f2121e9519 | ||
|
|
29d05a50bf | ||
|
|
f00f559a12 | ||
|
|
ceba6835dd | ||
|
|
b33c1a30ac | ||
|
|
72fbb1550f | ||
|
|
1e7df5ad3d | ||
|
|
ca16ec783b | ||
|
|
b1a780832e | ||
|
|
9bb65ad5b2 | ||
|
|
db291d2d38 | ||
|
|
9dd0b17f15 | ||
|
|
12082fd5fd | ||
|
|
5ff6063961 | ||
|
|
3db3982fab | ||
|
|
bf90104330 |
@@ -188,9 +188,16 @@ jobs:
|
||||
- name: Build Packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Report bundle size
|
||||
- name: Report bundle size (thirdweb)
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
package_manager: pnpm
|
||||
directory: packages/thirdweb
|
||||
directory: packages/thirdweb
|
||||
|
||||
- name: Report bundle size (nexus)
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
package_manager: pnpm
|
||||
directory: packages/nexus
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig } from "checkly";
|
||||
import { Frequency } from "checkly/constructs";
|
||||
|
||||
export default defineConfig({
|
||||
checks: {
|
||||
activated: true,
|
||||
browserChecks: {
|
||||
frequency: Frequency.EVERY_24H,
|
||||
testMatch: "./tests/**/*.spec.ts",
|
||||
},
|
||||
checkMatch: "./**/*.check.ts",
|
||||
frequency: Frequency.EVERY_24H,
|
||||
ignoreDirectoriesMatch: [],
|
||||
locations: ["us-east-1", "eu-west-1"],
|
||||
muted: false,
|
||||
playwrightConfig: {
|
||||
use: {
|
||||
baseURL: process.env.ENVIRONMENT_URL || "https://thirdweb.com",
|
||||
},
|
||||
},
|
||||
runtimeId: "2023.09",
|
||||
tags: ["website"],
|
||||
},
|
||||
cli: {
|
||||
runLocation: "eu-west-1",
|
||||
},
|
||||
logicalId: "thirdweb-www",
|
||||
projectName: "thirdweb.com",
|
||||
repoUrl: "https://github.com/thirdweb-dev/dashboard",
|
||||
});
|
||||
@@ -10,7 +10,8 @@ module.exports = [
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/payments",
|
||||
"/payments/x402",
|
||||
"/x402",
|
||||
"/nexus",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"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",
|
||||
@@ -82,7 +83,6 @@
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
"@playwright/test": "1.53.2",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
@@ -99,7 +99,6 @@
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"checkly": "6.0.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
@@ -122,13 +121,11 @@
|
||||
"format": "biome format ./src --write",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"playwright": "playwright test",
|
||||
"postbuild": "next-sitemap",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"update-checkly": "npx checkly deploy"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
testDir: "./tests",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm run start",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
url: "http://127.0.0.1:3000",
|
||||
},
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -479,6 +479,11 @@ async function redirects() {
|
||||
destination: "/tokens",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/payments/x402",
|
||||
destination: "/x402",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ export function reportProductFeedback(properties: {
|
||||
*
|
||||
*/
|
||||
export function reportBridgePageLinkClick(params: {
|
||||
linkType: "integrate-bridge" | "trending-tokens";
|
||||
linkType: "bridge-docs" | "trending-tokens";
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export type AuthOption =
|
||||
| "steam"
|
||||
| "apple"
|
||||
| "coinbase"
|
||||
| "line";
|
||||
| "line"
|
||||
| "epic";
|
||||
|
||||
export type Ecosystem = {
|
||||
name: string;
|
||||
|
||||
@@ -82,7 +82,7 @@ const CHAIN_PLAN_TO_INFRA = {
|
||||
],
|
||||
};
|
||||
|
||||
export async function getChainSubscriptions(slug: string) {
|
||||
async function getChainSubscriptions(slug: string) {
|
||||
const allSubscriptions = await getTeamSubscriptions(slug);
|
||||
if (!allSubscriptions) {
|
||||
return null;
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function getUniversalBridgeTokens(props: {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
} as Record<string, string>,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function addUniversalBridgeTokenRoute(props: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
} as Record<string, string>,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function SkeletonBarChart(props: {
|
||||
fill={`url(#${fillAreaSkeletonId})`}
|
||||
radius={8}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
type="natural"
|
||||
type="monotone"
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Chain } from "thirdweb";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { BuyWidget, SwapWidget } from "thirdweb/react";
|
||||
import type { Wallet } from "thirdweb/wallets";
|
||||
import {
|
||||
reportAssetBuyCancelled,
|
||||
reportAssetBuyFailed,
|
||||
@@ -25,17 +27,46 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { appMetadata } from "../../constants/connect";
|
||||
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
|
||||
|
||||
type PageType = "asset" | "bridge" | "chain";
|
||||
|
||||
export function BuyAndSwapEmbed(props: {
|
||||
chain: Chain;
|
||||
tokenAddress: string | undefined;
|
||||
buyAmount: string | undefined;
|
||||
export type BuyAndSwapEmbedProps = {
|
||||
buyTab:
|
||||
| {
|
||||
buyToken:
|
||||
| {
|
||||
tokenAddress: string;
|
||||
chainId: number;
|
||||
amount?: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
| undefined;
|
||||
swapTab:
|
||||
| {
|
||||
sellToken:
|
||||
| {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
amount?: string;
|
||||
}
|
||||
| undefined;
|
||||
buyToken:
|
||||
| {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
amount?: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
| undefined;
|
||||
pageType: PageType;
|
||||
isTestnet: boolean | undefined;
|
||||
}) {
|
||||
wallets?: Wallet[];
|
||||
};
|
||||
|
||||
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
|
||||
@@ -84,24 +115,39 @@ export function BuyAndSwapEmbed(props: {
|
||||
|
||||
{tab === "buy" && (
|
||||
<BuyWidget
|
||||
amount={props.buyAmount || "1"}
|
||||
chain={props.chain}
|
||||
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;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
@@ -114,21 +160,27 @@ export function BuyAndSwapEmbed(props: {
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
chainId: buyChainId,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
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:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
@@ -141,24 +193,30 @@ export function BuyAndSwapEmbed(props: {
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
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:
|
||||
quote.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined,
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote.type === "buy"
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote.type === "onramp"
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
@@ -167,14 +225,13 @@ export function BuyAndSwapEmbed(props: {
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
theme={themeObj}
|
||||
tokenAddress={props.tokenAddress as `0x${string}`}
|
||||
paymentMethods={["card"]}
|
||||
/>
|
||||
)}
|
||||
@@ -184,18 +241,14 @@ export function BuyAndSwapEmbed(props: {
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none"
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
prefill={{
|
||||
// buy this token by default
|
||||
buyToken: {
|
||||
chainId: props.chain.id,
|
||||
tokenAddress: props.tokenAddress,
|
||||
},
|
||||
// sell the native token by default (but if buytoken is a native token, don't set)
|
||||
sellToken: props.tokenAddress
|
||||
? {
|
||||
chainId: props.chain.id,
|
||||
}
|
||||
: undefined,
|
||||
buyToken: props.swapTab?.buyToken,
|
||||
sellToken: props.swapTab?.sellToken,
|
||||
}}
|
||||
onError={(error, quote) => {
|
||||
const errorMessage = parseError(error);
|
||||
|
||||
@@ -172,7 +172,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
key={key}
|
||||
stackId={props.variant !== "stacked" ? undefined : "a"}
|
||||
stroke={`var(--color-${key})`}
|
||||
type="natural"
|
||||
type="monotone"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -5,25 +5,20 @@ import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FaqSection(props: {
|
||||
export function FaqAccordion(props: {
|
||||
faqs: Array<{ title: string; description: string }>;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="flex flex-col">
|
||||
{props.faqs.map((faq, faqIndex) => (
|
||||
<FaqItem
|
||||
key={faq.title}
|
||||
title={faq.title}
|
||||
description={faq.description}
|
||||
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex flex-col">
|
||||
{props.faqs.map((faq, faqIndex) => (
|
||||
<FaqItem
|
||||
key={faq.title}
|
||||
title={faq.title}
|
||||
description={faq.description}
|
||||
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { SocialProfile } from "thirdweb/react";
|
||||
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { WalletAddress, WalletAddressUI } from "./wallet-address";
|
||||
|
||||
const meta = {
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
title: "blocks/WalletAddress",
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
const client = storybookThirdwebClient as unknown as ThirdwebClient;
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-4xl flex-col gap-8 py-10">
|
||||
<BadgeContainer label="Social Profiles Loaded">
|
||||
<WalletAddressUI
|
||||
address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
||||
client={client}
|
||||
profiles={{
|
||||
data: vitalikEth,
|
||||
isPending: false,
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Social Profiles">
|
||||
<WalletAddressUI
|
||||
address="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
|
||||
client={client}
|
||||
shortenAddress
|
||||
profiles={{
|
||||
data: [],
|
||||
isPending: false,
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading Social Profiles">
|
||||
<WalletAddressUI
|
||||
address="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
|
||||
client={client}
|
||||
shortenAddress
|
||||
profiles={{
|
||||
data: [],
|
||||
isPending: true,
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Zero address">
|
||||
<WalletAddressUI
|
||||
address={ZERO_ADDRESS}
|
||||
client={client}
|
||||
profiles={{
|
||||
data: [],
|
||||
isPending: false,
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Real Component - Invalid Address">
|
||||
<WalletAddress address="not-an-address" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Real Component - vitalik.eth">
|
||||
<WalletAddress
|
||||
address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const vitalikEth: SocialProfile[] = [
|
||||
{
|
||||
type: "ens",
|
||||
name: "vitalik.eth",
|
||||
bio: "mi pinxe lo crino tcati",
|
||||
avatar: "https://euc.li/vitalik.eth",
|
||||
metadata: {
|
||||
name: "vitalik.eth",
|
||||
address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
avatar: "https://euc.li/vitalik.eth",
|
||||
description: "mi pinxe lo crino tcati",
|
||||
url: "https://vitalik.ca",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "farcaster",
|
||||
name: "vitalik.eth",
|
||||
bio: undefined,
|
||||
avatar:
|
||||
"https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original",
|
||||
metadata: {
|
||||
fid: 5650,
|
||||
bio: undefined,
|
||||
pfp: "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/b663cd63-fecf-4d0f-7f87-0e0b6fd42800/original",
|
||||
username: "vitalik.eth",
|
||||
addresses: [
|
||||
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
||||
"0x96B6bB2bd2Eba3b4Fbefd7DbAC448ad7B6CBf279",
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react";
|
||||
import { CircleSlashIcon, XIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
@@ -13,41 +12,56 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useClipboard } from "@/hooks/useClipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { CopyTextButton } from "../ui/CopyTextButton";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function WalletAddress(props: {
|
||||
type WalletAddressProps = {
|
||||
address: string | undefined;
|
||||
shortenAddress?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
client: ThirdwebClient;
|
||||
fallbackIcon?: React.ReactNode;
|
||||
}) {
|
||||
// default back to zero address if no address provided
|
||||
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
|
||||
|
||||
const [shortenedAddress, lessShortenedAddress] = useMemo(() => {
|
||||
return [
|
||||
props.shortenAddress !== false
|
||||
? `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
: address,
|
||||
`${address.slice(0, 14)}...${address.slice(-12)}`,
|
||||
];
|
||||
}, [address, props.shortenAddress]);
|
||||
};
|
||||
|
||||
export function WalletAddress(props: WalletAddressProps) {
|
||||
const profiles = useSocialProfiles({
|
||||
address: address,
|
||||
address: props.address,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
const { onCopy, hasCopied } = useClipboard(address, 2000);
|
||||
return (
|
||||
<WalletAddressUI
|
||||
{...props}
|
||||
profiles={{
|
||||
data: profiles.data || [],
|
||||
isPending: profiles.isPending,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletAddressUI(
|
||||
props: WalletAddressProps & {
|
||||
profiles: {
|
||||
data: SocialProfile[];
|
||||
isPending: boolean;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const address = props.address || ZERO_ADDRESS;
|
||||
|
||||
const shortenedAddress =
|
||||
props.shortenAddress !== false
|
||||
? `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
: address;
|
||||
|
||||
if (!isAddress(address)) {
|
||||
return (
|
||||
<ToolTipLabel hoverable label={address}>
|
||||
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
|
||||
<span className="flex items-center gap-2 underline-offset-4 hover:underline w-fit">
|
||||
<div className="flex size-5 items-center justify-center rounded-full border bg-background">
|
||||
<XIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -78,7 +92,7 @@ export function WalletAddress(props: {
|
||||
<HoverCardTrigger asChild tabIndex={-1}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2 px-0",
|
||||
"flex flex-row items-center gap-2 px-0 max-w-full truncate",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -88,89 +102,82 @@ export function WalletAddress(props: {
|
||||
<WalletAvatar
|
||||
address={address}
|
||||
iconClassName={props.iconClassName}
|
||||
profiles={profiles.data || []}
|
||||
profiles={props.profiles.data}
|
||||
thirdwebClient={props.client}
|
||||
fallbackIcon={props.fallbackIcon}
|
||||
/>
|
||||
)}
|
||||
<span className="cursor-pointer font-mono">
|
||||
{profiles.data?.[0]?.name || shortenedAddress}
|
||||
<span className="cursor-pointer font-mono max-w-full truncate">
|
||||
{props.profiles.data?.[0]?.name || shortenedAddress}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-80 border-border"
|
||||
className="w-[calc(100vw-2rem)] lg:w-[450px] border-border rounded-xl p-4 lg:p-6"
|
||||
onClick={(e) => {
|
||||
// do not close the hover card when clicking anywhere in the content
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg">Wallet Address</h3>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
onClick={onCopy}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
)}
|
||||
{hasCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="rounded bg-muted p-2 text-center font-mono text-sm">
|
||||
{lessShortenedAddress}
|
||||
</p>
|
||||
<h3 className="font-semibold text-lg">Social Profiles</h3>
|
||||
{profiles.isPending ? (
|
||||
<p className="text-muted-foreground text-sm">Loading profiles...</p>
|
||||
) : !profiles.data?.length ? (
|
||||
<p className="text-muted-foreground text-sm">No profiles found</p>
|
||||
) : (
|
||||
profiles.data?.map((profile) => {
|
||||
const walletAvatarLink = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: profile.avatar,
|
||||
});
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">Wallet Address</h3>
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center gap-2"
|
||||
key={profile.type + profile.name}
|
||||
>
|
||||
{walletAvatarLink && (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
alt={profile.name}
|
||||
className="object-cover"
|
||||
src={walletAvatarLink}
|
||||
/>
|
||||
{profile.name && (
|
||||
<AvatarFallback>
|
||||
{profile.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-4">
|
||||
<h4 className="font-semibold text-md">{profile.name}</h4>
|
||||
<Badge variant="outline">{profile.type}</Badge>
|
||||
<CopyTextButton
|
||||
textToShow={address}
|
||||
textToCopy={address}
|
||||
tooltip="Copy address"
|
||||
copyIconPosition="right"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground -translate-x-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 border-t pt-5 border-dashed">
|
||||
<h3 className="font-medium text-sm">Social Profiles</h3>
|
||||
|
||||
{props.profiles.isPending ? (
|
||||
<Skeleton className="h-4 w-[50%]" />
|
||||
) : !props.profiles.data?.length ? (
|
||||
<p className="text-muted-foreground text-sm">No profiles found</p>
|
||||
) : (
|
||||
<div className="!mt-2">
|
||||
{props.profiles.data?.map((profile) => {
|
||||
const walletAvatarLink = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: profile.avatar,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center gap-3 py-2"
|
||||
key={profile.type + profile.name}
|
||||
>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
alt={profile.name}
|
||||
className="object-cover"
|
||||
src={walletAvatarLink}
|
||||
/>
|
||||
{profile.name && (
|
||||
<AvatarFallback>
|
||||
{profile.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-sm leading-none">{profile.name}</h4>
|
||||
<span className="text-muted-foreground text-xs leading-none capitalize">
|
||||
{profile.type === "ens" ? "ENS" : profile.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{profile.bio && (
|
||||
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
|
||||
{profile.bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { resetAnalytics } from "@/analytics/reset";
|
||||
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
|
||||
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { appMetadata } from "@/constants/connect";
|
||||
import { popularChains } from "@/constants/popularChains";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useFavoriteChainIds } from "@/hooks/favorite-chains";
|
||||
@@ -138,11 +139,7 @@ export const CustomConnectWallet = (props: {
|
||||
return (
|
||||
<>
|
||||
<ConnectButton
|
||||
appMetadata={{
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
name: "thirdweb",
|
||||
url: "https://thirdweb.com",
|
||||
}}
|
||||
appMetadata={appMetadata}
|
||||
chain={props.chain}
|
||||
chains={allChainsV5}
|
||||
client={client}
|
||||
@@ -280,11 +277,7 @@ export function useCustomConnectModal() {
|
||||
return useCallback(
|
||||
(options: { chain?: Chain; client: ThirdwebClient }) => {
|
||||
return connect({
|
||||
appMetadata: {
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
name: "thirdweb",
|
||||
url: "https://thirdweb.com",
|
||||
},
|
||||
appMetadata,
|
||||
chain: options?.chain,
|
||||
client: options.client,
|
||||
privacyPolicyUrl: "/privacy-policy",
|
||||
|
||||
+12
-2
@@ -14,6 +14,15 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { SearchType } from "./types";
|
||||
|
||||
const searchTypeLabels: Record<SearchType, string> = {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
id: "Auth Identifier",
|
||||
address: "Address",
|
||||
externalWallet: "External Wallet",
|
||||
userId: "User Identifier",
|
||||
};
|
||||
|
||||
export function AdvancedSearchInput(props: {
|
||||
onSearch: (searchType: SearchType, query: string) => void;
|
||||
onClear: () => void;
|
||||
@@ -52,7 +61,8 @@ export function AdvancedSearchInput(props: {
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="id">ID</SelectItem>
|
||||
<SelectItem value="id">Auth Identifier</SelectItem>
|
||||
<SelectItem value="userId">User Identifier</SelectItem>
|
||||
<SelectItem value="address">Address</SelectItem>
|
||||
<SelectItem value="externalWallet">External Wallet</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -62,7 +72,7 @@ export function AdvancedSearchInput(props: {
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
|
||||
placeholder={`Search by ${searchType}...`}
|
||||
placeholder={`Search by ${searchTypeLabels[searchType]}...`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -12,18 +12,25 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CopyTextButton } from "../ui/CopyTextButton";
|
||||
|
||||
const getUserIdentifier = (user: WalletUser) => {
|
||||
const getAuthIdentifier = (user: WalletUser) => {
|
||||
const mainDetail = user.linkedAccounts[0]?.details;
|
||||
return (
|
||||
mainDetail?.id ??
|
||||
mainDetail?.email ??
|
||||
mainDetail?.phone ??
|
||||
mainDetail?.address ??
|
||||
mainDetail?.id ??
|
||||
user.id
|
||||
mainDetail?.address
|
||||
);
|
||||
};
|
||||
|
||||
const truncateIdentifier = (value: string) => {
|
||||
if (value.length <= 18) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 8)}...${value.slice(-4)}`;
|
||||
};
|
||||
|
||||
export function SearchResults(props: {
|
||||
results: WalletUser[];
|
||||
client: ThirdwebClient;
|
||||
@@ -51,6 +58,8 @@ export function SearchResults(props: {
|
||||
const mainDetail = user.linkedAccounts?.[0]?.details;
|
||||
const email = mainDetail?.email as string | undefined;
|
||||
const phone = mainDetail?.phone as string | undefined;
|
||||
const authIdentifier = getAuthIdentifier(user);
|
||||
const userIdentifier = user.id;
|
||||
|
||||
// Get external wallet addresses from linkedAccounts where type is 'siwe'
|
||||
const externalWalletAccounts =
|
||||
@@ -68,7 +77,34 @@ export function SearchResults(props: {
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
User Identifier
|
||||
</p>
|
||||
<p className="text-sm">{getUserIdentifier(user)}</p>
|
||||
{userIdentifier ? (
|
||||
<CopyTextButton
|
||||
textToShow={truncateIdentifier(userIdentifier)}
|
||||
textToCopy={userIdentifier}
|
||||
tooltip="Copy User Identifier"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">N/A</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Auth Identifier
|
||||
</p>
|
||||
{authIdentifier ? (
|
||||
<CopyTextButton
|
||||
textToShow={truncateIdentifier(authIdentifier)}
|
||||
textToCopy={authIdentifier}
|
||||
tooltip="Copy Auth Identifier"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">N/A</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{walletAddress && (
|
||||
|
||||
+69
-44
@@ -28,22 +28,42 @@ import { SearchResults } from "./SearchResults";
|
||||
import { searchUsers } from "./searchUsers";
|
||||
import type { SearchType } from "./types";
|
||||
|
||||
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
|
||||
const getAuthIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
|
||||
const mainDetail = accounts[0]?.details;
|
||||
return (
|
||||
mainDetail?.id ??
|
||||
mainDetail?.email ??
|
||||
mainDetail?.phone ??
|
||||
mainDetail?.address ??
|
||||
mainDetail?.id
|
||||
mainDetail?.address
|
||||
);
|
||||
};
|
||||
|
||||
const getExternalWallets = (accounts: WalletUser["linkedAccounts"]) => {
|
||||
return accounts?.filter((account) => account.type === "siwe") || [];
|
||||
const getPrimaryEmail = (accounts: WalletUser["linkedAccounts"]) => {
|
||||
const emailFromPrimary = accounts[0]?.details?.email;
|
||||
if (emailFromPrimary) {
|
||||
return emailFromPrimary;
|
||||
}
|
||||
|
||||
const emailAccount = accounts.find((account) => {
|
||||
return typeof account.details?.email === "string" && account.details.email;
|
||||
});
|
||||
|
||||
if (emailAccount && typeof emailAccount.details.email === "string") {
|
||||
return emailAccount.details.email;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<WalletUser>();
|
||||
|
||||
const truncateIdentifier = (value: string) => {
|
||||
if (value.length <= 18) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 8)}...${value.slice(-4)}`;
|
||||
};
|
||||
|
||||
export function InAppWalletUsersPageContent(
|
||||
props: {
|
||||
authToken: string;
|
||||
@@ -56,9 +76,30 @@ export function InAppWalletUsersPageContent(
|
||||
) {
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
columnHelper.accessor("id", {
|
||||
cell: (cell) => {
|
||||
const userId = cell.getValue();
|
||||
|
||||
if (!userId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyTextButton
|
||||
textToShow={truncateIdentifier(userId)}
|
||||
textToCopy={userId}
|
||||
tooltip="Copy User Identifier"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: "User Identifier",
|
||||
id: "user_identifier",
|
||||
}),
|
||||
columnHelper.accessor("linkedAccounts", {
|
||||
cell: (cell) => {
|
||||
const identifier = getUserIdentifier(cell.getValue());
|
||||
const identifier = getAuthIdentifier(cell.getValue());
|
||||
|
||||
if (!identifier) {
|
||||
return "N/A";
|
||||
@@ -66,21 +107,17 @@ export function InAppWalletUsersPageContent(
|
||||
|
||||
return (
|
||||
<CopyTextButton
|
||||
textToShow={
|
||||
identifier.length > 30
|
||||
? `${identifier.slice(0, 30)}...`
|
||||
: identifier
|
||||
}
|
||||
textToShow={truncateIdentifier(identifier)}
|
||||
textToCopy={identifier}
|
||||
tooltip="Copy User Identifier"
|
||||
tooltip="Copy Auth Identifier"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
header: "User Identifier",
|
||||
id: "user_identifier",
|
||||
header: "Auth Identifier",
|
||||
id: "auth_identifier",
|
||||
}),
|
||||
columnHelper.accessor("wallets", {
|
||||
cell: (cell) => {
|
||||
@@ -94,33 +131,24 @@ export function InAppWalletUsersPageContent(
|
||||
}),
|
||||
columnHelper.accessor("linkedAccounts", {
|
||||
cell: (cell) => {
|
||||
const externalWallets = getExternalWallets(cell.getValue());
|
||||
if (externalWallets.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">None</span>;
|
||||
const email = getPrimaryEmail(cell.getValue());
|
||||
|
||||
if (!email) {
|
||||
return <span className="text-muted-foreground text-sm">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{externalWallets.slice(0, 2).map((account) => {
|
||||
const address = account.details?.address as string | undefined;
|
||||
return address ? (
|
||||
<div
|
||||
key={`external-${address}-${account.details?.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<WalletAddress address={address} client={props.client} />
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
{externalWallets.length > 2 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{externalWallets.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CopyTextButton
|
||||
textToShow={email}
|
||||
textToCopy={email}
|
||||
tooltip="Copy Email"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: "External Wallets",
|
||||
id: "external_wallets",
|
||||
header: "Email",
|
||||
id: "email",
|
||||
}),
|
||||
columnHelper.accessor("wallets", {
|
||||
cell: (cell) => {
|
||||
@@ -230,20 +258,17 @@ export function InAppWalletUsersPageContent(
|
||||
});
|
||||
const csv = Papa.unparse(
|
||||
usersWallets.map((row) => {
|
||||
const externalWallets = getExternalWallets(row.linkedAccounts);
|
||||
const externalWalletAddresses = externalWallets
|
||||
.map((account) => account.details?.address)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const email = getPrimaryEmail(row.linkedAccounts);
|
||||
|
||||
return {
|
||||
address: row.wallets[0]?.address || "Uninitialized",
|
||||
created: row.wallets[0]?.createdAt
|
||||
? new Date(row.wallets[0].createdAt).toISOString()
|
||||
: "Wallet not created yet",
|
||||
external_wallets: externalWalletAddresses || "None",
|
||||
email: email || "N/A",
|
||||
login_methods: row.linkedAccounts.map((acc) => acc.type).join(", "),
|
||||
user_identifier: getUserIdentifier(row.linkedAccounts),
|
||||
auth_identifier: getAuthIdentifier(row.linkedAccounts) || "N/A",
|
||||
user_identifier: row.id || "N/A",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -18,10 +18,10 @@ configure({
|
||||
type APIWallet = ListUserWalletsResponses[200]["result"]["wallets"][0];
|
||||
type APIProfile = APIWallet["profiles"][0];
|
||||
|
||||
// Transform API response to match existing WalletUser format
|
||||
// Transform API response to match wallet user format
|
||||
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
|
||||
return {
|
||||
id: getProfileId(apiWallet.profiles[0]) || "",
|
||||
id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "",
|
||||
linkedAccounts: apiWallet.profiles.map((profile) => {
|
||||
// Create details object based on the profile data
|
||||
let details:
|
||||
@@ -105,6 +105,9 @@ export async function searchUsers(
|
||||
case "externalWallet":
|
||||
queryParams.externalWalletAddress = query;
|
||||
break;
|
||||
case "userId":
|
||||
queryParams.userId = query;
|
||||
break;
|
||||
}
|
||||
|
||||
// Use the generated API function with Bearer authentication
|
||||
|
||||
@@ -3,4 +3,5 @@ export type SearchType =
|
||||
| "phone"
|
||||
| "id"
|
||||
| "address"
|
||||
| "externalWallet";
|
||||
| "externalWallet"
|
||||
| "userId";
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CopyTextButton(props: {
|
||||
<Button
|
||||
aria-label={props.tooltip}
|
||||
className={cn(
|
||||
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground",
|
||||
"flex h-auto w-auto gap-2 rounded-lg px-1.5 py-0.5 font-normal text-foreground max-w-full truncate",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import { ArrowUpRightIcon, CheckIcon, CopyIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
import { ToolTipLabel } from "./tooltip";
|
||||
|
||||
export function LinkWithCopyButton(props: {
|
||||
href: string;
|
||||
textToShow: string;
|
||||
textToCopy: string;
|
||||
copyTooltip: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", props.className)}>
|
||||
<ToolTipLabel label={props.copyTooltip}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto shrink-0 opacity-70 hover:opacity-100 text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(props.textToCopy);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<Link
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:underline flex items-center gap-1 tabular-nums flex-1 truncate hover:text-foreground group"
|
||||
>
|
||||
<span className="max-w-full truncate">{props.textToShow}</span>
|
||||
<ArrowUpRightIcon className="size-3.5 opacity-70 shrink-0 group-hover:opacity-100" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const appMetadata = {
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
name: "thirdweb",
|
||||
url: "https://thirdweb.com",
|
||||
};
|
||||
@@ -59,7 +59,10 @@ export async function fetchDashboardContractMetadata(
|
||||
|
||||
return {
|
||||
contractType: compilerMetadata?.name || "",
|
||||
image: contractMetadata.image || "",
|
||||
image:
|
||||
contractMetadata.image && typeof contractMetadata.image === "string"
|
||||
? contractMetadata.image
|
||||
: "",
|
||||
name: contractName,
|
||||
symbol: contractSymbol,
|
||||
};
|
||||
|
||||
@@ -76,10 +76,10 @@ const fetchAccountList = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Transform API response to match existing WalletUser format
|
||||
// Transform API response to match the wallet user format
|
||||
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
|
||||
return {
|
||||
id: getProfileId(apiWallet.profiles[0]) || "",
|
||||
id: apiWallet.userId || getProfileId(apiWallet.profiles[0]) || "",
|
||||
linkedAccounts: apiWallet.profiles.map((profile) => {
|
||||
// Create details object based on the profile data
|
||||
let details:
|
||||
|
||||
@@ -5,6 +5,10 @@ const customClaimAmounts: Record<number, number> = {
|
||||
631571: 0.1,
|
||||
// Sophon testnet
|
||||
531050104: 5,
|
||||
// Arc testnet
|
||||
5042002: 1,
|
||||
// Injective testnet
|
||||
1439: 1,
|
||||
};
|
||||
|
||||
const defaultClaimAmount = 0.01;
|
||||
|
||||
@@ -21,8 +21,8 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
|
||||
primaryButtonText: "hsl(var(--inverted-foreground))",
|
||||
primaryText: "hsl(var(--foreground))",
|
||||
scrollbarBg: "hsl(var(--muted))",
|
||||
secondaryButtonBg: "hsl(var(--secondary))",
|
||||
secondaryButtonHoverBg: "hsl(var(--secondary)/80%)",
|
||||
secondaryButtonBg: "hsl(var(--secondary)/70%)",
|
||||
secondaryButtonHoverBg: "hsl(var(--secondary))",
|
||||
secondaryButtonText: "hsl(var(--secondary-foreground))",
|
||||
secondaryIconColor: "hsl(var(--secondary-foreground))",
|
||||
secondaryIconHoverBg: "hsl(var(--accent))",
|
||||
@@ -33,7 +33,7 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
|
||||
separatorLine: "hsl(var(--border))",
|
||||
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
|
||||
success: "hsl(var(--success-text))",
|
||||
tertiaryBg: "hsl(var(--muted)/30%)",
|
||||
tertiaryBg: "hsl(var(--muted)/50%)",
|
||||
tooltipBg: "hsl(var(--popover))",
|
||||
tooltipText: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
|
||||
+14
-6
@@ -1,18 +1,26 @@
|
||||
"use client";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
|
||||
export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
return (
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyAndSwapEmbed
|
||||
isTestnet={props.chain.testnet}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineDashboardChain(props.chain.chainId, props.chain)}
|
||||
buyAmount={undefined}
|
||||
tokenAddress={undefined}
|
||||
swapTab={{
|
||||
sellToken: {
|
||||
chainId: props.chain.chainId,
|
||||
tokenAddress: NATIVE_TOKEN_ADDRESS,
|
||||
},
|
||||
buyToken: undefined,
|
||||
}}
|
||||
buyTab={{
|
||||
buyToken: {
|
||||
chainId: props.chain.chainId,
|
||||
tokenAddress: NATIVE_TOKEN_ADDRESS,
|
||||
},
|
||||
}}
|
||||
pageType="chain"
|
||||
/>
|
||||
</GridPatternEmbedContainer>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { getRawAccount } from "@/api/account/get-account";
|
||||
import { FaqSection } from "@/components/blocks/faq-section";
|
||||
import { FaqAccordion } from "@/components/blocks/faq-section";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getChain, getCustomChainMetadata } from "../../utils";
|
||||
import { fetchChainSeo } from "./apis/chain-seo";
|
||||
@@ -67,10 +67,21 @@ export default async function Page(props: Props) {
|
||||
)}
|
||||
|
||||
{chainSeo?.faqs && chainSeo.faqs.length > 0 && (
|
||||
<div className="py-10">
|
||||
<FaqSection faqs={chainSeo.faqs} />
|
||||
</div>
|
||||
<FaqSection faqs={chainSeo.faqs} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqSection(props: {
|
||||
faqs: Array<{ title: string; description: string }>;
|
||||
}) {
|
||||
return (
|
||||
<section className="py-10">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<FaqAccordion faqs={props.faqs} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-3
@@ -10,8 +10,7 @@ import * as ERC20Ext from "thirdweb/extensions/erc20";
|
||||
import * as ERC721Ext from "thirdweb/extensions/erc721";
|
||||
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
|
||||
import { download } from "thirdweb/storage";
|
||||
import type { OverrideEntry } from "thirdweb/utils";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { isRecord, maxUint256, type OverrideEntry } from "thirdweb/utils";
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
ClaimCondition as LegacyClaimCondition,
|
||||
@@ -86,7 +85,7 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
]);
|
||||
const snapshot = await fetchSnapshot(
|
||||
condition.merkleRoot,
|
||||
contractMetadata.merkle,
|
||||
isRecord(contractMetadata.merkle) ? contractMetadata.merkle : {},
|
||||
options.contract.client,
|
||||
);
|
||||
return {
|
||||
|
||||
+16
-9
@@ -225,6 +225,10 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
|
||||
moduleInfo: {
|
||||
bytecodeUri: selectedModule.metadata.bytecodeUri,
|
||||
},
|
||||
isStylus:
|
||||
(selectedModule.metadata.compilers?.stylus &&
|
||||
selectedModule.metadata.compilers?.stylus?.length > 0) ||
|
||||
false,
|
||||
});
|
||||
},
|
||||
queryKey: [
|
||||
@@ -432,6 +436,7 @@ async function isModuleCompatible(options: {
|
||||
moduleInfo: {
|
||||
bytecodeUri: string;
|
||||
};
|
||||
isStylus: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
// 1. get module's bytecode
|
||||
@@ -444,15 +449,17 @@ async function isModuleCompatible(options: {
|
||||
|
||||
// 2. check compatibility with core and installed modules
|
||||
try {
|
||||
const isCompatible = await checkModulesCompatibility({
|
||||
chain: options.contractInfo.chain,
|
||||
client: options.client,
|
||||
coreBytecode: options.contractInfo.bytecode,
|
||||
moduleBytecodes: [
|
||||
moduleBytecode,
|
||||
...options.contractInfo.installedModuleBytecodes,
|
||||
],
|
||||
});
|
||||
const isCompatible =
|
||||
options.isStylus ||
|
||||
(await checkModulesCompatibility({
|
||||
chain: options.contractInfo.chain,
|
||||
client: options.client,
|
||||
coreBytecode: options.contractInfo.bytecode,
|
||||
moduleBytecodes: [
|
||||
moduleBytecode,
|
||||
...options.contractInfo.installedModuleBytecodes,
|
||||
],
|
||||
}));
|
||||
|
||||
return isCompatible;
|
||||
} catch (e) {
|
||||
|
||||
+4
-1
@@ -47,7 +47,10 @@ export default async function Image({
|
||||
chainName: info.chainMetadata.name,
|
||||
contractAddress: info.serverContract.address,
|
||||
displayName: contractDisplayName,
|
||||
logo: contractMetadata.image,
|
||||
logo:
|
||||
contractMetadata.image && typeof contractMetadata.image === "string"
|
||||
? contractMetadata.image
|
||||
: undefined,
|
||||
});
|
||||
} catch {
|
||||
return contractOGImageTemplate({
|
||||
|
||||
+5
-5
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { ConnectButton } from "thirdweb/react";
|
||||
import type { Wallet } from "thirdweb/wallets";
|
||||
import { appMetadata } from "@/constants/connect";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
@@ -10,6 +12,7 @@ const client = getClientThirdwebClient();
|
||||
|
||||
export function PublicPageConnectButton(props: {
|
||||
connectButtonClassName?: string;
|
||||
wallets?: Wallet[];
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
@@ -17,11 +20,7 @@ export function PublicPageConnectButton(props: {
|
||||
|
||||
return (
|
||||
<ConnectButton
|
||||
appMetadata={{
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
name: "thirdweb",
|
||||
url: "https://thirdweb.com",
|
||||
}}
|
||||
appMetadata={appMetadata}
|
||||
autoConnect={false}
|
||||
chains={allChainsV5}
|
||||
client={client}
|
||||
@@ -38,6 +37,7 @@ export function PublicPageConnectButton(props: {
|
||||
}}
|
||||
// we have an AutoConnect already added in root layout with AA configuration
|
||||
theme={getSDKTheme(t)}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+60
-28
@@ -89,16 +89,31 @@ export async function ERC20PublicPage(props: {
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
image={contractMetadata.image}
|
||||
image={
|
||||
contractMetadata.image &&
|
||||
typeof contractMetadata.image === "string"
|
||||
? contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
isDashboardUser={isDashboardUser}
|
||||
name={contractMetadata.name}
|
||||
name={
|
||||
contractMetadata.name && typeof contractMetadata.name === "string"
|
||||
? contractMetadata.name
|
||||
: // if we do not have a contract name fall back to the address
|
||||
props.clientContract.address
|
||||
}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
symbol={
|
||||
contractMetadata.symbol &&
|
||||
typeof contractMetadata.symbol === "string"
|
||||
? contractMetadata.symbol
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,21 +158,25 @@ export async function ERC20PublicPage(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBuyEmbed && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyEmbed
|
||||
chainMetadata={props.chainMetadata}
|
||||
claimConditionMeta={claimConditionMeta}
|
||||
clientContract={props.clientContract}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenDecimals={tokenDecimals}
|
||||
tokenName={contractMetadata.name}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</GridPatternEmbedContainer>
|
||||
</div>
|
||||
)}
|
||||
{showBuyEmbed &&
|
||||
contractMetadata.name &&
|
||||
typeof contractMetadata.name === "string" &&
|
||||
contractMetadata.symbol &&
|
||||
typeof contractMetadata.symbol === "string" && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyEmbed
|
||||
chainMetadata={props.chainMetadata}
|
||||
claimConditionMeta={claimConditionMeta}
|
||||
clientContract={props.clientContract}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenDecimals={tokenDecimals}
|
||||
tokenName={contractMetadata.name}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</GridPatternEmbedContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container flex max-w-7xl grow flex-col pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
@@ -167,12 +186,15 @@ export async function ERC20PublicPage(props: {
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
{contractMetadata.symbol &&
|
||||
typeof contractMetadata.symbol === "string" && (
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,11 +221,21 @@ function BuyEmbed(props: {
|
||||
if (!props.claimConditionMeta) {
|
||||
return (
|
||||
<BuyAndSwapEmbed
|
||||
chain={props.clientContract.chain}
|
||||
tokenAddress={props.clientContract.address}
|
||||
buyAmount={undefined}
|
||||
// chain={props.clientContract.chain}
|
||||
swapTab={{
|
||||
sellToken: {
|
||||
chainId: props.clientContract.chain.id,
|
||||
tokenAddress: props.clientContract.address,
|
||||
},
|
||||
buyToken: undefined,
|
||||
}}
|
||||
buyTab={{
|
||||
buyToken: {
|
||||
chainId: props.clientContract.chain.id,
|
||||
tokenAddress: props.clientContract.address,
|
||||
},
|
||||
}}
|
||||
pageType="asset"
|
||||
isTestnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-3
@@ -53,6 +53,13 @@ export async function NFTPublicPage(props: {
|
||||
const _isTokenByIndexSupported =
|
||||
props.type === "erc721" && isTokenByIndexSupported(functionSelectors);
|
||||
|
||||
// FIXME: this is technically a bad fallback but we gotta do what we gotta do
|
||||
const contractMetadataWithNameAndSymbolFallback = {
|
||||
...contractMetadata,
|
||||
// fall back to the contract address if the name is not set
|
||||
name: contractMetadata.name || props.clientContract.address,
|
||||
symbol: contractMetadata.symbol || "",
|
||||
};
|
||||
const buyNFTDropCard = nftDropClaimParams ? (
|
||||
<BuyNFTDropCardServer
|
||||
chainMetadata={props.chainMetadata}
|
||||
@@ -73,7 +80,7 @@ export async function NFTPublicPage(props: {
|
||||
<NFTsGrid
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
collectionMetadata={contractMetadata}
|
||||
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
|
||||
gridClassName={
|
||||
buyNFTDropCard
|
||||
? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
@@ -110,7 +117,7 @@ export async function NFTPublicPage(props: {
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
contractMetadata={contractMetadata}
|
||||
contractMetadata={contractMetadataWithNameAndSymbolFallback}
|
||||
isDashboardUser={isDashboardUser}
|
||||
>
|
||||
<ResponsiveLayout
|
||||
@@ -138,7 +145,7 @@ export async function NFTPublicPage(props: {
|
||||
<PageLoadTokenViewerSheet
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
collectionMetadata={contractMetadata}
|
||||
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
|
||||
tokenByIndexSupported={_isTokenByIndexSupported}
|
||||
tokenId={BigInt(props.tokenId)}
|
||||
type={props.type}
|
||||
|
||||
+8
-3
@@ -37,7 +37,7 @@ export function TokenViewerSheet(
|
||||
clientContract: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
collectionMetadata: {
|
||||
name: string;
|
||||
name: string | null;
|
||||
image?: string;
|
||||
};
|
||||
type: "erc1155" | "erc721";
|
||||
@@ -57,6 +57,11 @@ export function TokenViewerSheet(
|
||||
) {
|
||||
const tokenId = props.variant === "fetch-data" ? props.tokenId : props.nft.id;
|
||||
|
||||
const collectionMetadataWithNameFallback = {
|
||||
...props.collectionMetadata,
|
||||
name: props.collectionMetadata.name || props.clientContract.address,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
@@ -78,7 +83,7 @@ export function TokenViewerSheet(
|
||||
<FetchAndRenderTokenInfo
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
collectionMetadata={props.collectionMetadata}
|
||||
collectionMetadata={collectionMetadataWithNameFallback}
|
||||
tokenByIndexSupported={props.tokenByIndexSupported}
|
||||
tokenId={props.tokenId}
|
||||
type={props.type}
|
||||
@@ -86,7 +91,7 @@ export function TokenViewerSheet(
|
||||
) : (
|
||||
<TokenInfoUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
collectionMetadata={props.collectionMetadata}
|
||||
collectionMetadata={collectionMetadataWithNameFallback}
|
||||
contract={props.clientContract}
|
||||
data={props.nft}
|
||||
tokenId={tokenId}
|
||||
|
||||
+4
-1
@@ -84,7 +84,10 @@ export const SettingsMetadata = ({
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
let image: string | undefined = metadata.data?.image;
|
||||
let image: string | undefined =
|
||||
metadata.data?.image && typeof metadata.data.image === "string"
|
||||
? metadata.data.image
|
||||
: undefined;
|
||||
try {
|
||||
image = image
|
||||
? // eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
+68
-53
@@ -3,14 +3,24 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { CodeClient } from "@workspace/ui/components/code/code.client";
|
||||
import { Img } from "@workspace/ui/components/img";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import { ArrowRightIcon, CircleCheckIcon, CircleXIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
CircleCheckIcon,
|
||||
CircleXIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getAddress,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import type { Status, Token } from "thirdweb/bridge";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import { toTokens } from "thirdweb/utils";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { LinkWithCopyButton } from "@/components/ui/link-with-copy-button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchChain } from "@/utils/fetchChain";
|
||||
@@ -46,9 +56,9 @@ export function BridgeStatus(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="px-6 lg:px-8 py-7 space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground "> Status </p>
|
||||
<div className="px-6 lg:px-8 py-7 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground "> Status </p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -75,14 +85,15 @@ export function BridgeStatus(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground ">Payment ID</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground ">Payment ID</p>
|
||||
<CopyTextButton
|
||||
textToCopy={bridgeStatus.paymentId}
|
||||
textToShow={`${bridgeStatus.paymentId.slice(0, 6)}...${bridgeStatus.paymentId.slice(-4)}`}
|
||||
textToShow={bridgeStatus.paymentId}
|
||||
tooltip="Payment ID"
|
||||
className="text-sm translate-x-1.5"
|
||||
className="text-sm -translate-x-1.5 tabular-nums text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
iconClassName="text-muted-foreground/70"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +101,7 @@ export function BridgeStatus(props: {
|
||||
|
||||
{purchaseDataString && (
|
||||
<div className="px-6 lg:px-8 py-7 space-y-2 border-t border-dashed">
|
||||
<p className="text-sm text-muted-foreground ">Purchase Data</p>
|
||||
<p className="text-sm text-foreground ">Purchase Data</p>
|
||||
<CodeClient
|
||||
code={purchaseDataString}
|
||||
lang="json"
|
||||
@@ -113,6 +124,10 @@ function TokenInfo(props: {
|
||||
txHash: string | undefined;
|
||||
}) {
|
||||
const chainQuery = useChainQuery(props.token.chainId);
|
||||
const isNativeToken =
|
||||
props.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
|
||||
|
||||
const tokenAddress = getAddress(props.token.address);
|
||||
|
||||
return (
|
||||
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
|
||||
@@ -121,8 +136,9 @@ function TokenInfo(props: {
|
||||
{props.label}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="pb-7 pt-6 border-b border-dashed">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative hover:ring-2 hover:ring-offset-2 hover:ring-offset-card hover:ring-foreground/30 rounded-full">
|
||||
<Link
|
||||
@@ -178,10 +194,11 @@ function TokenInfo(props: {
|
||||
render={(data) => (
|
||||
<Link
|
||||
href={`/${chainQuery.data?.slug || props.token.chainId}`}
|
||||
className="text-sm text-muted-foreground leading-none hover:underline"
|
||||
className="text-sm text-muted-foreground leading-none hover:underline flex items-center gap-1"
|
||||
target="_blank"
|
||||
>
|
||||
{data}
|
||||
{data}{" "}
|
||||
<ArrowUpRightIcon className="size-3.5 text-muted-foreground/70 shrink-0" />
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
@@ -189,41 +206,44 @@ function TokenInfo(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="h-8" />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2.5 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">{props.addressLabel}</p>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground ">{props.addressLabel}</p>
|
||||
<WalletAddress
|
||||
address={props.walletAddress}
|
||||
address={getAddress(props.walletAddress)}
|
||||
client={props.client}
|
||||
className="py-0.5 h-auto text-sm [&>*]:!font-sans"
|
||||
iconClassName="size-3.5"
|
||||
className="py-0.5 h-auto text-sm [&>*]:!font-sans tabular-nums [&>*]:font-normal text-muted-foreground hover:text-foreground"
|
||||
iconClassName="size-3"
|
||||
shortenAddress={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">Token Address</p>
|
||||
<CopyTextButton
|
||||
textToCopy={props.token.address}
|
||||
textToShow={`${props.token.address.slice(0, 6)}...${props.token.address.slice(-4)}`}
|
||||
tooltip="Token Address"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground ">Token Address</p>
|
||||
<LinkWithCopyButton
|
||||
href={
|
||||
isNativeToken
|
||||
? `/${chainQuery.data?.slug || props.token.chainId}`
|
||||
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
|
||||
}
|
||||
textToShow={tokenAddress}
|
||||
textToCopy={tokenAddress}
|
||||
copyTooltip="Copy Token Address"
|
||||
className="-translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">Transaction Hash</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground ">Transaction Hash</p>
|
||||
{props.txHash ? (
|
||||
<CopyTextButton
|
||||
<LinkWithCopyButton
|
||||
className="-translate-x-1"
|
||||
href={`/${chainQuery.data?.slug || props.token.chainId}/tx/${props.txHash}`}
|
||||
textToShow={`${props.txHash.slice(0, 12)}...${props.txHash.slice(-4)}`}
|
||||
textToCopy={props.txHash}
|
||||
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
|
||||
tooltip="Transaction Hash"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
copyTooltip="Copy Transaction Hash"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground ">N/A</p>
|
||||
@@ -288,10 +308,8 @@ function FailedBridgeStatusContent(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="px-6 lg:px-8 py-7 space-y-1.5 border-b border-dashed">
|
||||
<h3 className="text-base font-medium tracking-tight mb-3">
|
||||
Transactions
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-lg font-medium tracking-tight mb-4">Transactions</h3>
|
||||
<div className="space-y-4">
|
||||
{props.transactions.map((tx) => {
|
||||
return (
|
||||
<TxHashRow
|
||||
@@ -328,7 +346,7 @@ function TxHashRow(props: {
|
||||
const chainQuery = useChainQuery(props.chainId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
@@ -336,26 +354,23 @@ function TxHashRow(props: {
|
||||
uri: chainQuery.data?.icon?.url,
|
||||
})}
|
||||
alt={chainQuery.data?.name}
|
||||
className="size-4 rounded-full"
|
||||
className="size-3.5 rounded-full"
|
||||
fallback={
|
||||
<div className="size-4 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
<div className="size-3.5 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
}
|
||||
/>
|
||||
<SkeletonContainer
|
||||
skeletonData={`Chain ${props.chainId}`}
|
||||
loadedData={chainQuery.data?.name}
|
||||
render={(data) => (
|
||||
<p className="text-sm text-muted-foreground">{data}</p>
|
||||
)}
|
||||
render={(data) => <p className="text-sm text-foreground">{data}</p>}
|
||||
/>
|
||||
</div>
|
||||
<CopyTextButton
|
||||
<LinkWithCopyButton
|
||||
href={`/${chainQuery.data?.slug || props.chainId}/tx/${props.txHash}`}
|
||||
textToShow={props.txHash}
|
||||
textToCopy={props.txHash}
|
||||
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
|
||||
tooltip="Copy Transaction Hash"
|
||||
className="text-sm"
|
||||
copyIconPosition="right"
|
||||
variant="ghost"
|
||||
copyTooltip="Copy Transaction Hash"
|
||||
className="-translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Clock4Icon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import { toTokens } from "thirdweb";
|
||||
import { getAddress, toTokens } from "thirdweb";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
@@ -153,6 +153,9 @@ function GeneericTxDetails(props: {
|
||||
|
||||
const timestamp = getDatefromTimestamp(block.timestamp);
|
||||
|
||||
const fromAddress = getAddress(transaction.from);
|
||||
const toAddress = transaction.to ? getAddress(transaction.to) : undefined;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 lg:p-6 bg-card">
|
||||
{/* section 1 */}
|
||||
@@ -216,8 +219,8 @@ function GeneericTxDetails(props: {
|
||||
<section className="border-b py-6 space-y-5 lg:space-y-3">
|
||||
<GridItem label="From" tooltip="The sending party of the transaction.">
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.from}
|
||||
textToShow={transaction.from}
|
||||
textToCopy={fromAddress}
|
||||
textToShow={fromAddress}
|
||||
tooltip="Copy from address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
@@ -225,14 +228,14 @@ function GeneericTxDetails(props: {
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
{transaction.to && (
|
||||
{toAddress && (
|
||||
<GridItem
|
||||
label="Interacted with (To)"
|
||||
tooltip="The receiving party of the transaction (could be a contract address)."
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.to}
|
||||
textToShow={transaction.to}
|
||||
textToCopy={toAddress}
|
||||
textToShow={toAddress}
|
||||
tooltip="Copy to address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
|
||||
@@ -183,13 +183,64 @@ const STYLUS = {
|
||||
],
|
||||
description:
|
||||
"Airdrop your NFTs or tokens to a large number of recipients. Built with Arbitrum Stylus.",
|
||||
displayName: "Arbitrum Stylus Contracts",
|
||||
displayName: "Airdrop - Arbitrum Stylus",
|
||||
id: "stylus",
|
||||
name: "Stylus",
|
||||
showInExplore: true,
|
||||
isBeta: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const MODULAR_CONTRACTS_STYLUS = {
|
||||
contracts: [
|
||||
// erc721 token
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC721",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC721",
|
||||
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC721",
|
||||
],
|
||||
{
|
||||
description: "ERC721 NFTs that only owners can mint.",
|
||||
title: "Modular NFT Collection",
|
||||
},
|
||||
],
|
||||
// erc1155 token
|
||||
[
|
||||
"thirdweb.eth/ERC1155CoreInitializable",
|
||||
[
|
||||
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC1155",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC1155",
|
||||
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC1155",
|
||||
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
|
||||
],
|
||||
{
|
||||
description: "ERC1155 NFTs that only owners can mint.",
|
||||
title: "Modular Edition",
|
||||
},
|
||||
],
|
||||
// erc20 token
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC20",
|
||||
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC20",
|
||||
],
|
||||
{
|
||||
description: "ERC20 Tokens that only owners can mint.",
|
||||
title: "Modular Token",
|
||||
},
|
||||
],
|
||||
],
|
||||
description:
|
||||
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
|
||||
displayName: "Modular Contracts - Arbitrum Stylus",
|
||||
id: "modular-contracts",
|
||||
name: "modular",
|
||||
showInExplore: true,
|
||||
isBeta: true,
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const CATEGORIES: Record<string, ExploreCategory> = {
|
||||
[POPULAR.id]: POPULAR,
|
||||
[NFTS.id]: NFTS,
|
||||
@@ -203,6 +254,7 @@ const CATEGORIES: Record<string, ExploreCategory> = {
|
||||
[COMMERCE.id]: COMMERCE,
|
||||
[STAKING.id]: STAKING,
|
||||
[GOVERNANCE.id]: GOVERNANCE,
|
||||
[MODULAR_CONTRACTS_STYLUS.id]: MODULAR_CONTRACTS_STYLUS,
|
||||
};
|
||||
|
||||
export function getCategory(id: string) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
import { BookOpenIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
|
||||
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
@@ -9,7 +14,7 @@ export function PageHeader(props: { containerClassName?: string }) {
|
||||
<div className="border-b">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
"container flex max-w-7xl justify-between py-4",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
@@ -22,18 +27,49 @@ export function PageHeader(props: { containerClassName?: string }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
aria-label="View Documentation"
|
||||
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
|
||||
>
|
||||
<BookOpenIcon className="size-5" />
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
</div>
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleThemeButton(props: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<ClientOnly
|
||||
ssr={<Skeleton className="size-[36px] rounded-full border bg-accent" />}
|
||||
>
|
||||
<Button
|
||||
aria-label="Toggle theme"
|
||||
className={cn(
|
||||
"h-auto w-auto rounded-full p-2 text-muted-foreground hover:text-foreground",
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<SunIcon className="size-5 " />
|
||||
) : (
|
||||
<MoonIcon className="size-5 " />
|
||||
)}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { bridgeStats } from "../../../bridge/data";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { TokenPage } from "./components/token-page";
|
||||
|
||||
@@ -36,7 +37,8 @@ export default async function Page() {
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
85+ chains, 4500+ tokens and 9+ million routes supported
|
||||
{bridgeStats.supportedChains} chains, {bridgeStats.supportedTokens}{" "}
|
||||
tokens and {bridgeStats.supportedRoutes} routes supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
|
||||
import { StripeRedirectErrorPage } from "../../_components/StripeRedirectErrorPage";
|
||||
import { getPlanCancelUrl } from "../../utils/billing";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CancelPlanPage(props: {
|
||||
params: Promise<{
|
||||
team_id: string;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
getInvoicePaymentUrl,
|
||||
} from "../../../utils/billing";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CheckoutPage(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
|
||||
import { StripeRedirectErrorPage } from "../../_components/StripeRedirectErrorPage";
|
||||
import { getBillingPortalUrl } from "../../utils/billing";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ManageBillingPage(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
"use client";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { AutoConnect } from "thirdweb/react";
|
||||
import type { SmartWalletOptions } from "thirdweb/wallets";
|
||||
|
||||
export function TWAutoConnect(props: {
|
||||
accountAbstraction?: SmartWalletOptions;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<AutoConnect
|
||||
accountAbstraction={props.accountAbstraction}
|
||||
client={props.client}
|
||||
/>
|
||||
);
|
||||
export function TWAutoConnect(props: { client: ThirdwebClient }) {
|
||||
return <AutoConnect client={props.client} />;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ export default async function DropPage({
|
||||
]);
|
||||
|
||||
const thumbnail =
|
||||
project.thumbnail || nft.metadata.image || contractMetadata.image || "";
|
||||
project.thumbnail ||
|
||||
nft.metadata.image ||
|
||||
(typeof contractMetadata.image === "string" ? contractMetadata.image : "");
|
||||
|
||||
const displayName = contractMetadata.name || nft.metadata.name || "";
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
WalletCardsIcon,
|
||||
} from "lucide-react";
|
||||
import { FullWidthSidebarLayout } from "@/components/blocks/full-width-sidebar-layout";
|
||||
|
||||
@@ -18,11 +17,6 @@ export function TeamSidebarLayout(props: {
|
||||
layoutPath: string;
|
||||
children: React.ReactNode;
|
||||
ecosystems: Array<{ name: string; slug: string }>;
|
||||
chainSubscriptions: Array<{
|
||||
chainId: number;
|
||||
chainName: string;
|
||||
slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const { layoutPath, children } = props;
|
||||
|
||||
@@ -72,24 +66,6 @@ export function TeamSidebarLayout(props: {
|
||||
{
|
||||
separator: true,
|
||||
} as const,
|
||||
// infrastructure below here
|
||||
// TODO: make this one link to an overview page that has a list of all the chains currently deployed
|
||||
{
|
||||
links: [
|
||||
...props.chainSubscriptions.map((chainSubscription) => ({
|
||||
href: `${layoutPath}/~/infrastructure/${chainSubscription.slug}`,
|
||||
label: chainSubscription.chainName,
|
||||
})),
|
||||
{
|
||||
href: `${layoutPath}/~/infrastructure/deploy`,
|
||||
label: "Deploy Infrastructure",
|
||||
},
|
||||
],
|
||||
subMenu: {
|
||||
icon: WalletCardsIcon,
|
||||
label: "Chain Infrastucture",
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerSidebarLinks={[
|
||||
{
|
||||
|
||||
@@ -4,13 +4,11 @@ import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/project/projects";
|
||||
import { fetchEcosystemList } from "@/api/team/ecosystems";
|
||||
import { getTeamBySlug, getTeams } from "@/api/team/get-team";
|
||||
import { getChainSubscriptions } from "@/api/team/team-subscription";
|
||||
import { CustomChatButton } from "@/components/chat/CustomChatButton";
|
||||
import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { siwaExamplePrompts } from "@/constants/siwa-example-prompts";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getChain } from "../../../(dashboard)/(chain)/utils";
|
||||
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
|
||||
import { StaffModeNotice } from "./_components/StaffModeNotice";
|
||||
import { TeamSidebarLayout } from "./TeamSidebarLayout";
|
||||
@@ -21,52 +19,26 @@ export default async function TeamLayout(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const [
|
||||
accountAddress,
|
||||
account,
|
||||
teams,
|
||||
authToken,
|
||||
team,
|
||||
ecosystems,
|
||||
chainSubscriptions,
|
||||
] = await Promise.all([
|
||||
getAuthTokenWalletAddress(),
|
||||
getValidAccount(`/team/${params.team_slug}`),
|
||||
getTeams(),
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
fetchEcosystemList(params.team_slug),
|
||||
getChainSubscriptions(params.team_slug),
|
||||
]);
|
||||
const [accountAddress, account, teams, authToken, team, ecosystems] =
|
||||
await Promise.all([
|
||||
getAuthTokenWalletAddress(),
|
||||
getValidAccount(`/team/${params.team_slug}`),
|
||||
getTeams(),
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
fetchEcosystemList(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!teams || !accountAddress || !authToken || !team) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const [teamsAndProjects, chainSidebarLinks] = await Promise.all([
|
||||
Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
projects: await getProjects(team.slug),
|
||||
team,
|
||||
})),
|
||||
),
|
||||
chainSubscriptions
|
||||
? await Promise.all(
|
||||
chainSubscriptions.map(async (chainSubscription) => {
|
||||
if (!chainSubscription.chainId) {
|
||||
throw new Error("Chain ID is required");
|
||||
}
|
||||
const chain = await getChain(chainSubscription.chainId);
|
||||
|
||||
return {
|
||||
chainId: chain.chainId,
|
||||
chainName: chain.name,
|
||||
slug: chain.slug,
|
||||
};
|
||||
}),
|
||||
).catch(() => [])
|
||||
: [],
|
||||
]);
|
||||
const teamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
projects: await getProjects(team.slug),
|
||||
team,
|
||||
})),
|
||||
);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
@@ -92,9 +64,6 @@ export default async function TeamLayout(props: {
|
||||
</div>
|
||||
|
||||
<TeamSidebarLayout
|
||||
chainSubscriptions={chainSidebarLinks.sort(
|
||||
(a, b) => a.chainId - b.chainId,
|
||||
)}
|
||||
ecosystems={ecosystems.map((ecosystem) => ({
|
||||
name: ecosystem.name,
|
||||
slug: ecosystem.slug,
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ const authOptions = [
|
||||
"apple",
|
||||
"coinbase",
|
||||
"line",
|
||||
"epic",
|
||||
] as const satisfies AuthOption[];
|
||||
|
||||
type AuthOptionsFormData = {
|
||||
|
||||
+2
-5
@@ -11,6 +11,7 @@ import {
|
||||
useActiveWalletConnectionStatus,
|
||||
} from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { appMetadata } from "@/constants/connect";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
@@ -65,11 +66,7 @@ export const NebulaConnectWallet = (props: {
|
||||
const { customDetailsButton } = props;
|
||||
return (
|
||||
<ConnectButton
|
||||
appMetadata={{
|
||||
logoUrl: "https://thirdweb.com/favicon.ico",
|
||||
name: "thirdweb",
|
||||
url: "https://thirdweb.com",
|
||||
}}
|
||||
appMetadata={appMetadata}
|
||||
autoConnect={false}
|
||||
chains={allChainsV5}
|
||||
client={props.client}
|
||||
|
||||
+6
-2
@@ -4,9 +4,14 @@ import { ProjectFTUX } from "./ProjectFTUX";
|
||||
|
||||
const meta = {
|
||||
component: ProjectFTUX,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container py-8 pb-20">
|
||||
<div className="container py-8 pb-20 max-w-7xl">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
@@ -31,6 +36,5 @@ export const Default: Story = {
|
||||
],
|
||||
},
|
||||
teamSlug: "bar",
|
||||
managementAccessToken: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
+1
-80
@@ -15,33 +15,12 @@ import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
|
||||
import { ContractIcon } from "@/icons/ContractIcon";
|
||||
import { InsightIcon } from "@/icons/InsightIcon";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
import {
|
||||
getProjectWallet,
|
||||
type ProjectWalletSummary,
|
||||
} from "@/lib/server/project-wallet";
|
||||
import { ClientIDSection } from "./ClientIDSection";
|
||||
import { ProjectWalletControls } from "./ProjectWalletControls.client";
|
||||
import { ProjectWalletSetup } from "./ProjectWalletSetup.client";
|
||||
import { SecretKeySection } from "./SecretKeySection";
|
||||
|
||||
export async function ProjectFTUX(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallet?: ProjectWalletSummary | undefined;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const projectWallet = props.wallet ?? (await getProjectWallet(props.project));
|
||||
|
||||
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<ProjectWalletSection
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
/>
|
||||
<IntegrateAPIKeySection project={props.project} />
|
||||
<ProductsSection
|
||||
projectSlug={props.project.slug}
|
||||
@@ -53,64 +32,6 @@ export async function ProjectFTUX(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWalletSection(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallet: ProjectWalletSummary | undefined;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const defaultLabel = getProjectWalletLabel(props.project.name);
|
||||
const walletAddress = props.wallet?.address;
|
||||
const label = props.wallet?.label ?? defaultLabel;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full border border-border bg-background p-2">
|
||||
<WalletProductIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg tracking-tight">
|
||||
Project Wallet
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Use it for deployments, payments, and API integrations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{walletAddress ? (
|
||||
<>
|
||||
<ProjectWalletControls
|
||||
label={label}
|
||||
project={props.project}
|
||||
walletAddress={walletAddress}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/transactions`}
|
||||
>
|
||||
View Transactions
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ProjectWalletSetup
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Integrate API key section ------------------------------------------------------------
|
||||
|
||||
function IntegrateAPIKeySection({ project }: { project: Project }) {
|
||||
|
||||
-747
@@ -1,747 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
ShuffleIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toWei } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import { isAddress } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
|
||||
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
|
||||
type ProjectWalletControlsProps = {
|
||||
walletAddress: string;
|
||||
label: string;
|
||||
project: Pick<
|
||||
Project,
|
||||
"id" | "publishableKey" | "teamId" | "services" | "name"
|
||||
>;
|
||||
defaultChainId?: number;
|
||||
};
|
||||
|
||||
export function ProjectWalletControls(props: ProjectWalletControlsProps) {
|
||||
const { walletAddress, label, project, defaultChainId } = props;
|
||||
const [isSendOpen, setIsSendOpen] = useState(false);
|
||||
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
|
||||
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
|
||||
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const client = useMemo(() => getClientThirdwebClient(), []);
|
||||
const chain = useV5DashboardChain(selectedChainId);
|
||||
const queryClient = useQueryClient();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const engineCloudService = useMemo(
|
||||
() => project.services?.find((service) => service.name === "engineCloud"),
|
||||
[project.services],
|
||||
);
|
||||
const managementAccessToken =
|
||||
engineCloudService?.managementAccessToken ?? undefined;
|
||||
const isManagedVault = !!engineCloudService?.encryptedAdminKey;
|
||||
|
||||
const serverWalletsQuery = useQuery({
|
||||
enabled: !!managementAccessToken,
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
return [] as ProjectWalletSummary[];
|
||||
}
|
||||
|
||||
return listProjectServerWallets({
|
||||
managementAccessToken,
|
||||
projectId: project.id,
|
||||
});
|
||||
},
|
||||
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const serverWallets = serverWalletsQuery.data ?? [];
|
||||
const currentWalletAddressLower = walletAddress.toLowerCase();
|
||||
const otherWallets = useMemo(() => {
|
||||
return serverWallets.filter((wallet) => {
|
||||
return wallet.address.toLowerCase() !== currentWalletAddressLower;
|
||||
});
|
||||
}, [serverWallets, currentWalletAddressLower]);
|
||||
|
||||
const canChangeWallet = otherWallets.length > 0;
|
||||
|
||||
const handleOpenChangeWallet = () => {
|
||||
const currentWallet = serverWallets.find(
|
||||
(wallet) => wallet.address.toLowerCase() === currentWalletAddressLower,
|
||||
);
|
||||
|
||||
const nextWalletId =
|
||||
otherWallets[0]?.id ?? currentWallet?.id ?? serverWallets[0]?.id;
|
||||
|
||||
setSelectedWalletId(nextWalletId);
|
||||
setIsChangeWalletOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseChangeWallet = () => {
|
||||
setIsChangeWalletOpen(false);
|
||||
setSelectedWalletId(undefined);
|
||||
};
|
||||
|
||||
const selectedWallet = useMemo(() => {
|
||||
if (!selectedWalletId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return serverWallets.find((wallet) => wallet.id === selectedWalletId);
|
||||
}, [selectedWalletId, serverWallets]);
|
||||
|
||||
const isSelectionDifferent = Boolean(
|
||||
selectedWallet &&
|
||||
selectedWallet.address.toLowerCase() !== currentWalletAddressLower,
|
||||
);
|
||||
|
||||
const changeWalletMutation = useMutation({
|
||||
mutationFn: async (wallet: ProjectWalletSummary) => {
|
||||
await updateDefaultProjectWallet({
|
||||
project: project as Project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update project wallet";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: async (_, wallet) => {
|
||||
const descriptionLabel = wallet.label ?? wallet.address;
|
||||
toast.success("Project wallet updated", {
|
||||
description: `Now pointing to ${descriptionLabel}`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"project",
|
||||
project.id,
|
||||
"server-wallets",
|
||||
managementAccessToken,
|
||||
],
|
||||
});
|
||||
handleCloseChangeWallet();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const balanceQuery = useWalletBalance({
|
||||
address: walletAddress,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
|
||||
const balanceDisplay = balanceQuery.data
|
||||
? `${balanceQuery.data.displayValue} ${balanceQuery.data.symbol}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-background p-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row">
|
||||
<div className="flex-1">
|
||||
<p className="text-muted-foreground text-xs">Address</p>
|
||||
<WalletAddress address={walletAddress} client={client} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-muted-foreground text-xs">Label</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-mono text-sm break-all py-2 sm:py-0">
|
||||
{label}
|
||||
</p>
|
||||
{canChangeWallet && (
|
||||
<Button
|
||||
className="h-8 gap-1 sm:ml-4"
|
||||
onClick={handleOpenChangeWallet}
|
||||
size="sm"
|
||||
disabled={serverWalletsQuery.isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
<ShuffleIcon className="size-3.5" />
|
||||
Change default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex-1 flex-col">
|
||||
<p className="text-xs text-muted-foreground pb-2">Balance</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
{balanceQuery.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : balanceDisplay ? (
|
||||
<span className="text-md tracking-tight">
|
||||
{balanceDisplay}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">N/A</span>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Refresh balance"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={balanceQuery.isFetching}
|
||||
onClick={() => balanceQuery.refetch()}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCcwIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
balanceQuery.isFetching && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open wallet actions"
|
||||
className="h-9 w-9"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 rounded-xl">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Receive funds
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-col flex-1">
|
||||
<p className="text-xs text-muted-foreground pb-2">Network</p>
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="max-w-xs rounded-lg"
|
||||
client={client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
onChange={setSelectedChainId}
|
||||
placeholder="Select network"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendProjectWalletModal
|
||||
chainId={selectedChainId}
|
||||
client={client}
|
||||
isManagedVault={isManagedVault}
|
||||
label={label}
|
||||
onClose={() => setIsSendOpen(false)}
|
||||
onSuccess={() => balanceQuery.refetch()}
|
||||
open={isSendOpen}
|
||||
publishableKey={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
walletAddress={walletAddress}
|
||||
/>
|
||||
|
||||
<FundWalletModal
|
||||
checkoutWidgetTitle={`Fund ${label}`}
|
||||
client={client}
|
||||
defaultChainId={selectedChainId}
|
||||
description="Use your card or crypto to deposit into this server wallet"
|
||||
onOpenChange={setIsReceiveOpen}
|
||||
open={isReceiveOpen}
|
||||
recipientAddress={walletAddress}
|
||||
title="Fund project wallet"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleCloseChangeWallet();
|
||||
}
|
||||
}}
|
||||
open={isChangeWalletOpen}
|
||||
>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Change default wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose another server wallet to use as the default for this
|
||||
project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 pb-8 lg:px-6">
|
||||
{serverWalletsQuery.isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
) : serverWallets.length <= 1 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need at least two server wallets to pick a different
|
||||
default.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select
|
||||
onValueChange={(value) => setSelectedWalletId(value)}
|
||||
value={selectedWalletId ?? ""}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select server wallet">
|
||||
{selectedWallet ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={selectedWallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{selectedWallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
{selectedWallet.address.toLowerCase() ===
|
||||
currentWalletAddressLower && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Select server wallet"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverWallets.map((wallet) => {
|
||||
const isCurrent =
|
||||
wallet.address.toLowerCase() ===
|
||||
currentWalletAddressLower;
|
||||
|
||||
return (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={wallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{wallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button
|
||||
onClick={handleCloseChangeWallet}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={changeWalletMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedWallet ||
|
||||
!isSelectionDifferent ||
|
||||
changeWalletMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedWallet) {
|
||||
changeWalletMutation.mutate(selectedWallet);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{changeWalletMutation.isPending && (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
)}
|
||||
Save default
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createSendFormSchema = (secretKeyLabel: string) =>
|
||||
z.object({
|
||||
chainId: z.number({
|
||||
required_error: "Select a network",
|
||||
}),
|
||||
toAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Destination address is required")
|
||||
.refine((value) => Boolean(isAddress(value)), {
|
||||
message: "Enter a valid wallet address",
|
||||
}),
|
||||
amount: z.string().trim().min(1, "Amount is required"),
|
||||
secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`),
|
||||
vaultAccessToken: z.string().trim(),
|
||||
});
|
||||
|
||||
const SECRET_KEY_LABEL = "Project secret key";
|
||||
|
||||
type SendFormValues = z.infer<ReturnType<typeof createSendFormSchema>>;
|
||||
|
||||
function SendProjectWalletModal(props: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
walletAddress: string;
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
chainId: number;
|
||||
label: string;
|
||||
client: ReturnType<typeof getClientThirdwebClient>;
|
||||
isManagedVault: boolean;
|
||||
}) {
|
||||
const {
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
walletAddress,
|
||||
publishableKey,
|
||||
teamId,
|
||||
chainId,
|
||||
label,
|
||||
client,
|
||||
isManagedVault,
|
||||
} = props;
|
||||
|
||||
const secretKeyLabel = SECRET_KEY_LABEL;
|
||||
const secretKeyPlaceholder = "Enter your project secret key";
|
||||
const secretKeyHelper =
|
||||
"Your project secret key was generated when you created your project. If you lost it, regenerate one from Project settings.";
|
||||
const vaultAccessTokenHelper =
|
||||
"Vault access tokens are optional credentials with server wallet permissions. Manage them in Vault settings.";
|
||||
|
||||
const formSchema = useMemo(() => createSendFormSchema(SECRET_KEY_LABEL), []);
|
||||
|
||||
const form = useForm<SendFormValues>({
|
||||
defaultValues: {
|
||||
amount: "0",
|
||||
chainId,
|
||||
secretKey: "",
|
||||
vaultAccessToken: "",
|
||||
toAddress: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedChain = useV5DashboardChain(form.watch("chainId"));
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- form submission chainId must track selector state
|
||||
useEffect(() => {
|
||||
form.setValue("chainId", chainId);
|
||||
}, [chainId, form]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- reset cached inputs when modal closes to avoid leaking state
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const currentValues = form.getValues();
|
||||
form.reset({
|
||||
amount: "0",
|
||||
chainId,
|
||||
secretKey: currentValues.secretKey ?? "",
|
||||
vaultAccessToken: currentValues.vaultAccessToken ?? "",
|
||||
toAddress: "",
|
||||
});
|
||||
}
|
||||
}, [open, chainId, form]);
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (values: SendFormValues) => {
|
||||
const quantityWei = toWei(values.amount).toString();
|
||||
const secretKeyValue = values.secretKey.trim();
|
||||
const vaultAccessTokenValue = values.vaultAccessToken.trim();
|
||||
|
||||
const result = await sendProjectWalletTokens({
|
||||
chainId: values.chainId,
|
||||
publishableKey,
|
||||
quantityWei,
|
||||
recipientAddress: values.toAddress,
|
||||
teamId,
|
||||
walletAddress,
|
||||
secretKey: secretKeyValue,
|
||||
...(vaultAccessTokenValue
|
||||
? { vaultAccessToken: vaultAccessTokenValue }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const errorMessage =
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: "Failed to send funds";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return result.transactionIds;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess: (transactionIds) => {
|
||||
toast.success("Transfer submitted", {
|
||||
description:
|
||||
transactionIds && transactionIds.length > 0
|
||||
? `Transaction ID: ${transactionIds[0]}`
|
||||
: undefined,
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Send from {label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Execute a one-off transfer using your server wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
void sendMutation.mutateAsync(values);
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4 px-4 pb-8 lg:px-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
className="bg-card"
|
||||
client={client}
|
||||
disableDeprecated
|
||||
onChange={(nextChainId) => {
|
||||
field.onChange(nextChainId);
|
||||
}}
|
||||
placeholder="Select network"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="toAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Send to</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="0x..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Sending native token
|
||||
{selectedChain?.nativeCurrency?.symbol
|
||||
? ` (${selectedChain.nativeCurrency.symbol})`
|
||||
: ""}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{secretKeyLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder={secretKeyPlaceholder}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{secretKeyHelper}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isManagedVault && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vaultAccessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Vault access token
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
(optional)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder="Enter a vault access token (optional)"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{vaultAccessTokenHelper}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!form.formState.isValid || sendMutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{sendMutation.isPending && <Spinner className="size-4" />}
|
||||
Submit transfer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
import CreateServerWallet from "../../transactions/server-wallets/components/create-server-wallet.client";
|
||||
|
||||
export function ProjectWalletSetup(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const { project, teamSlug, managementAccessToken } = props;
|
||||
const router = useDashboardRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useMemo(() => getClientThirdwebClient(), []);
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const serverWalletsQuery = useQuery({
|
||||
enabled: Boolean(managementAccessToken),
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
return [] as ProjectWalletSummary[];
|
||||
}
|
||||
|
||||
return listProjectServerWallets({
|
||||
managementAccessToken,
|
||||
projectId: project.id,
|
||||
});
|
||||
},
|
||||
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const serverWallets = serverWalletsQuery.data ?? [];
|
||||
|
||||
const effectiveSelectedWalletId = useMemo(() => {
|
||||
if (
|
||||
selectedWalletId &&
|
||||
serverWallets.some((wallet) => wallet.id === selectedWalletId)
|
||||
) {
|
||||
return selectedWalletId;
|
||||
}
|
||||
|
||||
return serverWallets[0]?.id;
|
||||
}, [selectedWalletId, serverWallets]);
|
||||
|
||||
const selectedWallet = useMemo(() => {
|
||||
if (!effectiveSelectedWalletId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return serverWallets.find(
|
||||
(wallet) => wallet.id === effectiveSelectedWalletId,
|
||||
);
|
||||
}, [effectiveSelectedWalletId, serverWallets]);
|
||||
|
||||
const setDefaultWalletMutation = useMutation({
|
||||
mutationFn: async (wallet: ProjectWalletSummary) => {
|
||||
await updateDefaultProjectWallet({
|
||||
project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update project wallet";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: async (_, wallet) => {
|
||||
const descriptionLabel = wallet.label ?? wallet.address;
|
||||
toast.success("Project wallet updated", {
|
||||
description: `Now pointing to ${descriptionLabel}`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"project",
|
||||
project.id,
|
||||
"server-wallets",
|
||||
managementAccessToken,
|
||||
],
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const canSelectExisting =
|
||||
Boolean(managementAccessToken) && serverWallets.length > 0;
|
||||
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>No project wallet set</AlertTitle>
|
||||
<AlertDescription>
|
||||
Set a project wallet to use for dashboard and API integrations.
|
||||
</AlertDescription>
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
{serverWalletsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
<span>Loading existing server wallets…</span>
|
||||
</div>
|
||||
) : canSelectExisting ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Choose an existing server wallet
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These wallets were already created for this project. Pick one to
|
||||
set as the default.
|
||||
</p>
|
||||
<Select
|
||||
onValueChange={(value) => setSelectedWalletId(value)}
|
||||
value={effectiveSelectedWalletId ?? ""}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select server wallet">
|
||||
{selectedWallet ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={selectedWallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedWallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"Select server wallet"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverWallets.map((wallet) => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={wallet.address}
|
||||
client={client}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{wallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
disabled={!selectedWallet || setDefaultWalletMutation.isPending}
|
||||
onClick={() => {
|
||||
if (selectedWallet) {
|
||||
setDefaultWalletMutation.mutate(selectedWallet);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{setDefaultWalletMutation.isPending && (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
)}
|
||||
Set as project wallet
|
||||
</Button>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
setAsProjectWallet
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Create a server wallet to start using transactions and other
|
||||
project features.
|
||||
</p>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
setAsProjectWallet
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+736
@@ -0,0 +1,736 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
EllipsisVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
ShuffleIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { type ThirdwebClient, toWei } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import { isAddress } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
|
||||
type GetProjectServerWallets = (params: {
|
||||
managementAccessToken: string;
|
||||
projectId: string;
|
||||
}) => Promise<ProjectWalletSummary[]>;
|
||||
|
||||
type ProjectWalletControlsProps = {
|
||||
projectWallet: ProjectWalletSummary;
|
||||
project: Project;
|
||||
defaultChainId?: number;
|
||||
teamSlug: string;
|
||||
getProjectServerWallets: GetProjectServerWallets;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
const { projectWallet, project, defaultChainId } = props;
|
||||
const [isSendOpen, setIsSendOpen] = useState(false);
|
||||
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
|
||||
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
|
||||
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
|
||||
|
||||
const chain = useV5DashboardChain(selectedChainId);
|
||||
|
||||
const engineCloudService = useMemo(
|
||||
() => project.services?.find((service) => service.name === "engineCloud"),
|
||||
[project.services],
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
engineCloudService?.managementAccessToken ?? undefined;
|
||||
|
||||
const isManagedVault = !!engineCloudService?.encryptedAdminKey;
|
||||
|
||||
const serverWalletsQuery = useQuery({
|
||||
enabled: !!managementAccessToken,
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
return [] as ProjectWalletSummary[];
|
||||
}
|
||||
|
||||
return props.getProjectServerWallets({
|
||||
managementAccessToken,
|
||||
projectId: project.id,
|
||||
});
|
||||
},
|
||||
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const balanceQuery = useWalletBalance({
|
||||
address: projectWallet.address,
|
||||
chain,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
const canChangeWallet =
|
||||
serverWalletsQuery.data && serverWalletsQuery.data.length > 1;
|
||||
|
||||
const router = useDashboardRouter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-xl border border-border bg-card relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open wallet actions"
|
||||
className="p-2 h-auto w-auto absolute right-4 top-4 z-10"
|
||||
variant="ghost"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 rounded-xl">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Receive funds
|
||||
</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" />
|
||||
View transactions
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
Change project wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="p-4 relative flex flex-col gap-5">
|
||||
<div>
|
||||
<p className="text-sm text-foreground mb-1">Wallet Address</p>
|
||||
<CopyTextButton
|
||||
textToShow={projectWallet.address}
|
||||
textToCopy={projectWallet.address}
|
||||
tooltip="Copy wallet address"
|
||||
copyIconPosition="right"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground -translate-x-1.5 py-0 h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-dashed flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-foreground mb-1">Balance</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{balanceQuery.isFetching || !balanceQuery.data ? (
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{balanceQuery.data.displayValue} {balanceQuery.data.symbol}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label="Refresh balance"
|
||||
className="p-1 h-auto w-auto"
|
||||
disabled={balanceQuery.isFetching}
|
||||
onClick={() => balanceQuery.refetch()}
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcwIcon
|
||||
className={cn(
|
||||
"size-3 text-muted-foreground",
|
||||
balanceQuery.isFetching && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
client={props.client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
onChange={setSelectedChainId}
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendProjectWalletModal
|
||||
chainId={selectedChainId}
|
||||
client={props.client}
|
||||
isManagedVault={isManagedVault}
|
||||
label={projectWallet.label || ""}
|
||||
onClose={() => setIsSendOpen(false)}
|
||||
onSuccess={() => balanceQuery.refetch()}
|
||||
open={isSendOpen}
|
||||
publishableKey={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
walletAddress={projectWallet.address}
|
||||
/>
|
||||
|
||||
<FundWalletModal
|
||||
checkoutWidgetTitle="Fund Project Wallet"
|
||||
client={props.client}
|
||||
defaultChainId={selectedChainId}
|
||||
description="Use your card or crypto to deposit into this server wallet"
|
||||
onOpenChange={setIsReceiveOpen}
|
||||
open={isReceiveOpen}
|
||||
recipientAddress={projectWallet.address}
|
||||
title="Fund project wallet"
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={setIsChangeWalletOpen} open={isChangeWalletOpen}>
|
||||
<DialogContent className="gap-0 p-0 overflow-hidden">
|
||||
<ChangeProjectWalletDialogContent
|
||||
isOpen={isChangeWalletOpen}
|
||||
setIsOpen={setIsChangeWalletOpen}
|
||||
serverWallets={{
|
||||
data: serverWalletsQuery.data ?? [],
|
||||
isPending: serverWalletsQuery.isLoading,
|
||||
}}
|
||||
projectWallet={projectWallet}
|
||||
client={props.client}
|
||||
project={project}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeProjectWalletDialogContent(props: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
serverWallets: {
|
||||
data: ProjectWalletSummary[];
|
||||
isPending: boolean;
|
||||
};
|
||||
projectWallet: ProjectWalletSummary;
|
||||
managementAccessToken: string | undefined;
|
||||
project: Project;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
|
||||
props.projectWallet.id,
|
||||
);
|
||||
|
||||
const serverWallets = props.serverWallets.data;
|
||||
|
||||
const currentWalletAddressLower = props.projectWallet.address.toLowerCase();
|
||||
|
||||
const selectedWallet = useMemo(() => {
|
||||
if (!selectedWalletId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return serverWallets.find((wallet) => wallet.id === selectedWalletId);
|
||||
}, [selectedWalletId, serverWallets]);
|
||||
|
||||
const isSelectionDifferent = Boolean(
|
||||
selectedWallet &&
|
||||
selectedWallet.address.toLowerCase() !== currentWalletAddressLower,
|
||||
);
|
||||
|
||||
const changeWalletMutation = useMutation({
|
||||
mutationFn: async (wallet: ProjectWalletSummary) => {
|
||||
await updateDefaultProjectWallet({
|
||||
project: props.project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update project wallet";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Project wallet updated");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"project",
|
||||
props.project.id,
|
||||
"server-wallets",
|
||||
props.managementAccessToken,
|
||||
],
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Change Project Wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a server wallet to use as project wallet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 pb-8 lg:px-6">
|
||||
{props.serverWallets.isPending ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedWalletId(value);
|
||||
}}
|
||||
value={selectedWalletId ?? ""}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select server wallet">
|
||||
{selectedWallet ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={selectedWallet.address}
|
||||
client={props.client}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{selectedWallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
{selectedWallet.address.toLowerCase() ===
|
||||
currentWalletAddressLower && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Select server wallet"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverWallets.map((wallet) => {
|
||||
const isCurrent =
|
||||
wallet.address.toLowerCase() === currentWalletAddressLower;
|
||||
|
||||
return (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletAddress
|
||||
address={wallet.address}
|
||||
client={props.client}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
{wallet.label ?? "Unnamed server wallet"}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button
|
||||
onClick={() => props.setIsOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={changeWalletMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedWallet ||
|
||||
!isSelectionDifferent ||
|
||||
changeWalletMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedWallet) {
|
||||
changeWalletMutation.mutate(selectedWallet);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{changeWalletMutation.isPending && (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
)}
|
||||
Set as project wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createSendFormSchema = (secretKeyLabel: string) =>
|
||||
z.object({
|
||||
chainId: z.number({
|
||||
required_error: "Select a network",
|
||||
}),
|
||||
toAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Destination address is required")
|
||||
.refine((value) => Boolean(isAddress(value)), {
|
||||
message: "Enter a valid wallet address",
|
||||
}),
|
||||
amount: z.string().trim().min(1, "Amount is required"),
|
||||
secretKey: z.string().trim().min(1, `${secretKeyLabel} is required`),
|
||||
vaultAccessToken: z.string().trim(),
|
||||
});
|
||||
|
||||
const SECRET_KEY_LABEL = "Project secret key";
|
||||
|
||||
type SendFormValues = z.infer<ReturnType<typeof createSendFormSchema>>;
|
||||
|
||||
type SendProjectWalletModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
walletAddress: string;
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
chainId: number;
|
||||
label: string;
|
||||
client: ReturnType<typeof getClientThirdwebClient>;
|
||||
isManagedVault: boolean;
|
||||
};
|
||||
|
||||
const secretKeyLabel = SECRET_KEY_LABEL;
|
||||
const secretKeyPlaceholder = "Enter your project secret key";
|
||||
const secretKeyHelper =
|
||||
"Your project secret key was generated when you created your project. If you lost it, regenerate one from Project settings.";
|
||||
const vaultAccessTokenHelper =
|
||||
"Vault access tokens are optional credentials with server wallet permissions. Manage them in Vault settings.";
|
||||
|
||||
function SendProjectWalletModal(props: SendProjectWalletModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
props.onClose();
|
||||
}
|
||||
}}
|
||||
open={props.open}
|
||||
>
|
||||
<DialogContent className="gap-0 p-0 overflow-hidden">
|
||||
<SendProjectWalletModalContent {...props} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
const {
|
||||
onClose,
|
||||
onSuccess,
|
||||
walletAddress,
|
||||
publishableKey,
|
||||
teamId,
|
||||
chainId,
|
||||
label,
|
||||
client,
|
||||
isManagedVault,
|
||||
} = props;
|
||||
|
||||
const formSchema = useMemo(() => createSendFormSchema(SECRET_KEY_LABEL), []);
|
||||
|
||||
const form = useForm<SendFormValues>({
|
||||
defaultValues: {
|
||||
amount: "0",
|
||||
chainId,
|
||||
secretKey: "",
|
||||
vaultAccessToken: "",
|
||||
toAddress: "",
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedChain = useV5DashboardChain(form.watch("chainId"));
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (values: SendFormValues) => {
|
||||
const quantityWei = toWei(values.amount).toString();
|
||||
const secretKeyValue = values.secretKey.trim();
|
||||
const vaultAccessTokenValue = values.vaultAccessToken.trim();
|
||||
|
||||
const result = await sendProjectWalletTokens({
|
||||
chainId: values.chainId,
|
||||
publishableKey,
|
||||
quantityWei,
|
||||
recipientAddress: values.toAddress,
|
||||
teamId,
|
||||
walletAddress,
|
||||
secretKey: secretKeyValue,
|
||||
...(vaultAccessTokenValue
|
||||
? { vaultAccessToken: vaultAccessTokenValue }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const errorMessage =
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: "Failed to send funds";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return result.transactionIds;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess: (transactionIds) => {
|
||||
toast.success("Transfer submitted", {
|
||||
description:
|
||||
transactionIds && transactionIds.length > 0
|
||||
? `Transaction ID: ${transactionIds[0]}`
|
||||
: undefined,
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="gap-0 p-0">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Send from {label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Execute a one-off transfer using your server wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
void sendMutation.mutateAsync(values);
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4 px-4 pb-8 lg:px-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
className="bg-card"
|
||||
client={client}
|
||||
disableDeprecated
|
||||
onChange={(nextChainId) => {
|
||||
field.onChange(nextChainId);
|
||||
}}
|
||||
placeholder="Select network"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="toAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Send to</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="off" placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<Input inputMode="decimal" min="0" step="any" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Sending native token
|
||||
{selectedChain?.nativeCurrency?.symbol
|
||||
? ` (${selectedChain.nativeCurrency.symbol})`
|
||||
: ""}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{secretKeyLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder={secretKeyPlaceholder}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{secretKeyHelper}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isManagedVault && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vaultAccessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Vault access token
|
||||
<span className="text-muted-foreground"> (optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder="Enter a vault access token (optional)"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{vaultAccessTokenHelper}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!form.formState.isValid || sendMutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{sendMutation.isPending && <Spinner className="size-4" />}
|
||||
Submit transfer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { projectStub } from "@/storybook/stubs";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { ProjectWalletSectionUI } from "./project-wallet";
|
||||
|
||||
const meta = {
|
||||
component: ProjectWalletSectionUI,
|
||||
title: "Project/ProjectWalletSection",
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container py-8 pb-20 max-w-7xl">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectWalletSectionUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const projectWithManagedAccessToken = projectStub("foo", "bar");
|
||||
projectWithManagedAccessToken.services = [
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: "managed-access-token",
|
||||
},
|
||||
];
|
||||
|
||||
const projectWithoutManagedAccessToken = projectStub("foo", "bar");
|
||||
|
||||
const projectWallet1: ProjectWalletSummary = {
|
||||
id: "server-wallet-id",
|
||||
address: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
label: "Project Wallet 1",
|
||||
};
|
||||
|
||||
const projectWallet2: ProjectWalletSummary = {
|
||||
id: "server-wallet-id-2",
|
||||
address: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
|
||||
label: "Project Wallet 2",
|
||||
};
|
||||
|
||||
export const NoProjectWalletSetNoManagedAccessToken: Story = {
|
||||
args: {
|
||||
project: projectWithoutManagedAccessToken,
|
||||
client: storybookThirdwebClient,
|
||||
teamSlug: "bar",
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoProjectWalletSetWithManagedAccessToken: Story = {
|
||||
args: {
|
||||
project: projectWithManagedAccessToken,
|
||||
client: storybookThirdwebClient,
|
||||
teamSlug: "bar",
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoProjectWalletSetWithManagedAccessTokenAndServerWallets: Story = {
|
||||
args: {
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
client: storybookThirdwebClient,
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoProjectWalletSetLoading: Story = {
|
||||
args: {
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
client: storybookThirdwebClient,
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectWalletSetMultipleServerWallets: Story = {
|
||||
args: {
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
client: storybookThirdwebClient,
|
||||
projectWallet: projectWallet1,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectWalletSetSingleServerWallet: Story = {
|
||||
args: {
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
projectWallet: projectWallet1,
|
||||
client: storybookThirdwebClient,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1];
|
||||
},
|
||||
},
|
||||
};
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { listProjectServerWallets } from "@/actions/project-wallet/list-server-wallets";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
import { CreateServerWallet } from "../../transactions/server-wallets/components/create-server-wallet.client";
|
||||
import { ProjectWalletDetailsSection } from "./project-wallet-details";
|
||||
|
||||
type GetProjectServerWallets = (params: {
|
||||
managementAccessToken: string;
|
||||
projectId: string;
|
||||
}) => Promise<ProjectWalletSummary[]>;
|
||||
|
||||
function CreateProjectWalletSection(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
getProjectServerWallets: GetProjectServerWallets;
|
||||
}) {
|
||||
const { project, teamSlug } = props;
|
||||
const router = useDashboardRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useMemo(() => getClientThirdwebClient(), []);
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isSelectDialogOpen, setIsSelectDialogOpen] = useState(false);
|
||||
|
||||
const managementAccessToken =
|
||||
props.project.services?.find((service) => service.name === "engineCloud")
|
||||
?.managementAccessToken ?? undefined;
|
||||
|
||||
const serverWalletsQuery = useQuery({
|
||||
enabled: Boolean(managementAccessToken),
|
||||
queryFn: async () => {
|
||||
if (!managementAccessToken) {
|
||||
return [] as ProjectWalletSummary[];
|
||||
}
|
||||
|
||||
return props.getProjectServerWallets({
|
||||
managementAccessToken,
|
||||
projectId: project.id,
|
||||
});
|
||||
},
|
||||
queryKey: ["project", project.id, "server-wallets", managementAccessToken],
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const serverWallets = serverWalletsQuery.data ?? [];
|
||||
|
||||
const effectiveSelectedWalletId = useMemo(() => {
|
||||
if (
|
||||
selectedWalletId &&
|
||||
serverWallets.some((wallet) => wallet.id === selectedWalletId)
|
||||
) {
|
||||
return selectedWalletId;
|
||||
}
|
||||
|
||||
return serverWallets[0]?.id;
|
||||
}, [selectedWalletId, serverWallets]);
|
||||
|
||||
const selectedWallet = useMemo(() => {
|
||||
if (!effectiveSelectedWalletId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return serverWallets.find(
|
||||
(wallet) => wallet.id === effectiveSelectedWalletId,
|
||||
);
|
||||
}, [effectiveSelectedWalletId, serverWallets]);
|
||||
|
||||
const setDefaultWalletMutation = useMutation({
|
||||
mutationFn: async (wallet: ProjectWalletSummary) => {
|
||||
await updateDefaultProjectWallet({
|
||||
project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update project wallet";
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: async (_, wallet) => {
|
||||
const descriptionLabel = wallet.label ?? wallet.address;
|
||||
toast.success("Project wallet updated", {
|
||||
description: `Now pointing to ${descriptionLabel}`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"project",
|
||||
project.id,
|
||||
"server-wallets",
|
||||
managementAccessToken,
|
||||
],
|
||||
});
|
||||
setIsSelectDialogOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const canSelectExisting =
|
||||
Boolean(managementAccessToken) && serverWallets.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 bg-card">
|
||||
<div className="rounded-full p-2 border bg-background inline-flex mb-4">
|
||||
<XIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-foreground mb-0.5">
|
||||
No Project Wallet set
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set a project wallet to set the default sender in thirdweb API.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-5 text-foreground">
|
||||
{serverWalletsQuery.isLoading ? (
|
||||
<Skeleton className="h-[36px] max-w-lg rounded-full" />
|
||||
) : canSelectExisting ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
className="bg-background rounded-full gap-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedWalletId(effectiveSelectedWalletId);
|
||||
setIsSelectDialogOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
||||
Select a server wallet
|
||||
</Button>
|
||||
|
||||
<CreateServerWallet
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
setAsProjectWallet
|
||||
button={{ size: "sm", iconClassName: "size-3.5" }}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setIsSelectDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
open={isSelectDialogOpen}
|
||||
>
|
||||
<DialogContent className="gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Select project wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a server wallet to use as the default for this
|
||||
project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 pb-8 lg:px-6">
|
||||
{serverWalletsQuery.isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
) : serverWallets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No server wallets found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select
|
||||
onValueChange={(value) => setSelectedWalletId(value)}
|
||||
value={selectedWalletId ?? ""}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select server wallet" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverWallets.map((wallet) => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center gap-3">
|
||||
<WalletAddress
|
||||
address={wallet.address}
|
||||
client={client}
|
||||
className="h-auto py-1"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{wallet.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsSelectDialogOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={setDefaultWalletMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!selectedWallet || setDefaultWalletMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedWallet) {
|
||||
setDefaultWalletMutation.mutate(selectedWallet);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{setDefaultWalletMutation.isPending && (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
)}
|
||||
Set as project wallet
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<CreateServerWallet
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
setAsProjectWallet
|
||||
button={{ size: "sm", iconClassName: "size-3.5" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWalletSectionUI(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
projectWallet: ProjectWalletSummary | undefined;
|
||||
getProjectServerWallets: GetProjectServerWallets;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Project Wallet
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The server wallet to be used as the default sender in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/reference"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb API
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{props.projectWallet ? (
|
||||
<ProjectWalletDetailsSection
|
||||
project={props.project}
|
||||
projectWallet={props.projectWallet}
|
||||
client={props.client}
|
||||
teamSlug={props.teamSlug}
|
||||
getProjectServerWallets={props.getProjectServerWallets}
|
||||
/>
|
||||
) : (
|
||||
<CreateProjectWalletSection
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
getProjectServerWallets={props.getProjectServerWallets}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWalletSection(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
projectWallet: ProjectWalletSummary | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<ProjectWalletSectionUI
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
client={props.client}
|
||||
projectWallet={props.projectWallet}
|
||||
getProjectServerWallets={listProjectServerWallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-1
@@ -59,7 +59,6 @@ export function ErrorRate({ datapoints }: { datapoints: ResultItem[] }) {
|
||||
key={tag}
|
||||
stackId="a"
|
||||
stroke={`var(--color-${tag})`}
|
||||
type="natural"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
+8
@@ -14,6 +14,7 @@ type AggregatedMetrics = {
|
||||
|
||||
export function ProjectHighlightsCard(props: {
|
||||
selectedChart: string | undefined;
|
||||
aggregatedUserStats: InAppWalletStats[];
|
||||
userStats: InAppWalletStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
teamSlug: string;
|
||||
@@ -22,6 +23,7 @@ export function ProjectHighlightsCard(props: {
|
||||
}) {
|
||||
const {
|
||||
selectedChart,
|
||||
aggregatedUserStats,
|
||||
userStats,
|
||||
volumeStats,
|
||||
teamSlug,
|
||||
@@ -70,6 +72,12 @@ export function ProjectHighlightsCard(props: {
|
||||
: "activeUsers"
|
||||
}
|
||||
aggregateFn={(_data, key) => {
|
||||
if (key === "activeUsers") {
|
||||
return aggregatedUserStats.reduce(
|
||||
(acc, curr) => acc + curr.uniqueWalletsConnected,
|
||||
0,
|
||||
);
|
||||
}
|
||||
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
|
||||
}}
|
||||
chartConfig={chartConfig}
|
||||
|
||||
@@ -39,10 +39,8 @@ import type {
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
|
||||
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
|
||||
import {
|
||||
ProjectFTUX,
|
||||
ProjectWalletSection,
|
||||
} from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { ProjectWalletSection } from "./components/project-wallet/project-wallet";
|
||||
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsChartCardAsync } from "./components/Transactions";
|
||||
import { ProjectHighlightsCard } from "./overview/highlights-card";
|
||||
@@ -112,9 +110,6 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
});
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
const managementAccessToken =
|
||||
project.services?.find((service) => service.name === "engineCloud")
|
||||
?.managementAccessToken ?? undefined;
|
||||
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
@@ -135,14 +130,17 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
actions: null,
|
||||
}}
|
||||
>
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
projectWallet={projectWallet}
|
||||
client={client}
|
||||
/>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
{isActive ? (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
<ResponsiveTimeFilters defaultRange={defaultRange} />
|
||||
<ProjectAnalytics
|
||||
authToken={authToken}
|
||||
@@ -155,12 +153,7 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectFTUX
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
<ProjectFTUX project={project} teamSlug={params.team_slug} />
|
||||
)}
|
||||
</ProjectPage>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
@@ -385,8 +378,18 @@ async function AsyncAppHighlightsCard(props: {
|
||||
params: PageParams;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [walletUserStatsTimeSeries, universalBridgeUsage] =
|
||||
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
|
||||
await Promise.allSettled([
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
@@ -415,6 +418,11 @@ async function AsyncAppHighlightsCard(props: {
|
||||
) {
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={
|
||||
aggregatedUserStats.status === "fulfilled"
|
||||
? aggregatedUserStats.value
|
||||
: []
|
||||
}
|
||||
selectedChart={props.selectedChart}
|
||||
selectedChartQueryParam={props.selectedChartQueryParam}
|
||||
teamSlug={props.params.team_slug}
|
||||
|
||||
+81
-31
@@ -1,45 +1,95 @@
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
|
||||
import { getTransactionAnalyticsSummary } from "../lib/analytics-summary.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { TransactionAnalyticsFilter } from "./filter";
|
||||
import { TransactionsChartCard } from "./tx-chart/tx-chart";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
import { EngineChecklist } from "./ftux.client";
|
||||
import { TransactionAnalyticsSummary } from "./summary";
|
||||
import { TransactionsAnalytics } from "./tx-chart/tx-chart";
|
||||
|
||||
export function TransactionsAnalyticsPageContent(props: {
|
||||
searchParams: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
project: Project;
|
||||
showAnalytics: boolean;
|
||||
wallets?: Wallet[];
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
teamId: string;
|
||||
isManagedVault: boolean;
|
||||
testTxWithWallet: string | undefined;
|
||||
testSolanaTxWithWallet: string | undefined;
|
||||
solanaWallets: SolanaWallet[];
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{props.showAnalytics && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<TransactionAnalyticsFilter />
|
||||
</div>
|
||||
<TransactionsChartCard
|
||||
project={props.project}
|
||||
searchParams={props.searchParams}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets ?? []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UnifiedTransactionsTable
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
const engineTxSummaryQuery = useQuery({
|
||||
queryKey: [
|
||||
"engine-tx-analytics-summary",
|
||||
props.teamId,
|
||||
props.project.publishableKey,
|
||||
props.authToken,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const data = await getTransactionAnalyticsSummary({
|
||||
clientId: props.project.publishableKey,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
if (engineTxSummaryQuery.isPending) {
|
||||
return (
|
||||
<div className="flex h-[642px] grow items-center justify-center bg-card rounded-xl border">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const hasTransactions = engineTxSummaryQuery.data
|
||||
? engineTxSummaryQuery.data.totalCount > 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-10">
|
||||
<EngineChecklist
|
||||
isManagedVault={props.isManagedVault}
|
||||
client={props.client}
|
||||
hasTransactions={hasTransactions}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
testTxWithWallet={props.testTxWithWallet}
|
||||
testSolanaTxWithWallet={props.testSolanaTxWithWallet}
|
||||
wallets={props.wallets}
|
||||
solanaWallets={props.solanaWallets}
|
||||
/>
|
||||
|
||||
{props.showAnalytics && hasTransactions && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={props.project.publishableKey}
|
||||
teamId={props.project.teamId}
|
||||
initialData={engineTxSummaryQuery.data}
|
||||
/>
|
||||
<TransactionsAnalytics
|
||||
project={props.project}
|
||||
authToken={props.authToken}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets ?? []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UnifiedTransactionsTable
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useResponsiveSearchParams,
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
import { DateRangeSelector } from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { normalizeTimeISOString } from "@/lib/time";
|
||||
import { getTxAnalyticsFiltersFromSearchParams } from "../lib/utils";
|
||||
|
||||
export function TransactionAnalyticsFilter() {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
const { range, interval } = getTxAnalyticsFiltersFromSearchParams({
|
||||
from: responsiveSearchParams.from,
|
||||
interval: responsiveSearchParams.interval,
|
||||
to: responsiveSearchParams.to,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
|
||||
<DateRangeSelector
|
||||
popoverAlign="end"
|
||||
range={range}
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-24
@@ -3,14 +3,13 @@ import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
|
||||
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
|
||||
import { CreateServerWallet } from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
import { SendTestSolanaTransaction } from "./send-test-solana-tx.client";
|
||||
import { SendTestTransaction } from "./send-test-tx.client";
|
||||
|
||||
interface Props {
|
||||
managementAccessToken: string | undefined;
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
solanaWallets: SolanaWallet[];
|
||||
@@ -29,11 +28,12 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
const steps: Step[] = [];
|
||||
steps.push({
|
||||
children: (
|
||||
<CreateServerWalletStep
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
<div className="mt-4 flex flex-row gap-4">
|
||||
<CreateServerWallet
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
completed: props.wallets.length > 0 || props.hasTransactions,
|
||||
description:
|
||||
@@ -61,7 +61,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
});
|
||||
return steps;
|
||||
}, [
|
||||
props.managementAccessToken,
|
||||
props.project,
|
||||
props.wallets,
|
||||
props.hasTransactions,
|
||||
@@ -113,19 +112,3 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function CreateServerWalletStep(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-row gap-4">
|
||||
<CreateServerWallet
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { ActivityIcon, CoinsIcon } from "lucide-react";
|
||||
import { toEther } from "thirdweb/utils";
|
||||
import { StatCard } from "@/components/analytics/stat"; // Assuming correct path
|
||||
import type { TransactionSummaryData } from "../lib/analytics";
|
||||
import type { TransactionSummaryData } from "../lib/analytics-summary.client";
|
||||
|
||||
// Renders the UI based on fetched data or pending state
|
||||
function TransactionAnalyticsSummaryUI(props: {
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
import type { TransactionStats } from "@/types/analytics";
|
||||
|
||||
export async function getTransactionsChartData({
|
||||
teamId,
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
interval,
|
||||
authToken,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
authToken: string;
|
||||
}): Promise<TransactionStats[]> {
|
||||
const filters = {
|
||||
endDate: to,
|
||||
resolution: interval,
|
||||
startDate: from,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
|
||||
{
|
||||
body: JSON.stringify(filters),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// TODO - need to handle this error state, like we do with the connect charts
|
||||
throw new Error(
|
||||
`Error fetching transactions chart data: ${response.status} ${
|
||||
response.statusText
|
||||
} - ${await response.text().catch(() => "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionsChartResponse = {
|
||||
result: {
|
||||
analytics: Array<{
|
||||
timeBucket: string;
|
||||
chainId: string;
|
||||
count: number;
|
||||
}>;
|
||||
metadata: {
|
||||
resolution: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const data = (await response.json()) as TransactionsChartResponse;
|
||||
|
||||
return data.result.analytics.map((stat) => ({
|
||||
chainId: Number(stat.chainId),
|
||||
count: stat.count,
|
||||
date: stat.timeBucket,
|
||||
}));
|
||||
}
|
||||
+3
-9
@@ -108,10 +108,10 @@ export function TransactionsChartCardUI(props: {
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Daily Transactions
|
||||
Transactions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Amount of daily transactions by chain.
|
||||
transactions broken down by chain
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
@@ -194,13 +194,7 @@ function EmptyChartContent(props: {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
|
||||
<span className="!pointer-events-auto relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
Waiting for transactions...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">No transactions found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+48
-53
@@ -1,73 +1,68 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { getTransactionsChart } from "../../lib/analytics";
|
||||
import { getTxAnalyticsFiltersFromSearchParams } from "../../lib/utils";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
getLastNDaysRange,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { normalizeTimeISOString } from "@/lib/time";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import { getTransactionsChartData } from "./data";
|
||||
import { TransactionsChartCardUI } from "./tx-chart-ui";
|
||||
|
||||
async function AsyncTransactionsChartCard(props: {
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
export function TransactionsAnalytics(props: {
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const data = await getTransactionsChart({
|
||||
const [range, setRange] = useState(() => getLastNDaysRange("last-30"));
|
||||
const [interval, setInterval] = useState<"day" | "week">("day");
|
||||
|
||||
const params = {
|
||||
clientId: props.project.publishableKey,
|
||||
from: props.from,
|
||||
interval: props.interval,
|
||||
from: normalizeTimeISOString(range.from),
|
||||
interval: interval,
|
||||
teamId: props.project.teamId,
|
||||
to: props.to,
|
||||
to: normalizeTimeISOString(range.to),
|
||||
authToken: props.authToken,
|
||||
};
|
||||
|
||||
const engineTxAnalytics = useQuery({
|
||||
queryKey: ["engine-tx-analytics", params],
|
||||
queryFn: async () => {
|
||||
const data = await getTransactionsChartData(params);
|
||||
return data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<TransactionsChartCardUI
|
||||
isPending={false}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
userOpStats={data}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
|
||||
<DateRangeSelector
|
||||
popoverAlign="end"
|
||||
range={range}
|
||||
setRange={setRange}
|
||||
/>
|
||||
|
||||
export function TransactionsChartCard(props: {
|
||||
searchParams: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const { range, interval } = getTxAnalyticsFiltersFromSearchParams(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
// TODO - change this if this component does not end up using these params
|
||||
fallback={
|
||||
<TransactionsChartCardUI
|
||||
isPending={true}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
userOpStats={[]}
|
||||
wallets={[]}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncTransactionsChartCard
|
||||
from={range.from.toISOString()}
|
||||
interval={interval}
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={setInterval}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TransactionsChartCardUI
|
||||
isPending={engineTxAnalytics.isPending}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
to={range.to.toISOString()}
|
||||
userOpStats={engineTxAnalytics.data ?? []}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-22
@@ -40,6 +40,13 @@ import {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -57,8 +64,9 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchSolanaBalance } from "../lib/getSolanaBalance";
|
||||
import { updateDefaultProjectWallet } from "../lib/vault.client";
|
||||
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
|
||||
import { CreateServerWallet } from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types";
|
||||
import { CreateSolanaWallet } from "../solana-wallets/components/create-solana-wallet.client";
|
||||
import { UpgradeSolanaPermissions } from "../solana-wallets/components/upgrade-solana-permissions.client";
|
||||
@@ -77,9 +85,9 @@ interface ServerWalletsTableProps {
|
||||
solanaTotalPages: number;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
solanaPermissionError?: boolean;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
@@ -88,7 +96,6 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
solanaWallets,
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
evmTotalRecords,
|
||||
evmCurrentPage,
|
||||
evmTotalPages,
|
||||
@@ -97,10 +104,14 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
solanaTotalPages,
|
||||
client,
|
||||
solanaPermissionError,
|
||||
authToken,
|
||||
} = props;
|
||||
|
||||
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
|
||||
const [selectedChainId, setSelectedChainId] = useState<number>(1);
|
||||
const [selectedSolanaChain, setSelectedSolanaChain] = useState<
|
||||
"solana:mainnet" | "solana:devnet"
|
||||
>("solana:mainnet");
|
||||
const [showSmartAccount, setShowSmartAccount] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -134,11 +145,7 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
<div className="flex flex-row gap-2.5">
|
||||
{activeChain === "evm" && (
|
||||
<>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
<CreateServerWallet project={project} teamSlug={teamSlug} />
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
onChange={setSelectedChainId}
|
||||
@@ -151,12 +158,33 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
</>
|
||||
)}
|
||||
{activeChain === "solana" && (
|
||||
<CreateSolanaWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
disabled={solanaPermissionError}
|
||||
/>
|
||||
<>
|
||||
<CreateSolanaWallet
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
disabled={solanaPermissionError}
|
||||
/>
|
||||
<Select
|
||||
value={selectedSolanaChain}
|
||||
onValueChange={(value) =>
|
||||
setSelectedSolanaChain(
|
||||
value as "solana:mainnet" | "solana:devnet",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50">
|
||||
<SelectValue placeholder="Select network" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="solana:mainnet" className="rounded-lg">
|
||||
Solana Mainnet
|
||||
</SelectItem>
|
||||
<SelectItem value="solana:devnet" className="rounded-lg">
|
||||
Solana Devnet
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -285,6 +313,8 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
authToken={authToken}
|
||||
chainId={selectedSolanaChain}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -514,11 +544,15 @@ function SolanaWalletRow({
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
authToken,
|
||||
chainId,
|
||||
}: {
|
||||
wallet: SolanaWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
chainId: "solana:mainnet" | "solana:devnet";
|
||||
}) {
|
||||
const engineService = project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
@@ -554,7 +588,12 @@ function SolanaWalletRow({
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaWalletBalance publicKey={wallet.publicKey} />
|
||||
<SolanaWalletBalance
|
||||
publicKey={wallet.publicKey}
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
chainId={chainId}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
@@ -746,16 +785,27 @@ function WalletBalance({
|
||||
);
|
||||
}
|
||||
|
||||
function SolanaWalletBalance({ publicKey }: { publicKey: string }) {
|
||||
function SolanaWalletBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId,
|
||||
}: {
|
||||
publicKey: string;
|
||||
authToken: string;
|
||||
clientId: string;
|
||||
chainId: "solana:mainnet" | "solana:devnet";
|
||||
}) {
|
||||
const balance = useQuery({
|
||||
queryFn: async () => {
|
||||
// TODO: Implement actual Solana balance fetching
|
||||
return {
|
||||
displayValue: "0",
|
||||
symbol: "SOL",
|
||||
};
|
||||
return await fetchSolanaBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId,
|
||||
});
|
||||
},
|
||||
queryKey: ["solanaWalletBalance", publicKey],
|
||||
queryKey: ["solanaWalletBalance", publicKey, chainId],
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
|
||||
export type TransactionSummaryData = {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string; // Keep fetched data structure
|
||||
};
|
||||
|
||||
type AnalyticsSummaryApiResponse = {
|
||||
result: {
|
||||
summary: {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string;
|
||||
};
|
||||
metadata: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Fetches data from the /analytics-summary endpoint
|
||||
export async function getTransactionAnalyticsSummary(props: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
authToken: string;
|
||||
}): Promise<TransactionSummaryData> {
|
||||
const body = {};
|
||||
const defaultData: TransactionSummaryData = {
|
||||
totalCount: 0,
|
||||
totalGasCostWei: "0",
|
||||
totalGasUnitsUsed: "0",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
|
||||
{
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
console.error("Unauthorized fetching transaction summary");
|
||||
return defaultData;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnalyticsSummaryApiResponse;
|
||||
|
||||
return {
|
||||
totalCount: data.result.summary.totalCount ?? 0,
|
||||
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
|
||||
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction summary:", error);
|
||||
return defaultData;
|
||||
}
|
||||
}
|
||||
-147
@@ -1,157 +1,10 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
import type { TransactionStats } from "@/types/analytics";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionsResponse,
|
||||
} from "../analytics/tx-table/types";
|
||||
|
||||
// Define the structure of the data we expect back from our fetch function
|
||||
export type TransactionSummaryData = {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string; // Keep fetched data structure
|
||||
};
|
||||
|
||||
// Define the structure of the API response
|
||||
type AnalyticsSummaryApiResponse = {
|
||||
result: {
|
||||
summary: {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string;
|
||||
};
|
||||
metadata: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Fetches data from the /analytics-summary endpoint
|
||||
export async function getTransactionAnalyticsSummary(props: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
}): Promise<TransactionSummaryData> {
|
||||
const authToken = await getAuthToken();
|
||||
const body = {};
|
||||
const defaultData: TransactionSummaryData = {
|
||||
totalCount: 0,
|
||||
totalGasCostWei: "0",
|
||||
totalGasUnitsUsed: "0",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
|
||||
{
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
console.error("Unauthorized fetching transaction summary");
|
||||
return defaultData;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnalyticsSummaryApiResponse;
|
||||
|
||||
return {
|
||||
totalCount: data.result.summary.totalCount ?? 0,
|
||||
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
|
||||
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction summary:", error);
|
||||
return defaultData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTransactionsChart({
|
||||
teamId,
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
interval,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
}): Promise<TransactionStats[]> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const filters = {
|
||||
endDate: to,
|
||||
resolution: interval,
|
||||
startDate: from,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
|
||||
{
|
||||
body: JSON.stringify(filters),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// TODO - need to handle this error state, like we do with the connect charts
|
||||
throw new Error(
|
||||
`Error fetching transactions chart data: ${response.status} ${
|
||||
response.statusText
|
||||
} - ${await response.text().catch(() => "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionsChartResponse = {
|
||||
result: {
|
||||
analytics: Array<{
|
||||
timeBucket: string;
|
||||
chainId: string;
|
||||
count: number;
|
||||
}>;
|
||||
metadata: {
|
||||
resolution: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const data = (await response.json()) as TransactionsChartResponse;
|
||||
|
||||
return data.result.analytics.map((stat) => ({
|
||||
chainId: Number(stat.chainId),
|
||||
count: stat.count,
|
||||
date: stat.timeBucket,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSingleTransaction({
|
||||
teamId,
|
||||
clientId,
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { configure, getSolanaWalletBalance } from "@thirdweb-dev/api";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
// Configure the API client to use the correct base URL
|
||||
configure({
|
||||
override: {
|
||||
baseUrl: THIRDWEB_API_HOST,
|
||||
},
|
||||
});
|
||||
|
||||
export async function fetchSolanaBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId = "solana:mainnet",
|
||||
}: {
|
||||
publicKey: string;
|
||||
authToken: string;
|
||||
clientId: string;
|
||||
chainId?: "solana:mainnet" | "solana:devnet";
|
||||
}): Promise<{
|
||||
displayValue: string;
|
||||
symbol: string;
|
||||
value: string;
|
||||
decimals: number;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await getSolanaWalletBalance({
|
||||
path: {
|
||||
address: publicKey,
|
||||
},
|
||||
query: {
|
||||
chainId,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
console.error(
|
||||
"Error fetching Solana balance:",
|
||||
response.error || "No data returned",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
displayValue: response.data.result.displayValue,
|
||||
symbol: "SOL",
|
||||
value: response.data.result.value,
|
||||
decimals: response.data.result.decimals,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching Solana balance:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
|
||||
export function getTxAnalyticsFiltersFromSearchParams(params: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
}) {
|
||||
return getFiltersFromSearchParams({
|
||||
defaultRange: "last-30",
|
||||
from: params.from,
|
||||
interval: params.interval,
|
||||
to: params.to,
|
||||
});
|
||||
}
|
||||
+16
-37
@@ -7,10 +7,7 @@ import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
|
||||
import { EngineChecklist } from "./analytics/ftux.client";
|
||||
import { TransactionAnalyticsSummary } from "./analytics/summary";
|
||||
import { ServerWalletsTable } from "./components/server-wallets-table.client";
|
||||
import { getTransactionAnalyticsSummary } from "./lib/analytics";
|
||||
import type { Wallet } from "./server-wallets/wallet-table/types";
|
||||
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
|
||||
@@ -110,12 +107,6 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
const isSolanaPermissionError =
|
||||
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
|
||||
|
||||
const initialData = await getTransactionAnalyticsSummary({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
}).catch(() => undefined);
|
||||
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
@@ -152,42 +143,30 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
<EngineChecklist
|
||||
isManagedVault={isManagedVault}
|
||||
client={client}
|
||||
hasTransactions={hasTransactions}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
|
||||
testSolanaTxWithWallet={
|
||||
searchParams.testSolanaTxWithWallet as string | undefined
|
||||
}
|
||||
wallets={wallets ?? []}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
/>
|
||||
{hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet && (
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={project.publishableKey}
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* transactions */}
|
||||
<TransactionsAnalyticsPageContent
|
||||
client={client}
|
||||
authToken={authToken}
|
||||
project={project}
|
||||
searchParams={searchParams}
|
||||
showAnalytics={
|
||||
hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet
|
||||
}
|
||||
teamSlug={params.team_slug}
|
||||
wallets={wallets}
|
||||
wallets={wallets ?? []}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamId={project.teamId}
|
||||
isManagedVault={isManagedVault}
|
||||
testTxWithWallet={
|
||||
typeof searchParams.testTxWithWallet === "string"
|
||||
? searchParams.testTxWithWallet
|
||||
: undefined
|
||||
}
|
||||
testSolanaTxWithWallet={
|
||||
typeof searchParams.testSolanaTxWithWallet === "string"
|
||||
? searchParams.testSolanaTxWithWallet
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Server Wallets (EVM + Solana) */}
|
||||
@@ -207,7 +186,6 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={eoas.data.items as Wallet[]}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
project={project}
|
||||
solanaCurrentPage={solanCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
@@ -217,6 +195,7 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
solanaPermissionError={isSolanaPermissionError}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+28
-9
@@ -15,18 +15,26 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createProjectServerWallet } from "../../lib/vault.client";
|
||||
|
||||
export default function CreateServerWallet(props: {
|
||||
export function CreateServerWallet(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
setAsProjectWallet?: boolean;
|
||||
button?: {
|
||||
size?: "default" | "sm" | "lg";
|
||||
iconClassName?: string;
|
||||
};
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [label, setLabel] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const managementAccessToken =
|
||||
props.project.services?.find((service) => service.name === "engineCloud")
|
||||
?.managementAccessToken ?? undefined;
|
||||
|
||||
const createEoaMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
managementAccessToken,
|
||||
@@ -53,12 +61,12 @@ export default function CreateServerWallet(props: {
|
||||
});
|
||||
|
||||
const handleCreateServerWallet = async () => {
|
||||
if (!props.managementAccessToken) {
|
||||
if (!managementAccessToken) {
|
||||
router.push(`/team/${props.teamSlug}/${props.project.slug}/vault`);
|
||||
} else {
|
||||
await createEoaMutation.mutateAsync({
|
||||
label,
|
||||
managementAccessToken: props.managementAccessToken,
|
||||
managementAccessToken: managementAccessToken,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -68,21 +76,32 @@ export default function CreateServerWallet(props: {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="gap-1.5 rounded-full bg-background"
|
||||
className="gap-1.5 rounded-full bg-background text-foreground"
|
||||
variant="outline"
|
||||
size={props.button?.size}
|
||||
onClick={() =>
|
||||
props.managementAccessToken
|
||||
managementAccessToken
|
||||
? setModalOpen(true)
|
||||
: router.push(`/team/${props.teamSlug}/${props.project.slug}/vault`)
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
<Spinner
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground",
|
||||
props.button?.iconClassName,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<PlusIcon className="size-4 text-muted-foreground" />
|
||||
<PlusIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground",
|
||||
props.button?.iconClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.managementAccessToken
|
||||
{managementAccessToken
|
||||
? isLoading
|
||||
? "Creating..."
|
||||
: "Create Server Wallet"
|
||||
|
||||
+8
-5
@@ -20,7 +20,6 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { createSolanaAccount } from "../lib/vault.client";
|
||||
|
||||
export function CreateSolanaWallet(props: {
|
||||
managementAccessToken: string | undefined;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
disabled?: boolean;
|
||||
@@ -29,14 +28,18 @@ export function CreateSolanaWallet(props: {
|
||||
const [label, setLabel] = useState("");
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const managementAccessToken =
|
||||
props.project.services?.find((service) => service.name === "engineCloud")
|
||||
?.managementAccessToken ?? undefined;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!props.managementAccessToken) {
|
||||
if (!managementAccessToken) {
|
||||
throw new Error("No management access token");
|
||||
}
|
||||
|
||||
const result = await createSolanaAccount({
|
||||
managementAccessToken: props.managementAccessToken,
|
||||
managementAccessToken: managementAccessToken,
|
||||
label: label.trim(),
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
@@ -67,8 +70,8 @@ export function CreateSolanaWallet(props: {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-2 rounded-full bg-foreground hover:bg-foreground/90"
|
||||
variant="outline"
|
||||
className="gap-1.5 rounded-full bg-background text-foreground"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
|
||||
+1
@@ -87,6 +87,7 @@ const authMethodsToPickFrom: InAppWalletAuth[] = [
|
||||
"line",
|
||||
"x",
|
||||
"tiktok",
|
||||
"epic",
|
||||
"coinbase",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
|
||||
+1
-1
@@ -193,7 +193,6 @@ export default async function Page(props: {
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={eoas.data.items as Wallet[]}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
project={project}
|
||||
solanaCurrentPage={solanCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
@@ -203,6 +202,7 @@ export default async function Page(props: {
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
solanaPermissionError={isSolanaPermissionError || false}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,154 @@
|
||||
import type { Format } from "@number-flow/react";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import Image, { type StaticImageData } from "next/image";
|
||||
import type { BuyAndSwapEmbedProps } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { FaqAccordion } from "@/components/blocks/faq-section";
|
||||
import ChainsImage from "../assets/chains.png";
|
||||
import RoutesImage from "../assets/routes.png";
|
||||
import TokensImage from "../assets/tokens.png";
|
||||
import { bridgeStats } from "../data";
|
||||
import { AnimatedNumbers } from "./client/animated-numbers";
|
||||
import { UniversalBridgeEmbed } from "./client/UniversalBridgeEmbed";
|
||||
import { BridgePageHeader } from "./header";
|
||||
|
||||
export function BridgePageUI(props: {
|
||||
title: React.ReactNode;
|
||||
buyTab: BuyAndSwapEmbedProps["buyTab"];
|
||||
swapTab: BuyAndSwapEmbedProps["swapTab"];
|
||||
}) {
|
||||
return (
|
||||
<div className="grow flex flex-col">
|
||||
<BridgePageHeader />
|
||||
|
||||
<div className="flex grow items-center justify-center px-4 relative pt-12 pb-20 lg:py-28 min-h-[calc(100dvh-60px)] overflow-hidden">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed buyTab={props.buyTab} swapTab={props.swapTab} />
|
||||
</div>
|
||||
|
||||
<HeadingSection title={props.title} />
|
||||
|
||||
<div className="h-20 lg:h-40" />
|
||||
|
||||
<BridgeFaqSection />
|
||||
|
||||
<div className="h-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingSection(props: { title: React.ReactNode }) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="mb-3 lg:mb-6">{props.title}</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm text-pretty text-center lg:text-lg mb-6 lg:mb-8">
|
||||
Seamlessly move your assets across {bridgeStats.supportedChains} chains
|
||||
with the best rates and fastest execution
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 items-center lg:justify-center flex-wrap">
|
||||
<DataSquare data={90} label="Chains Supported" src={ChainsImage} />
|
||||
<DataSquare
|
||||
data={6700}
|
||||
label="Tokens Supported"
|
||||
src={TokensImage}
|
||||
format={{ notation: "compact" }}
|
||||
/>
|
||||
<DataSquare
|
||||
data={14000000}
|
||||
format={{ notation: "compact" }}
|
||||
label="Routes Available"
|
||||
src={RoutesImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataSquare(props: {
|
||||
data: number;
|
||||
label: string;
|
||||
src: StaticImageData;
|
||||
format?: Format;
|
||||
imageClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-2 lg:py-0 size-full lg:size-[220px] rounded-xl border hover:bg-card bg-card/50 relative shrink-0 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-1 text-3xl lg:text-5xl font-medium tracking-tight font-mono mb-1 h-[45px] lg:h-[56px]">
|
||||
<AnimatedNumbers
|
||||
value={props.data}
|
||||
format={props.format}
|
||||
suffix="+"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">{props.label}</p>
|
||||
</div>
|
||||
<Image
|
||||
draggable={false}
|
||||
src={props.src}
|
||||
className={cn(
|
||||
"object-cover h-full lg:h-auto absolute right-0 top-0 bottom-0 left-1/2 lg:left-0 lg:top-auto invert dark:invert-0 opacity-50 lg:opacity-100",
|
||||
props.imageClassName,
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/20 hidden lg:block",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bridgeFaqs: Array<{ title: string; description: string }> = [
|
||||
{
|
||||
title: "What is bridging in crypto?",
|
||||
description:
|
||||
"Crypto bridging (cross-chain bridging) moves tokens between blockchains so you can use assets across networks. In thirdweb Bridge, connect your wallet, choose the source token/network and destination token/network, review the route and price, then confirm. Assets arrive after finality, often under ~10 seconds on fast routes, though timing depends on networks and congestion.",
|
||||
},
|
||||
{
|
||||
title: "How does crypto bridging work?",
|
||||
description:
|
||||
"Bridge smart contracts lock or burn tokens on the source chain and mint or release equivalents on the destination via verified cross-chain providers. thirdweb Bridge automatically finds the fastest, lowest-cost route and may use different mechanisms based on networks and liquidity. Arrival can range from seconds to minutes depending on finality; many routes complete in ~10 seconds",
|
||||
},
|
||||
{
|
||||
title: "What is a crypto asset swap?",
|
||||
description:
|
||||
"A crypto swap exchanges one token for another via a DEX or aggregator. thirdweb Bridge lets you bridge + swap in one step. For example, ETH on Ethereum to USDC on Base, by selecting your start and end tokens/networks and confirming.",
|
||||
},
|
||||
{
|
||||
title: "How can I get stablecoins like USDC or USDT?",
|
||||
description:
|
||||
"Use thirdweb Bridge to convert assets you hold into USDC or USDT on your chosen network: select your current token/network, pick the stablecoin (USDC, USDT, etc) on the destination, and confirm. You can also buy stablecoins with fiat in the Buy flow and bridge if needed. Always verify official token contract addresses.",
|
||||
},
|
||||
{
|
||||
title: "What is the cost of bridging and swapping?",
|
||||
description:
|
||||
"Costs include gas on each chain, bridge/liquidity provider fees, and any DEX swap fees or price impact. thirdweb Bridge compares routes and selects the best price route. Save by using lower-gas times or combining bridge + swap in one flow.",
|
||||
},
|
||||
];
|
||||
|
||||
function BridgeFaqSection() {
|
||||
return (
|
||||
<section className="container max-w-2xl">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-4 lg:mb-8 tracking-tight text-center">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<FaqAccordion faqs={bridgeFaqs} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,15 @@
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TWAutoConnect } from "../../../(app)/components/autoconnect";
|
||||
|
||||
const thirdwebClient = getClientThirdwebClient();
|
||||
|
||||
export function BridgeProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<TWAutoConnect client={thirdwebClient} />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { TokenInfo } from "thirdweb/react";
|
||||
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { createWallet } from "thirdweb/wallets";
|
||||
import {
|
||||
BuyAndSwapEmbed,
|
||||
type BuyAndSwapEmbedProps,
|
||||
} from "@/components/blocks/BuyAndSwapEmbed";
|
||||
import { appMetadata } from "@/constants/connect";
|
||||
|
||||
export function UniversalBridgeEmbed({
|
||||
chainId,
|
||||
token,
|
||||
amount,
|
||||
}: {
|
||||
chainId?: number;
|
||||
token: TokenInfo | undefined;
|
||||
amount?: string;
|
||||
export const bridgeWallets = [
|
||||
createWallet("io.metamask"),
|
||||
createWallet("com.coinbase.wallet", {
|
||||
appMetadata,
|
||||
}),
|
||||
createWallet("me.rainbow"),
|
||||
createWallet("io.rabby"),
|
||||
createWallet("io.zerion.wallet"),
|
||||
createWallet("com.okex.wallet"),
|
||||
];
|
||||
|
||||
export function UniversalBridgeEmbed(props: {
|
||||
buyTab: BuyAndSwapEmbedProps["buyTab"];
|
||||
swapTab: BuyAndSwapEmbedProps["swapTab"];
|
||||
}) {
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<BuyAndSwapEmbed
|
||||
chain={chain}
|
||||
buyAmount={amount}
|
||||
tokenAddress={token?.address}
|
||||
buyTab={props.buyTab}
|
||||
swapTab={props.swapTab}
|
||||
pageType="bridge"
|
||||
isTestnet={chain.testnet}
|
||||
wallets={bridgeWallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import NumberFlow, { type Format } from "@number-flow/react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
export function AnimatedNumbers(props: {
|
||||
value: number;
|
||||
format?: Format;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [value, setValue] = useState(props.value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) {
|
||||
// when the div becomes visible, set the value to the props value
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setValue(props.value);
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(ref.current);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<NumberFlow
|
||||
willChange
|
||||
value={value}
|
||||
format={props.format}
|
||||
suffix={props.suffix}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
import { ChevronRightIcon, DollarSignIcon, TrendingUpIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { reportBridgePageLinkClick } from "@/analytics/report";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function PillLink(props: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
linkType: "integrate-bridge" | "trending-tokens";
|
||||
}) {
|
||||
const Icon =
|
||||
props.linkType === "integrate-bridge" ? DollarSignIcon : TrendingUpIcon;
|
||||
return (
|
||||
<Link
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
onClick={() => reportBridgePageLinkClick({ linkType: props.linkType })}
|
||||
className={cn(
|
||||
"shadow-sm text-center justify-center inline-flex items-center gap-4 text-foreground group",
|
||||
"text-sm bg-card/50 backdrop-blur-lg border border-border/70",
|
||||
"rounded-full px-4 py-2.5 transition-colors duration-300 text-pretty leading-5",
|
||||
"hover:bg-pink-300/20 dark:hover:bg-pink-950/30 hover:text-foreground hover:border-pink-400 dark:hover:border-pink-800",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="size-4 shrink-0 text-pink-600" />
|
||||
{props.children}
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground/50 group-hover:translate-x-1 transition-all duration-300 group-hover:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
"use client";
|
||||
import { BookOpenIcon, CoinsIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { useTheme } from "next-themes";
|
||||
import { reportBridgePageLinkClick } from "@/analytics/report";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
|
||||
import { bridgeWallets } from "./client/UniversalBridgeEmbed";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
export function BridgePageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<div className="border-b border-border/70">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-4",
|
||||
"container flex max-w-7xl justify-between py-3 lg:py-4",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="flex items-center gap-2" href="/team">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link className="flex items-center gap-2" href="/home">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
@@ -22,18 +29,68 @@ export function PageHeader(props: { containerClassName?: string }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton className="bg-transparent" />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
<div className="flex items-center gap-3 lg:gap-5">
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<Link
|
||||
onClick={() => {
|
||||
reportBridgePageLinkClick({ linkType: "trending-tokens" });
|
||||
}}
|
||||
target="_blank"
|
||||
href="/tokens"
|
||||
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
|
||||
>
|
||||
<span className="text-sm sr-only md:not-sr-only">
|
||||
Trending Tokens
|
||||
</span>
|
||||
<CoinsIcon className="size-5 md:hidden" />
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => {
|
||||
reportBridgePageLinkClick({ linkType: "bridge-docs" });
|
||||
}}
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
aria-label="View Documentation"
|
||||
className="text-sm text-muted-foreground hover:text-foreground p-2 hover:bg-accent rounded-full transition-all duration-100"
|
||||
>
|
||||
<BookOpenIcon className="size-5" />
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
</div>
|
||||
<PublicPageConnectButton
|
||||
connectButtonClassName="!rounded-full"
|
||||
wallets={bridgeWallets}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleThemeButton(props: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<ClientOnly
|
||||
ssr={<Skeleton className="size-[36px] rounded-full border bg-accent" />}
|
||||
>
|
||||
<Button
|
||||
aria-label="Toggle theme"
|
||||
className={cn(
|
||||
"h-auto w-auto rounded-full p-2 text-muted-foreground hover:text-foreground",
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<SunIcon className="size-5 " />
|
||||
) : (
|
||||
<MoonIcon className="size-5 " />
|
||||
)}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { setThirdwebDomains } from "thirdweb/utils";
|
||||
import {
|
||||
NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
NEXT_PUBLIC_IPFS_GATEWAY_URL,
|
||||
} from "@/constants/public-envs";
|
||||
import {
|
||||
THIRDWEB_BRIDGE_URL,
|
||||
THIRDWEB_BUNDLER_DOMAIN,
|
||||
THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
THIRDWEB_PAY_DOMAIN,
|
||||
THIRDWEB_RPC_DOMAIN,
|
||||
THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
THIRDWEB_STORAGE_DOMAIN,
|
||||
} from "@/constants/urls";
|
||||
import { getVercelEnv } from "@/utils/vercel";
|
||||
|
||||
function getBridgeThirdwebClient() {
|
||||
if (getVercelEnv() !== "production") {
|
||||
// if not on production: run this when creating a client to set the domains
|
||||
setThirdwebDomains({
|
||||
bridge: THIRDWEB_BRIDGE_URL,
|
||||
bundler: THIRDWEB_BUNDLER_DOMAIN,
|
||||
inAppWallet: THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
insight: THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
pay: THIRDWEB_PAY_DOMAIN,
|
||||
rpc: THIRDWEB_RPC_DOMAIN,
|
||||
social: THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
storage: THIRDWEB_STORAGE_DOMAIN,
|
||||
});
|
||||
}
|
||||
|
||||
// During build time, client ID might not be available
|
||||
if (!NEXT_PUBLIC_DASHBOARD_CLIENT_ID) {
|
||||
// Return a minimal client that will fail gracefully at runtime if needed
|
||||
return createThirdwebClient({
|
||||
clientId: "dummy-build-time-client",
|
||||
config: {
|
||||
storage: {
|
||||
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
|
||||
},
|
||||
},
|
||||
secretKey: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return createThirdwebClient({
|
||||
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
config: {
|
||||
storage: {
|
||||
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
|
||||
},
|
||||
},
|
||||
secretKey: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const bridgeAppThirdwebClient = getBridgeThirdwebClient();
|
||||
@@ -0,0 +1,5 @@
|
||||
export const bridgeStats = {
|
||||
supportedChains: "90+",
|
||||
supportedTokens: "6700+",
|
||||
supportedRoutes: "14M+",
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,181 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { fetchChain } from "@/utils/fetchChain";
|
||||
import { BridgePageUI } from "../../components/bridge-page";
|
||||
import { bridgeStats } from "../../data";
|
||||
import { generateTokenPairSlugs, getTokenPairData } from "./slug-map";
|
||||
|
||||
type Params = {
|
||||
"token-pair": string;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<Params>;
|
||||
};
|
||||
|
||||
export async function generateStaticParams(): Promise<Params[]> {
|
||||
return generateTokenPairSlugs().map((slug) => ({
|
||||
"token-pair": slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps,
|
||||
): Promise<Metadata | undefined> {
|
||||
const params = await props.params;
|
||||
const tokenPair = params["token-pair"];
|
||||
const tokenMetadata = await getTokenMetadata(tokenPair).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
if (!tokenMetadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const title = getTitle(tokenMetadata);
|
||||
const description = `Bridge and swap ${bridgeStats.supportedTokens} tokens across ${bridgeStats.supportedChains} chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality`;
|
||||
|
||||
const result: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default async function Page(props: { params: Promise<Params> }) {
|
||||
const params = await props.params;
|
||||
|
||||
const tokenMetadata = await getTokenMetadata(params["token-pair"]);
|
||||
if (!tokenMetadata) {
|
||||
redirect("/bridge");
|
||||
}
|
||||
|
||||
const { fromToken, toToken, fromChain, toChain } = tokenMetadata;
|
||||
|
||||
return (
|
||||
<BridgePageUI
|
||||
buyTab={{
|
||||
buyToken: {
|
||||
tokenAddress: toToken.address,
|
||||
chainId: toToken.chainId,
|
||||
},
|
||||
}}
|
||||
swapTab={{
|
||||
sellToken: {
|
||||
tokenAddress: fromToken.address,
|
||||
chainId: fromToken.chainId,
|
||||
},
|
||||
buyToken: {
|
||||
tokenAddress: toToken.address,
|
||||
chainId: toToken.chainId,
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<h1 className="text-center text-2xl md:text-5xl font-semibold tracking-tighter text-balance max-w-5xl block mx-auto">
|
||||
{getTitle({
|
||||
fromToken,
|
||||
toToken,
|
||||
fromChain,
|
||||
toChain,
|
||||
})}
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanedChainName(chainName: string) {
|
||||
return chainName.replaceAll("mainnet", "").replaceAll("Mainnet", "").trim();
|
||||
}
|
||||
|
||||
function cleanTokenName(tokenName: string) {
|
||||
return tokenName.replaceAll("Token", "").replaceAll("token", "").trim();
|
||||
}
|
||||
|
||||
async function getTokenMetadata(slug: string) {
|
||||
const tokenPairData = getTokenPairData(slug);
|
||||
if (!tokenPairData) {
|
||||
redirect("/bridge");
|
||||
}
|
||||
|
||||
const [fromTokenTokens, toTokenTokens, fromChain, toChain] =
|
||||
await Promise.all([
|
||||
Bridge.tokens({
|
||||
client: serverThirdwebClient,
|
||||
chainId: tokenPairData.from.chainId,
|
||||
tokenAddress: tokenPairData.from.tokenAddress,
|
||||
}).catch(() => undefined),
|
||||
Bridge.tokens({
|
||||
client: serverThirdwebClient,
|
||||
chainId: tokenPairData.to.chainId,
|
||||
tokenAddress: tokenPairData.to.tokenAddress,
|
||||
}).catch(() => undefined),
|
||||
fetchChain(tokenPairData.from.chainId),
|
||||
fetchChain(tokenPairData.to.chainId),
|
||||
]);
|
||||
|
||||
const fromToken = fromTokenTokens?.[0];
|
||||
const toToken = toTokenTokens?.[0];
|
||||
|
||||
if (!fromToken || !toToken || !fromChain || !toChain) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
fromToken,
|
||||
toToken,
|
||||
fromChain,
|
||||
toChain,
|
||||
};
|
||||
}
|
||||
|
||||
function getTitle(data: {
|
||||
fromToken: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
address: string;
|
||||
};
|
||||
toToken: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
address: string;
|
||||
};
|
||||
fromChain: ChainMetadata;
|
||||
toChain: ChainMetadata;
|
||||
}) {
|
||||
const isSameChain = data.fromChain.chainId === data.toChain.chainId;
|
||||
const fromTokenName = cleanTokenName(data.fromToken.name);
|
||||
const toTokenName = cleanTokenName(data.toToken.name);
|
||||
|
||||
const fromTokenFullName =
|
||||
fromTokenName === data.fromToken.symbol ||
|
||||
data.fromToken.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? data.fromToken.symbol
|
||||
: `${fromTokenName} (${data.fromToken.symbol})`;
|
||||
|
||||
const toTokenFullName =
|
||||
toTokenName === data.toToken.symbol ||
|
||||
data.toToken.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? data.toToken.symbol
|
||||
: `${toTokenName} (${data.toToken.symbol})`;
|
||||
|
||||
// if both symbol are same: Swap {symbol} from {fromChain} to {toChain}
|
||||
if (fromTokenFullName === toTokenFullName) {
|
||||
return `Swap ${fromTokenFullName} from ${cleanedChainName(data.fromChain.name)} to ${cleanedChainName(data.toChain.name)}`;
|
||||
}
|
||||
|
||||
const line1 = `Swap ${fromTokenFullName} to ${toTokenFullName}`;
|
||||
const line2 = isSameChain
|
||||
? `on ${cleanedChainName(data.fromChain.name)}`
|
||||
: `cross-chain from ${cleanedChainName(data.fromChain.name)} to ${cleanedChainName(data.toChain.name)}`;
|
||||
|
||||
return `${line1} ${line2}`;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
|
||||
type Tokens = Record<
|
||||
string,
|
||||
| {
|
||||
chainId: number;
|
||||
tokens: Record<string, string | undefined>;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
|
||||
const tokens: Tokens = {
|
||||
eth: {
|
||||
chainId: 1,
|
||||
tokens: {
|
||||
usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
usdt: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
shib: "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE",
|
||||
},
|
||||
},
|
||||
bnb: {
|
||||
chainId: 56,
|
||||
tokens: {
|
||||
ada: "0x3EE2200Efb3400fAbB9AacF31297cBdD1d435D47",
|
||||
},
|
||||
},
|
||||
avax: {
|
||||
chainId: 43114,
|
||||
tokens: {},
|
||||
},
|
||||
doge: {
|
||||
chainId: 2000,
|
||||
tokens: {},
|
||||
},
|
||||
base: {
|
||||
chainId: 8453,
|
||||
tokens: {
|
||||
usdc: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
weth: "0x4200000000000000000000000000000000000006",
|
||||
},
|
||||
},
|
||||
pol: {
|
||||
chainId: 137,
|
||||
tokens: {
|
||||
usdc: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
||||
bnb: "0x3BA4c387f786bFEE076A58914F5Bd38d668B42c3",
|
||||
shib: "0x6f8a06447ff6fcf75d803135a7de15ce88c1d4ec",
|
||||
weth: "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619",
|
||||
avax: "0x2C89bbc92BD86F8075d1DEcc58C7F4E0107f286b",
|
||||
},
|
||||
},
|
||||
arb: {
|
||||
chainId: 42161,
|
||||
tokens: {
|
||||
usdc: "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type TokenPairData = {
|
||||
from: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
};
|
||||
to: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
};
|
||||
};
|
||||
|
||||
// examples: eth-to-eth-usdc, eth-usdc-to-pol, base-to-eth
|
||||
export function getTokenPairData(slug: string): TokenPairData | undefined {
|
||||
const [fromSlug, toSlug] = slug.split("-to-");
|
||||
if (!fromSlug || !toSlug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fromTokenInfo = getTokenInfo(fromSlug);
|
||||
const toTokenInfo = getTokenInfo(toSlug);
|
||||
|
||||
if (!fromTokenInfo || !toTokenInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
from: fromTokenInfo,
|
||||
to: toTokenInfo,
|
||||
};
|
||||
}
|
||||
|
||||
function getTokenInfo(slug: string):
|
||||
| {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
}
|
||||
| undefined {
|
||||
const [chainSlug, tokenSlug] = slug.split("-");
|
||||
|
||||
if (!chainSlug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chainObject = tokens[chainSlug];
|
||||
|
||||
if (!chainObject) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenAddress = tokenSlug
|
||||
? chainObject.tokens[tokenSlug]
|
||||
: NATIVE_TOKEN_ADDRESS;
|
||||
|
||||
if (!tokenAddress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
chainId: chainObject.chainId,
|
||||
tokenAddress,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateTokenPairSlugs() {
|
||||
const tokenIds = Object.keys(tokens);
|
||||
|
||||
// generate all possible token pair slugs
|
||||
const tokenSlugs: string[] = [];
|
||||
for (const chainSlug of tokenIds) {
|
||||
// erc20s and native token
|
||||
if (tokens[chainSlug]) {
|
||||
tokenSlugs.push(chainSlug); // native token
|
||||
for (const tokenSlug in tokens[chainSlug].tokens) {
|
||||
tokenSlugs.push(`${chainSlug}-${tokenSlug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routeSlugs: string[] = [];
|
||||
for (const fromSlug of tokenSlugs) {
|
||||
for (const toSlug of tokenSlugs) {
|
||||
if (fromSlug === toSlug) {
|
||||
continue;
|
||||
}
|
||||
routeSlugs.push(`${fromSlug}-to-${toSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
return routeSlugs;
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { DotIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { FaqSection } from "@/components/blocks/faq-section";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { PillLink } from "./components/client/pill-link";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { bridgeAppThirdwebClient } from "./constants";
|
||||
import { isAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { BridgePageUI } from "./components/bridge-page";
|
||||
import { bridgeStats } from "./data";
|
||||
|
||||
const title = "thirdweb Bridge: Buy, Bridge & Swap Crypto on 85+ Chains";
|
||||
const description =
|
||||
"Bridge and swap 4500+ tokens across 85+ chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality";
|
||||
const title = `thirdweb Bridge: Buy, Bridge & Swap Crypto on ${bridgeStats.supportedChains} Chains`;
|
||||
const description = `Bridge and swap ${bridgeStats.supportedTokens} tokens across ${bridgeStats.supportedChains} chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
@@ -25,163 +15,66 @@ export const metadata: Metadata = {
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function BridgePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
type SearchParams = {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const { chainId, tokenAddress, amount } = await searchParams;
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
let symbol: string | undefined;
|
||||
let decimals: number | undefined;
|
||||
let tokenName: string | undefined;
|
||||
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
|
||||
const onlyNumber = (v: string) =>
|
||||
Number.isNaN(Number(v)) ? undefined : Number(v);
|
||||
|
||||
if (chainId && tokenAddress) {
|
||||
try {
|
||||
const metadata = await getCurrencyMetadata({
|
||||
contract: getContract({
|
||||
address: tokenAddress as Address,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(Number(chainId)),
|
||||
client: bridgeAppThirdwebClient,
|
||||
}),
|
||||
});
|
||||
({ symbol, decimals, name: tokenName } = metadata);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch token metadata:", error);
|
||||
// Continue with undefined values; the component should handle gracefully
|
||||
}
|
||||
}
|
||||
// output is buy, input is sell
|
||||
const sellChain = parse(searchParams.inputChain, onlyNumber);
|
||||
const sellCurrency = parse(searchParams.inputCurrency, onlyAddress);
|
||||
|
||||
const buyChain = parse(searchParams.outputChain, onlyNumber);
|
||||
const buyCurrency = parse(searchParams.outputCurrency, onlyAddress);
|
||||
|
||||
return (
|
||||
<div className="grow flex flex-col relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<PageHeader />
|
||||
|
||||
<div className="h-12 lg:h-20" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container">
|
||||
<h1 className="text-3xl md:text-6xl font-bold mb-6 tracking-tighter text-center leading-8 lg:leading-none text-balance">
|
||||
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
|
||||
chain, instantly
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center justify-center">
|
||||
<DataPill>85+ Chains Supported</DataPill>
|
||||
<DotIcon className="size-3 text-muted-foreground lg:hidden" />
|
||||
<DataPill>4500+ Tokens Supported</DataPill>
|
||||
<DataPill>9+ Million Routes Available</DataPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-12 lg:h-16" />
|
||||
|
||||
<div className="flex grow items-center justify-center px-4 relative">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed
|
||||
amount={amount as string}
|
||||
chainId={chainId ? Number(chainId) : undefined}
|
||||
token={
|
||||
symbol && decimals && tokenName
|
||||
? {
|
||||
address: tokenAddress as Address,
|
||||
name: tokenName,
|
||||
symbol,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-32" />
|
||||
|
||||
<div className="flex flex-col gap-4 items-center container">
|
||||
<PillLink
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
linkType="integrate-bridge"
|
||||
>
|
||||
Accept Payments in Any Token Right Inside Your App
|
||||
</PillLink>
|
||||
|
||||
<PillLink
|
||||
href="https://thirdweb.com/tokens"
|
||||
linkType="trending-tokens"
|
||||
>
|
||||
Discover Trending Tokens
|
||||
</PillLink>
|
||||
</div>
|
||||
|
||||
<div className="h-32" />
|
||||
|
||||
<div className="container max-w-2xl">
|
||||
<BridgeFaqSection />
|
||||
</div>
|
||||
|
||||
<div className="h-32" />
|
||||
|
||||
<div className="relative">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPill(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="lg:bg-card flex items-center gap-1.5 text-muted-foreground text-xs lg:backdrop-blur-lg lg:border lg:border-border/70 rounded-full lg:px-3 lg:py-1.5 hover:text-foreground transition-colors duration-300">
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/20 hidden lg:block",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
<BridgePageUI
|
||||
buyTab={{
|
||||
buyToken: buyChain
|
||||
? {
|
||||
chainId: buyChain,
|
||||
tokenAddress: buyCurrency || NATIVE_TOKEN_ADDRESS,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
swapTab={{
|
||||
sellToken: sellChain
|
||||
? {
|
||||
chainId: sellChain,
|
||||
tokenAddress: sellCurrency || NATIVE_TOKEN_ADDRESS,
|
||||
}
|
||||
: undefined,
|
||||
buyToken: buyChain
|
||||
? {
|
||||
chainId: buyChain,
|
||||
tokenAddress: buyCurrency || NATIVE_TOKEN_ADDRESS,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
title={
|
||||
<h1 className="text-3xl md:text-6xl font-semibold tracking-tighter text-balance text-center">
|
||||
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
|
||||
chain, instantly
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bridgeFaqs: Array<{ title: string; description: string }> = [
|
||||
{
|
||||
title: "What is bridging in crypto?",
|
||||
description:
|
||||
"Crypto bridging (cross-chain bridging) moves tokens between blockchains so you can use assets across networks. In thirdweb Bridge, connect your wallet, choose the source token/network and destination token/network, review the route and price, then confirm. Assets arrive after finality, often under ~10 seconds on fast routes, though timing depends on networks and congestion.",
|
||||
},
|
||||
{
|
||||
title: "How does crypto bridging work?",
|
||||
description:
|
||||
"Bridge smart contracts lock or burn tokens on the source chain and mint or release equivalents on the destination via verified cross-chain providers. thirdweb Bridge automatically finds the fastest, lowest-cost route and may use different mechanisms based on networks and liquidity. Arrival can range from seconds to minutes depending on finality; many routes complete in ~10 seconds",
|
||||
},
|
||||
{
|
||||
title: "What is a crypto asset swap?",
|
||||
description:
|
||||
"A crypto swap exchanges one token for another via a DEX or aggregator. thirdweb Bridge lets you bridge + swap in one step. For example, ETH on Ethereum to USDC on Base, by selecting your start and end tokens/networks and confirming.",
|
||||
},
|
||||
{
|
||||
title: "How can I get stablecoins like USDC or USDT?",
|
||||
description:
|
||||
"Use thirdweb Bridge to convert assets you hold into USDC or USDT on your chosen network: select your current token/network, pick the stablecoin (USDC, USDT, etc) on the destination, and confirm. You can also buy stablecoins with fiat in the Buy flow and bridge if needed. Always verify official token contract addresses.",
|
||||
},
|
||||
{
|
||||
title: "What is the cost of bridging and swapping?",
|
||||
description:
|
||||
"Costs include gas on each chain, bridge/liquidity provider fees, and any DEX swap fees or price impact. thirdweb Bridge compares routes and selects the best price route. Save by using lower-gas times or combining bridge + swap in one flow.",
|
||||
},
|
||||
];
|
||||
|
||||
function BridgeFaqSection() {
|
||||
return <FaqSection faqs={bridgeFaqs} />;
|
||||
function parse<T>(
|
||||
value: string | string[] | undefined,
|
||||
fn: (value: string) => T | undefined,
|
||||
): T | undefined {
|
||||
if (typeof value === "string") {
|
||||
return fn(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const loginOptions = [
|
||||
createWallet("io.rabby"),
|
||||
createWallet("me.rainbow"),
|
||||
createWallet("io.zerion.wallet"),
|
||||
createWallet("com.okex.wallet"),
|
||||
];
|
||||
|
||||
const inAppWalletLoginOptions = [
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import { Img } from "@workspace/ui/components/img";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { payAppThirdwebClient } from "../constants";
|
||||
|
||||
export function PayIdPageHeader(props: {
|
||||
projectName: string;
|
||||
projectIcon: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border/70">
|
||||
<header className="container flex max-w-7xl justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{props.projectIcon && (
|
||||
<Img
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: props.projectIcon,
|
||||
client: payAppThirdwebClient,
|
||||
}) || ""
|
||||
}
|
||||
alt=""
|
||||
className="rounded-full size-6 object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
{props.projectName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 lg:gap-5">
|
||||
<ToggleThemeButton />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleThemeButton(props: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<ClientOnly
|
||||
ssr={<Skeleton className="size-[36px] rounded-full border bg-accent" />}
|
||||
>
|
||||
<Button
|
||||
aria-label="Toggle theme"
|
||||
className={cn(
|
||||
"h-auto w-auto rounded-full p-2 text-muted-foreground hover:text-foreground",
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<SunIcon className="size-5 " />
|
||||
) : (
|
||||
<MoonIcon className="size-5 " />
|
||||
)}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Bridge, defineChain, toTokens } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { getPaymentLink } from "@/api/universal-bridge/links";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import {
|
||||
API_SERVER_SECRET,
|
||||
DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
} from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveEns } from "@/lib/ens";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { PayPageWidget } from "../components/client/PayPageWidget.client";
|
||||
import { payAppThirdwebClient } from "../constants";
|
||||
import { PayIdPageHeader } from "./header";
|
||||
|
||||
const title = "thirdweb Pay";
|
||||
const description = "Fast, secure, and simple payments.";
|
||||
@@ -54,26 +49,10 @@ export default async function PayPage({
|
||||
tokenAddress: paymentLink.destinationToken.address,
|
||||
});
|
||||
|
||||
const chainPromise = getChainMetadata(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
defineChain(Number(paymentLink.destinationToken.chainId)),
|
||||
);
|
||||
|
||||
const recipientPromise = resolveEns(
|
||||
paymentLink.receiver,
|
||||
getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const [tokens, projectMetadata, chain, recipientEnsOrAddress] =
|
||||
await Promise.all([
|
||||
tokensPromise,
|
||||
projectMetadataPromise,
|
||||
chainPromise,
|
||||
recipientPromise,
|
||||
]);
|
||||
const [tokens, projectMetadata] = await Promise.all([
|
||||
tokensPromise,
|
||||
projectMetadataPromise,
|
||||
]);
|
||||
|
||||
const token = tokens[0];
|
||||
if (!token) {
|
||||
@@ -81,122 +60,20 @@ export default async function PayPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
forcedTheme={theme === "light" ? "light" : "dark"}
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
>
|
||||
<div className="flex z-10 flex-col lg:flex-row h-full w-full">
|
||||
<header className="min-w-full lg:min-w-[500px] border-b lg:border-r lg:h-full bg-card flex flex-col gap-4 items-start p-4 lg:p-8">
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-start gap-4">
|
||||
{projectMetadata.image && (
|
||||
<img
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: projectMetadata.image,
|
||||
client: payAppThirdwebClient,
|
||||
}) || ""
|
||||
}
|
||||
alt={projectMetadata.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">{projectMetadata.name}</h2>
|
||||
</div>
|
||||
{projectMetadata.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{projectMetadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex flex-col min-h-dvh w-full">
|
||||
<ThemeProvider
|
||||
defaultTheme={theme === "light" ? "light" : "dark"}
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
>
|
||||
<PayIdPageHeader
|
||||
projectIcon={projectMetadata.image || undefined}
|
||||
projectName={projectMetadata.name}
|
||||
/>
|
||||
|
||||
<div className="hidden lg:block my-4 w-full">
|
||||
{paymentLink.amount && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Details</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{token.iconUri && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: token.iconUri,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={token.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{toTokens(BigInt(paymentLink.amount), token.decimals)}{" "}
|
||||
{token.symbol}
|
||||
</div>
|
||||
{token.prices.USD && (
|
||||
<span>
|
||||
$
|
||||
{(
|
||||
Number(token.prices.USD) *
|
||||
Number(
|
||||
toTokens(BigInt(paymentLink.amount), token.decimals),
|
||||
)
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chain && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Network</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{chain.icon?.url && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: chain.icon.url,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={chain.name}
|
||||
width={chain.icon.width}
|
||||
height={chain.icon.height}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{chain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recipientEnsOrAddress.ensName ||
|
||||
(recipientEnsOrAddress.address && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Seller</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
{recipientEnsOrAddress.ensName ??
|
||||
shortenAddress(recipientEnsOrAddress.address)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto hidden lg:block">
|
||||
<Badge className="flex items-center gap-1.5 bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
Secured by thirdweb
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex justify-center p-12 w-full items-center">
|
||||
<main className="flex justify-center py-12 w-full items-center grow overflow-hidden relative">
|
||||
<DotsBackgroundPattern />
|
||||
<PayPageWidget
|
||||
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
|
||||
chainId={Number(paymentLink.destinationToken.chainId)}
|
||||
@@ -210,8 +87,8 @@ export default async function PayPage({
|
||||
token={token}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,7 +105,24 @@ async function getProjectMetadata(clientId: string) {
|
||||
}
|
||||
|
||||
const { data } = (await response.json()) as {
|
||||
data: { name: string; image: string | null; description: string | null };
|
||||
data: { name: string; image: string | null };
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/15 hidden lg:block",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user