Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8003e93f19 | ||
|
|
51c8318df7 | ||
|
|
f043f1eef4 | ||
|
|
d68da2239b | ||
|
|
6a6b0fba96 | ||
|
|
c934102c51 | ||
|
|
8c58c131ac | ||
|
|
2779afc15f | ||
|
|
fe50abd5ab | ||
|
|
e830109274 | ||
|
|
e7f42e6ac5 | ||
|
|
91ff13b8aa |
@@ -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",
|
||||
});
|
||||
@@ -11,6 +11,7 @@ module.exports = [
|
||||
"/account-abstraction",
|
||||
"/payments",
|
||||
"/payments/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 |
@@ -683,7 +683,22 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track which tokens are users are clicking on in the token list page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*
|
||||
*/
|
||||
export function reportTokenRowClicked(params: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
}) {
|
||||
posthog.capture("token row clicked", params);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
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);
|
||||
@@ -208,7 +261,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onSuccess={(quote) => {
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
|
||||
@@ -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,83 @@
|
||||
"use client";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { useClipboard } from "@/hooks/useClipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SolanaAddress(props: {
|
||||
address: string;
|
||||
shortenAddress?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const shortenedAddress = useMemo(() => {
|
||||
return props.shortenAddress !== false
|
||||
? `${props.address.slice(0, 4)}...${props.address.slice(-4)}`
|
||||
: props.address;
|
||||
}, [props.address, props.shortenAddress]);
|
||||
|
||||
const lessShortenedAddress = useMemo(() => {
|
||||
return `${props.address.slice(0, 8)}...${props.address.slice(-8)}`;
|
||||
}, [props.address]);
|
||||
|
||||
const { onCopy, hasCopied } = useClipboard(props.address, 2000);
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild tabIndex={-1}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2 px-0",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="link"
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
|
||||
<span className="cursor-pointer font-mono text-sm">
|
||||
{shortenedAddress}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-80 border-border"
|
||||
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">Solana Public Key</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="break-all rounded bg-muted p-3 text-center font-mono text-xs leading-relaxed">
|
||||
{lessShortenedAddress}
|
||||
</p>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
Solana public key for blockchain transactions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -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,8 @@ const customClaimAmounts: Record<number, number> = {
|
||||
631571: 0.1,
|
||||
// Sophon testnet
|
||||
531050104: 5,
|
||||
// Arc testnet
|
||||
5042002: 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 {
|
||||
|
||||
+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
|
||||
|
||||
+1
@@ -137,6 +137,7 @@ export function SettingsPlatformFees({
|
||||
onChange={(value) =>
|
||||
form.setValue("platform_fee_basis_points", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
value={form.watch("platform_fee_basis_points")}
|
||||
|
||||
+1
@@ -151,6 +151,7 @@ export function SettingsRoyalties({
|
||||
onChange={(value) =>
|
||||
form.setValue("seller_fee_basis_points", value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
value={form.watch("seller_fee_basis_points")}
|
||||
|
||||
+59
-50
@@ -3,7 +3,12 @@ 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 type { Status, Token } from "thirdweb/bridge";
|
||||
@@ -11,6 +16,7 @@ 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 +52,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 +81,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 +97,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 +120,8 @@ function TokenInfo(props: {
|
||||
txHash: string | undefined;
|
||||
}) {
|
||||
const chainQuery = useChainQuery(props.token.chainId);
|
||||
const isNativeToken =
|
||||
props.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
|
||||
@@ -121,8 +130,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 +188,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 +200,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}
|
||||
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
|
||||
<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={props.token.address}
|
||||
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"
|
||||
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 +302,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 +340,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 +348,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>
|
||||
);
|
||||
|
||||
@@ -311,7 +311,10 @@ function GeneericTxDetails(props: {
|
||||
label="Burnt fees"
|
||||
tooltip="The amount of fees that were burnt by the transaction."
|
||||
>
|
||||
<p>{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}</p>
|
||||
<p>
|
||||
{toEther((block.baseFeePerGas || 0n) * receipt.gasUsed)}{" "}
|
||||
{chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { reportTokenRowClicked } from "@/analytics/report";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -122,6 +123,12 @@ export function TokensTable(props: {
|
||||
className="gap-2 rounded-full bg-card hover:border-foreground/50 text-muted-foreground hover:bg-inverted hover:text-inverted-foreground"
|
||||
>
|
||||
<Link
|
||||
onClick={() =>
|
||||
reportTokenRowClicked({
|
||||
chainId: token.chainId,
|
||||
tokenAddress: token.address,
|
||||
})
|
||||
}
|
||||
aria-label={`Buy ${token.symbol}`}
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+6
-6
@@ -48,7 +48,11 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
href: `${props.layoutPath}/transactions`,
|
||||
icon: ArrowLeftRightIcon,
|
||||
label: "Transactions",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Transactions <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/contracts`,
|
||||
@@ -81,11 +85,7 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
href: `${props.layoutPath}/tokens`,
|
||||
icon: TokenIcon,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Tokens <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
label: "Tokens",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+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"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-3
@@ -1,10 +1,10 @@
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { TransactionAnalyticsFilter } from "./filter";
|
||||
import { TransactionsChartCard } from "./tx-chart/tx-chart";
|
||||
import { TransactionsTable } from "./tx-table/tx-table";
|
||||
|
||||
export function TransactionsAnalyticsPageContent(props: {
|
||||
searchParams: {
|
||||
@@ -34,11 +34,10 @@ export function TransactionsAnalyticsPageContent(props: {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TransactionsTable
|
||||
<UnifiedTransactionsTable
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
|
||||
+24
-24
@@ -3,17 +3,20 @@ 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[];
|
||||
hasTransactions: boolean;
|
||||
teamSlug: string;
|
||||
testTxWithWallet?: string | undefined;
|
||||
testSolanaTxWithWallet?: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
isManagedVault: boolean;
|
||||
}
|
||||
@@ -25,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:
|
||||
@@ -57,7 +61,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
});
|
||||
return steps;
|
||||
}, [
|
||||
props.managementAccessToken,
|
||||
props.project,
|
||||
props.wallets,
|
||||
props.hasTransactions,
|
||||
@@ -85,6 +88,19 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (props.testSolanaTxWithWallet) {
|
||||
return (
|
||||
<SendTestSolanaTransaction
|
||||
isManagedVault={props.isManagedVault}
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
walletId={props.testSolanaTxWithWallet}
|
||||
wallets={props.solanaWallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (finalSteps.length === 0 || isComplete) {
|
||||
return null;
|
||||
}
|
||||
@@ -96,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>
|
||||
);
|
||||
}
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLinkIcon, Loader2Icon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import * as z from "zod";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { TryItOut } from "../server-wallets/components/try-it-out";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
secretKey: z.string().min(1, "Secret key is required"),
|
||||
walletIndex: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function SendTestSolanaTransactionModal(props: {
|
||||
wallets?: SolanaWallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [, setTxChain] = useQueryState("txChain", {
|
||||
history: "push",
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
secretKey: "",
|
||||
walletIndex:
|
||||
props.wallets && props.walletId
|
||||
? props.wallets
|
||||
.findIndex((w) => w.id === props.walletId)
|
||||
?.toString()
|
||||
.replace("-1", "0")
|
||||
: "0",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedWalletIndex = Number.parseInt(form.watch("walletIndex"));
|
||||
const selectedWallet = props.wallets?.[selectedWalletIndex];
|
||||
|
||||
const sendDummySolanaTxMutation = useMutation({
|
||||
mutationFn: async (args: { walletAddress: string; secretKey: string }) => {
|
||||
const response = await engineCloudProxy({
|
||||
body: JSON.stringify({
|
||||
executionOptions: {
|
||||
chainId: "solana:devnet",
|
||||
signerAddress: args.walletAddress,
|
||||
maxBlockhashRetries: 0,
|
||||
commitment: "confirmed",
|
||||
},
|
||||
instructions: [
|
||||
{
|
||||
programId: "11111111111111111111111111111111", // System Program
|
||||
accounts: [
|
||||
{
|
||||
pubkey: args.walletAddress,
|
||||
isSigner: true,
|
||||
isWritable: true,
|
||||
},
|
||||
{
|
||||
pubkey: args.walletAddress,
|
||||
isSigner: false,
|
||||
isWritable: true,
|
||||
},
|
||||
],
|
||||
data: "AgAAAAAAAAAAAAAAAA==", // Transfer 0 SOL (base64)
|
||||
encoding: "base64",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
...(props.isManagedVault
|
||||
? { "x-secret-key": args.secretKey }
|
||||
: { "x-vault-access-token": args.secretKey }),
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/v1/solana/transaction",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = response.error ?? "Failed to send Solana transaction";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Test Solana transaction sent successfully!");
|
||||
// Set the transaction chain to solana so the table switches
|
||||
setTxChain("solana");
|
||||
// Close the modal after successful transaction
|
||||
setTimeout(() => {
|
||||
props.onOpenChange(false);
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = sendDummySolanaTxMutation.isPending;
|
||||
|
||||
// Early return in render phase
|
||||
if (!props.wallets || props.wallets.length === 0 || !selectedWallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await sendDummySolanaTxMutation.mutateAsync({
|
||||
secretKey: data.secretKey,
|
||||
walletAddress: selectedWallet.publicKey,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["solana-transactions", props.project.id],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Solana Transaction</DialogTitle>
|
||||
<DialogDescription>
|
||||
Test your Solana server wallet by sending a 0 SOL transfer transaction
|
||||
on Solana Devnet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Funding Alert */}
|
||||
<Alert>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<p className="font-medium">⚠️ Fund your wallet first</p>
|
||||
<p className="text-sm">
|
||||
Your Solana wallet needs SOL on Devnet to pay for transaction fees.
|
||||
Get free Devnet SOL from the Solana faucet:
|
||||
</p>
|
||||
<Link
|
||||
href="https://faucet.solana.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-link-foreground hover:underline text-sm"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
https://faucet.solana.com/
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Wallet Selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Select Wallet</p>
|
||||
<Select
|
||||
disabled={props.wallets.length === 1}
|
||||
value={form.watch("walletIndex")}
|
||||
onValueChange={(value) => form.setValue("walletIndex", value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.wallets.map((wallet, index) => (
|
||||
<SelectItem
|
||||
key={wallet.id}
|
||||
value={index.toString()}
|
||||
className="font-mono"
|
||||
>
|
||||
{wallet.metadata.label || `Wallet ${index + 1}`} (
|
||||
{wallet.publicKey.slice(0, 6)}...
|
||||
{wallet.publicKey.slice(-4)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Wallet Address Display */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Public Key</p>
|
||||
<code className="text-xs font-mono bg-muted p-2 rounded break-all">
|
||||
{selectedWallet.publicKey}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Network Info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Network</p>
|
||||
<div className="flex items-center gap-2 bg-muted p-2 rounded">
|
||||
<div className="size-4 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
|
||||
<span className="text-sm">Solana Devnet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret Key Input */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">
|
||||
{props.isManagedVault ? "Secret Key" : "Vault Access Token"}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...form.register("secretKey")}
|
||||
className="pr-10 font-mono"
|
||||
placeholder={
|
||||
props.isManagedVault
|
||||
? "Enter your secret key"
|
||||
: "Enter your vault access token"
|
||||
}
|
||||
type="password"
|
||||
/>
|
||||
<LockIcon className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{form.formState.errors.secretKey && (
|
||||
<p className="text-destructive text-sm">
|
||||
{form.formState.errors.secretKey.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full min-w-[200px] md:w-auto"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Transaction"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendTestSolanaTransaction(props: {
|
||||
wallets?: SolanaWallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
expanded?: boolean;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
// Early return in render phase
|
||||
if (!props.wallets || props.wallets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full rounded-md border bg-background p-6">
|
||||
<TryItOut useEngineAPI={false} />
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" className="w-full md:w-auto">
|
||||
Test Request
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<SendTestSolanaTransactionModal
|
||||
{...props}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
/>
|
||||
</Dialog>
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (props.walletId) {
|
||||
router.replace(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
);
|
||||
} else {
|
||||
localStorage.setItem("solanEngineFtuxCompleted", "true");
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
{props.walletId ? "Close" : "Complete Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
export type SolanaTransactionStatus =
|
||||
| "QUEUED"
|
||||
| "SUBMITTED"
|
||||
| "CONFIRMED"
|
||||
| "FAILED";
|
||||
|
||||
type SolanaTransactionParamsSerialized = {
|
||||
instructions: Array<{
|
||||
programId: string;
|
||||
keys: Array<{
|
||||
pubkey: string;
|
||||
isSigner: boolean;
|
||||
isWritable: boolean;
|
||||
}>;
|
||||
data: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SolanaExecutionParamsSerialized = {
|
||||
type: "SOLANA";
|
||||
signerAddress: string;
|
||||
chainId: string;
|
||||
commitment?: "processed" | "confirmed" | "finalized";
|
||||
computeUnitLimit?: number;
|
||||
computeUnitPrice?: number;
|
||||
skipPreflight?: boolean;
|
||||
};
|
||||
|
||||
type SolanaExecutorError = {
|
||||
errorCode?: string;
|
||||
message?: string;
|
||||
innerError?: unknown;
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SolanaTransactionMeta = {
|
||||
err?: unknown | null;
|
||||
status?: {
|
||||
Ok?: unknown | null;
|
||||
};
|
||||
fee: number;
|
||||
preBalances: number[];
|
||||
postBalances: number[];
|
||||
innerInstructions: unknown[];
|
||||
logMessages: string[];
|
||||
preTokenBalances: unknown[];
|
||||
postTokenBalances: unknown[];
|
||||
rewards: unknown[];
|
||||
loadedAddresses: {
|
||||
writable: string[];
|
||||
readonly: string[];
|
||||
};
|
||||
computeUnitsConsumed?: number;
|
||||
costUnits?: number;
|
||||
};
|
||||
|
||||
type SolanaTransactionDetails = {
|
||||
transaction: {
|
||||
signatures: string[];
|
||||
message: {
|
||||
header: {
|
||||
numRequiredSignatures: number;
|
||||
numReadonlySignedAccounts: number;
|
||||
numReadonlyUnsignedAccounts: number;
|
||||
};
|
||||
accountKeys: string[];
|
||||
recentBlockhash: string;
|
||||
instructions: Array<{
|
||||
programIdIndex: number;
|
||||
accounts: number[];
|
||||
data: string;
|
||||
stackHeight?: number;
|
||||
}>;
|
||||
addressTableLookups: unknown[];
|
||||
};
|
||||
};
|
||||
meta: SolanaTransactionMeta;
|
||||
version: number | "legacy";
|
||||
};
|
||||
|
||||
type SolanaExecutionResultSerialized =
|
||||
| {
|
||||
status: "QUEUED";
|
||||
}
|
||||
| {
|
||||
status: "FAILED";
|
||||
error: SolanaExecutorError;
|
||||
}
|
||||
| {
|
||||
status: "SUBMITTED";
|
||||
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
|
||||
signature: string;
|
||||
submissionAttemptNumber: number;
|
||||
}
|
||||
| ({
|
||||
status: "CONFIRMED";
|
||||
signature: string;
|
||||
signerAddress: string;
|
||||
chainId: string;
|
||||
submissionAttemptNumber: number;
|
||||
slot: number;
|
||||
blockTime: number | null;
|
||||
transaction: SolanaTransactionDetails;
|
||||
} & (
|
||||
| {
|
||||
onchainStatus: "SUCCESS";
|
||||
}
|
||||
| {
|
||||
onchainStatus: "REVERTED";
|
||||
error?: string;
|
||||
}
|
||||
));
|
||||
|
||||
export type SolanaTransaction = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
chainId: string;
|
||||
signerAddress: string;
|
||||
transactionParams: SolanaTransactionParamsSerialized;
|
||||
signature: string | null;
|
||||
status: SolanaTransactionStatus | null;
|
||||
confirmedAt: Date | null;
|
||||
confirmedAtSlot: string | null;
|
||||
blockTime: number | null;
|
||||
enrichedData: unknown[];
|
||||
executionParams: SolanaExecutionParamsSerialized;
|
||||
executionResult: SolanaExecutionResultSerialized | null;
|
||||
createdAt: Date;
|
||||
errorMessage: string | null;
|
||||
cancelledAt: Date | null;
|
||||
};
|
||||
|
||||
export type SolanaTransactionsResponse = {
|
||||
transactions: SolanaTransaction[];
|
||||
pagination: {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
-447
@@ -1,447 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionsResponse,
|
||||
} from "./types";
|
||||
|
||||
// TODO - add Status selector dropdown here
|
||||
export function TransactionsTableUI(props: {
|
||||
getData: (params: {
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}) => Promise<TransactionsResponse>;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallets?: Wallet[];
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [status, setStatus] = useState<TransactionStatus | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [id, setId] = useState<string | undefined>(undefined);
|
||||
const [from, setFrom] = useState<string | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useQuery({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () => props.getData({ page, status, id, from }),
|
||||
queryKey: ["transactions", props.project.id, page, status, id, from],
|
||||
refetchInterval: autoUpdate ? 4_000 : false,
|
||||
});
|
||||
|
||||
const transactions = transactionsQuery.data?.transactions ?? [];
|
||||
|
||||
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
|
||||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
|
||||
|
||||
const autoUpdateId = useId();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Transaction History
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Transactions sent from server wallets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
|
||||
<Label htmlFor={autoUpdateId}>Auto Update</Label>
|
||||
<Switch
|
||||
checked={autoUpdate}
|
||||
id={autoUpdateId}
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setId(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by Queue ID"
|
||||
value={id || ""}
|
||||
/>
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setFrom(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by wallet address"
|
||||
value={from || ""}
|
||||
/>
|
||||
<div>
|
||||
<StatusSelector
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
// reset page
|
||||
setPage(1);
|
||||
}}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>From</TableHead>
|
||||
<TableHead>Tx Hash</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton
|
||||
? new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={i} />
|
||||
))
|
||||
: transactions.map((tx) => (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{/* Queue ID */}
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id ? tx.id : ""}
|
||||
className="text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Chain Id */}
|
||||
<TableCell>
|
||||
<TxChainCell
|
||||
chainId={tx.chainId || undefined}
|
||||
client={props.client}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Status */}
|
||||
<TableCell>
|
||||
<TxStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
|
||||
{/* From Address */}
|
||||
<TableCell>
|
||||
{tx.from ? (
|
||||
<WalletAddress
|
||||
address={tx.from}
|
||||
client={props.client}
|
||||
/>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Tx Hash */}
|
||||
<TableCell>
|
||||
<TxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
|
||||
{/* Queued At */}
|
||||
<TableCell>
|
||||
<TxQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const statusDetails = {
|
||||
CONFIRMED: {
|
||||
name: "Confirmed",
|
||||
type: "success",
|
||||
},
|
||||
FAILED: {
|
||||
name: "Failed",
|
||||
type: "destructive",
|
||||
},
|
||||
QUEUED: {
|
||||
name: "Queued",
|
||||
type: "warning",
|
||||
},
|
||||
SUBMITTED: {
|
||||
name: "Submitted",
|
||||
type: "warning",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StatusSelector(props: {
|
||||
status: TransactionStatus | undefined;
|
||||
setStatus: (value: TransactionStatus | undefined) => void;
|
||||
}) {
|
||||
const statuses = Object.keys(statusDetails) as TransactionStatus[];
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
if (v === "all") {
|
||||
props.setStatus(undefined);
|
||||
} else {
|
||||
props.setStatus(v as TransactionStatus);
|
||||
}
|
||||
}}
|
||||
value={props.status || "all"}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px] rounded-full">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All Transactions</SelectItem>
|
||||
{statuses.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{statusDetails[item].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="h-[73px]">
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[152px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[155px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[58px] rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[145px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[108px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TxChainCell(props: {
|
||||
chainId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { chainId } = props;
|
||||
const { idToChain } = useAllChainsData();
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = idToChain.get(Number.parseInt(chainId));
|
||||
|
||||
if (!chain) {
|
||||
return `Chain ID: ${chainId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<div className="max-w-[150px] truncate">
|
||||
{chain.name ?? `Chain ID: ${chainId}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxStatusCell(props: { transaction: Transaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const minedAt = transaction.confirmedAt;
|
||||
const status =
|
||||
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && minedAt
|
||||
? `Completed ${format(new Date(minedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge className="gap-2" variant={statusDetails[status].type}>
|
||||
{statusDetails[status].name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function TxHashCell(props: { transaction: Transaction }) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { chainId, transactionHash } = props.transaction;
|
||||
if (!transactionHash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
|
||||
if (!explorer) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
copyIconPosition="left"
|
||||
textToCopy={transactionHash}
|
||||
textToShow={shortHash}
|
||||
tooltip="Copy transaction hash"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer.url}/tx/${transactionHash}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TxQueuedAtCell(props: { transaction: Transaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Wallet } from "../../server-wallets/wallet-table/types";
|
||||
import { TransactionsTableUI } from "./tx-table-ui";
|
||||
import type { TransactionStatus, TransactionsResponse } from "./types";
|
||||
|
||||
export function TransactionsTable(props: {
|
||||
project: Project;
|
||||
wallets?: Wallet[];
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<TransactionsTableUI
|
||||
client={props.client}
|
||||
getData={async ({ page, status, id, from }) => {
|
||||
return await getTransactions({
|
||||
page,
|
||||
project: props.project,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
});
|
||||
}}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}) {
|
||||
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": project.publishableKey,
|
||||
"x-team-id": project.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
pathname: `/v1/transactions`,
|
||||
searchParams: {
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
id: id ?? undefined,
|
||||
from: from ?? undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!transactions.ok) {
|
||||
throw new Error(transactions.error);
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
+19
@@ -97,3 +97,22 @@ export type TransactionsResponse = {
|
||||
transactions: Transaction[];
|
||||
pagination: Pagination;
|
||||
};
|
||||
|
||||
export const statusDetails = {
|
||||
CONFIRMED: {
|
||||
name: "Confirmed",
|
||||
type: "success",
|
||||
},
|
||||
FAILED: {
|
||||
name: "Failed",
|
||||
type: "destructive",
|
||||
},
|
||||
QUEUED: {
|
||||
name: "Queued",
|
||||
type: "warning",
|
||||
},
|
||||
SUBMITTED: {
|
||||
name: "Submitted",
|
||||
type: "warning",
|
||||
},
|
||||
} as const;
|
||||
|
||||
+824
@@ -0,0 +1,824 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
CheckIcon,
|
||||
MoreVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
predictSmartAccountAddress,
|
||||
} from "thirdweb/wallets/smart";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { SolanaAddress } from "@/components/blocks/solana-address";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
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 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";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
|
||||
type WalletChain = "evm" | "solana";
|
||||
|
||||
interface ServerWalletsTableProps {
|
||||
evmWallets: EVMWallet[];
|
||||
evmTotalRecords: number;
|
||||
evmCurrentPage: number;
|
||||
evmTotalPages: number;
|
||||
solanaWallets: SolanaWallet[];
|
||||
solanaTotalRecords: number;
|
||||
solanaCurrentPage: number;
|
||||
solanaTotalPages: number;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
solanaPermissionError?: boolean;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
const {
|
||||
evmWallets,
|
||||
solanaWallets,
|
||||
project,
|
||||
teamSlug,
|
||||
evmTotalRecords,
|
||||
evmCurrentPage,
|
||||
evmTotalPages,
|
||||
solanaTotalRecords,
|
||||
solanaCurrentPage,
|
||||
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();
|
||||
|
||||
const wallets = activeChain === "evm" ? evmWallets : solanaWallets;
|
||||
const currentPage =
|
||||
activeChain === "evm" ? evmCurrentPage : solanaCurrentPage;
|
||||
const totalPages = activeChain === "evm" ? evmTotalPages : solanaTotalPages;
|
||||
const totalRecords =
|
||||
activeChain === "evm" ? evmTotalRecords : solanaTotalRecords;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<WalletProductIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Server Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage server wallets for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="flex flex-row gap-2.5">
|
||||
{activeChain === "evm" && (
|
||||
<>
|
||||
<CreateServerWallet project={project} teamSlug={teamSlug} />
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
onChange={setSelectedChainId}
|
||||
client={client}
|
||||
disableChainId
|
||||
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeChain === "solana" && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{activeChain === "evm" && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label
|
||||
className="text-sm text-muted-foreground"
|
||||
htmlFor="showSmartAccount"
|
||||
>
|
||||
Show ERC4337 Smart Account
|
||||
</Label>
|
||||
<Switch
|
||||
id="showSmartAccount"
|
||||
checked={showSmartAccount}
|
||||
onCheckedChange={setShowSmartAccount}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chain Tabs */}
|
||||
<div className="px-6">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: (
|
||||
<span className="flex items-center gap-2">
|
||||
EVM Wallets
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
{evmTotalRecords}
|
||||
</Badge>
|
||||
</span>
|
||||
),
|
||||
onClick: () => setActiveChain("evm"),
|
||||
isActive: activeChain === "evm",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<span className="flex items-center gap-2">
|
||||
Solana Wallets
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
{solanaTotalRecords}
|
||||
</Badge>
|
||||
</span>
|
||||
),
|
||||
onClick: () => setActiveChain("solana"),
|
||||
isActive: activeChain === "solana",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table Content */}
|
||||
{activeChain === "solana" && solanaPermissionError ? (
|
||||
<div className="p-6">
|
||||
<UpgradeSolanaPermissions project={project} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer className="rounded-none border-x-0 border-b-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="w-[280px]">
|
||||
{activeChain === "evm"
|
||||
? showSmartAccount
|
||||
? "Smart Account Address"
|
||||
: "Wallet Address"
|
||||
: "Public Key"}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px]">
|
||||
<div className="flex w-[180px] items-center gap-1.5">
|
||||
Balance
|
||||
{wallets.length > 0 && (
|
||||
<ToolTipLabel
|
||||
contentClassName="capitalize font-normal tracking-normal leading-normal"
|
||||
label="Refresh Balance"
|
||||
>
|
||||
<Button
|
||||
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
|
||||
onClick={async (e) => {
|
||||
const buttonEl = e.currentTarget;
|
||||
buttonEl.setAttribute("data-pending", "true");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
activeChain === "evm"
|
||||
? ["walletBalance", selectedChainId]
|
||||
: ["solanaWalletBalance"],
|
||||
});
|
||||
buttonEl.setAttribute("data-pending", "false");
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeChain === "evm" &&
|
||||
evmWallets.map((wallet) => (
|
||||
<EVMWalletRow
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
chainId={selectedChainId}
|
||||
showSmartAccount={showSmartAccount}
|
||||
/>
|
||||
))}
|
||||
{activeChain === "solana" &&
|
||||
solanaWallets.map((wallet) => (
|
||||
<SolanaWalletRow
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
authToken={authToken}
|
||||
chainId={selectedSolanaChain}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{wallets.length === 0 && (
|
||||
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<XIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
No {activeChain === "evm" ? "EVM" : "Solana"} wallets found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<WalletsPagination
|
||||
activeChain={activeChain}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
teamSlug={teamSlug}
|
||||
projectSlug={project.slug}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wallets Pagination Component
|
||||
function WalletsPagination({
|
||||
activeChain,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
teamSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
activeChain: WalletChain;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalRecords: number;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const pageParam = activeChain === "evm" ? "page" : "solana_page";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center border-t p-6">
|
||||
<div className="mb-4 text-muted-foreground text-sm">
|
||||
Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
|
||||
currentPage > 1 ? currentPage - 1 : 1
|
||||
}`}
|
||||
legacyBehavior
|
||||
passHref
|
||||
>
|
||||
<PaginationPrevious
|
||||
className={
|
||||
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={`page-${pageNumber}`}>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${pageNumber}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationLink isActive={currentPage === pageNumber}>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
|
||||
currentPage < totalPages ? currentPage + 1 : totalPages
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationNext
|
||||
className={
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Wallet Row Component
|
||||
function EVMWalletRow({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
chainId,
|
||||
showSmartAccount,
|
||||
}: {
|
||||
wallet: EVMWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
showSmartAccount: boolean;
|
||||
}) {
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
const smartAccountQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
return await predictSmartAccountAddress({
|
||||
adminAddress: wallet.address,
|
||||
chain: chain,
|
||||
client: client,
|
||||
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
});
|
||||
},
|
||||
enabled: showSmartAccount,
|
||||
queryKey: ["smart-account-address", wallet.address, chainId],
|
||||
});
|
||||
|
||||
const engineService = project.services.find((s) => s.name === "engineCloud");
|
||||
const isDefault =
|
||||
engineService?.projectWalletAddress &&
|
||||
wallet.address.toLowerCase() ===
|
||||
engineService.projectWalletAddress.toLowerCase();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{showSmartAccount ? (
|
||||
smartAccountQuery.isPending ? (
|
||||
<Skeleton className="h-7 w-24" />
|
||||
) : smartAccountQuery.data ? (
|
||||
<WalletAddress address={smartAccountQuery.data} client={client} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)
|
||||
) : (
|
||||
<WalletAddress address={wallet.address} client={client} />
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{showSmartAccount ? (
|
||||
smartAccountQuery.isPending ? (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
) : smartAccountQuery.data ? (
|
||||
<WalletBalance
|
||||
address={smartAccountQuery.data}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)
|
||||
) : (
|
||||
<WalletBalance
|
||||
address={wallet.address}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<EVMWalletActions
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
wallet={wallet}
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
fundAddress={
|
||||
showSmartAccount ? smartAccountQuery.data : wallet.address
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Wallet Row Component
|
||||
function SolanaWalletRow({
|
||||
wallet,
|
||||
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",
|
||||
) as { projectSolanaWalletPublicKey?: string } | undefined;
|
||||
|
||||
const isDefault =
|
||||
engineService?.projectSolanaWalletPublicKey &&
|
||||
wallet.publicKey.toLowerCase() ===
|
||||
engineService.projectSolanaWalletPublicKey.toLowerCase();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaAddress address={wallet.publicKey} shortenAddress={true} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaWalletBalance
|
||||
publicKey={wallet.publicKey}
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
chainId={chainId}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaWalletActions
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
wallet={wallet}
|
||||
client={client}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared Components
|
||||
function DateCell({ date }: { date: string }) {
|
||||
if (!date) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const dateObj = new Date(date);
|
||||
return (
|
||||
<ToolTipLabel label={format(dateObj, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function EVMWalletActions({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
chainId,
|
||||
fundAddress,
|
||||
}: {
|
||||
wallet: EVMWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
fundAddress: string | undefined;
|
||||
}) {
|
||||
const [showFundModal, setShowFundModal] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateDefaultProjectWallet({
|
||||
project,
|
||||
projectWalletAddress: wallet.address,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Wallet set as project wallet");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to set default wallet",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="rounded-xl">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?testTxWithWallet=${wallet.id}`}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send test transaction
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{fundAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowFundModal(true)}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<WalletProductIcon className="size-4 text-muted-foreground" />
|
||||
Fund wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDefaultMutation.mutate()}
|
||||
disabled={setDefaultMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<CheckIcon className="size-4 text-muted-foreground" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{fundAddress && (
|
||||
<FundWalletModal
|
||||
open={showFundModal}
|
||||
onOpenChange={setShowFundModal}
|
||||
title="Fund server wallet"
|
||||
description="Send funds to the server wallet"
|
||||
recipientAddress={fundAddress}
|
||||
client={client}
|
||||
defaultChainId={chainId}
|
||||
checkoutWidgetTitle={
|
||||
wallet.metadata.label
|
||||
? `Fund ${wallet.metadata.label}`
|
||||
: "Fund server wallet"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SolanaWalletActions({
|
||||
wallet,
|
||||
project,
|
||||
teamSlug,
|
||||
}: {
|
||||
wallet: SolanaWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="rounded-xl">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?testSolanaTxWithWallet=${wallet.id}`}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send test transaction
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletBalance({
|
||||
address,
|
||||
chainId,
|
||||
client,
|
||||
}: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
const balance = useWalletBalance({
|
||||
address,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
return <Skeleton className="h-5 w-16" />;
|
||||
}
|
||||
|
||||
if (!balance.data) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{balance.data.displayValue} {balance.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SolanaWalletBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId,
|
||||
}: {
|
||||
publicKey: string;
|
||||
authToken: string;
|
||||
clientId: string;
|
||||
chainId: "solana:mainnet" | "solana:devnet";
|
||||
}) {
|
||||
const balance = useQuery({
|
||||
queryFn: async () => {
|
||||
return await fetchSolanaBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId,
|
||||
});
|
||||
},
|
||||
queryKey: ["solanaWalletBalance", publicKey, chainId],
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
return <Skeleton className="h-5 w-16" />;
|
||||
}
|
||||
|
||||
if (!balance.data) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{balance.data.displayValue} {balance.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+877
@@ -0,0 +1,877 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useId, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { SolanaAddress } from "@/components/blocks/solana-address";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type {
|
||||
SolanaTransaction,
|
||||
SolanaTransactionStatus,
|
||||
SolanaTransactionsResponse,
|
||||
} from "../analytics/solana-tx-table/types";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionsResponse,
|
||||
} from "../analytics/tx-table/types";
|
||||
import { getSolanaNetworkName, getSolscanUrl } from "../lib/solana-utils";
|
||||
|
||||
type TransactionChain = "evm" | "solana";
|
||||
|
||||
interface UnifiedTransactionsTableProps {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export function UnifiedTransactionsTable({
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
}: UnifiedTransactionsTableProps) {
|
||||
const router = useDashboardRouter();
|
||||
|
||||
// Use nuqs to manage chain selection in URL - this is the source of truth
|
||||
const [activeChainParam, setActiveChainParam] = useQueryState("txChain", {
|
||||
defaultValue: "evm",
|
||||
history: "push",
|
||||
});
|
||||
|
||||
const activeChain = (activeChainParam || "evm") as TransactionChain;
|
||||
const setActiveChain = (chain: TransactionChain) =>
|
||||
setActiveChainParam(chain);
|
||||
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [status, setStatus] = useState<
|
||||
TransactionStatus | SolanaTransactionStatus | undefined
|
||||
>(undefined);
|
||||
const [id, setId] = useState<string | undefined>(undefined);
|
||||
const [from, setFrom] = useState<string | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Fetch EVM transactions
|
||||
const evmTransactionsQuery = useQuery({
|
||||
enabled: activeChain === "evm",
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
getEVMTransactions({
|
||||
page,
|
||||
project,
|
||||
status: status as TransactionStatus | undefined,
|
||||
id,
|
||||
from,
|
||||
}),
|
||||
queryKey: ["evm-transactions", project.id, page, status, id, from],
|
||||
refetchInterval: autoUpdate && activeChain === "evm" ? 4_000 : false,
|
||||
});
|
||||
|
||||
// Fetch Solana transactions
|
||||
const solanaTransactionsQuery = useQuery({
|
||||
enabled: activeChain === "solana",
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
getSolanaTransactions({
|
||||
page,
|
||||
project,
|
||||
status: status as SolanaTransactionStatus | undefined,
|
||||
id,
|
||||
from,
|
||||
}),
|
||||
queryKey: ["solana-transactions", project.id, page, status, id, from],
|
||||
refetchInterval: autoUpdate && activeChain === "solana" ? 4_000 : false,
|
||||
});
|
||||
|
||||
const autoUpdateId = useId();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{/* Unified Header & Filters */}
|
||||
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Transaction History
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Transactions sent from server wallets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-border border-t pt-4 lg:border-none lg:pt-0">
|
||||
<Label htmlFor={autoUpdateId}>Auto Update</Label>
|
||||
<Switch
|
||||
checked={autoUpdate}
|
||||
id={autoUpdateId}
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
<Input
|
||||
className="w-full sm:max-w-[200px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setId(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by Queue ID"
|
||||
value={id || ""}
|
||||
/>
|
||||
<Input
|
||||
className="w-full sm:max-w-[200px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setFrom(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by wallet address"
|
||||
value={from || ""}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<ChainSelector
|
||||
activeChain={activeChain}
|
||||
setActiveChain={(chain) => {
|
||||
setActiveChain(chain);
|
||||
setPage(1);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
/>
|
||||
<StatusSelector
|
||||
activeChain={activeChain}
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
setPage(1);
|
||||
}}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render different table structures based on chain */}
|
||||
{activeChain === "evm" ? (
|
||||
<EVMTransactionsTable
|
||||
client={client}
|
||||
page={page}
|
||||
project={project}
|
||||
query={evmTransactionsQuery}
|
||||
router={router}
|
||||
setPage={setPage}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
) : (
|
||||
<SolanaTransactionsTable
|
||||
page={page}
|
||||
project={project}
|
||||
query={solanaTransactionsQuery}
|
||||
router={router}
|
||||
setPage={setPage}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Transactions Table Component
|
||||
function EVMTransactionsTable(props: {
|
||||
query: ReturnType<typeof useQuery<TransactionsResponse>>;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
client: ThirdwebClient;
|
||||
router: ReturnType<typeof useDashboardRouter>;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const { query, page, setPage, client, router, teamSlug, project } = props;
|
||||
const pageSize = 10;
|
||||
|
||||
const transactions = query.data?.transactions ?? [];
|
||||
const totalCount = query.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(query.isPlaceholderData && query.isFetching) ||
|
||||
(query.isLoading && !query.isPlaceholderData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>From</TableHead>
|
||||
<TableHead>Tx Hash</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton
|
||||
? new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={i} />
|
||||
))
|
||||
: transactions.map((tx) => (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id}
|
||||
className="text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMChainCell
|
||||
chainId={tx.chainId || undefined}
|
||||
client={client}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tx.from ? (
|
||||
<WalletAddress address={tx.from} client={client} />
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMTxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EVMQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Transactions Table Component
|
||||
function SolanaTransactionsTable(props: {
|
||||
query: ReturnType<typeof useQuery<SolanaTransactionsResponse>>;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
router: ReturnType<typeof useDashboardRouter>;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const { query, page, setPage, router, teamSlug, project } = props;
|
||||
const pageSize = 10;
|
||||
|
||||
const transactions = query.data?.transactions ?? [];
|
||||
const totalCount = query.data?.pagination.totalCount ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const showPagination = totalCount > pageSize;
|
||||
|
||||
const showSkeleton =
|
||||
(query.isPlaceholderData && query.isFetching) ||
|
||||
(query.isLoading && !query.isPlaceholderData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer className="rounded-none border-x-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Queue ID</TableHead>
|
||||
<TableHead>Network</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Signer</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Queued</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{showSkeleton
|
||||
? new Array(pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
|
||||
<SkeletonRow key={i} />
|
||||
))
|
||||
: transactions.map((tx) => (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
key={`${tx.id}${tx.chainId}`}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/team/${teamSlug}/${project.slug}/transactions/tx/${tx.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<CopyAddressButton
|
||||
address={tx.id}
|
||||
className="text-muted-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaChainCell chainId={tx.chainId} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaStatusCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaAddress
|
||||
address={tx.signerAddress}
|
||||
shortenAddress={true}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaTxHashCell transaction={tx} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SolanaQueuedAtCell transaction={tx} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!showSkeleton && transactions.length === 0 && (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Chain selector component
|
||||
function ChainSelector(props: {
|
||||
activeChain: TransactionChain;
|
||||
setActiveChain: (chain: TransactionChain) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(v) => props.setActiveChain(v as TransactionChain)}
|
||||
value={props.activeChain}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px] rounded-full">
|
||||
<SelectValue placeholder="Select Chain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="evm">EVM</SelectItem>
|
||||
<SelectItem value="solana">Solana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Status selector component
|
||||
const evmStatusDetails = {
|
||||
CONFIRMED: { name: "Confirmed", type: "success" as const },
|
||||
FAILED: { name: "Failed", type: "destructive" as const },
|
||||
QUEUED: { name: "Queued", type: "warning" as const },
|
||||
SUBMITTED: { name: "Submitted", type: "warning" as const },
|
||||
};
|
||||
|
||||
const solanaStatusDetails = {
|
||||
CONFIRMED: { name: "Confirmed", type: "success" as const },
|
||||
FAILED: { name: "Failed", type: "destructive" as const },
|
||||
QUEUED: { name: "Queued", type: "warning" as const },
|
||||
SUBMITTED: { name: "Submitted", type: "warning" as const },
|
||||
};
|
||||
|
||||
function StatusSelector(props: {
|
||||
activeChain: TransactionChain;
|
||||
status: TransactionStatus | SolanaTransactionStatus | undefined;
|
||||
setStatus: (
|
||||
value: TransactionStatus | SolanaTransactionStatus | undefined,
|
||||
) => void;
|
||||
}) {
|
||||
const statusDetails =
|
||||
props.activeChain === "evm" ? evmStatusDetails : solanaStatusDetails;
|
||||
const statuses = Object.keys(statusDetails) as (
|
||||
| TransactionStatus
|
||||
| SolanaTransactionStatus
|
||||
)[];
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
if (v === "all") {
|
||||
props.setStatus(undefined);
|
||||
} else {
|
||||
props.setStatus(v as TransactionStatus | SolanaTransactionStatus);
|
||||
}
|
||||
}}
|
||||
value={props.status || "all"}
|
||||
>
|
||||
<SelectTrigger className="min-w-[160px] rounded-full">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All Transactions</SelectItem>
|
||||
{statuses.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{statusDetails[item].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton row component
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow className="h-[73px]">
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[152px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[155px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[58px] rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[145px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[108px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Chain cell component
|
||||
function EVMChainCell(props: {
|
||||
chainId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { chainId } = props;
|
||||
const { idToChain } = useAllChainsData();
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = idToChain.get(Number.parseInt(chainId));
|
||||
|
||||
if (!chain) {
|
||||
return `Chain ID: ${chainId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon?.url}
|
||||
/>
|
||||
<div className="max-w-[150px] truncate">
|
||||
{chain.name ?? `Chain ID: ${chainId}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Chain cell component
|
||||
function SolanaChainCell(props: { chainId: string | undefined }) {
|
||||
const { chainId } = props;
|
||||
if (!chainId) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const network = getSolanaNetworkName(chainId);
|
||||
const displayName = network.charAt(0).toUpperCase() + network.slice(1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
|
||||
<span>Solana {displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Status cell component
|
||||
function EVMStatusCell(props: { transaction: Transaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const minedAt = transaction.confirmedAt;
|
||||
const status =
|
||||
(transaction.executionResult?.status as TransactionStatus) ?? "QUEUED";
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && minedAt
|
||||
? `Completed ${format(new Date(minedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
const statusDetails = evmStatusDetails[status] || {
|
||||
name: status,
|
||||
type: "default" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge className="gap-2" variant={statusDetails.type}>
|
||||
{statusDetails.name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Status cell component
|
||||
function SolanaStatusCell(props: { transaction: SolanaTransaction }) {
|
||||
const { transaction } = props;
|
||||
const { errorMessage } = transaction;
|
||||
const status =
|
||||
(transaction.executionResult?.status as SolanaTransactionStatus) ??
|
||||
transaction.status ??
|
||||
"QUEUED";
|
||||
|
||||
const onchainStatus =
|
||||
transaction.executionResult &&
|
||||
"onchainStatus" in transaction.executionResult
|
||||
? transaction.executionResult.onchainStatus
|
||||
: null;
|
||||
|
||||
const tooltip =
|
||||
onchainStatus !== "REVERTED"
|
||||
? errorMessage
|
||||
: status === "CONFIRMED" && transaction.confirmedAt
|
||||
? `Completed ${format(new Date(transaction.confirmedAt), "PP pp")}`
|
||||
: undefined;
|
||||
|
||||
const statusDetails = solanaStatusDetails[status] || {
|
||||
name: status || "QUEUED",
|
||||
type: "default" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolTipLabel hoverable label={tooltip}>
|
||||
<Badge className="gap-2" variant={statusDetails.type}>
|
||||
{statusDetails.name}
|
||||
{errorMessage && <InfoIcon className="size-3" />}
|
||||
</Badge>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Tx Hash cell component
|
||||
function EVMTxHashCell(props: { transaction: Transaction }) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const { chainId, transactionHash } = props.transaction;
|
||||
if (!transactionHash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
|
||||
const explorer = chain?.explorers?.[0];
|
||||
|
||||
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
|
||||
if (!explorer) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
copyIconPosition="left"
|
||||
textToCopy={transactionHash}
|
||||
textToShow={shortHash}
|
||||
tooltip="Copy transaction hash"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`${explorer.url}/tx/${transactionHash}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Tx Hash cell component
|
||||
function SolanaTxHashCell(props: { transaction: SolanaTransaction }) {
|
||||
const { chainId, signature, executionResult } = props.transaction;
|
||||
|
||||
// Get signature from executionResult if available, otherwise use top-level signature
|
||||
let hash = signature;
|
||||
if (
|
||||
executionResult &&
|
||||
"signature" in executionResult &&
|
||||
executionResult.signature
|
||||
) {
|
||||
hash = executionResult.signature;
|
||||
}
|
||||
|
||||
if (!hash) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const shortHash = `${hash.slice(0, 6)}...${hash.slice(-4)}`;
|
||||
const explorerUrl = chainId ? getSolscanUrl(hash, chainId) : null;
|
||||
|
||||
if (!explorerUrl) {
|
||||
return (
|
||||
<CopyTextButton
|
||||
className="font-mono text-muted-foreground text-sm"
|
||||
copyIconPosition="left"
|
||||
textToCopy={hash}
|
||||
textToShow={shortHash}
|
||||
tooltip="Copy transaction signature"
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="-translate-x-2 gap-2 font-mono"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={explorerUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{shortHash}{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// EVM Queued At cell component
|
||||
function EVMQueuedAtCell(props: { transaction: Transaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
// Solana Queued At cell component
|
||||
function SolanaQueuedAtCell(props: { transaction: SolanaTransaction }) {
|
||||
const value = props.transaction.createdAt;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return (
|
||||
<ToolTipLabel label={format(date, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
async function getEVMTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}): Promise<TransactionsResponse> {
|
||||
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": project.publishableKey,
|
||||
"x-team-id": project.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
pathname: `/v1/transactions`,
|
||||
searchParams: {
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
id: id ?? undefined,
|
||||
from: from ?? undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!transactions.ok) {
|
||||
return {
|
||||
transactions: [],
|
||||
pagination: {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
|
||||
async function getSolanaTransactions({
|
||||
project,
|
||||
page,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
}: {
|
||||
project: Project;
|
||||
page: number;
|
||||
status: SolanaTransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}): Promise<SolanaTransactionsResponse> {
|
||||
const transactions = await engineCloudProxy<{
|
||||
result: SolanaTransactionsResponse;
|
||||
}>({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": project.publishableKey,
|
||||
"x-team-id": project.teamId,
|
||||
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
|
||||
},
|
||||
method: "GET",
|
||||
pathname: `/v1/solana/transactions`,
|
||||
searchParams: {
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
id: id ?? undefined,
|
||||
from: from ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!transactions.ok) {
|
||||
return {
|
||||
transactions: [],
|
||||
pagination: {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return transactions.data.result;
|
||||
}
|
||||
+95
-10
@@ -205,6 +205,64 @@ export async function getSingleTransaction({
|
||||
return data.transactions[0];
|
||||
}
|
||||
|
||||
export async function getSingleSolanaTransaction({
|
||||
teamId,
|
||||
clientId,
|
||||
transactionId,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
transactionId: string;
|
||||
}): Promise<
|
||||
import("../analytics/solana-tx-table/types").SolanaTransaction | undefined
|
||||
> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const filters = {
|
||||
filters: [
|
||||
{
|
||||
field: "id",
|
||||
operation: "OR",
|
||||
values: [transactionId],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/search`,
|
||||
{
|
||||
body: JSON.stringify(filters),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const errorText = await response.text().catch(() => "Unknown error");
|
||||
throw new Error(
|
||||
`Error fetching single Solana transaction data: ${response.status} ${
|
||||
response.statusText
|
||||
} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
const data =
|
||||
rawData.result as import("../analytics/solana-tx-table/types").SolanaTransactionsResponse;
|
||||
|
||||
return data.transactions[0];
|
||||
}
|
||||
|
||||
// Activity log types
|
||||
export type ActivityLogEntry = {
|
||||
id: string;
|
||||
@@ -260,18 +318,45 @@ export async function getTransactionActivityLogs({
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
if (response.status === 401 || response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ActivityLogsResponse;
|
||||
return data.result.activityLogs;
|
||||
}
|
||||
|
||||
export async function getSolanaTransactionActivityLogs({
|
||||
teamId,
|
||||
clientId,
|
||||
transactionId,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
transactionId: string;
|
||||
}): Promise<ActivityLogEntry[]> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/solana/transactions/activity-logs?transactionId=${transactionId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
"x-chain-id": "solana:devnet", // TODO: Support multiple Solana networks
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Don't throw on 404 - activity logs might not exist for all transactions
|
||||
if (response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Error fetching activity logs: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Get the Solscan explorer URL for a given transaction signature and chain ID
|
||||
* @param signature - The transaction signature
|
||||
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
|
||||
* @returns The Solscan URL for the transaction
|
||||
*/
|
||||
export function getSolscanUrl(signature: string, chainId: string): string {
|
||||
const network = chainId.split(":")[1] || "mainnet";
|
||||
|
||||
// Solscan uses different subdomains for different networks
|
||||
switch (network.toLowerCase()) {
|
||||
case "devnet":
|
||||
return `https://solscan.io/tx/${signature}?cluster=devnet`;
|
||||
case "testnet":
|
||||
return `https://solscan.io/tx/${signature}?cluster=testnet`;
|
||||
default:
|
||||
return `https://solscan.io/tx/${signature}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display network name from a Solana chain ID (capitalized)
|
||||
* @param chainId - The chain ID in format "solana:mainnet", "solana:devnet", or "solana:testnet"
|
||||
* @returns The capitalized network name for display (e.g., "Devnet")
|
||||
*/
|
||||
export function getSolanaNetworkName(chainId: string): string {
|
||||
const network = chainId.split(":")[1] || "mainnet";
|
||||
return network.charAt(0).toUpperCase() + network.slice(1);
|
||||
}
|
||||
+182
-24
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { encrypt } from "@thirdweb-dev/service-utils";
|
||||
import { decrypt, encrypt } from "@thirdweb-dev/service-utils";
|
||||
import {
|
||||
createAccessToken,
|
||||
createEoa,
|
||||
@@ -514,29 +514,6 @@ export async function createWalletAccessToken(props: {
|
||||
},
|
||||
type: "eoa:signStructuredMessage",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "eoa:read",
|
||||
},
|
||||
{
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
@@ -795,6 +772,187 @@ async function createManagementAccessToken(props: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades existing access tokens to include Solana permissions
|
||||
* This is needed when a project was created before Solana support was added
|
||||
*
|
||||
* Returns an object with success/error instead of throwing for Next.js server actions
|
||||
*/
|
||||
export async function upgradeAccessTokensForSolana(props: {
|
||||
project: Project;
|
||||
projectSecretKey?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: {
|
||||
managementToken: string;
|
||||
walletToken?: string;
|
||||
};
|
||||
}> {
|
||||
const { project, projectSecretKey } = props;
|
||||
|
||||
try {
|
||||
// Find the engineCloud service
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
if (!engineCloudService) {
|
||||
return {
|
||||
success: false,
|
||||
error: "No engineCloud service found on project",
|
||||
};
|
||||
}
|
||||
|
||||
const vaultClient = await initVaultClient();
|
||||
const hasEncryptedAdminKey = !!engineCloudService.encryptedAdminKey;
|
||||
|
||||
// Check if this is an ejected vault (no encrypted admin key stored)
|
||||
if (!hasEncryptedAdminKey) {
|
||||
// For ejected vaults, we only need to update the management token
|
||||
// User manages their own admin key, so we can't create wallet tokens
|
||||
|
||||
// We need the admin key from the user
|
||||
if (!projectSecretKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Admin key required. Please enter your vault admin key.",
|
||||
};
|
||||
}
|
||||
|
||||
// For ejected vault, the "secret key" parameter is actually the admin key
|
||||
const managementTokenResult = await createManagementAccessToken({
|
||||
project,
|
||||
adminKey: projectSecretKey,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
if (!managementTokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create management token: ${managementTokenResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Update only the management token for ejected vaults
|
||||
// Keep everything else the same (no encrypted keys to update)
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
...engineCloudService,
|
||||
managementAccessToken: managementTokenResult.data.accessToken,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
managementToken: managementTokenResult.data.accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For non-ejected vaults (with encrypted admin key)
|
||||
if (!projectSecretKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Project secret key is required to upgrade tokens",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the project secret key
|
||||
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
|
||||
if (!project.secretKeys.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid project secret key",
|
||||
};
|
||||
}
|
||||
|
||||
// Decrypt the admin key (we know it exists from the hasEncryptedAdminKey check)
|
||||
const adminKey = await decrypt(
|
||||
engineCloudService.encryptedAdminKey as string,
|
||||
projectSecretKey,
|
||||
);
|
||||
|
||||
// Create new tokens with Solana permissions
|
||||
const [managementTokenResult, walletTokenResult] = await Promise.all([
|
||||
createManagementAccessToken({ project, adminKey, vaultClient }),
|
||||
createWalletAccessToken({ project, adminKey, vaultClient }),
|
||||
]);
|
||||
|
||||
if (!managementTokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create management token: ${managementTokenResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!walletTokenResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create wallet token: ${walletTokenResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
// Encrypt the new wallet token
|
||||
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
encrypt(adminKey, projectSecretKey),
|
||||
encrypt(walletToken.accessToken, projectSecretKey),
|
||||
]);
|
||||
|
||||
// Update the project with new tokens
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
...engineCloudService,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
managementToken: managementToken.accessToken,
|
||||
walletToken: walletToken.accessToken,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upgrade access tokens",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string) {
|
||||
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
|
||||
}
|
||||
|
||||
+73
-17
@@ -9,9 +9,13 @@ 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 { ServerWalletsTable } from "./server-wallets/wallet-table/wallet-table";
|
||||
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TransactionsAnalyticsPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -20,7 +24,9 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
to?: string | string[] | undefined;
|
||||
interval?: string | string[] | undefined;
|
||||
testTxWithWallet?: string | string[] | undefined;
|
||||
testSolanaTxWithWallet?: string | string[] | undefined;
|
||||
page?: string;
|
||||
solana_page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams, authToken] = await Promise.all([
|
||||
@@ -58,6 +64,7 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
|
||||
const pageSize = 10;
|
||||
const currentPage = Number.parseInt(searchParams.page ?? "1");
|
||||
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
|
||||
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
@@ -77,6 +84,32 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
|
||||
const wallets = eoas.data?.items as Wallet[] | undefined;
|
||||
|
||||
// Fetch Solana accounts - gracefully handle permission errors
|
||||
let solanaAccounts: {
|
||||
data: { items: SolanaWallet[]; totalRecords: number };
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
if (managementAccessToken) {
|
||||
solanaAccounts = await listSolanaAccounts({
|
||||
managementAccessToken,
|
||||
page: solanCurrentPage,
|
||||
limit: pageSize,
|
||||
projectId: project.id,
|
||||
});
|
||||
} else {
|
||||
solanaAccounts = {
|
||||
data: { items: [], totalRecords: 0 },
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if error is a permission error
|
||||
const isSolanaPermissionError =
|
||||
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
|
||||
|
||||
const initialData = await getTransactionAnalyticsSummary({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
@@ -123,43 +156,66 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
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 && (
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={project.publishableKey}
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
{hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet && (
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={project.publishableKey}
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* transactions */}
|
||||
<TransactionsAnalyticsPageContent
|
||||
client={client}
|
||||
project={project}
|
||||
searchParams={searchParams}
|
||||
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
|
||||
showAnalytics={
|
||||
hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet
|
||||
}
|
||||
teamSlug={params.team_slug}
|
||||
wallets={wallets}
|
||||
/>
|
||||
|
||||
{/* server wallets */}
|
||||
{/* Server Wallets (EVM + Solana) */}
|
||||
{eoas.error ? (
|
||||
<div>Error: {eoas.error.message}</div>
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{eoas.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
currentPage={currentPage}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
evmCurrentPage={currentPage}
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={eoas.data.items as Wallet[]}
|
||||
project={project}
|
||||
solanaCurrentPage={solanCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
solanaAccounts.data.totalRecords / pageSize,
|
||||
)}
|
||||
solanaTotalRecords={solanaAccounts.data.totalRecords}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
totalRecords={eoas.data.totalRecords}
|
||||
wallets={eoas.data.items as Wallet[]}
|
||||
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"
|
||||
|
||||
-519
@@ -1,519 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
CheckIcon,
|
||||
MoreVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
predictSmartAccountAddress,
|
||||
} from "thirdweb/wallets/smart";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateDefaultProjectWallet } from "../../lib/vault.client";
|
||||
import CreateServerWallet from "../components/create-server-wallet.client";
|
||||
import type { Wallet } from "./types";
|
||||
|
||||
export function ServerWalletsTableUI({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
managementAccessToken,
|
||||
totalRecords,
|
||||
currentPage,
|
||||
totalPages,
|
||||
client,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
totalRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const [selectedChainId, setSelectedChainId] = useState<number>(1);
|
||||
const [showSmartAccount, setShowSmartAccount] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<WalletProductIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Server Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage server wallets for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="flex flex-row gap-2.5">
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
onChange={setSelectedChainId}
|
||||
client={client}
|
||||
disableChainId
|
||||
className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50"
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label
|
||||
className="text-sm text-muted-foreground"
|
||||
htmlFor="showSmartAccount"
|
||||
>
|
||||
Show ERC4337 Smart Account
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
id="showSmartAccount"
|
||||
checked={showSmartAccount}
|
||||
onCheckedChange={setShowSmartAccount}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-none border-x-0 border-b-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="w-[280px]">
|
||||
{showSmartAccount
|
||||
? "Smart Account Address"
|
||||
: "Wallet Address"}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[80px]">
|
||||
<div className="flex w-[180px] items-center gap-1.5">
|
||||
Balance
|
||||
{wallets.length > 0 && (
|
||||
<ToolTipLabel
|
||||
contentClassName="capitalize font-normal tracking-normal leading-normal"
|
||||
label="Refresh Balance"
|
||||
>
|
||||
<Button
|
||||
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
|
||||
onClick={async (e) => {
|
||||
const buttonEl = e.currentTarget;
|
||||
buttonEl.setAttribute("data-pending", "true");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["walletBalance", selectedChainId],
|
||||
});
|
||||
|
||||
buttonEl.setAttribute("data-pending", "false");
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>Created </TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{wallets.map((wallet) => (
|
||||
<ServerWalletTableRow
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
chainId={selectedChainId}
|
||||
showSmartAccount={showSmartAccount}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{wallets.length === 0 && (
|
||||
<div className="py-24 flex flex-col items-center justify-center px-4 text-center gap-4">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<XIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">No server wallets found</p>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col items-center border-t p-6">
|
||||
<div className="mb-4 text-muted-foreground text-sm">
|
||||
Found {totalRecords} server wallets
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
|
||||
currentPage > 1 ? currentPage - 1 : 1
|
||||
}`}
|
||||
legacyBehavior
|
||||
passHref
|
||||
>
|
||||
<PaginationPrevious
|
||||
className={
|
||||
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={`page-${pageNumber}`}>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${pageNumber}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationLink isActive={currentPage === pageNumber}>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
|
||||
currentPage < totalPages ? currentPage + 1 : totalPages
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationNext
|
||||
className={
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerWalletTableRow(props: {
|
||||
wallet: Wallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
showSmartAccount: boolean;
|
||||
}) {
|
||||
const { wallet, project, teamSlug, client, chainId, showSmartAccount } =
|
||||
props;
|
||||
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
const smartAccountAddressQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
const smartAccountAddress = await predictSmartAccountAddress({
|
||||
adminAddress: wallet.address,
|
||||
|
||||
chain: chain,
|
||||
client: client,
|
||||
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
|
||||
});
|
||||
return smartAccountAddress;
|
||||
},
|
||||
enabled: showSmartAccount,
|
||||
queryKey: ["smart-account-address", wallet.address, chainId],
|
||||
});
|
||||
|
||||
// Get the project wallet address
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
const defaultWalletAddress = engineCloudService?.projectWalletAddress;
|
||||
const isDefaultWallet =
|
||||
defaultWalletAddress &&
|
||||
wallet.address.toLowerCase() === defaultWalletAddress.toLowerCase();
|
||||
|
||||
return (
|
||||
<TableRow key={wallet.id}>
|
||||
{/* Label */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
{isDefaultWallet && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Address */}
|
||||
<TableCell>
|
||||
{showSmartAccount ? (
|
||||
<div>
|
||||
{smartAccountAddressQuery.data ? (
|
||||
<WalletAddress
|
||||
address={smartAccountAddressQuery.data}
|
||||
client={client}
|
||||
/>
|
||||
) : smartAccountAddressQuery.isPending ? (
|
||||
<Skeleton className="h-7 w-24" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WalletAddress address={wallet.address} client={client} />
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Balance */}
|
||||
<TableCell>
|
||||
{showSmartAccount && (
|
||||
// biome-ignore lint/complexity/noUselessFragments: keep for readability
|
||||
<>
|
||||
{smartAccountAddressQuery.isPending ? (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
) : smartAccountAddressQuery.data ? (
|
||||
<WalletBalanceCell
|
||||
address={smartAccountAddressQuery.data}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showSmartAccount && (
|
||||
<WalletBalanceCell
|
||||
address={wallet.address}
|
||||
chainId={chainId}
|
||||
client={client}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<WalletDateCell date={wallet.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<WalletActionsDropdown
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
fundWalletAddress={
|
||||
showSmartAccount ? smartAccountAddressQuery.data : wallet.address
|
||||
}
|
||||
wallet={wallet}
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletDateCell({ date }: { date: string }) {
|
||||
if (!date) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const dateObj = new Date(date);
|
||||
return (
|
||||
<ToolTipLabel label={format(dateObj, "PP pp z")}>
|
||||
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletActionsDropdown(props: {
|
||||
fundWalletAddress: string | undefined;
|
||||
wallet: Wallet;
|
||||
teamSlug: string;
|
||||
project: Project;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
}) {
|
||||
const [showFundModal, setShowFundModal] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const setAsDefaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateDefaultProjectWallet({
|
||||
project: props.project,
|
||||
projectWalletAddress: props.wallet.address,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Wallet set as project wallet");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to set default wallet",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="rounded-xl">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/transactions?testTxWithWallet=${props.wallet.id}`}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send test transaction
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{props.fundWalletAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowFundModal(true)}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<WalletProductIcon className="size-4 text-muted-foreground" />
|
||||
Fund wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAsDefaultMutation.mutate()}
|
||||
disabled={setAsDefaultMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 rounded-lg"
|
||||
>
|
||||
<CheckIcon className="size-4 text-muted-foreground" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{props.fundWalletAddress && (
|
||||
<FundWalletModal
|
||||
open={showFundModal}
|
||||
onOpenChange={setShowFundModal}
|
||||
title="Fund server wallet"
|
||||
description="Send funds to the server wallet"
|
||||
recipientAddress={props.fundWalletAddress}
|
||||
client={props.client}
|
||||
defaultChainId={props.chainId}
|
||||
checkoutWidgetTitle={
|
||||
props.wallet.metadata.label
|
||||
? `Fund ${props.wallet.metadata.label}`
|
||||
: "Fund server wallet"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletBalanceCell(props: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const chain = useV5DashboardChain(props.chainId);
|
||||
const balance = useWalletBalance({
|
||||
address: props.address,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
return <Skeleton className="h-5 w-16" />;
|
||||
}
|
||||
|
||||
if (!balance.data) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{balance.data.displayValue} {balance.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Wallet } from "./types";
|
||||
import { ServerWalletsTableUI } from "./wallet-table-ui.client";
|
||||
|
||||
export function ServerWalletsTable({
|
||||
wallets,
|
||||
project,
|
||||
teamSlug,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
managementAccessToken,
|
||||
client,
|
||||
}: {
|
||||
wallets: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
totalRecords: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<ServerWalletsTableUI
|
||||
client={client}
|
||||
currentPage={currentPage}
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
wallets={wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { createSolanaAccount } from "../lib/vault.client";
|
||||
|
||||
export function CreateSolanaWallet(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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 (!managementAccessToken) {
|
||||
throw new Error("No management access token");
|
||||
}
|
||||
|
||||
const result = await createSolanaAccount({
|
||||
managementAccessToken: managementAccessToken,
|
||||
label: label.trim(),
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw result.error || new Error("Failed to create Solana wallet");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Solana wallet created successfully");
|
||||
setOpen(false);
|
||||
setLabel("");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create Solana wallet",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5 rounded-full bg-background text-foreground"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create Solana Wallet
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Solana Wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new Solana server wallet for your project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="label">Wallet Label</Label>
|
||||
<Input
|
||||
id="label"
|
||||
placeholder="e.g., Production Wallet"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create Wallet"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { KeyRoundIcon, Loader2Icon, ShieldCheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { upgradeAccessTokensForSolana } from "../../lib/vault.client";
|
||||
|
||||
const formSchema = z.object({
|
||||
secretKey: z.string().min(1, "Secret key is required"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function UpgradeSolanaPermissions(props: { project: Project }) {
|
||||
const router = useDashboardRouter();
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
// Check if this is an ejected vault
|
||||
const engineCloudService = props.project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
);
|
||||
const isEjectedVault = !engineCloudService?.encryptedAdminKey;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
secretKey: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
return upgradeAccessTokensForSolana({
|
||||
project: props.project,
|
||||
projectSecretKey: data.secretKey,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Failed to upgrade Solana permissions");
|
||||
return;
|
||||
}
|
||||
setIsComplete(true);
|
||||
toast.success("Solana permissions enabled successfully!");
|
||||
// Refresh the page after a short delay to show updated state
|
||||
setTimeout(() => {
|
||||
router.refresh();
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upgrade Solana permissions",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<Card className="border-success/50 bg-success/5">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-success/10">
|
||||
<ShieldCheckIcon className="size-5 text-success" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-success">
|
||||
Solana Access Enabled
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your project now has full Solana functionality
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-background/50 p-3 border border-border/50">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Refreshing page to load Solana wallets...
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-purple-500/10">
|
||||
<KeyRoundIcon className="size-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Enable Solana Functionality</CardTitle>
|
||||
<CardDescription>
|
||||
Upgrade your project to support Solana wallets and transactions
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
{isEjectedVault
|
||||
? "Why do you need my admin key?"
|
||||
: "Why do you need my secret key?"}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 space-y-2 text-sm">
|
||||
<p>
|
||||
Your project was created before Solana support was added. To
|
||||
enable Solana features, we need to update your access tokens with
|
||||
new permissions.
|
||||
</p>
|
||||
{isEjectedVault ? (
|
||||
<>
|
||||
<p>
|
||||
Since you're using an ejected vault, your admin key is used
|
||||
to:
|
||||
</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>Create new access tokens with Solana permissions</li>
|
||||
<li>Update your management token with the new permissions</li>
|
||||
</ul>
|
||||
<p className="font-medium">
|
||||
Your admin key is never stored and is only used during this
|
||||
one-time upgrade process.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Your secret key is used to:</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>Decrypt your existing admin credentials</li>
|
||||
<li>Create new access tokens with Solana permissions</li>
|
||||
<li>Re-encrypt and securely store the updated credentials</li>
|
||||
</ul>
|
||||
<p className="font-medium">
|
||||
Your secret key is never stored and is only used during this
|
||||
one-time upgrade process.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((data) => upgradeMutation.mutate(data))}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secretKey">
|
||||
{isEjectedVault ? "Vault Admin Key" : "Project Secret Key"}
|
||||
</Label>
|
||||
<Input
|
||||
{...form.register("secretKey")}
|
||||
id="secretKey"
|
||||
placeholder={
|
||||
isEjectedVault
|
||||
? "Enter your vault admin key"
|
||||
: "Enter your secret key"
|
||||
}
|
||||
type="password"
|
||||
disabled={upgradeMutation.isPending}
|
||||
className="font-mono"
|
||||
/>
|
||||
{form.formState.errors.secretKey && (
|
||||
<p className="text-destructive text-sm">
|
||||
{form.formState.errors.secretKey.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{isEjectedVault
|
||||
? "This is the admin key you received when you ejected your vault"
|
||||
: "You can find your secret key in your project settings"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={upgradeMutation.isPending || !form.formState.isValid}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{upgradeMutation.isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
Upgrading...
|
||||
</>
|
||||
) : (
|
||||
"Enable Solana"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
createVaultClient,
|
||||
createSolanaAccount as vaultCreateSolanaAccount,
|
||||
listSolanaAccounts as vaultListSolanaAccounts,
|
||||
} from "@thirdweb-dev/vault-sdk";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import type { SolanaWallet } from "../wallet-table/types";
|
||||
|
||||
interface VaultSolanaAccountListItem {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata: {
|
||||
projectId?: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SolanaAccountResponse {
|
||||
pubkey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function listSolanaAccounts(params: {
|
||||
managementAccessToken: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
projectId?: string;
|
||||
}): Promise<{
|
||||
data: {
|
||||
items: SolanaWallet[];
|
||||
totalRecords: number;
|
||||
};
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
}> {
|
||||
const { managementAccessToken, page = 1, limit = 100, projectId } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return {
|
||||
data: {
|
||||
items: [],
|
||||
totalRecords: 0,
|
||||
},
|
||||
error: new Error("Missing managementAccessToken or vault URL"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await vaultListSolanaAccounts({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: page - 1, // Vault SDK uses 0-based pagination
|
||||
pageSize: limit,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return {
|
||||
data: {
|
||||
items: [],
|
||||
totalRecords: 0,
|
||||
},
|
||||
error: response.error
|
||||
? new Error(JSON.stringify(response.error))
|
||||
: new Error("Failed to fetch Solana accounts"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const items = (response.data.items || []) as VaultSolanaAccountListItem[];
|
||||
|
||||
// Filter by projectId and type, transform to SolanaWallet type
|
||||
const wallets: SolanaWallet[] = items
|
||||
.filter((item) => {
|
||||
// Filter by projectId
|
||||
if (projectId && item.metadata?.projectId !== projectId) {
|
||||
return false;
|
||||
}
|
||||
// Only include server-wallet type
|
||||
return !item.metadata?.type || item.metadata.type === "server-wallet";
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
publicKey: item.pubkey,
|
||||
metadata: {
|
||||
type: "server-wallet",
|
||||
projectId: item.metadata?.projectId || projectId || "",
|
||||
label: item.metadata?.label,
|
||||
},
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: {
|
||||
items: wallets,
|
||||
totalRecords: wallets.length, // Use filtered count since we're filtering by projectId
|
||||
},
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to list Solana accounts", error);
|
||||
return {
|
||||
data: {
|
||||
items: [],
|
||||
totalRecords: 0,
|
||||
},
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSolanaAccount(params: {
|
||||
managementAccessToken: string;
|
||||
label: string;
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
}): Promise<{
|
||||
data: SolanaAccountResponse | null;
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
}> {
|
||||
const { managementAccessToken, label, projectId, teamId } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return {
|
||||
data: null,
|
||||
error: new Error("Missing managementAccessToken or vault URL"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await vaultCreateSolanaAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
metadata: {
|
||||
label,
|
||||
projectId,
|
||||
teamId,
|
||||
type: "server-wallet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return {
|
||||
data: null,
|
||||
error: response.error
|
||||
? new Error(JSON.stringify(response.error))
|
||||
: new Error("Failed to create Solana account"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
pubkey: response.data.pubkey,
|
||||
createdAt: response.data.createdAt,
|
||||
updatedAt: response.data.updatedAt,
|
||||
},
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to create Solana account", error);
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type SolanaWallet = {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
metadata: {
|
||||
type: string;
|
||||
projectId: string;
|
||||
label?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
+7
-5
@@ -1,25 +1,27 @@
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TransactionLayout({
|
||||
export default async function TransactionLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { team_slug: string; project_slug: string };
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const { team_slug, project_slug } = await params;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/transactions`}
|
||||
href={`/team/${team_slug}/${project_slug}/transactions`}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
Back to Transactions
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
<div className="flex-1 space-y-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+44
-13
@@ -15,9 +15,12 @@ import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import type { Transaction } from "../../analytics/tx-table/types";
|
||||
import {
|
||||
getSingleSolanaTransaction,
|
||||
getSingleTransaction,
|
||||
getSolanaTransactionActivityLogs,
|
||||
getTransactionActivityLogs,
|
||||
} from "../../lib/analytics";
|
||||
import { SolanaTransactionDetailsUI } from "./solana-transaction-details-ui";
|
||||
import { TransactionDetailsUI } from "./transaction-details-ui";
|
||||
|
||||
type AbiItem =
|
||||
@@ -195,17 +198,18 @@ export default async function TransactionPage({
|
||||
redirect(`/team/${team_slug}`);
|
||||
}
|
||||
|
||||
const [transactionData, activityLogs] = await Promise.all([
|
||||
// Try fetching both EVM and Solana transactions
|
||||
const [evmTransactionData, solanaTransactionData] = await Promise.all([
|
||||
getSingleTransaction({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
}),
|
||||
getTransactionActivityLogs({
|
||||
}).catch(() => null),
|
||||
getSingleSolanaTransaction({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
}),
|
||||
}).catch(() => null),
|
||||
]);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
@@ -213,23 +217,50 @@ export default async function TransactionPage({
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
if (!transactionData) {
|
||||
notFound();
|
||||
// Determine which transaction type we have and fetch appropriate activity logs
|
||||
if (solanaTransactionData) {
|
||||
const activityLogs = await getSolanaTransactionActivityLogs({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
});
|
||||
|
||||
// Render Solana transaction details
|
||||
return (
|
||||
<SolanaTransactionDetailsUI
|
||||
activityLogs={activityLogs}
|
||||
client={client}
|
||||
project={project}
|
||||
teamSlug={team_slug}
|
||||
transaction={solanaTransactionData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Decode transaction data on the server
|
||||
const decodedTransactionData = await decodeTransactionData(transactionData);
|
||||
if (evmTransactionData) {
|
||||
const activityLogs = await getTransactionActivityLogs({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
transactionId: id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-2">
|
||||
// Decode transaction data on the server for EVM
|
||||
const decodedTransactionData =
|
||||
await decodeTransactionData(evmTransactionData);
|
||||
|
||||
// Render EVM transaction details
|
||||
return (
|
||||
<TransactionDetailsUI
|
||||
activityLogs={activityLogs}
|
||||
client={client}
|
||||
decodedTransactionData={decodedTransactionData}
|
||||
project={project}
|
||||
teamSlug={team_slug}
|
||||
transaction={transactionData}
|
||||
transaction={evmTransactionData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// No transaction found
|
||||
notFound();
|
||||
}
|
||||
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { stringify } from "thirdweb/utils";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { SolanaAddress } from "@/components/blocks/solana-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import type { SolanaTransaction } from "../../analytics/solana-tx-table/types";
|
||||
import type { ActivityLogEntry } from "../../lib/analytics";
|
||||
import { getSolanaNetworkName, getSolscanUrl } from "../../lib/solana-utils";
|
||||
|
||||
const solanaStatusDetails = {
|
||||
CONFIRMED: { name: "Confirmed", variant: "success" as const },
|
||||
FAILED: { name: "Failed", variant: "destructive" as const },
|
||||
QUEUED: { name: "Queued", variant: "warning" as const },
|
||||
SUBMITTED: { name: "Submitted", variant: "warning" as const },
|
||||
};
|
||||
|
||||
export function SolanaTransactionDetailsUI({
|
||||
transaction,
|
||||
activityLogs,
|
||||
}: {
|
||||
transaction: SolanaTransaction;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
project: Project;
|
||||
activityLogs: ActivityLogEntry[];
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "logs" | "raw">(
|
||||
"overview",
|
||||
);
|
||||
|
||||
// Extract relevant data from transaction
|
||||
const {
|
||||
id,
|
||||
chainId,
|
||||
signerAddress,
|
||||
signature,
|
||||
confirmedAt,
|
||||
createdAt,
|
||||
executionResult,
|
||||
errorMessage,
|
||||
} = transaction;
|
||||
|
||||
const status = (executionResult?.status ||
|
||||
transaction.status ||
|
||||
"QUEUED") as keyof typeof solanaStatusDetails;
|
||||
|
||||
// Parse network from chainId
|
||||
const network = getSolanaNetworkName(chainId);
|
||||
const networkDisplay = network.charAt(0).toUpperCase() + network.slice(1);
|
||||
|
||||
// Calculate time difference between creation and confirmation
|
||||
const confirmationTime =
|
||||
confirmedAt && createdAt
|
||||
? new Date(confirmedAt).getTime() -
|
||||
(createdAt instanceof Date ? createdAt : new Date(createdAt)).getTime()
|
||||
: null;
|
||||
|
||||
// Get signature from executionResult if available
|
||||
const txSignature =
|
||||
(executionResult && "signature" in executionResult
|
||||
? executionResult.signature
|
||||
: signature) || null;
|
||||
|
||||
// Get slot and blockTime
|
||||
const slot =
|
||||
executionResult && "slot" in executionResult
|
||||
? executionResult.slot
|
||||
: transaction.confirmedAtSlot;
|
||||
const blockTime =
|
||||
executionResult && "blockTime" in executionResult
|
||||
? executionResult.blockTime
|
||||
: transaction.blockTime;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Transaction ID Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Transaction Details
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">Queue ID:</span>
|
||||
<CopyTextButton
|
||||
className="font-mono"
|
||||
copyIconPosition="left"
|
||||
textToCopy={id}
|
||||
textToShow={id}
|
||||
tooltip="Copy Queue ID"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className="gap-2 text-base"
|
||||
variant={solanaStatusDetails[status].variant}
|
||||
>
|
||||
{solanaStatusDetails[status].name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "Overview",
|
||||
onClick: () => setActiveTab("overview"),
|
||||
isActive: activeTab === "overview",
|
||||
},
|
||||
{
|
||||
name: "Activity Logs",
|
||||
onClick: () => setActiveTab("logs"),
|
||||
isActive: activeTab === "logs",
|
||||
},
|
||||
{
|
||||
name: "Raw Data",
|
||||
onClick: () => setActiveTab("raw"),
|
||||
isActive: activeTab === "raw",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Transaction Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<InfoRow label="Network" value={`Solana ${networkDisplay}`} />
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={solanaStatusDetails[status].name}
|
||||
/>
|
||||
<InfoRow label="Signer Address">
|
||||
<SolanaAddress address={signerAddress} shortenAddress={false} />
|
||||
</InfoRow>
|
||||
{txSignature && (
|
||||
<InfoRow label="Signature">
|
||||
<Link
|
||||
href={getSolscanUrl(txSignature, chainId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-link-foreground hover:underline text-sm"
|
||||
>
|
||||
<code className="font-mono">
|
||||
{txSignature.slice(0, 12)}...{txSignature.slice(-12)}
|
||||
</code>
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</InfoRow>
|
||||
)}
|
||||
{slot && <InfoRow label="Slot" value={slot.toString()} />}
|
||||
{errorMessage && (
|
||||
<InfoRow label="Error">
|
||||
<span className="text-destructive text-sm">
|
||||
{errorMessage}
|
||||
</span>
|
||||
</InfoRow>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timing Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timing Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<InfoRow label="Queued At">
|
||||
<ToolTipLabel
|
||||
label={format(
|
||||
createdAt instanceof Date ? createdAt : new Date(createdAt),
|
||||
"PP pp z",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNowStrict(
|
||||
createdAt instanceof Date
|
||||
? createdAt
|
||||
: new Date(createdAt),
|
||||
{ addSuffix: true },
|
||||
)}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
</InfoRow>
|
||||
{confirmedAt && (
|
||||
<>
|
||||
<InfoRow label="Confirmed At">
|
||||
<ToolTipLabel
|
||||
label={format(new Date(confirmedAt), "PP pp z")}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNowStrict(new Date(confirmedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
</InfoRow>
|
||||
{confirmationTime && (
|
||||
<InfoRow
|
||||
label="Confirmation Time"
|
||||
value={`${(confirmationTime / 1000).toFixed(2)}s`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{blockTime && (
|
||||
<InfoRow
|
||||
label="Block Time"
|
||||
value={format(new Date(blockTime * 1000), "PP pp z")}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transaction Instructions */}
|
||||
{transaction.transactionParams &&
|
||||
transaction.transactionParams.instructions.length > 0 && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{transaction.transactionParams.instructions.map(
|
||||
(instruction, index) => (
|
||||
<InstructionCard
|
||||
key={`instruction-${instruction.programId}-${index}`}
|
||||
index={index}
|
||||
instruction={instruction}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Execution Details */}
|
||||
{executionResult && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Execution Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CodeClient
|
||||
code={stringify(executionResult, null, 2)}
|
||||
lang="json"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Logs Tab */}
|
||||
{activeTab === "logs" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLogs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No activity logs available
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activityLogs.map((log) => (
|
||||
<ActivityLogItem key={log.id} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Raw Data Tab */}
|
||||
{activeTab === "raw" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Raw Transaction Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CodeClient code={stringify(transaction, null, 2)} lang="json" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-muted-foreground text-sm font-medium">{label}</dt>
|
||||
<dd className="text-sm">
|
||||
{children || <span className="break-all">{value || "N/A"}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionCard({
|
||||
instruction,
|
||||
index,
|
||||
}: {
|
||||
instruction: {
|
||||
programId: string;
|
||||
keys: Array<{
|
||||
pubkey: string;
|
||||
isSigner: boolean;
|
||||
isWritable: boolean;
|
||||
}>;
|
||||
data: string;
|
||||
};
|
||||
index: number;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="font-medium text-sm">Instruction {index + 1}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium mb-1">
|
||||
Program ID
|
||||
</p>
|
||||
<code className="text-xs font-mono break-all">
|
||||
{instruction.programId}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium mb-1">
|
||||
Accounts ({instruction.keys.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{instruction.keys.map((key, idx) => (
|
||||
<div
|
||||
key={`${key.pubkey}-${idx}`}
|
||||
className="rounded border border-border bg-background p-2"
|
||||
>
|
||||
<code className="text-xs font-mono break-all">
|
||||
{key.pubkey}
|
||||
</code>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{key.isSigner && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Signer
|
||||
</Badge>
|
||||
)}
|
||||
{key.isWritable && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Writable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium mb-1">
|
||||
Data
|
||||
</p>
|
||||
<code className="text-xs font-mono break-all block bg-background p-2 rounded border border-border">
|
||||
{instruction.data || "No data"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLogItem({ log }: { log: ActivityLogEntry }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{log.eventType}
|
||||
</Badge>
|
||||
<span className="text-sm">{log.stageName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(new Date(log.timestamp), "PP pp")}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && log.payload && (
|
||||
<div className="mt-3">
|
||||
<CodeClient
|
||||
code={
|
||||
typeof log.payload === "string"
|
||||
? log.payload
|
||||
: stringify(log.payload, null, 2)
|
||||
}
|
||||
lang="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -22,8 +22,8 @@ import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
|
||||
import type { Transaction } from "../../analytics/tx-table/types";
|
||||
import { statusDetails } from "../../analytics/tx-table/types";
|
||||
import type { ActivityLogEntry } from "../../lib/analytics";
|
||||
import type { DecodedTransactionData, DecodedTransactionResult } from "./page";
|
||||
|
||||
|
||||
+57
-9
@@ -12,11 +12,15 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client";
|
||||
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
|
||||
import { ServerWalletsTable } from "../transactions/server-wallets/wallet-table/wallet-table";
|
||||
import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types";
|
||||
import { InAppWalletAnalytics } from "./analytics/chart";
|
||||
import { InAppWalletsSummary } from "./analytics/chart/Summary";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{
|
||||
@@ -25,6 +29,7 @@ export default async function Page(props: {
|
||||
type?: string;
|
||||
interval?: string;
|
||||
page?: string;
|
||||
solana_page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [searchParams, params] = await Promise.all([
|
||||
@@ -66,7 +71,9 @@ export default async function Page(props: {
|
||||
// Fetch server wallets with pagination (5 per page)
|
||||
const pageSize = 5;
|
||||
const currentPage = Number.parseInt(searchParams.page ?? "1");
|
||||
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
|
||||
|
||||
// Fetch EVM wallets
|
||||
const eoas =
|
||||
vaultClient && managementAccessToken
|
||||
? await listEoas({
|
||||
@@ -84,7 +91,32 @@ export default async function Page(props: {
|
||||
})
|
||||
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
|
||||
|
||||
const serverWallets = eoas.data?.items as Wallet[] | undefined;
|
||||
// Fetch Solana wallets
|
||||
let solanaAccounts: {
|
||||
data: { items: SolanaWallet[]; totalRecords: number };
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
if (managementAccessToken) {
|
||||
solanaAccounts = await listSolanaAccounts({
|
||||
managementAccessToken,
|
||||
page: solanCurrentPage,
|
||||
limit: pageSize,
|
||||
projectId: project.id,
|
||||
});
|
||||
} else {
|
||||
solanaAccounts = {
|
||||
data: { items: [], totalRecords: 0 },
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for Solana permission errors
|
||||
const isSolanaPermissionError = solanaAccounts.error?.message?.includes(
|
||||
"AUTH_INSUFFICIENT_SCOPE",
|
||||
);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
@@ -143,18 +175,34 @@ export default async function Page(props: {
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
{/* Server Wallets Section */}
|
||||
{/* Server Wallets Section (EVM + Solana) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{eoas.error ? null : (
|
||||
{eoas.error ? (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{eoas.error.message || "Failed to load EVM wallets"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
currentPage={currentPage}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
evmCurrentPage={currentPage}
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={eoas.data.items as Wallet[]}
|
||||
project={project}
|
||||
solanaCurrentPage={solanCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
solanaAccounts.data.totalRecords / pageSize,
|
||||
)}
|
||||
solanaTotalRecords={solanaAccounts.data.totalRecords}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
totalRecords={eoas.data.totalRecords}
|
||||
wallets={serverWallets ?? []}
|
||||
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();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user