Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e830109274 | ||
|
|
e7f42e6ac5 | ||
|
|
91ff13b8aa | ||
|
|
47354d5a19 | ||
|
|
8280efb991 | ||
|
|
5f11ae0cd4 | ||
|
|
c9a0c23c6c | ||
|
|
43b0680124 | ||
|
|
cccbde9a2e | ||
|
|
85a6afc377 | ||
|
|
e74aca9c90 | ||
|
|
b0cac940bd | ||
|
|
1a19e14845 | ||
|
|
464af6a54c | ||
|
|
f3c55d7139 | ||
|
|
9545b43675 | ||
|
|
869bbcfa68 | ||
|
|
35c7d21e36 | ||
|
|
edf3aad8c4 | ||
|
|
ab4190e23a | ||
|
|
9086cdba20 | ||
|
|
0082e996ca | ||
|
|
28955e0ac5 | ||
|
|
05550d045f | ||
|
|
a0c45cb25d | ||
|
|
6fc4d838ff | ||
|
|
fe940b3c66 | ||
|
|
080d7f2122 | ||
|
|
ae75073a4a | ||
|
|
781a626461 | ||
|
|
f630912b9b | ||
|
|
163ab8afe1 | ||
|
|
62cfbb7441 | ||
|
|
d7104f69f4 | ||
|
|
23545cb999 | ||
|
|
f9283f389b | ||
|
|
9fbb29dc25 | ||
|
|
71a1e58eaf | ||
|
|
68b48f4faa | ||
|
|
1c660d4712 | ||
|
|
30b5c30ba0 | ||
|
|
86461f9a07 | ||
|
|
2ebba6139a | ||
|
|
f3bacc16cb | ||
|
|
968913d55b | ||
|
|
bd70db4a5d | ||
|
|
d9a138a90a | ||
|
|
777aed1e5f | ||
|
|
fbed4be959 | ||
|
|
5a26217a43 | ||
|
|
938c1e2f60 | ||
|
|
af9a73d930 | ||
|
|
34c5d24043 | ||
|
|
e612f16a85 | ||
|
|
4a30b9c89a | ||
|
|
ef9cf3409b | ||
|
|
fd20a6cabb | ||
|
|
9095041ea7 | ||
|
|
2dbb9e2de4 | ||
|
|
00b8adf3a2 | ||
|
|
ae3dfa36c3 | ||
|
|
eb603f8908 | ||
|
|
e11b0da20e | ||
|
|
4c51fe6a8f | ||
|
|
64034c0ddb | ||
|
|
90c1e576ff | ||
|
|
27d5a81c49 | ||
|
|
dbadc23e04 | ||
|
|
21d5d0b6af | ||
|
|
b2239c91c9 | ||
|
|
ca701a48c2 | ||
|
|
94aae902a1 | ||
|
|
0c7149eb44 | ||
|
|
ce9220ea00 | ||
|
|
99c2af5e25 | ||
|
|
7495f46222 | ||
|
|
b8afa98de6 | ||
|
|
496615feae | ||
|
|
e2931df858 | ||
|
|
85bde21dc9 | ||
|
|
2c84ac4089 | ||
|
|
5ee449fc9c | ||
|
|
a94e9c2396 | ||
|
|
64f5e474bb | ||
|
|
aa0d23e966 | ||
|
|
b888413ca6 | ||
|
|
108a79ab3e | ||
|
|
51390f3af0 | ||
|
|
fb8c95a325 | ||
|
|
cf02cbf3af | ||
|
|
f152139d73 | ||
|
|
16307019be | ||
|
|
10fb338877 | ||
|
|
95412c75b9 | ||
|
|
7bc513c770 | ||
|
|
9069351e1a | ||
|
|
8f6363f4e4 | ||
|
|
a83104efff | ||
|
|
7936ebb6e9 | ||
|
|
3b986d52e5 | ||
|
|
ee4e13e098 | ||
|
|
8dc9e4102d | ||
|
|
33fc3e5226 | ||
|
|
ed47e93f1d | ||
|
|
1f7cda6698 | ||
|
|
c26fc676c9 | ||
|
|
e1cccd7a10 | ||
|
|
3646964f2e | ||
|
|
db145b5621 |
@@ -2,7 +2,7 @@ name: Auto Author Assign
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review, draft]
|
||||
types: [opened, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = [
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/payments",
|
||||
"/payments/x402",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
|
||||
interface VaultWalletListItem {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata?: {
|
||||
label?: string;
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
type?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function listProjectServerWallets(params: {
|
||||
managementAccessToken: string;
|
||||
projectId: string;
|
||||
pageSize?: number;
|
||||
}): Promise<ProjectWalletSummary[]> {
|
||||
const { managementAccessToken, projectId, pageSize = 100 } = params;
|
||||
|
||||
if (!managementAccessToken || !NEXT_PUBLIC_THIRDWEB_VAULT_URL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: 0,
|
||||
// @ts-expect-error - Vault SDK expects snake_case pagination fields
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data?.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = response.data.items as VaultWalletListItem[];
|
||||
|
||||
return items
|
||||
.filter((item) => {
|
||||
return (
|
||||
item.metadata?.projectId === projectId &&
|
||||
(!item.metadata?.type || item.metadata.type === "server-wallet")
|
||||
);
|
||||
})
|
||||
.map<ProjectWalletSummary>((item) => ({
|
||||
id: item.id,
|
||||
address: item.address,
|
||||
label: item.metadata?.label ?? undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to list project server wallets", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use server";
|
||||
|
||||
import { configure, sendTokens } from "@thirdweb-dev/api";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
configure({
|
||||
override: {
|
||||
baseUrl: THIRDWEB_API_HOST,
|
||||
},
|
||||
});
|
||||
|
||||
export async function sendProjectWalletTokens(options: {
|
||||
walletAddress: string;
|
||||
recipientAddress: string;
|
||||
chainId: number;
|
||||
quantityWei: string;
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
tokenAddress?: string;
|
||||
secretKey: string;
|
||||
vaultAccessToken?: string;
|
||||
}) {
|
||||
const {
|
||||
walletAddress,
|
||||
recipientAddress,
|
||||
chainId,
|
||||
quantityWei,
|
||||
publishableKey,
|
||||
teamId,
|
||||
tokenAddress,
|
||||
secretKey,
|
||||
vaultAccessToken,
|
||||
} = options;
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
error: "A project secret key is required to send funds.",
|
||||
ok: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const response = await sendTokens({
|
||||
body: {
|
||||
chainId,
|
||||
from: walletAddress,
|
||||
recipients: [
|
||||
{
|
||||
address: recipientAddress,
|
||||
quantity: quantityWei,
|
||||
},
|
||||
],
|
||||
...(tokenAddress ? { tokenAddress } : {}),
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": publishableKey,
|
||||
"x-secret-key": secretKey,
|
||||
"x-team-id": teamId,
|
||||
...(vaultAccessToken ? { "x-vault-access-token": vaultAccessToken } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
return {
|
||||
error: response.error || "Failed to submit transfer request.",
|
||||
ok: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
transactionIds: response.data.result?.transactionIds ?? [],
|
||||
} as const;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import {
|
||||
@@ -88,6 +88,9 @@ export async function fetchClientSecret(team: Team) {
|
||||
throw new Error("No customer ID found");
|
||||
}
|
||||
|
||||
// try to get the gclid cookie
|
||||
const gclid = (await cookies()).get("gclid")?.value;
|
||||
|
||||
// Create Checkout Sessions from body params.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
ui_mode: "embedded",
|
||||
@@ -117,6 +120,8 @@ export async function fetchClientSecret(team: Team) {
|
||||
missing_payment_method: "cancel",
|
||||
},
|
||||
},
|
||||
// if gclid exists, set it as a metadata field so we can attribute the conversion later
|
||||
metadata: gclid ? { gclid } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export function reportContractDeployed(properties: {
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
deploymentType?: "asset";
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
@@ -40,6 +41,7 @@ export function reportContractDeployed(properties: {
|
||||
export function reportContractDeployFailed(properties: {
|
||||
errorMessage: string;
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
}) {
|
||||
@@ -171,9 +173,7 @@ export function reportOnboardingCompleted() {
|
||||
*
|
||||
*/
|
||||
export function reportFaucetUsed(properties: { chainId: number }) {
|
||||
posthog.capture("faucet used", {
|
||||
chainId: properties.chainId,
|
||||
});
|
||||
posthog.capture("faucet used", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -197,12 +197,7 @@ export function reportChainConfigurationAdded(properties: {
|
||||
decimals: number;
|
||||
};
|
||||
}) {
|
||||
posthog.capture("chain configuration added", {
|
||||
chainId: properties.chainId,
|
||||
chainName: properties.chainName,
|
||||
nativeCurrency: properties.nativeCurrency,
|
||||
rpcURLs: properties.rpcURLs,
|
||||
});
|
||||
posthog.capture("chain configuration added", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -227,12 +222,9 @@ export function reportAssetBuySuccessful(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset buy successful", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset buy successful", properties);
|
||||
}
|
||||
|
||||
type TokenSwapParams = {
|
||||
@@ -349,16 +341,12 @@ export function reportTokenSwapCancelled(properties: TokenSwapParams) {
|
||||
*/
|
||||
export function reportAssetBuyFailed(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
error: string;
|
||||
}) {
|
||||
posthog.capture("asset buy failed", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
});
|
||||
posthog.capture("asset buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,14 +359,11 @@ export function reportAssetBuyFailed(properties: {
|
||||
*/
|
||||
export function reportAssetBuyCancelled(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
contractType: AssetContractType | undefined;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy cancelled", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset buy cancelled", properties);
|
||||
}
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
@@ -423,10 +408,7 @@ export function reportAssetCreationStepConfigured(
|
||||
step: "coin-info" | "token-distribution" | "launch-coin";
|
||||
},
|
||||
) {
|
||||
posthog.capture("asset creation step configured", {
|
||||
assetType: properties.assetType,
|
||||
step: properties.step,
|
||||
});
|
||||
posthog.capture("asset creation step configured", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,11 +422,10 @@ export function reportAssetCreationStepConfigured(
|
||||
export function reportAssetCreationSuccessful(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
contractType: AssetContractType;
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset creation successful", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
posthog.capture("asset creation successful", properties);
|
||||
}
|
||||
|
||||
type CoinCreationStep =
|
||||
@@ -466,7 +447,12 @@ type CoinCreationStep =
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
properties: {
|
||||
contractType: AssetContractType;
|
||||
error: string;
|
||||
is_testnet: boolean | undefined;
|
||||
chainId: number;
|
||||
} & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step:
|
||||
@@ -481,12 +467,7 @@ export function reportAssetCreationFailed(
|
||||
}
|
||||
),
|
||||
) {
|
||||
posthog.capture("asset creation failed", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
step: properties.step,
|
||||
});
|
||||
posthog.capture("asset creation failed", properties);
|
||||
}
|
||||
|
||||
type UpsellParams = {
|
||||
@@ -582,6 +563,7 @@ export function reportPaymentLinkBuyFailed(properties: {
|
||||
export function reportAssetPageview(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("asset pageview", properties);
|
||||
}
|
||||
@@ -593,7 +575,10 @@ export function reportAssetPageview(properties: {
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportChainPageview(properties: { chainId: number }) {
|
||||
export function reportChainPageview(properties: {
|
||||
chainId: number;
|
||||
is_testnet: boolean | undefined;
|
||||
}) {
|
||||
posthog.capture("chain pageview", properties);
|
||||
}
|
||||
|
||||
@@ -686,8 +671,19 @@ export function reportProductFeedback(properties: {
|
||||
feedback: string;
|
||||
source: "desktop" | "mobile";
|
||||
}) {
|
||||
posthog.capture("product feedback submitted", {
|
||||
feedback: properties.feedback,
|
||||
source: properties.source,
|
||||
});
|
||||
posthog.capture("product feedback submitted", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track conversions for the bridge page links
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*
|
||||
*/
|
||||
export function reportBridgePageLinkClick(params: {
|
||||
linkType: "integrate-bridge" | "trending-tokens";
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ type ActiveStatus = {
|
||||
pay: boolean;
|
||||
inAppWallet: boolean;
|
||||
ecosystemWallet: boolean;
|
||||
engineCloud: boolean;
|
||||
};
|
||||
|
||||
export const isProjectActive = unstable_cache(
|
||||
@@ -479,6 +480,7 @@ export const isProjectActive = unstable_cache(
|
||||
pay: false,
|
||||
rpc: false,
|
||||
sdk: false,
|
||||
engineCloud: false,
|
||||
storage: false,
|
||||
} as ActiveStatus;
|
||||
}
|
||||
@@ -488,7 +490,7 @@ export const isProjectActive = unstable_cache(
|
||||
},
|
||||
["isProjectActive"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
revalidate: 30, // 30 seconds
|
||||
},
|
||||
);
|
||||
|
||||
@@ -592,7 +594,7 @@ const cached_getUniversalBridgeUsage = unstable_cache(
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
`Failed to fetch bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -636,7 +638,7 @@ const cached_getUniversalBridgeWalletUsage = unstable_cache(
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch universal bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
`Failed to fetch bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use server";
|
||||
import type { Address } from "thirdweb";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
|
||||
const UB_BASE_URL = NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
|
||||
|
||||
type Webhook = {
|
||||
export type Webhook = {
|
||||
url: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
secret: string;
|
||||
version?: number; // TODO (UB) make this mandatory after migration
|
||||
};
|
||||
|
||||
export async function getWebhooks(props: { clientId: string; teamId: string }) {
|
||||
const authToken = await getAuthToken();
|
||||
export async function getWebhooks(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -36,28 +36,55 @@ export async function getWebhooks(props: { clientId: string; teamId: string }) {
|
||||
return json.data as Array<Webhook>;
|
||||
}
|
||||
|
||||
export async function createWebhook(props: {
|
||||
export async function getWebhookById(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
webhookId: string;
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as Webhook;
|
||||
}
|
||||
|
||||
export async function createWebhook(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
version?: number;
|
||||
url: string;
|
||||
label: string;
|
||||
secret?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
body: JSON.stringify({
|
||||
label: props.label,
|
||||
secret: props.secret,
|
||||
url: props.url,
|
||||
version: props.version,
|
||||
label: params.label,
|
||||
secret: params.secret,
|
||||
url: params.url,
|
||||
version: params.version,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -67,23 +94,23 @@ export async function createWebhook(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return (await res.json()) as Webhook;
|
||||
}
|
||||
|
||||
export async function deleteWebhook(props: {
|
||||
export async function deleteWebhook(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${props.webhookId}`,
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
@@ -94,7 +121,151 @@ export async function deleteWebhook(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateWebhook(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
body: {
|
||||
version?: number;
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${encodeURIComponent(params.webhookId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params.body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as Webhook;
|
||||
}
|
||||
|
||||
export type WebhookSend = {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
webhookUrl: string;
|
||||
createdAt: string;
|
||||
onrampId: string | null;
|
||||
paymentId: string | null;
|
||||
response: string | null;
|
||||
responseStatus: number;
|
||||
status: "PENDING" | "COMPLETED" | "FAILED";
|
||||
success: boolean;
|
||||
transactionId: string | null;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
type WebhookSendsResponse = {
|
||||
data: WebhookSend[];
|
||||
pagination: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getWebhookSends(options: {
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
webhookId: string;
|
||||
success?: boolean;
|
||||
}): Promise<WebhookSendsResponse> {
|
||||
const { limit, offset, success, webhookId, authToken } = options;
|
||||
|
||||
const url = new URL(
|
||||
`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhook-sends`,
|
||||
);
|
||||
|
||||
url.searchParams.set("webhookId", webhookId);
|
||||
|
||||
if (limit !== undefined) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (success !== undefined) {
|
||||
url.searchParams.set("success", success.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-client-id": options.projectClientId,
|
||||
"x-team-id": options.teamId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorJson = await response.json();
|
||||
throw new Error(errorJson.message);
|
||||
}
|
||||
|
||||
return (await response.json()) as WebhookSendsResponse;
|
||||
}
|
||||
|
||||
export async function resendWebhook(
|
||||
params: {
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
} & (
|
||||
| {
|
||||
paymentId: string;
|
||||
}
|
||||
| {
|
||||
onrampId: string;
|
||||
}
|
||||
),
|
||||
) {
|
||||
const url = new URL(
|
||||
`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/developer/webhooks/retry`,
|
||||
);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
"paymentId" in params
|
||||
? { paymentId: params.paymentId }
|
||||
: { onrampId: params.onrampId },
|
||||
),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": params.projectClientId,
|
||||
"x-team-id": params.teamId,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorJson = await response.json();
|
||||
throw new Error(errorJson.message);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type PaymentLink = {
|
||||
@@ -116,17 +287,17 @@ type PaymentLink = {
|
||||
amount: bigint;
|
||||
};
|
||||
|
||||
export async function getPaymentLinks(props: {
|
||||
export async function getPaymentLinks(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}): Promise<Array<PaymentLink>> {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -159,7 +330,7 @@ export async function getPaymentLinks(props: {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPaymentLink(props: {
|
||||
export async function createPaymentLink(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
@@ -171,26 +342,25 @@ export async function createPaymentLink(props: {
|
||||
amount: bigint;
|
||||
purchaseData?: unknown;
|
||||
};
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
|
||||
body: JSON.stringify({
|
||||
title: props.title,
|
||||
imageUrl: props.imageUrl,
|
||||
title: params.title,
|
||||
imageUrl: params.imageUrl,
|
||||
intent: {
|
||||
destinationChainId: props.intent.destinationChainId,
|
||||
destinationTokenAddress: props.intent.destinationTokenAddress,
|
||||
receiver: props.intent.receiver,
|
||||
amount: props.intent.amount.toString(),
|
||||
purchaseData: props.intent.purchaseData,
|
||||
destinationChainId: params.intent.destinationChainId,
|
||||
destinationTokenAddress: params.intent.destinationTokenAddress,
|
||||
receiver: params.intent.receiver,
|
||||
amount: params.intent.amount.toString(),
|
||||
purchaseData: params.intent.purchaseData,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -207,20 +377,20 @@ export async function createPaymentLink(props: {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deletePaymentLink(props: {
|
||||
export async function deletePaymentLink(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
paymentLinkId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`,
|
||||
`${UB_BASE_URL}/v1/developer/links/${encodeURIComponent(params.paymentLinkId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
@@ -231,7 +401,7 @@ export async function deletePaymentLink(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type Fee = {
|
||||
@@ -241,14 +411,17 @@ export type Fee = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function getFees(props: { clientId: string; teamId: string }) {
|
||||
const authToken = await getAuthToken();
|
||||
export async function getFees(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -262,23 +435,23 @@ export async function getFees(props: { clientId: string; teamId: string }) {
|
||||
return json.data as Fee;
|
||||
}
|
||||
|
||||
export async function updateFee(props: {
|
||||
export async function updateFee(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
feeRecipient: string;
|
||||
feeBps: number;
|
||||
authToken: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
|
||||
body: JSON.stringify({
|
||||
feeBps: props.feeBps,
|
||||
feeRecipient: props.feeRecipient,
|
||||
feeBps: params.feeBps,
|
||||
feeRecipient: params.feeRecipient,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
@@ -288,7 +461,7 @@ export async function updateFee(props: {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type PaymentsResponse = {
|
||||
@@ -345,36 +518,34 @@ export type Payment = {
|
||||
|
||||
export type BridgePayment = Extract<Payment, { type: BridgePaymentType }>;
|
||||
|
||||
export async function getPayments(props: {
|
||||
export async function getPayments(params: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
paymentLinkId?: string;
|
||||
limit?: number;
|
||||
authToken: string;
|
||||
offset?: number;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
// Build URL with query parameters if provided
|
||||
const url = new URL(`${UB_BASE_URL}/v1/developer/payments`);
|
||||
|
||||
if (props.limit) {
|
||||
url.searchParams.append("limit", props.limit.toString());
|
||||
if (params.limit) {
|
||||
url.searchParams.append("limit", params.limit.toString());
|
||||
}
|
||||
|
||||
if (props.offset) {
|
||||
url.searchParams.append("offset", props.offset.toString());
|
||||
if (params.offset) {
|
||||
url.searchParams.append("offset", params.offset.toString());
|
||||
}
|
||||
|
||||
if (props.paymentLinkId) {
|
||||
url.searchParams.append("paymentLinkId", props.paymentLinkId);
|
||||
if (params.paymentLinkId) {
|
||||
url.searchParams.append("paymentLinkId", params.paymentLinkId);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": params.clientId,
|
||||
"x-team-id": params.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
tokenAddress: string | undefined;
|
||||
buyAmount: string | undefined;
|
||||
pageType: PageType;
|
||||
isTestnet: boolean | undefined;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
@@ -67,7 +68,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
}, [props.pageType]);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl">
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl min-w-0">
|
||||
<div className="flex gap-2.5 p-4 border-b border-dashed">
|
||||
<TabButton
|
||||
label="Swap"
|
||||
@@ -85,7 +86,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
<BuyWidget
|
||||
amount={props.buyAmount || "1"}
|
||||
chain={props.chain}
|
||||
className="!rounded-2xl !w-full !border-none"
|
||||
className="!rounded-2xl !border-none"
|
||||
title=""
|
||||
client={client}
|
||||
connectOptions={{
|
||||
@@ -116,6 +117,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
chainId: props.chain.id,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -141,6 +143,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -166,6 +169,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: undefined,
|
||||
is_testnet: props.isTestnet,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -179,7 +183,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
<SwapWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none !w-full"
|
||||
className="!rounded-2xl !border-none"
|
||||
prefill={{
|
||||
// buy this token by default
|
||||
buyToken: {
|
||||
@@ -236,9 +240,10 @@ function TabButton(props: {
|
||||
return (
|
||||
<Button
|
||||
onClick={props.onClick}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-full text-muted-foreground px-5 text-base bg-accent",
|
||||
props.isActive && "text-foreground border-foreground",
|
||||
"rounded-full text-muted-foreground px-4 bg-accent",
|
||||
props.isActive && "text-foreground border-foreground/50",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function ToggleThemeButton() {
|
||||
export function ToggleThemeButton(props: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
@@ -15,7 +16,10 @@ export function ToggleThemeButton() {
|
||||
>
|
||||
<Button
|
||||
aria-label="Toggle theme"
|
||||
className="h-auto w-auto rounded-full border border-border bg-background p-2"
|
||||
className={cn(
|
||||
"h-auto w-auto rounded-full border border-border bg-background p-2",
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FaqSection(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>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqItem(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpenn] = useState(false);
|
||||
const contentId = useId();
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<div className={cn("border-b border-dashed", props.className)}>
|
||||
<h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpenn(!isOpen)}
|
||||
aria-controls={contentId}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
"w-full justify-between h-auto py-5 text-base text-muted-foreground hover:bg-transparent pl-0 text-wrap text-left gap-6",
|
||||
isOpen && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{props.title}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</h3>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-base leading-7 pb-6 max-w-4xl",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
id={contentId}
|
||||
>
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export function GridPatternEmbedContainer(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-xl overflow-hidden relative">
|
||||
<div className="flex justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 sm:rounded-xl sm:overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
@@ -17,7 +17,7 @@ export function GridPatternEmbedContainer(props: {
|
||||
"linear-gradient(to bottom right,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="sm:w-[420px] z-10">{props.children}</div>
|
||||
<div className="z-10 min-w-0">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function AdvancedSearchInput(props: {
|
||||
value={searchType}
|
||||
onValueChange={(value) => setSearchType(value as SearchType)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] bg-card">
|
||||
<SelectTrigger className="w-[140px] bg-background rounded-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -61,7 +61,7 @@ export function AdvancedSearchInput(props: {
|
||||
<div className="flex flex-1">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="bg-card pl-9 border-r-0 rounded-r-none"
|
||||
className="bg-background pl-9 border-r-0 rounded-r-none rounded-l-full"
|
||||
placeholder={`Search by ${searchType}...`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -86,7 +86,7 @@ export function AdvancedSearchInput(props: {
|
||||
onClick={handleSearch}
|
||||
variant="outline"
|
||||
disabled={!query.trim() || props.isLoading}
|
||||
className="rounded-l-none gap-2 bg-card disabled:opacity-100"
|
||||
className="rounded-l-none rounded-r-full gap-2 bg-background disabled:opacity-100"
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-4" />}
|
||||
Search
|
||||
|
||||
+22
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -264,11 +264,24 @@ export function InAppWalletUsersPageContent(
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col md:flex-row lg:items-center justify-end gap-3">
|
||||
<div className="w-full max-w-lg">
|
||||
<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">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
@@ -277,7 +290,7 @@ export function InAppWalletUsersPageContent(
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-card"
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
@@ -298,11 +311,11 @@ export function InAppWalletUsersPageContent(
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-b-none"
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="in-app wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
|
||||
function AnnouncementBannerUI(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
|
||||
@@ -64,7 +64,7 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
const vaultTokens = await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
@@ -74,6 +74,13 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const managementAccessToken = vaultTokens.managementToken?.accessToken;
|
||||
|
||||
if (!managementAccessToken) {
|
||||
throw new Error("Missing management access token for project wallet");
|
||||
}
|
||||
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -119,12 +119,17 @@ export function useCsvUpload<
|
||||
}
|
||||
Papa.parse(csv, {
|
||||
complete: (results) => {
|
||||
const data = props.csvParser(results.data as T[]);
|
||||
if (!data[0]?.address) {
|
||||
try {
|
||||
const data = props.csvParser(results.data as T[]);
|
||||
if (!data[0]?.address) {
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
setRawData(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
setRawData(data);
|
||||
},
|
||||
header: true,
|
||||
});
|
||||
|
||||
@@ -379,12 +379,27 @@ export function useEngineTransactions(params: {
|
||||
page?: number;
|
||||
status?: EngineStatus;
|
||||
};
|
||||
id?: string;
|
||||
}) {
|
||||
const { instanceUrl, autoUpdate, authToken } = params;
|
||||
const { instanceUrl, autoUpdate, authToken, id } = params;
|
||||
|
||||
return useQuery({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
if (id) {
|
||||
const res = await fetch(`${instanceUrl}transaction/status/${id}`, {
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
const transaction = (json.result as Transaction) || {};
|
||||
return {
|
||||
transactions: [transaction],
|
||||
totalCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${instanceUrl}transaction/get-all`);
|
||||
if (params.queryParams) {
|
||||
for (const key in params.queryParams) {
|
||||
@@ -402,7 +417,6 @@ export function useEngineTransactions(params: {
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return (json.result as TransactionResponse) || {};
|
||||
},
|
||||
queryKey: engineKeys.transactions(instanceUrl, params),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const PROJECT_WALLET_LABEL_SUFFIX = " Wallet";
|
||||
const PROJECT_WALLET_LABEL_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Builds the default label for a project's primary server wallet.
|
||||
* Ensures a stable naming convention so the wallet can be identified across flows.
|
||||
*/
|
||||
export function getProjectWalletLabel(projectName: string | undefined) {
|
||||
const baseName = projectName?.trim() || "Project";
|
||||
const maxBaseLength = Math.max(
|
||||
1,
|
||||
PROJECT_WALLET_LABEL_MAX_LENGTH - PROJECT_WALLET_LABEL_SUFFIX.length,
|
||||
);
|
||||
|
||||
const normalizedBase =
|
||||
baseName.length > maxBaseLength
|
||||
? baseName.slice(0, maxBaseLength).trimEnd()
|
||||
: baseName;
|
||||
|
||||
return `${normalizedBase}${PROJECT_WALLET_LABEL_SUFFIX}`;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import "server-only";
|
||||
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
|
||||
export type ProjectWalletSummary = {
|
||||
id: string;
|
||||
address: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type VaultWalletListItem = {
|
||||
id: string;
|
||||
address: string;
|
||||
metadata?: {
|
||||
label?: string;
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getProjectWallet(
|
||||
project: Project,
|
||||
): Promise<ProjectWalletSummary | undefined> {
|
||||
const engineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
engineCloudService?.managementAccessToken || undefined;
|
||||
const projectWalletAddress = (
|
||||
engineCloudService as { projectWalletAddress?: string } | undefined
|
||||
)?.projectWalletAddress;
|
||||
|
||||
if (
|
||||
!managementAccessToken ||
|
||||
!NEXT_PUBLIC_THIRDWEB_VAULT_URL ||
|
||||
!projectWalletAddress
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultClient = await createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
});
|
||||
|
||||
const response = await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: 0,
|
||||
// @ts-expect-error - SDK expects snake_case for pagination arguments
|
||||
page_size: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items = response.data.items as VaultWalletListItem[] | undefined;
|
||||
|
||||
if (!items?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const defaultLabel = getProjectWalletLabel(project.name);
|
||||
|
||||
const serverWallets = items.filter(
|
||||
(item) => item.metadata?.projectId === project.id,
|
||||
);
|
||||
|
||||
const defaultWallet = serverWallets.find(
|
||||
(item) =>
|
||||
item.address.toLowerCase() === projectWalletAddress.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!defaultWallet) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: defaultWallet.id,
|
||||
address: defaultWallet.address,
|
||||
label: defaultWallet.metadata?.label ?? defaultLabel,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load project wallet", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
type ChainSeo = {
|
||||
title: string;
|
||||
description: string;
|
||||
og: {
|
||||
title: string;
|
||||
description: string;
|
||||
site_name: string;
|
||||
url: string;
|
||||
};
|
||||
faqs: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
chain: {
|
||||
chainId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
testnet: boolean;
|
||||
is_deprecated: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchChainSeo = unstable_cache(
|
||||
async (chainId: number) => {
|
||||
const url = new URL(
|
||||
`https://seo-pages-generator-5814.zeet-nftlabs.zeet.app/chain/${chainId}`,
|
||||
);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return res.json() as Promise<ChainSeo>;
|
||||
},
|
||||
["chain-seo"],
|
||||
{ revalidate: 60 * 60 * 24 }, // 24 hours
|
||||
);
|
||||
+1
@@ -8,6 +8,7 @@ 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}
|
||||
|
||||
+2
-1
@@ -2,10 +2,11 @@
|
||||
import { reportChainPageview } from "@/analytics/report";
|
||||
import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
|
||||
export function ChainPageView(props: { chainId: number }) {
|
||||
export function ChainPageView(props: { chainId: number; is_testnet: boolean }) {
|
||||
useEffectOnce(() => {
|
||||
reportChainPageview({
|
||||
chainId: props.chainId,
|
||||
is_testnet: props.is_testnet,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function ChainOverviewSection(props: { chain: ChainMetadata }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Chain Overview" />
|
||||
<div className="grid grid-cols-1 gap-6 rounded-lg border bg-card p-4 md:grid-cols-2 md:p-6 lg:grid-cols-3 lg:gap-8">
|
||||
<div className="grid grid-cols-1 gap-6 rounded-xl border bg-card p-4 md:grid-cols-2 md:p-6 lg:grid-cols-3 lg:gap-6">
|
||||
{/* Info */}
|
||||
{chain.infoURL && (
|
||||
<PrimaryInfoItem title="Info">
|
||||
|
||||
+8
-6
@@ -1,4 +1,3 @@
|
||||
import { CircleCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadataWithServices } from "@/types/chain";
|
||||
import { products } from "../../../../components/server/products";
|
||||
@@ -24,15 +23,18 @@ export function SupportedProductsSection(props: {
|
||||
{enabledProducts.map((product) => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border"
|
||||
className="relative rounded-xl border bg-card p-4 hover:border-active-border"
|
||||
key={product.id}
|
||||
>
|
||||
<CircleCheckIcon className="absolute top-4 right-4 size-5 text-success-text" />
|
||||
<product.icon className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="flex mb-4">
|
||||
<div className="p-2 rounded-full border bg-background">
|
||||
<product.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-1.5 font-medium">
|
||||
<h3 className="mb-1">
|
||||
<Link
|
||||
className="before:absolute before:inset-0"
|
||||
className="before:absolute before:inset-0 text-base font-medium"
|
||||
href={product.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export function ExplorersSection(props: {
|
||||
key={explorer.url}
|
||||
>
|
||||
<ExternalLinkIcon className="absolute top-4 right-4 size-4 text-muted-foreground" />
|
||||
<h3 className="mb-1 font-semibold text-base capitalize">
|
||||
<h3 className="mb-1 font-medium text-base capitalize">
|
||||
{explorer.name}
|
||||
</h3>
|
||||
<Link
|
||||
|
||||
+1
-3
@@ -6,9 +6,7 @@ export function PrimaryInfoItem(props: {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-base text-muted-foreground">
|
||||
{props.title}
|
||||
</h3>
|
||||
<h3 className="text-base text-muted-foreground">{props.title}</h3>
|
||||
{props.titleIcon}
|
||||
</div>
|
||||
{props.children}
|
||||
|
||||
+37
-23
@@ -22,39 +22,51 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
|
||||
import { StarButton } from "../../components/client/star-button";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { getChain, getCustomChainMetadata } from "../../utils";
|
||||
import { fetchChainSeo } from "./apis/chain-seo";
|
||||
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
|
||||
import { ChainPageView } from "./components/client/chain-pageview";
|
||||
import { ChainHeader } from "./components/server/chain-header";
|
||||
|
||||
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
|
||||
type Params = Promise<{ chain_id: string }>;
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
params: Params;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
|
||||
const title = `${sanitizedChainName}: RPC and Chain Settings`;
|
||||
const chainSeo = await fetchChainSeo(Number(chain.chainId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const description = `Use the best ${sanitizedChainName} RPC and add to your wallet. Discover the chain ID, native token, explorers, and ${
|
||||
chain.testnet && chain.faucets?.length ? "faucet options" : "more"
|
||||
}.`;
|
||||
if (!chainSeo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
title: chainSeo.title,
|
||||
description: chainSeo.description,
|
||||
metadataBase: new URL("https://thirdweb.com"),
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
title: chainSeo.og.title,
|
||||
description: chainSeo.og.description,
|
||||
siteName: "thirdweb",
|
||||
type: "website",
|
||||
url: "https://thirdweb.com",
|
||||
},
|
||||
twitter: {
|
||||
title: chainSeo.og.title,
|
||||
description: chainSeo.og.description,
|
||||
card: "summary_large_image",
|
||||
creator: "@thirdweb",
|
||||
site: "@thirdweb",
|
||||
},
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
// this is the dashboard layout file
|
||||
export default async function ChainPageLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ chain_id: string }>;
|
||||
params: Params;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
@@ -67,12 +79,14 @@ export default async function ChainPageLayout(props: {
|
||||
redirect(chain.slug);
|
||||
}
|
||||
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const customChainMetadata = getCustomChainMetadata(chain.chainId);
|
||||
const chainSeo = await fetchChainSeo(chain.chainId);
|
||||
const client = getClientThirdwebClient(undefined);
|
||||
const description = customChainMetadata?.about || chainSeo?.description;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<ChainPageView chainId={chain.chainId} />
|
||||
<ChainPageView chainId={chain.chainId} is_testnet={chain.testnet} />
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
@@ -123,7 +137,7 @@ export default async function ChainPageLayout(props: {
|
||||
<ChainHeader
|
||||
chain={chain}
|
||||
client={client}
|
||||
headerImageUrl={chainMetadata?.headerImgUrl}
|
||||
headerImageUrl={customChainMetadata?.headerImgUrl}
|
||||
logoUrl={chain.icon?.url}
|
||||
/>
|
||||
|
||||
@@ -131,7 +145,7 @@ export default async function ChainPageLayout(props: {
|
||||
|
||||
<div className="flex flex-col gap-3 md:gap-2">
|
||||
{/* Gas Sponsored badge - Mobile */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
{customChainMetadata?.gasSponsored && (
|
||||
<div className="flex md:hidden">
|
||||
<GasSponsoredBadge />
|
||||
</div>
|
||||
@@ -153,7 +167,7 @@ export default async function ChainPageLayout(props: {
|
||||
)}
|
||||
|
||||
{/* Gas Sponsored badge - Desktop */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
{customChainMetadata?.gasSponsored && (
|
||||
<div className="hidden md:block">
|
||||
<GasSponsoredBadge />
|
||||
</div>
|
||||
@@ -161,9 +175,9 @@ export default async function ChainPageLayout(props: {
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
{chainMetadata?.about && (
|
||||
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base">
|
||||
{chainMetadata.about}
|
||||
{description && (
|
||||
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base text-pretty max-w-3xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { getRawAccount } from "@/api/account/get-account";
|
||||
import { FaqSection } from "@/components/blocks/faq-section";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import { getChain, getCustomChainMetadata } from "../../utils";
|
||||
import { fetchChainSeo } from "./apis/chain-seo";
|
||||
import { BuyFundsSection } from "./components/client/BuyFundsSection";
|
||||
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
|
||||
import { ChainCTA } from "./components/server/cta-card";
|
||||
@@ -9,22 +11,27 @@ import { ExplorersSection } from "./components/server/explorer-section";
|
||||
import { FaucetSection } from "./components/server/FaucetSection";
|
||||
import { SupportedProductsSection } from "./components/server/SupportedProductsSection";
|
||||
|
||||
export default async function Page(props: {
|
||||
type Props = {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
};
|
||||
|
||||
export default async function Page(props: Props) {
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const customChainMetadata = getCustomChainMetadata(Number(params.chain_id));
|
||||
const chainSeo = await fetchChainSeo(Number(chain.chainId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const client = getClientThirdwebClient();
|
||||
const isDeprecated = chain.status === "deprecated";
|
||||
|
||||
const account = await getRawAccount();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{/* Custom CTA */}
|
||||
{(chainMetadata?.cta?.title || chainMetadata?.cta?.description) && (
|
||||
<ChainCTA {...chainMetadata.cta} />
|
||||
{(customChainMetadata?.cta?.title ||
|
||||
customChainMetadata?.cta?.description) && (
|
||||
<ChainCTA {...customChainMetadata.cta} />
|
||||
)}
|
||||
|
||||
{/* Deprecated Alert */}
|
||||
@@ -58,6 +65,12 @@ export default async function Page(props: {
|
||||
{chain.services.filter((s) => s.enabled).length > 0 && (
|
||||
<SupportedProductsSection services={chain.services} />
|
||||
)}
|
||||
|
||||
{chainSeo?.faqs && chainSeo.faqs.length > 0 && (
|
||||
<div className="py-10">
|
||||
<FaqSection faqs={chainSeo.faqs} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+21
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import type { NFT, ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import { getNFT as getErc721NFT } from "thirdweb/extensions/erc721";
|
||||
import { getNFT as getErc1155NFT } from "thirdweb/extensions/erc1155";
|
||||
@@ -89,7 +89,7 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="flex h-[400px] items-center justify-center py-20">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
@@ -179,15 +179,26 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
<NFTDetailsTab client={contract.client} nft={nft} />
|
||||
)}
|
||||
|
||||
{tabs.map((tb) => {
|
||||
return (
|
||||
tb.title === tab && (
|
||||
<div className="w-full" key={tb.title}>
|
||||
{tb.children}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Spinner className="size-4" />
|
||||
<p className="text-sm text-muted-foreground"> Loading </p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tabs.map((tb) => {
|
||||
return (
|
||||
tb.title === tab && (
|
||||
<div className="w-full" key={tb.title}>
|
||||
{tb.children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ export function NFTGetAllTable({
|
||||
includeOwners: true,
|
||||
start: currentPage * pageSize,
|
||||
tokenByIndex,
|
||||
useIndexer: false, // TEMPORARY, TODO: Remove when this is fixed in insight
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+2
@@ -5,11 +5,13 @@ import { useEffectOnce } from "@/hooks/useEffectOnce";
|
||||
export function AssetPageView(props: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
is_testnet: boolean;
|
||||
}) {
|
||||
useEffectOnce(() => {
|
||||
reportAssetPageview({
|
||||
assetType: props.assetType,
|
||||
chainId: props.chainId,
|
||||
is_testnet: props.is_testnet,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
@@ -122,6 +122,7 @@ export function TokenDropClaim(props: {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
toast.error("Failed to approve spending", {
|
||||
@@ -160,6 +161,7 @@ export function TokenDropClaim(props: {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
toast.error("Failed to buy tokens", {
|
||||
@@ -172,6 +174,7 @@ export function TokenDropClaim(props: {
|
||||
assetType: "coin",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC20",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
setStepsUI({
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUnixTime, subDays } from "date-fns";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
|
||||
@@ -35,6 +36,11 @@ export function useTokenTransfers(params: {
|
||||
url.searchParams.set("page", params.page.toString());
|
||||
url.searchParams.set("limit", params.limit.toString());
|
||||
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
|
||||
const THIRTY_DAYS_AGO = subDays(new Date(), 30);
|
||||
url.searchParams.set(
|
||||
"block_timestamp_from",
|
||||
getUnixTime(THIRTY_DAYS_AGO).toString(),
|
||||
);
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
@@ -46,7 +52,12 @@ export function useTokenTransfers(params: {
|
||||
return data;
|
||||
},
|
||||
queryKey: ["token-transfers", params],
|
||||
refetchInterval: 5000,
|
||||
refetchInterval: (data) => {
|
||||
if (data?.state.error) {
|
||||
return false;
|
||||
}
|
||||
return 5000;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
|
||||
+6
-1
@@ -76,7 +76,11 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
|
||||
<AssetPageView
|
||||
assetType="coin"
|
||||
chainId={props.chainMetadata.chainId}
|
||||
is_testnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-7xl" />
|
||||
|
||||
<div className="border-b border-dashed">
|
||||
@@ -199,6 +203,7 @@ function BuyEmbed(props: {
|
||||
tokenAddress={props.clientContract.address}
|
||||
buyAmount={undefined}
|
||||
pageType="asset"
|
||||
isTestnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -18,7 +18,11 @@ export function NFTPublicPageLayout(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<AssetPageView assetType="nft" chainId={props.chainMetadata.chainId} />
|
||||
<AssetPageView
|
||||
assetType="nft"
|
||||
chainId={props.chainMetadata.chainId}
|
||||
is_testnet={props.chainMetadata.testnet}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-8xl" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-8xl">
|
||||
|
||||
+4
@@ -120,6 +120,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
console.error(errorMessage);
|
||||
@@ -147,6 +148,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
assetType: "nft",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
props.onSuccess?.();
|
||||
@@ -163,6 +165,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -180,6 +183,7 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC1155",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+4
@@ -77,6 +77,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -103,6 +104,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
assetType: "nft",
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
props.onSuccess?.();
|
||||
@@ -115,6 +117,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -131,6 +134,7 @@ export function BuyNFTDrop(props: BuyNFTDropProps) {
|
||||
chainId: props.contract.chain.id,
|
||||
contractType: "DropERC721",
|
||||
error: errorMessage,
|
||||
is_testnet: props.chainMetadata.testnet,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+17
-5
@@ -190,25 +190,37 @@ export async function generateContractLayoutMetadata(params: {
|
||||
.replace("Testnet", "")
|
||||
.trim();
|
||||
|
||||
const title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
|
||||
let title = `${contractDisplayName} | ${cleanedChainName} Smart Contract`;
|
||||
let description = "";
|
||||
|
||||
if (isERC721 || isERC1155) {
|
||||
description = `View tokens, source code, transactions, balances, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}.`;
|
||||
} else if (isERC20) {
|
||||
description = `View ERC20 tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
|
||||
title = `${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} | Buy, Swap, Bridge & Price`;
|
||||
description = `Buy, swap & bridge ${contractMetadata.name} (${contractMetadata.symbol}) on ${cleanedChainName} with thirdweb Bridge. View contract address, holders, analytics, transactions and live price.`;
|
||||
} else {
|
||||
description = `View tokens, transactions, balances, source code, and analytics for the ${contractDisplayName} smart contract on ${cleanedChainName}`;
|
||||
description = `View tokens, transactions, balances, source code, and analytics for the ${contractMetadata.name} smart contract on ${cleanedChainName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
description: description,
|
||||
title: title,
|
||||
openGraph: {
|
||||
description: description,
|
||||
title: title,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
const fallbackTitle = `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`;
|
||||
const fallbackDescription = `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`;
|
||||
|
||||
return {
|
||||
description: `View tokens, transactions, balances, source code, and analytics for the smart contract on Chain ID ${params.chainIdOrSlug}`,
|
||||
title: `${shortenIfAddress(params.contractAddress)} | ${params.chainIdOrSlug}`,
|
||||
description: fallbackDescription,
|
||||
title: fallbackTitle,
|
||||
openGraph: {
|
||||
description: fallbackDescription,
|
||||
title: fallbackTitle,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type { Status } from "thirdweb/bridge";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { BridgeStatus } from "./bridge-status";
|
||||
|
||||
const meta = {
|
||||
component: BridgeStatus,
|
||||
title: "chain/tx/bridge-status",
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-6xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof BridgeStatus>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const completedStatus: Status = {
|
||||
status: "COMPLETED",
|
||||
paymentId:
|
||||
"0x8e0a0d9c0254a75a3004a28e0fb69bae7f2d659938ddd461753627270930db60",
|
||||
originAmount: 129935n,
|
||||
destinationAmount: 100000n,
|
||||
transactions: [
|
||||
{
|
||||
chainId: 42161,
|
||||
transactionHash:
|
||||
"0x37daf698878e73590adb2d6f833e810304a9981829ad983fb355245af1183107",
|
||||
},
|
||||
{
|
||||
chainId: 8453,
|
||||
transactionHash:
|
||||
"0xc72abba2761bb48f605125dc12c17b15088303db960816fa12316f0c90796417",
|
||||
},
|
||||
],
|
||||
originChainId: 42161,
|
||||
originTokenAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
originToken: {
|
||||
chainId: 42161,
|
||||
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
symbol: "USDC",
|
||||
name: "USD Coin",
|
||||
decimals: 6,
|
||||
iconUri: "https://ethereum-optimism.github.io/data/USDC/logo.png",
|
||||
},
|
||||
destinationChainId: 8453,
|
||||
destinationTokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
destinationToken: {
|
||||
chainId: 8453,
|
||||
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
symbol: "USDC",
|
||||
name: "USD Coin",
|
||||
decimals: 6,
|
||||
iconUri:
|
||||
"https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694",
|
||||
},
|
||||
sender: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
|
||||
receiver: "0x2a4f24F935Eb178e3e7BA9B53A5Ee6d8407C0709",
|
||||
};
|
||||
|
||||
const pendingStatus: Status = {
|
||||
...completedStatus,
|
||||
status: "PENDING",
|
||||
};
|
||||
|
||||
const failedStatus: Status = {
|
||||
...completedStatus,
|
||||
status: "FAILED",
|
||||
};
|
||||
|
||||
const notFoundStatus: Status = {
|
||||
...completedStatus,
|
||||
status: "NOT_FOUND",
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
bridgeStatus: completedStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
bridgeStatus: pendingStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
bridgeStatus: failedStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotFound: Story = {
|
||||
args: {
|
||||
bridgeStatus: notFoundStatus,
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPurchaseData: Story = {
|
||||
args: {
|
||||
bridgeStatus: {
|
||||
...completedStatus,
|
||||
purchaseData: {
|
||||
userId: "68d645b7ded999651272bf1e",
|
||||
credits: 32000,
|
||||
transactionId: "fd2606d1-90df-45c6-bd2c-b19a34764a31",
|
||||
},
|
||||
},
|
||||
client: storybookThirdwebClient,
|
||||
},
|
||||
};
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
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 Link from "next/link";
|
||||
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
|
||||
import type { Status, Token } from "thirdweb/bridge";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import { toTokens } from "thirdweb/utils";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchChain } from "@/utils/fetchChain";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type PurchaseData = Exclude<Status, { status: "NOT_FOUND" }>["purchaseData"];
|
||||
|
||||
export function BridgeStatus(props: {
|
||||
bridgeStatus: Status;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { bridgeStatus } = props;
|
||||
const purchaseDataString =
|
||||
bridgeStatus.status !== "NOT_FOUND" && bridgeStatus.purchaseData
|
||||
? getPurchaseData(bridgeStatus.purchaseData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border relative">
|
||||
{bridgeStatus.status === "FAILED" &&
|
||||
bridgeStatus.transactions.length > 0 && (
|
||||
<FailedBridgeStatusContent
|
||||
transactions={bridgeStatus.transactions}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(bridgeStatus.status === "COMPLETED" ||
|
||||
bridgeStatus.status === "PENDING") && (
|
||||
<BridgeStatusContent
|
||||
bridgeStatus={bridgeStatus}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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={cn(
|
||||
"flex items-center gap-1.5 text-sm capitalize",
|
||||
bridgeStatus.status === "COMPLETED" && "text-green-500",
|
||||
bridgeStatus.status === "PENDING" && "text-blue-500",
|
||||
bridgeStatus.status === "FAILED" && "text-red-500",
|
||||
)}
|
||||
>
|
||||
{bridgeStatus.status === "COMPLETED" && (
|
||||
<CircleCheckIcon className="size-3.5" />
|
||||
)}
|
||||
{bridgeStatus.status === "PENDING" && (
|
||||
<Spinner className="size-3.5" />
|
||||
)}
|
||||
{bridgeStatus.status === "FAILED" && (
|
||||
<CircleXIcon className="size-3.5" />
|
||||
)}
|
||||
{bridgeStatus.status === "NOT_FOUND" && (
|
||||
<CircleXIcon className="size-3.5" />
|
||||
)}
|
||||
|
||||
{bridgeStatus.status.toLowerCase().replace("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground ">Payment ID</p>
|
||||
<CopyTextButton
|
||||
textToCopy={bridgeStatus.paymentId}
|
||||
textToShow={`${bridgeStatus.paymentId.slice(0, 6)}...${bridgeStatus.paymentId.slice(-4)}`}
|
||||
tooltip="Payment ID"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<CodeClient
|
||||
code={purchaseDataString}
|
||||
lang="json"
|
||||
className="[&_code]:text-xs"
|
||||
scrollableClassName="p-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenInfo(props: {
|
||||
label: string;
|
||||
addressLabel: string;
|
||||
token: Token;
|
||||
amountWei: bigint | undefined;
|
||||
walletAddress: string;
|
||||
client: ThirdwebClient;
|
||||
txHash: string | undefined;
|
||||
}) {
|
||||
const chainQuery = useChainQuery(props.token.chainId);
|
||||
|
||||
return (
|
||||
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{props.label}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="pb-7 pt-6 border-b border-dashed">
|
||||
<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
|
||||
href={
|
||||
props.token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${chainQuery.data?.slug || props.token.chainId}`
|
||||
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
|
||||
}
|
||||
target="_blank"
|
||||
aria-label="View Token"
|
||||
className="absolute inset-0 z-10"
|
||||
/>
|
||||
|
||||
<Img
|
||||
src={props.token.iconUri}
|
||||
alt={props.token.name}
|
||||
className="size-10 rounded-full"
|
||||
fallback={
|
||||
<div className="size-10 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-0.5 -right-0.5 bg-background p-0.5 rounded-full">
|
||||
<Img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: chainQuery.data?.icon?.url,
|
||||
})}
|
||||
alt={props.token.name}
|
||||
className="size-4 rounded-full"
|
||||
fallback={
|
||||
<div className="size-4 from-blue-500 to-foreground rounded-full bg-gradient-to-b" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-lg text-foreground font-medium tracking-tight leading-none">
|
||||
{props.amountWei ? (
|
||||
<>
|
||||
{toTokens(props.amountWei, props.token.decimals)}{" "}
|
||||
{props.token.symbol}
|
||||
</>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</p>
|
||||
<SkeletonContainer
|
||||
skeletonData={`Chain ${props.token.chainId}`}
|
||||
loadedData={chainQuery.data?.name}
|
||||
className="[&_*]:flex"
|
||||
render={(data) => (
|
||||
<Link
|
||||
href={`/${chainQuery.data?.slug || props.token.chainId}`}
|
||||
className="text-sm text-muted-foreground leading-none hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{data}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2.5 justify-between">
|
||||
<p className="text-sm text-muted-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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">Token Address</p>
|
||||
<CopyTextButton
|
||||
textToCopy={props.token.address}
|
||||
textToShow={`${props.token.address.slice(0, 6)}...${props.token.address.slice(-4)}`}
|
||||
tooltip="Token Address"
|
||||
className="text-sm translate-x-1.5"
|
||||
copyIconPosition="left"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<p className="text-sm text-muted-foreground ">Transaction Hash</p>
|
||||
{props.txHash ? (
|
||||
<CopyTextButton
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground ">N/A</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BridgeStatusContent(props: {
|
||||
bridgeStatus: Exclude<Status, { status: "FAILED" } | { status: "NOT_FOUND" }>;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { bridgeStatus } = props;
|
||||
|
||||
const fromTxHash = bridgeStatus.transactions.find(
|
||||
(tx) => tx.chainId === bridgeStatus.originChainId,
|
||||
)?.transactionHash;
|
||||
const toTxHash = bridgeStatus.transactions.find(
|
||||
(tx) => tx.chainId === bridgeStatus.destinationChainId,
|
||||
)?.transactionHash;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 border-b border-dashed">
|
||||
<TokenInfo
|
||||
label="From"
|
||||
token={bridgeStatus.originToken}
|
||||
amountWei={bridgeStatus.originAmount}
|
||||
walletAddress={bridgeStatus.sender}
|
||||
client={props.client}
|
||||
txHash={fromTxHash}
|
||||
addressLabel="Sender"
|
||||
/>
|
||||
|
||||
<div className="border-t lg:border-l lg:border-t-0 border-dashed relative">
|
||||
<div className="p-1.5 rounded-full bg-background absolute top-0 lg:top-1/2 left-1/2 lg:left-0 -translate-y-1/2 -translate-x-1/2 border">
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground rotate-90 lg:rotate-0" />
|
||||
</div>
|
||||
<TokenInfo
|
||||
label="To"
|
||||
token={bridgeStatus.destinationToken}
|
||||
// TODO - destinationAmount is not available in PENDING status, fix it in API
|
||||
amountWei={
|
||||
bridgeStatus.status === "COMPLETED"
|
||||
? bridgeStatus.destinationAmount
|
||||
: undefined
|
||||
}
|
||||
walletAddress={bridgeStatus.receiver}
|
||||
client={props.client}
|
||||
txHash={toTxHash}
|
||||
addressLabel="Receiver"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedBridgeStatusContent(props: {
|
||||
transactions: { chainId: number; transactionHash: string }[];
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
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">
|
||||
{props.transactions.map((tx) => {
|
||||
return (
|
||||
<TxHashRow
|
||||
key={tx.chainId}
|
||||
client={props.client}
|
||||
chainId={tx.chainId}
|
||||
txHash={tx.transactionHash}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useChainQuery(chainId: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!chainId,
|
||||
queryKey: ["chain-metadata", chainId],
|
||||
queryFn: async () => {
|
||||
if (!chainId) {
|
||||
return null;
|
||||
}
|
||||
return fetchChain(chainId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function TxHashRow(props: {
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
txHash: string;
|
||||
}) {
|
||||
const chainQuery = useChainQuery(props.chainId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: chainQuery.data?.icon?.url,
|
||||
})}
|
||||
alt={chainQuery.data?.name}
|
||||
className="size-4 rounded-full"
|
||||
fallback={
|
||||
<div className="size-4 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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<CopyTextButton
|
||||
textToCopy={props.txHash}
|
||||
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
|
||||
tooltip="Copy Transaction Hash"
|
||||
className="text-sm"
|
||||
copyIconPosition="right"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BridgeStatusWithPolling(props: {
|
||||
bridgeStatus: Status;
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
transactionHash: string;
|
||||
}) {
|
||||
const bridgeStatusQuery = useQuery({
|
||||
enabled:
|
||||
props.bridgeStatus.status === "PENDING" ||
|
||||
props.bridgeStatus.status === "NOT_FOUND",
|
||||
queryKey: ["bridge-status", props.transactionHash, props.chainId],
|
||||
queryFn: () =>
|
||||
status({
|
||||
chainId: props.chainId,
|
||||
transactionHash: props.transactionHash as `0x${string}`,
|
||||
client: props.client,
|
||||
}),
|
||||
refetchInterval(query) {
|
||||
const status = query.state.data?.status;
|
||||
if (status === "COMPLETED" || status === "FAILED") {
|
||||
return false;
|
||||
}
|
||||
return 5000;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BridgeStatus
|
||||
bridgeStatus={bridgeStatusQuery.data || props.bridgeStatus}
|
||||
client={props.client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getPurchaseData(purchaseData: PurchaseData) {
|
||||
try {
|
||||
return JSON.stringify(purchaseData, null, 2);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+349
-126
@@ -1,16 +1,42 @@
|
||||
import { toTokens, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { PlainTextCodeBlock } from "@workspace/ui/components/code/plaintext-code";
|
||||
import { ToolTipLabel } from "@workspace/ui/components/tooltip";
|
||||
import { formatDate, formatDistanceToNowStrict, fromUnixTime } from "date-fns";
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
Clock4Icon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import { toTokens } from "thirdweb";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
eth_getBlockByHash,
|
||||
eth_getTransactionByHash,
|
||||
eth_getTransactionReceipt,
|
||||
getRpcClient,
|
||||
} from "thirdweb/rpc";
|
||||
import { hexToNumber, shortenAddress, toEther } from "thirdweb/utils";
|
||||
import type { TransactionReceipt } from "thirdweb/transaction";
|
||||
import { hexToNumber, toEther } from "thirdweb/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { getChain } from "../../../utils";
|
||||
import { BridgeStatusWithPolling } from "./bridge-status";
|
||||
import { BridgeAndOverviewTabs } from "./tabs";
|
||||
|
||||
type Transaction = Awaited<ReturnType<typeof eth_getTransactionByHash>>;
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ chain_id: string; txHash: `0x${string}` }>;
|
||||
@@ -25,142 +51,339 @@ export default async function Page(props: {
|
||||
client: serverThirdwebClient,
|
||||
});
|
||||
|
||||
const [transaction, receipt] = await Promise.all([
|
||||
const [transaction, receipt, bridgeStatus] = await Promise.all([
|
||||
eth_getTransactionByHash(rpcRequest, {
|
||||
hash: params.txHash,
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
eth_getTransactionReceipt(rpcRequest, {
|
||||
hash: params.txHash,
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
status({
|
||||
chainId: chain.chainId,
|
||||
transactionHash: params.txHash,
|
||||
client: serverThirdwebClient,
|
||||
}).catch(() => undefined),
|
||||
]);
|
||||
if (!transaction.blockHash) {
|
||||
return <div>no tx found</div>;
|
||||
|
||||
if (!transaction?.blockHash || !receipt) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center grow">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="p-2 rounded-full bg-background border">
|
||||
<CircleAlertIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
Transaction not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = await eth_getBlockByHash(rpcRequest, {
|
||||
blockHash: transaction.blockHash,
|
||||
});
|
||||
|
||||
const overviewContent = (
|
||||
<GeneericTxDetails
|
||||
transaction={transaction}
|
||||
receipt={receipt}
|
||||
block={block}
|
||||
chain={chain}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="container flex flex-col gap-y-10 py-6">
|
||||
<header className="flex flex-col gap-y-2">
|
||||
<p className="font-bold text-3xl">Transaction Details</p>
|
||||
<div className="flex gap-x-2">
|
||||
{shortenAddress(transaction.from)} called on{" "}
|
||||
{shortenAddress(transaction.to || ZERO_ADDRESS)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="pb-20">
|
||||
<div className="flex h-14 items-center border-border border-b">
|
||||
<Breadcrumb className="container max-w-6xl">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/chainlist">Chainlist</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={`/${chain.slug}`}>
|
||||
{chain.name.replace("Mainnet", "")}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Transaction Details</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
<div className="container max-w-6xl py-10">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Transaction Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Transaction hash</div>
|
||||
<p>{transaction.hash}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
Status {/* and method */}
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<Badge
|
||||
className={
|
||||
receipt.status === "success" ? "bg-green-500" : "bg-red-500"
|
||||
}
|
||||
>
|
||||
{receipt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Block</div>
|
||||
<p>{Number(transaction.blockNumber)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Timestamp</div>
|
||||
<p>
|
||||
{new Date(
|
||||
Number.parseInt(block.timestamp.toString()) * 1000,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">From</div>
|
||||
<p>{transaction.from}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
Interacted with contract
|
||||
</div>
|
||||
<p>{transaction.to}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Value</div>
|
||||
<p>{toEther(transaction.value)} ETH</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Transaction fee</div>
|
||||
<p>{toEther((transaction.gasPrice || 0n) * receipt.gasUsed)} ETH</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Gas price</div>
|
||||
<p>{toEther(transaction.gasPrice || 0n)} ETH</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Gas usage</div>
|
||||
<p>{receipt.gasUsed.toString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Gas fees (Gwei)</div>
|
||||
<p>
|
||||
Base: {toTokens(block.baseFeePerGas || 0n, 9)} | Max:{" "}
|
||||
{toTokens(transaction.maxFeePerGas || 0n, 9)} | Max priority:{" "}
|
||||
{transaction.maxPriorityFeePerGas?.toString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Burnt fees</div>
|
||||
<p>{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<section className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex items-center gap-x-2">Other</div>
|
||||
<p>
|
||||
Txn type: {hexToNumber(transaction.typeHex || "0x0")} (
|
||||
{transaction.type}) | Nonce: {transaction.nonce} | Position:{" "}
|
||||
{transaction.transactionIndex}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 items-start">
|
||||
<div className="flex items-center gap-x-2">Raw Input:</div>
|
||||
<div className="break-words rounded-lg bg-card p-4 text-sm">
|
||||
{transaction.input}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container max-w-6xl">
|
||||
{bridgeStatus ? (
|
||||
<BridgeAndOverviewTabs
|
||||
bridgeStatus={
|
||||
<BridgeStatusWithPolling
|
||||
bridgeStatus={bridgeStatus}
|
||||
client={getClientThirdwebClient()}
|
||||
chainId={chain.chainId}
|
||||
transactionHash={params.txHash}
|
||||
/>
|
||||
}
|
||||
overview={overviewContent}
|
||||
/>
|
||||
) : (
|
||||
overviewContent
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneericTxDetails(props: {
|
||||
transaction: Transaction;
|
||||
receipt: TransactionReceipt;
|
||||
block: {
|
||||
timestamp: bigint;
|
||||
baseFeePerGas: bigint | null;
|
||||
};
|
||||
chain: ChainMetadata;
|
||||
}) {
|
||||
const { transaction, receipt, chain, block } = props;
|
||||
|
||||
const timestamp = getDatefromTimestamp(block.timestamp);
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 lg:p-6 bg-card">
|
||||
{/* section 1 */}
|
||||
<section className="border-b pb-6 space-y-5 lg:space-y-3">
|
||||
<GridItem
|
||||
label="Transaction hash"
|
||||
tooltip="A TxHash or transaction hash is a unique 66-character identifier that is generated whenever a transaction is executed."
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.hash}
|
||||
textToShow={transaction.hash}
|
||||
tooltip="Copy transaction hash"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
className="truncate -translate-x-1.5 max-w-full"
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
<GridItem label="Status" tooltip="The status of the transaction.">
|
||||
<Badge
|
||||
className="py-0.5 gap-1.5 px-1.5 capitalize"
|
||||
variant={receipt.status === "success" ? "success" : "destructive"}
|
||||
>
|
||||
{receipt.status === "success" ? (
|
||||
<CircleCheckIcon className="size-3" />
|
||||
) : (
|
||||
<CircleAlertIcon className="size-3" />
|
||||
)}
|
||||
{receipt.status}
|
||||
</Badge>
|
||||
</GridItem>
|
||||
|
||||
{transaction.blockNumber && (
|
||||
<GridItem
|
||||
label="Block"
|
||||
tooltip="Number of the block in which the transaction is recorded. Block confirmations indicate how many blocks have been added since the transaction was produced."
|
||||
>
|
||||
<p>{Number(transaction.blockNumber)}</p>
|
||||
</GridItem>
|
||||
)}
|
||||
|
||||
{timestamp && (
|
||||
<GridItem
|
||||
label="Timestamp"
|
||||
tooltip="The date and time at which a transaction is validated."
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Clock4Icon className="size-3" />
|
||||
<p>
|
||||
{formatDistanceToNowStrict(timestamp, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
<p>({formatDate(timestamp, "PP pp z")})</p>
|
||||
</div>
|
||||
</GridItem>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* section 2 */}
|
||||
<section className="border-b py-6 space-y-5 lg:space-y-3">
|
||||
<GridItem label="From" tooltip="The sending party of the transaction.">
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.from}
|
||||
textToShow={transaction.from}
|
||||
tooltip="Copy from address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
className="truncate -translate-x-1.5 max-w-full"
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
{transaction.to && (
|
||||
<GridItem
|
||||
label="Interacted with (To)"
|
||||
tooltip="The receiving party of the transaction (could be a contract address)."
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.to}
|
||||
textToShow={transaction.to}
|
||||
tooltip="Copy to address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
className="truncate -translate-x-1.5 max-w-full"
|
||||
/>
|
||||
</GridItem>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* section 3 */}
|
||||
<section className="py-6 space-y-5 lg:space-y-3 border-b">
|
||||
<GridItem
|
||||
label="Value"
|
||||
tooltip="The value being transacted in ETH and fiat value. Note: You can click the fiat value (if available) to see historical value at the time of transaction."
|
||||
>
|
||||
<p>
|
||||
{toEther(transaction.value)} {chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Transaction Fee"
|
||||
tooltip="Amount paid to the validator for processing the transaction."
|
||||
>
|
||||
<p>
|
||||
{toEther((transaction.gasPrice || 0n) * receipt.gasUsed)}{" "}
|
||||
{chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Gas Price"
|
||||
tooltip="The price per gas unit the sender is willing to pay"
|
||||
>
|
||||
<p>
|
||||
{toEther(transaction.gasPrice || 0n)} {chain.nativeCurrency?.symbol}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Gas Usage"
|
||||
tooltip="The amount of gas used by the transaction."
|
||||
>
|
||||
<p>{receipt.gasUsed.toString()}</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Gas Fees"
|
||||
tooltip="Base Fee refers to the network Base Fee at the time of the block, while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay for their tx & to give to the miner respectively."
|
||||
>
|
||||
<p className="flex gap-2.5">
|
||||
<span>Base: {toTokens(block.baseFeePerGas || 0n, 9)} Gwei</span>
|
||||
|
||||
{transaction.maxFeePerGas && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span>
|
||||
Max: {toTokens(transaction.maxFeePerGas || 0n, 9)} Gwei
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{transaction.maxPriorityFeePerGas && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span>
|
||||
Max priority: {transaction.maxPriorityFeePerGas?.toString()}{" "}
|
||||
Gwei
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Burnt fees"
|
||||
tooltip="The amount of fees that were burnt by the transaction."
|
||||
>
|
||||
<p>{toEther(block.baseFeePerGas || 0n * receipt.gasUsed)}</p>
|
||||
</GridItem>
|
||||
</section>
|
||||
|
||||
{/* section 4 */}
|
||||
<section className="pt-6 space-y-5 lg:space-y-3">
|
||||
<GridItem
|
||||
label="Other Attributes"
|
||||
tooltip="Other data related to this transaction."
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="gap-1 py-1">
|
||||
<span className="text-muted-foreground">Txn type:</span>
|
||||
<span className="capitalize">
|
||||
{hexToNumber(transaction.typeHex || "0x0")} ({transaction.type})
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1 py-1">
|
||||
<span className="text-muted-foreground">Nonce: </span>
|
||||
<span>{transaction.nonce}</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1 py-1">
|
||||
<span className="text-muted-foreground">Position: </span>
|
||||
<span>{transaction.transactionIndex}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</GridItem>
|
||||
|
||||
<GridItem
|
||||
label="Input Data"
|
||||
tooltip="Additional data included for this transaction. Commonly used as part of contract interaction or as a message sent to the recipient."
|
||||
>
|
||||
<PlainTextCodeBlock
|
||||
code={transaction.input}
|
||||
className="[&_code]:!whitespace-pre-wrap break-all [&_code]:text-xs text-muted-foreground w-full"
|
||||
scrollableClassName="max-h-[400px] p-2 min-h-[80px]"
|
||||
/>
|
||||
</GridItem>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridItem(props: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 lg:grid grid-cols-[300px_1fr] items-start">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToolTipLabel label={props.tooltip}>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-auto p-1 hidden lg:block"
|
||||
>
|
||||
<div>
|
||||
<InfoIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<p className="text-muted-foreground text-sm">{props.label}</p>
|
||||
</div>
|
||||
<div className="text-sm w-full">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDatefromTimestamp(timestamp: bigint) {
|
||||
try {
|
||||
return fromUnixTime(Number(timestamp));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
|
||||
export function BridgeAndOverviewTabs(props: {
|
||||
bridgeStatus: React.ReactNode;
|
||||
overview: React.ReactNode;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"bridge" | "overview">("bridge");
|
||||
return (
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "Bridge",
|
||||
onClick: () => setTab("bridge"),
|
||||
isActive: tab === "bridge",
|
||||
},
|
||||
{
|
||||
name: "Overview",
|
||||
onClick: () => setTab("overview"),
|
||||
isActive: tab === "overview",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-3" />
|
||||
{tab === "bridge" && props.bridgeStatus}
|
||||
{tab === "overview" && props.overview}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -5,7 +5,7 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { ChainSupportedService } from "@/types/chain";
|
||||
import { ChainIcon } from "../../../components/server/chain-icon";
|
||||
import { getChainMetadata } from "../../../utils";
|
||||
import { getCustomChainMetadata } from "../../../utils";
|
||||
|
||||
type ChainListCardProps = {
|
||||
favoriteButton: JSX.Element | undefined;
|
||||
@@ -18,7 +18,7 @@ type ChainListCardProps = {
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export async function ChainListCard({
|
||||
export function ChainListCard({
|
||||
isDeprecated,
|
||||
chainId,
|
||||
chainName,
|
||||
@@ -28,7 +28,7 @@ export async function ChainListCard({
|
||||
favoriteButton,
|
||||
iconUrl,
|
||||
}: ChainListCardProps) {
|
||||
const chainMetadata = await getChainMetadata(chainId);
|
||||
const customChainMetadata = getCustomChainMetadata(chainId);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
@@ -90,9 +90,9 @@ export async function ChainListCard({
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{(isDeprecated || chainMetadata?.gasSponsored) && (
|
||||
{(isDeprecated || customChainMetadata?.gasSponsored) && (
|
||||
<div className="mt-5 flex gap-5 border-t pt-4">
|
||||
{!isDeprecated && chainMetadata?.gasSponsored && (
|
||||
{!isDeprecated && customChainMetadata?.gasSponsored && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TicketCheckIcon className="size-5 text-foreground" />
|
||||
<p className="text-sm">Gas Sponsored</p>
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { ChainSupportedService } from "@/types/chain";
|
||||
import { ChainIcon } from "../../../components/server/chain-icon";
|
||||
import { products } from "../../../components/server/products";
|
||||
import { getChainMetadata } from "../../../utils";
|
||||
import { getCustomChainMetadata } from "../../../utils";
|
||||
|
||||
type ChainListRowProps = {
|
||||
favoriteButton: JSX.Element | undefined;
|
||||
@@ -26,7 +26,7 @@ type ChainListRowProps = {
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export async function ChainListRow({
|
||||
export function ChainListRow({
|
||||
isDeprecated,
|
||||
chainId,
|
||||
chainName,
|
||||
@@ -36,7 +36,7 @@ export async function ChainListRow({
|
||||
favoriteButton,
|
||||
iconUrl,
|
||||
}: ChainListRowProps) {
|
||||
const chainMetadata = await getChainMetadata(chainId);
|
||||
const chainMetadata = getCustomChainMetadata(chainId);
|
||||
return (
|
||||
<TableRow className="hover:bg-accent/50" linkBox>
|
||||
{/* Name */}
|
||||
|
||||
@@ -914,9 +914,9 @@ const chainMetaRecord = {
|
||||
} satisfies Record<number, ExtraChainMetadata>;
|
||||
// END TEMPORARY
|
||||
|
||||
export async function getChainMetadata(
|
||||
export function getCustomChainMetadata(
|
||||
chainId: number,
|
||||
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
|
||||
): (ExtraChainMetadata & { gasSponsored?: true }) | null {
|
||||
// TODO: fetch this from the API
|
||||
if (chainId in chainMetaRecord) {
|
||||
return {
|
||||
|
||||
+5
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/constants/addresses";
|
||||
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
|
||||
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import {
|
||||
useCustomFactoryAbi,
|
||||
useFunctionParamsFromABI,
|
||||
@@ -206,6 +207,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
const activeAccount = useActiveAccount();
|
||||
const walletChain = useActiveWalletChain();
|
||||
const walletChainMetadata = useV5DashboardChain(walletChain?.id);
|
||||
|
||||
const { onError } = useTxNotifications(
|
||||
"Successfully deployed contract",
|
||||
"Failed to deploy contract",
|
||||
@@ -656,6 +659,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
chainId: walletChain.id,
|
||||
contractName: metadata.name,
|
||||
publisher: rewriteTwPublisher(metadata.publisher),
|
||||
is_testnet: walletChainMetadata?.testnet,
|
||||
});
|
||||
|
||||
deployStatusModal.nextStep();
|
||||
@@ -694,6 +698,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
contractName: metadata.name,
|
||||
errorMessage: parsedError,
|
||||
publisher: rewriteTwPublisher(metadata.publisher),
|
||||
is_testnet: walletChain.testnet,
|
||||
});
|
||||
|
||||
deployStatusModal.close();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { ActivityIcon, SearchIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,11 +15,22 @@ const client = getClientThirdwebClient();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
type Filter =
|
||||
| { type: "filter"; value: "volume" | "market_cap" }
|
||||
| { type: "search"; value: string };
|
||||
|
||||
export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [chainId, setChainId] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
|
||||
const [filter, _setFilter] = useState<Filter>({
|
||||
type: "filter",
|
||||
value: "volume",
|
||||
});
|
||||
|
||||
const setFilter = useCallback((filter: Filter) => {
|
||||
_setFilter(filter);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -27,8 +38,7 @@ export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
{
|
||||
page,
|
||||
chainId,
|
||||
sortBy,
|
||||
search,
|
||||
filter,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
@@ -37,8 +47,8 @@ export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
chainId: chainId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
sortBy: search ? undefined : sortBy,
|
||||
query: search ? search : undefined,
|
||||
sortBy: filter.type === "filter" ? filter.value : undefined,
|
||||
query: filter.type === "search" ? filter.value : undefined,
|
||||
});
|
||||
},
|
||||
refetchOnMount: false,
|
||||
@@ -62,19 +72,19 @@ export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
<SortButton
|
||||
label="Popular"
|
||||
onClick={() => {
|
||||
setSortBy("market_cap");
|
||||
setSearch("");
|
||||
setFilter({ type: "filter", value: "market_cap" });
|
||||
}}
|
||||
isSelected={sortBy === "market_cap" && !search}
|
||||
isSelected={
|
||||
filter.type === "filter" && filter.value === "market_cap"
|
||||
}
|
||||
icon={ActivityIcon}
|
||||
/>
|
||||
<SortButton
|
||||
label="Trending"
|
||||
onClick={() => {
|
||||
setSortBy("volume");
|
||||
setSearch("");
|
||||
setFilter({ type: "filter", value: "volume" });
|
||||
}}
|
||||
isSelected={sortBy === "volume" && !search}
|
||||
isSelected={filter.type === "filter" && filter.value === "volume"}
|
||||
icon={TrendingUpIcon}
|
||||
/>
|
||||
</div>
|
||||
@@ -83,9 +93,11 @@ export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search Tokens"
|
||||
value={search}
|
||||
value={filter.type === "search" ? filter.value : ""}
|
||||
className="rounded-xl bg-card flex-1 pl-9"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setFilter({ type: "search", value: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ export function TokensTable(props: {
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Market cap</TableHead>
|
||||
<TableHead>Volume (24h)</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -64,6 +65,9 @@ export function TokensTable(props: {
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: tokens.length > 0
|
||||
@@ -81,6 +85,7 @@ export function TokensTable(props: {
|
||||
<AvatarImage
|
||||
src={token.iconUri ?? undefined}
|
||||
alt={token.symbol}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{token.symbol?.slice(0, 2)?.toUpperCase()}
|
||||
@@ -90,20 +95,9 @@ export function TokensTable(props: {
|
||||
<span className="font-medium leading-none text-base">
|
||||
{token.symbol}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -120,6 +114,29 @@ export function TokensTable(props: {
|
||||
? formatUsdCompact(token.volume24hUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full bg-card hover:border-foreground/50 text-muted-foreground hover:bg-inverted hover:text-inverted-foreground"
|
||||
>
|
||||
<Link
|
||||
aria-label={`Buy ${token.symbol}`}
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
Buy
|
||||
<ArrowRightIcon className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { PageHeader } from "./components/header";
|
||||
@@ -35,13 +34,10 @@ export default async function Page() {
|
||||
<h1 className="text-4xl font-bold tracking-tighter mb-1">
|
||||
Discover and swap any tokens on any chain, instantly
|
||||
</h1>
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://thirdweb.com/monetize/bridge"
|
||||
target="_blank"
|
||||
>
|
||||
Powered by thirdweb bridge
|
||||
</Link>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
85+ chains, 4500+ tokens and 9+ million routes supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TokenPage chains={chains} />
|
||||
|
||||
+11
-1
@@ -55,7 +55,7 @@ export function CreateProjectFormOnboarding(props: {
|
||||
<CreateProjectForm
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
const vaultTokens = await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
@@ -65,6 +65,16 @@ export function CreateProjectFormOnboarding(props: {
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const managementAccessToken =
|
||||
vaultTokens.managementToken?.accessToken;
|
||||
|
||||
if (!managementAccessToken) {
|
||||
throw new Error(
|
||||
"Missing management access token for project wallet",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
+29
-22
@@ -25,6 +25,34 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
|
||||
import { searchParams } from "../search-params";
|
||||
|
||||
function getStatusBadge(invoice: Stripe.Invoice) {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
case "open":
|
||||
// we treat "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice has a due date, use that, otherwise use the effective at date (which is the date the invoice was created)
|
||||
const effectiveDueDate = invoice.due_date || invoice.effective_at;
|
||||
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (effectiveDueDate && effectiveDueDate * 1000 < Date.now()) {
|
||||
return <Badge variant="destructive">Past Due</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function isInvoicePayable(invoice: Stripe.Invoice) {
|
||||
return invoice.status === "open" || invoice.status === "uncollectible";
|
||||
}
|
||||
|
||||
export function BillingHistory(props: {
|
||||
teamSlug: string;
|
||||
invoices: Stripe.Invoice[];
|
||||
@@ -57,27 +85,6 @@ export function BillingHistory(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (invoice: Stripe.Invoice) => {
|
||||
switch (invoice.status) {
|
||||
case "paid":
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
case "open":
|
||||
// we treate "uncollectible" as unpaid
|
||||
case "uncollectible": {
|
||||
// if the invoice due date is in the past, we want to display it as past due
|
||||
if (invoice.due_date && invoice.due_date * 1000 < Date.now()) {
|
||||
return <Badge variant="destructive">Past Due</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">Open</Badge>;
|
||||
}
|
||||
case "void":
|
||||
return <Badge variant="secondary">Void</Badge>;
|
||||
|
||||
default:
|
||||
return <Badge variant="outline">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (props.invoices.length === 0) {
|
||||
if (props.status === "open") {
|
||||
return (
|
||||
@@ -126,7 +133,7 @@ export function BillingHistory(props: {
|
||||
<TableCell>{getStatusBadge(invoice)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{invoice.status === "open" && (
|
||||
{isInvoicePayable(invoice) && (
|
||||
<>
|
||||
{/* always show the crypto payment button */}
|
||||
<ToolTipLabel
|
||||
|
||||
+1
-1
@@ -552,7 +552,7 @@ export function AccountAbstractionSettingsPage(
|
||||
you build your own rules.{" "}
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href="https://portal.thirdweb.com/wallets/smart-wallet/sponsorship-rules#setting-up-a-server-verifier"
|
||||
href="https://portal.thirdweb.com/transactions/policies#server-verifier"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
+3
-3
@@ -28,11 +28,11 @@ export function QuickStartSection(props: {
|
||||
icon={ArrowRightLeftIcon}
|
||||
setupTime={5}
|
||||
id="swap_tokens"
|
||||
features={["Swap any token", "Cross-chain swaps"]}
|
||||
features={["Swap any token", "Cross-chain swap"]}
|
||||
description="Swap tokens cross-chain with dedicated swapping endpoints."
|
||||
link={{
|
||||
href: `https://portal.thirdweb.com/payments/swap`,
|
||||
label: "Setup Swaps",
|
||||
href: `https://portal.thirdweb.com/bridge/swap`,
|
||||
label: "Setup Swap",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
+36
-20
@@ -2,6 +2,9 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Spinner } from "@workspace/ui/components/spinner";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -19,7 +22,6 @@ import {
|
||||
type RouteDiscoveryValidationSchema,
|
||||
routeDiscoveryValidationSchema,
|
||||
} from "@/schema/validations";
|
||||
import { RouteDiscoveryCard } from "./RouteDiscoveryCard";
|
||||
|
||||
export const RouteDiscovery = ({
|
||||
project,
|
||||
@@ -77,33 +79,21 @@ export const RouteDiscovery = ({
|
||||
},
|
||||
);
|
||||
|
||||
const errorText = form.getFieldState("tokenAddress").error?.message;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<RouteDiscoveryCard
|
||||
bottomText=""
|
||||
errorText={form.getFieldState("tokenAddress").error?.message}
|
||||
noPermissionText={undefined}
|
||||
saveButton={
|
||||
// Only show the submit button in the default state
|
||||
{
|
||||
disabled: !form.formState.isDirty,
|
||||
isPending: submitDiscoveryMutation.isPending,
|
||||
type: "submit",
|
||||
variant: "outline",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
<div className="relative rounded-lg border border-border bg-card">
|
||||
<div className="relative border-dashed border-b px-4 py-6 lg:px-6">
|
||||
<h3 className="font-semibold text-xl tracking-tight mb-1">
|
||||
Add a token to Bridge
|
||||
</h3>
|
||||
<p className="mt-1.5 mb-4 text-muted-foreground max-w-3xl text-sm text-pretty">
|
||||
<p className="mb-4 text-muted-foreground max-w-3xl text-sm text-pretty">
|
||||
Select your chain and input the token address to automatically
|
||||
kick-off the token route discovery process. <br /> This may take
|
||||
up to 20-40 minutes to complete.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -142,7 +132,33 @@ export const RouteDiscovery = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDiscoveryCard>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-4 lg:px-6">
|
||||
{errorText ? (
|
||||
<p className="text-destructive-text text-sm">{errorText}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="gap-1.5 rounded-full"
|
||||
disabled={
|
||||
!form.formState.isDirty || submitDiscoveryMutation.isPending
|
||||
}
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant={"outline"}
|
||||
>
|
||||
{submitDiscoveryMutation.isPending ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<PlusIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RouteDiscoveryCard(
|
||||
props: React.PropsWithChildren<{
|
||||
bottomText: React.ReactNode;
|
||||
header?: {
|
||||
description: string | undefined;
|
||||
title: string;
|
||||
};
|
||||
errorText: string | undefined;
|
||||
noPermissionText: string | undefined;
|
||||
saveButton?: {
|
||||
onClick?: () => void;
|
||||
disabled: boolean;
|
||||
isPending: boolean;
|
||||
type?: "submit";
|
||||
variant?:
|
||||
| "ghost"
|
||||
| "default"
|
||||
| "primary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary";
|
||||
className?: string;
|
||||
label?: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card">
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-border border-b px-4 py-6 lg:px-6",
|
||||
props.noPermissionText && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{props.header && (
|
||||
<>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
{props.header.title}
|
||||
</h3>
|
||||
{props.header.description && (
|
||||
<p className="mt-1.5 mb-4 text-foreground text-sm">
|
||||
{props.header.description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[60px] items-center justify-between gap-2 px-4 py-3 lg:px-6">
|
||||
{props.noPermissionText ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{props.noPermissionText}
|
||||
</p>
|
||||
) : props.errorText ? (
|
||||
<p className="text-destructive-text text-sm">{props.errorText}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{props.bottomText}</p>
|
||||
)}
|
||||
|
||||
{props.saveButton && !props.noPermissionText && (
|
||||
<Button
|
||||
className={cn("gap-2", props.saveButton.className)}
|
||||
disabled={props.saveButton.disabled || props.saveButton.isPending}
|
||||
onClick={props.saveButton.onClick}
|
||||
size="sm"
|
||||
type={props.saveButton.type}
|
||||
variant={props.saveButton.variant || "outline"}
|
||||
>
|
||||
{props.saveButton.isPending && <Spinner className="size-3" />}
|
||||
{props.saveButton.label ||
|
||||
(props.saveButton.isPending ? "Submit" : "Submit Token")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-65
@@ -11,6 +11,7 @@ import { PayAnalytics } from "../payments/components/PayAnalytics";
|
||||
import { getUniversalBridgeFiltersFromSearchParams } from "../payments/components/time";
|
||||
import { QuickStartSection } from "./QuickstartSection.client";
|
||||
import { RouteDiscovery } from "./RouteDiscovery";
|
||||
import { ViewTxStatus } from "./view-tx-status";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
@@ -69,72 +70,22 @@ export default async function Page(props: {
|
||||
},
|
||||
},
|
||||
links: [
|
||||
// TODO - add docs when bridge docs are added in portal
|
||||
// {
|
||||
// type: "docs",
|
||||
// href: "https://portal.thirdweb.com/payments",
|
||||
// },
|
||||
{
|
||||
type: "docs",
|
||||
href: "https://portal.thirdweb.com/bridge",
|
||||
},
|
||||
{
|
||||
type: "playground",
|
||||
href: "https://playground.thirdweb.com/bridge/swap-widget",
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
href: "https://api.thirdweb.com/reference#tag/bridge",
|
||||
},
|
||||
],
|
||||
}}
|
||||
footer={{
|
||||
center: {
|
||||
links: [
|
||||
{
|
||||
href: "https://playground.thirdweb.com/payments/ui-components",
|
||||
label: "UI Component",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/payments/fund-wallet",
|
||||
label: "Buy Crypto",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/payments/commerce",
|
||||
label: "Checkout",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/payments/transactions",
|
||||
label: "Transactions",
|
||||
},
|
||||
],
|
||||
title: "Demos",
|
||||
},
|
||||
left: {
|
||||
links: [
|
||||
{
|
||||
href: "https://portal.thirdweb.com/payments",
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/typescript/v5/convertCryptoToFiat",
|
||||
label: "TypeScript",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/pay/fund-wallets",
|
||||
label: "React",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/dotnet/universal-bridge/quickstart",
|
||||
label: ".NET",
|
||||
},
|
||||
],
|
||||
title: "Documentation",
|
||||
},
|
||||
right: {
|
||||
links: [
|
||||
{
|
||||
href: "https://www.youtube.com/watch?v=aBu175-VsNY",
|
||||
label: "Implement cross-chain payments in any app",
|
||||
},
|
||||
],
|
||||
title: "Tutorials",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<PayAnalytics
|
||||
client={client}
|
||||
@@ -149,12 +100,16 @@ export default async function Page(props: {
|
||||
|
||||
<RouteDiscovery client={client} project={project} />
|
||||
|
||||
<QuickStartSection
|
||||
projectSlug={params.project_slug}
|
||||
teamSlug={params.team_slug}
|
||||
clientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
<ViewTxStatus client={client} />
|
||||
|
||||
<div className="pt-4">
|
||||
<QuickStartSection
|
||||
projectSlug={params.project_slug}
|
||||
teamSlug={params.team_slug}
|
||||
clientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectPage>
|
||||
);
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function ViewTxStatus(props: { client: ThirdwebClient }) {
|
||||
const [txHash, setTxHash] = useState("");
|
||||
const [chainId, setChainId] = useState<number>(1);
|
||||
|
||||
const isValidTxHash = txHash.length === 66 && txHash.startsWith("0x");
|
||||
const isEnabled = !!txHash && !!chainId && isValidTxHash;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border">
|
||||
<div className="p-4 lg:p-6 mb-3">
|
||||
<h3 className="font-semibold text-xl tracking-tight mb-1">
|
||||
View transaction status
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Select chain and enter the transaction hash to view the transaction
|
||||
status.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 max-w-3xl">
|
||||
<div className="space-y-2">
|
||||
<Label>Chain</Label>
|
||||
<SingleNetworkSelector
|
||||
chainId={chainId}
|
||||
onChange={setChainId}
|
||||
client={props.client}
|
||||
disableChainId
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="view-tx-hash">Transaction Hash</Label>
|
||||
<Input
|
||||
id="view-tx-hash"
|
||||
value={txHash}
|
||||
onChange={(e) => setTxHash(e.target.value)}
|
||||
placeholder="0x.."
|
||||
/>
|
||||
{txHash && !isValidTxHash && (
|
||||
<p className="text-sm text-destructive-text">
|
||||
Invalid transaction hash
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 lg:px-6 py-4 border-t border-dashed justify-end items-center flex">
|
||||
<Button
|
||||
asChild
|
||||
disabled={!isEnabled}
|
||||
size="sm"
|
||||
variant={isEnabled ? "default" : "outline"}
|
||||
className="gap-1.5 rounded-full"
|
||||
>
|
||||
<Link href={`/${chainId.toString()}/tx/${txHash}`} target="_blank">
|
||||
View Transaction Status <ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
|
||||
type TabKey =
|
||||
| "ts"
|
||||
| "react"
|
||||
| "react-native"
|
||||
| "dotnet"
|
||||
| "unity"
|
||||
| "unreal"
|
||||
| "api"
|
||||
| "curl";
|
||||
|
||||
const tabNames: Record<TabKey, string> = {
|
||||
api: "API",
|
||||
ts: "TypeScript",
|
||||
react: "React",
|
||||
"react-native": "React Native",
|
||||
dotnet: ".NET",
|
||||
unity: "Unity",
|
||||
unreal: "Unreal Engine",
|
||||
curl: "cURL",
|
||||
};
|
||||
|
||||
export function IntegrateAPIKeyCodeTabs(props: {
|
||||
tabs: Record<TabKey, React.ReactNode>;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>("api");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={Object.entries(tabNames).map(([key, name]) => ({
|
||||
isActive: tab === key,
|
||||
name,
|
||||
onClick: () => setTab(key as TabKey),
|
||||
}))}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
{props.tabs[tab]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
@@ -31,5 +31,6 @@ export const Default: Story = {
|
||||
],
|
||||
},
|
||||
teamSlug: "bar",
|
||||
managementAccessToken: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
+101
-191
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { CodeServer } from "@/components/ui/code/code.server";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
|
||||
import { GithubIcon } from "@/icons/brand-icons/GithubIcon";
|
||||
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
|
||||
@@ -18,17 +15,34 @@ 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 { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
|
||||
import { ProjectWalletControls } from "./ProjectWalletControls.client";
|
||||
import { ProjectWalletSetup } from "./ProjectWalletSetup.client";
|
||||
import { SecretKeySection } from "./SecretKeySection";
|
||||
|
||||
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
|
||||
export async function ProjectFTUX(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
wallet?: ProjectWalletSummary | undefined;
|
||||
managementAccessToken: string | undefined;
|
||||
}) {
|
||||
const projectWallet = props.wallet ?? (await getProjectWallet(props.project));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<IntegrateAPIKeySection
|
||||
<ProjectWalletSection
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={props.managementAccessToken}
|
||||
/>
|
||||
<IntegrateAPIKeySection project={props.project} />
|
||||
<ProductsSection
|
||||
projectSlug={props.project.slug}
|
||||
teamSlug={props.teamSlug}
|
||||
@@ -39,15 +53,67 @@ export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Integrate API key section ------------------------------------------------------------
|
||||
|
||||
function IntegrateAPIKeySection({
|
||||
project,
|
||||
teamSlug,
|
||||
}: {
|
||||
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 }) {
|
||||
const secretKeyMasked = project.secretKeys[0]?.masked;
|
||||
const clientId = project.publishableKey;
|
||||
|
||||
@@ -69,194 +135,38 @@ function IntegrateAPIKeySection({
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
<IntegrationCodeExamples project={project} teamSlug={teamSlug} />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Run this command in your terminal to send a test transaction using
|
||||
your Project Wallet.
|
||||
</p>
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={curlCodeExample(project)}
|
||||
lang="bash"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCodeExamples(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<IntegrateAPIKeyCodeTabs
|
||||
tabs={{
|
||||
dotnet: (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={dotNotCodeExample(props.project)}
|
||||
lang="csharp"
|
||||
/>
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure your app's bundle ID in "Allowed Bundle IDs" in
|
||||
Project
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Go to{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
target="_blank"
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>{" "}
|
||||
and add your app's bundle ID to the "Allowed Bundle IDs" list.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
react: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={reactCodeExample(props.project)}
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
"react-native": (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={reactCodeExample(props.project)}
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
ts: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={typescriptCodeExample(props.project)}
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
unity: (
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure Client ID in Thirdweb Manager prefab
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Configure "Client ID" and "Bundle ID" in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/unity/v5/thirdwebmanager"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Thirdweb Manager prefab
|
||||
</UnderlineLink>
|
||||
<span className="block text-sm">
|
||||
Make sure to configure your app's bundle ID in "Allowed Bundle
|
||||
IDs" in{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
unreal: (
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
Configure Client ID in Thirdweb Unreal Plugin{" "}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
Configure "Client ID" and "Bundle ID" in{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb plugin settings
|
||||
</UnderlineLink>
|
||||
<span className="block text-sm">
|
||||
Make sure to configure your app's bundle ID in "Allowed Bundle
|
||||
IDs" in{" "}
|
||||
<UnderlineLink
|
||||
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Project settings
|
||||
</UnderlineLink>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
api: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={apiCodeExample(props.project)}
|
||||
lang="javascript"
|
||||
/>
|
||||
),
|
||||
curl: (
|
||||
<CodeServer
|
||||
className="bg-background"
|
||||
code={curlCodeExample(props.project)}
|
||||
lang="bash"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const typescriptCodeExample = (project: Project) => `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
// use clientId for client side usage
|
||||
clientId: "${project.publishableKey}",
|
||||
// use secretKey for server side usage
|
||||
secretKey: "${project.secretKeys[0]?.masked}", // replace this with full secret key
|
||||
});`;
|
||||
|
||||
const reactCodeExample = (project: Project) => `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "${project.publishableKey}",
|
||||
});`;
|
||||
|
||||
const dotNotCodeExample = (project: Project) => `\
|
||||
using Thirdweb;
|
||||
|
||||
// For web applications
|
||||
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}");
|
||||
|
||||
// For native applications - Replace "yourBundleId" with your app's bundle ID
|
||||
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}", bundleId: "yourBundleId");
|
||||
|
||||
// For backend applications (Note: below shown secret key is not the full secret key)
|
||||
var client = ThirdwebClient.Create(secretKey: "${project.secretKeys[0]?.masked}");`;
|
||||
|
||||
const apiCodeExample = (project: Project): string => `\
|
||||
// Server-side only: replace with your full secret key. Never expose in browser code.
|
||||
fetch('https://api.thirdweb.com/v1/wallets/server', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-secret-key': '${project.secretKeys[0]?.masked ?? "<YOUR_SECRET_KEY>"}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: 'treasury-wallet-123'
|
||||
})
|
||||
});`;
|
||||
|
||||
const curlCodeExample = (project: Project): string => `\
|
||||
// Server-side only: replace with your full secret key. Never expose in browser code.
|
||||
curl https://api.thirdweb.com/v1/wallets/server \\
|
||||
curl https://api.thirdweb.com/v1/transactions \\
|
||||
--request POST \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--header 'x-secret-key: ${project.secretKeys[0]?.masked ?? "<YOUR_SECRET_KEY>"}' \\
|
||||
--data '{
|
||||
"identifier": "treasury-wallet-123"
|
||||
}'`;
|
||||
"chainId": 421614,
|
||||
"transactions": [
|
||||
{
|
||||
"data": "0x",
|
||||
"to": "vitalik.eth",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}'
|
||||
`;
|
||||
|
||||
// products section ------------------------------------------------------------
|
||||
|
||||
function ProductsSection(props: { teamSlug: string; projectSlug: string }) {
|
||||
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
"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
@@ -0,0 +1,215 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+13
@@ -1,3 +1,4 @@
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -110,6 +111,7 @@ export function TransactionsTable(props: {
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<EngineStatus | undefined>(undefined);
|
||||
const [filterId, setFilterId] = useState<string | undefined>(undefined);
|
||||
const autoUpdateId = useId();
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useEngineTransactions({
|
||||
@@ -121,6 +123,7 @@ export function TransactionsTable(props: {
|
||||
page: page,
|
||||
status,
|
||||
},
|
||||
id: filterId,
|
||||
});
|
||||
|
||||
const transactions = transactionsQuery.data?.transactions ?? [];
|
||||
@@ -160,6 +163,16 @@ export function TransactionsTable(props: {
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
setFilterId(value || undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Filter by Queue ID"
|
||||
value={filterId || ""}
|
||||
/>
|
||||
<StatusSelector
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-fi
|
||||
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
@@ -38,7 +39,10 @@ import type {
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
|
||||
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import {
|
||||
ProjectFTUX,
|
||||
ProjectWalletSection,
|
||||
} from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsChartCardAsync } from "./components/Transactions";
|
||||
import { ProjectHighlightsCard } from "./overview/highlights-card";
|
||||
@@ -64,6 +68,9 @@ type PageProps = {
|
||||
searchParams: Promise<PageSearchParams>;
|
||||
};
|
||||
|
||||
// Revalidate this page data every 30 seconds
|
||||
export const revalidate = 30;
|
||||
|
||||
export default async function ProjectOverviewPage(props: PageProps) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
@@ -104,6 +111,11 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
const managementAccessToken =
|
||||
project.services?.find((service) => service.name === "engineCloud")
|
||||
?.managementAccessToken ?? undefined;
|
||||
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<ProjectPage
|
||||
@@ -125,6 +137,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
>
|
||||
{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}
|
||||
@@ -137,7 +155,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectFTUX project={project} teamSlug={params.team_slug} />
|
||||
<ProjectFTUX
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
wallet={projectWallet}
|
||||
managementAccessToken={managementAccessToken}
|
||||
/>
|
||||
)}
|
||||
</ProjectPage>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
|
||||
+1
@@ -156,6 +156,7 @@ export async function PayAnalytics(props: {
|
||||
client={props.client}
|
||||
projectClientId={props.projectClientId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ export function PaymentHistory(props: {
|
||||
client: ThirdwebClient;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const { data: payPurchaseData, isLoading } = useQuery<
|
||||
@@ -40,6 +41,7 @@ export function PaymentHistory(props: {
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
+4
@@ -15,6 +15,8 @@ export function QuickStartSection(props: {
|
||||
projectSlug: string;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
@@ -43,7 +45,9 @@ export function QuickStartSection(props: {
|
||||
action={
|
||||
<CreatePaymentLinkButton
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button
|
||||
className="w-full gap-2 group text-foreground bg-background"
|
||||
|
||||
+46
-9
@@ -45,7 +45,12 @@ const formSchema = z.object({
|
||||
});
|
||||
|
||||
export function CreatePaymentLinkButton(
|
||||
props: PropsWithChildren<{ clientId: string; teamId: string }>,
|
||||
props: PropsWithChildren<{
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -55,8 +60,10 @@ export function CreatePaymentLinkButton(
|
||||
<DialogContent className="p-0 !max-w-lg">
|
||||
<CreatePaymentLinkDialogContent
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
setOpen={setOpen}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -67,6 +74,8 @@ function CreatePaymentLinkDialogContent(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const client = getClientThirdwebClient();
|
||||
|
||||
@@ -119,6 +128,7 @@ function CreatePaymentLinkDialogContent(props: {
|
||||
amount: toUnits(values.amount.toString(), token.decimals),
|
||||
},
|
||||
title: values.title,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -182,14 +192,41 @@ function CreatePaymentLinkDialogContent(props: {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<Input
|
||||
className="w-full bg-card"
|
||||
{...field}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
placeholder="Address or ENS"
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
className="w-full bg-card sm:flex-1"
|
||||
{...field}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
placeholder="Address or ENS"
|
||||
required
|
||||
/>
|
||||
{props.projectWalletAddress && (
|
||||
<Button
|
||||
className="sm:w-auto"
|
||||
onClick={() => {
|
||||
if (!props.projectWalletAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
"recipient",
|
||||
props.projectWalletAddress,
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Use Project Wallet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
+25
-3
@@ -40,7 +40,12 @@ import { ErrorState } from "../../components/ErrorState";
|
||||
import { formatTokenAmount } from "../../components/format";
|
||||
import { CreatePaymentLinkButton } from "./CreatePaymentLinkButton.client";
|
||||
|
||||
export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
export function PaymentLinksTable(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
@@ -49,17 +54,28 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
|
||||
The payments you have created in this project
|
||||
</p>
|
||||
</div>
|
||||
<PaymentLinksTableInner clientId={props.clientId} teamId={props.teamId} />
|
||||
<PaymentLinksTableInner
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
function PaymentLinksTableInner(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const paymentLinksQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
return getPaymentLinks({
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
},
|
||||
queryKey: ["payment-links", props.clientId, props.teamId],
|
||||
@@ -71,6 +87,7 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
return await Promise.all(
|
||||
paymentLinks.map(async (paymentLink) => {
|
||||
const { data } = await getPayments({
|
||||
authToken: props.authToken,
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
paymentLinkId: paymentLink.id,
|
||||
@@ -120,7 +137,9 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
<CreatePaymentLinkButton
|
||||
key="create-payment-link"
|
||||
clientId={props.clientId}
|
||||
projectWalletAddress={props.projectWalletAddress}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button className="gap-2 rounded-full" variant="default" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -215,6 +234,7 @@ function PaymentLinksTableInner(props: { clientId: string; teamId: string }) {
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
paymentLinkId={paymentLink.id}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button size="icon" variant="ghost">
|
||||
<TrashIcon className="size-4" strokeWidth={1} />
|
||||
@@ -264,6 +284,7 @@ function DeletePaymentLinkButton(
|
||||
paymentLinkId: string;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -274,6 +295,7 @@ function DeletePaymentLinkButton(
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
paymentLinkId: id,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
|
||||
+9
@@ -6,6 +6,7 @@ import { getProject } from "@/api/project/projects";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { PayIcon } from "@/icons/PayIcon";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AdvancedSection } from "./components/AdvancedSection.client";
|
||||
import { QuickStartSection } from "./components/QuickstartSection.client";
|
||||
@@ -29,6 +30,8 @@ export default async function Page(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
@@ -51,7 +54,9 @@ export default async function Page(props: {
|
||||
component: (
|
||||
<CreatePaymentLinkButton
|
||||
clientId={project.publishableKey}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
>
|
||||
<Button className="gap-1.5 rounded-full" size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -141,13 +146,17 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col gap-12">
|
||||
<PaymentLinksTable
|
||||
clientId={project.publishableKey}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<QuickStartSection
|
||||
projectSlug={params.project_slug}
|
||||
teamSlug={params.team_slug}
|
||||
clientId={project.publishableKey}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
<AdvancedSection
|
||||
|
||||
+392
-196
@@ -3,16 +3,26 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import {
|
||||
DotIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type PropsWithChildren, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { randomPrivateKey } from "thirdweb/wallets";
|
||||
import { z } from "zod";
|
||||
import type { Webhook } from "@/api/universal-bridge/developer";
|
||||
import {
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
getWebhooks,
|
||||
updateWebhook,
|
||||
} from "@/api/universal-bridge/developer";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +37,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
@@ -45,29 +61,26 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { shortenString } from "@/utils/usedapp-external";
|
||||
|
||||
type PayWebhooksPageProps = {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
authToken: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
const webhooksQuery = useQuery({
|
||||
queryFn: async () => {
|
||||
return await getWebhooks({
|
||||
const res = await getWebhooks({
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
queryKey: ["webhooks", props.clientId, props.teamId],
|
||||
});
|
||||
@@ -78,11 +91,18 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
|
||||
if (!webhooksQuery.data?.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 rounded-lg border border-border p-8 text-center">
|
||||
<h2 className="font-semibold text-xl">No webhooks configured yet.</h2>
|
||||
<div className="flex flex-col items-center gap-6 rounded-lg border border-border bg-card p-8 text-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-xl">No webhooks configured</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a webhook to receive notifications for bridge, swap or onramp
|
||||
events.
|
||||
</p>
|
||||
</div>
|
||||
<CreatePaymentWebhookButton
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button className="gap-1.5 rounded-full">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -95,49 +115,24 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Url</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-center">Version</TableHead>
|
||||
<TableHead className="text-center">Delete</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooksQuery.data.map((webhook) => (
|
||||
<TableRow key={webhook.id}>
|
||||
<TableCell className="font-medium">{webhook.label}</TableCell>
|
||||
<TableCell>{webhook.url}</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{webhook.version || "1"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<DeleteWebhookButton
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
webhookId={webhook.id}
|
||||
>
|
||||
<Button size="icon" variant="ghost">
|
||||
<TrashIcon className="size-5" strokeWidth={1} />
|
||||
</Button>
|
||||
</DeleteWebhookButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="space-y-4">
|
||||
{webhooksQuery.data.map((webhook) => (
|
||||
<WebhookCard
|
||||
authToken={props.authToken}
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<CreatePaymentWebhookButton
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
>
|
||||
<Button className="gap-1.5 rounded-full">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -149,15 +144,105 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookCard(props: {
|
||||
webhook: Webhook;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
layoutPath: string;
|
||||
}) {
|
||||
const { webhook, clientId, teamId, layoutPath } = props;
|
||||
return (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className="rounded-xl border bg-card p-4 lg:p-6 relative hover:border-active-border"
|
||||
>
|
||||
<div className="flex mb-4">
|
||||
<div className="p-2 rounded-full bg-background border">
|
||||
<WebhookIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-medium mb-2">
|
||||
<Link
|
||||
href={`${layoutPath}/${webhook.id}`}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
{webhook.label || "Untitled Webhook"}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<EditPaymentWebhookButton
|
||||
clientId={clientId}
|
||||
teamId={teamId}
|
||||
webhook={webhook}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
|
||||
<DeleteWebhookButton
|
||||
clientId={clientId}
|
||||
teamId={teamId}
|
||||
webhookId={webhook.id}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created{" "}
|
||||
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
|
||||
</p>
|
||||
|
||||
{webhook.version && (
|
||||
<>
|
||||
<DotIcon className="size-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
v{webhook.version || "1"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[80%]">
|
||||
<CopyTextButton
|
||||
textToCopy={webhook.url}
|
||||
textToShow={webhook.url}
|
||||
tooltip="Copy URL"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-xs -translate-x-1.5 truncate"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
label: z.string().min(3, "Label must be at least 3 characters long"),
|
||||
secret: z.string().optional(),
|
||||
url: z.string().url("Please enter a valid URL."),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
function CreatePaymentWebhookButton(
|
||||
props: PropsWithChildren<PayWebhooksPageProps>,
|
||||
props: PropsWithChildren<{
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [secretStored, setSecretStored] = useState(false);
|
||||
@@ -166,26 +251,135 @@ function CreatePaymentWebhookButton(
|
||||
return randomPrivateKey();
|
||||
}, [open]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
defaultValues: {
|
||||
label: "",
|
||||
url: "",
|
||||
version: "2",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: z.infer<typeof formSchema>) => {
|
||||
await createWebhook({
|
||||
clientId: props.clientId,
|
||||
async function handleSubmit(values: z.infer<typeof formSchema>) {
|
||||
await createWebhook({
|
||||
clientId: props.clientId,
|
||||
authToken: props.authToken,
|
||||
label: values.label,
|
||||
secret,
|
||||
teamId: props.teamId,
|
||||
url: values.url,
|
||||
version: Number(values.version),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Webhook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Receive a webhook notification when a bridge, swap or onramp event
|
||||
occurs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<BridgeWebhookModalContent
|
||||
type="create"
|
||||
handleSubmit={handleSubmit}
|
||||
secret={secret}
|
||||
setOpen={setOpen}
|
||||
secretStored={secretStored}
|
||||
setSecretStored={setSecretStored}
|
||||
clientId={props.clientId}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPaymentWebhookButton(props: {
|
||||
webhook: Webhook;
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleSubmit(values: z.infer<typeof formSchema>) {
|
||||
await updateWebhook({
|
||||
clientId: props.clientId,
|
||||
webhookId: props.webhook.id,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
body: {
|
||||
label: values.label,
|
||||
secret,
|
||||
teamId: props.teamId,
|
||||
url: values.url,
|
||||
version: Number(values.version),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<BridgeWebhookModalContent
|
||||
type="edit"
|
||||
handleSubmit={handleSubmit}
|
||||
setOpen={setOpen}
|
||||
clientId={props.clientId}
|
||||
webhook={props.webhook}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BridgeWebhookModalContent(
|
||||
props: {
|
||||
clientId: string;
|
||||
handleSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||
setOpen: (value: boolean) => void;
|
||||
} & (
|
||||
| {
|
||||
type: "create";
|
||||
secret: string;
|
||||
secretStored: boolean;
|
||||
setSecretStored: (value: boolean) => void;
|
||||
}
|
||||
| {
|
||||
type: "edit";
|
||||
webhook: Webhook;
|
||||
}
|
||||
),
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
defaultValues:
|
||||
props.type === "create"
|
||||
? {
|
||||
label: "",
|
||||
url: "",
|
||||
version: "2",
|
||||
}
|
||||
: {
|
||||
label: props.webhook.label,
|
||||
url: props.webhook.url,
|
||||
version: props.webhook.version?.toString() || "2",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: z.infer<typeof formSchema>) => {
|
||||
await props.handleSubmit(values);
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -193,137 +387,129 @@ function CreatePaymentWebhookButton(
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
createMutation.mutateAsync(values, {
|
||||
onError: (err) => {
|
||||
toast.error("Failed to create webhook", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setSecretStored(false);
|
||||
toast.success("Webhook created successfully");
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
form.setValue("url", "");
|
||||
form.setValue("label", "");
|
||||
form.setValue("version", "2");
|
||||
form.setValue("secret", undefined);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Webhook</DialogTitle>
|
||||
<DialogDescription>
|
||||
Receive a webhook notification when a bridge, swap or onramp
|
||||
event occurs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>URL</RequiredFormLabel>
|
||||
<Input {...field} placeholder="https://" />
|
||||
<FormDescription>
|
||||
This is the URL that will receive the webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Label</RequiredFormLabel>
|
||||
<Input {...field} placeholder="My Webhook" />
|
||||
<FormDescription>
|
||||
A label to help you identify this webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="version"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Version</FormLabel>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="v2" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">v2</SelectItem>
|
||||
<SelectItem value="1">v1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the data format of the webhook payload (v2
|
||||
recommended, v1 for legacy users).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<FormLabel>Webhook Secret</FormLabel>
|
||||
|
||||
<CopyTextButton
|
||||
className="!h-auto my-1 w-full justify-between truncate bg-card px-3 py-3 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={secret}
|
||||
textToShow={shortenString(secret)}
|
||||
tooltip="Copy Webhook Secret"
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
mutation.mutateAsync(values, {
|
||||
onError: (err) => {
|
||||
toast.error("Failed to create webhook", {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
props.setOpen(false);
|
||||
toast.success(
|
||||
`Webhook ${props.type === "create" ? "created" : "updated"} successfully`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>URL</RequiredFormLabel>
|
||||
<Input {...field} placeholder="https://" autoComplete="off" />
|
||||
<FormDescription>
|
||||
Passed as a bearer token in all webhook requests to verify the
|
||||
authenticity of the request.
|
||||
This is the URL that will receive the webhook.
|
||||
</FormDescription>
|
||||
<CheckboxWithLabel className="my-2 text-foreground">
|
||||
<Checkbox
|
||||
checked={secretStored}
|
||||
onCheckedChange={(v) => {
|
||||
setSecretStored(!!v);
|
||||
}}
|
||||
/>
|
||||
I confirm that I've securely stored my webhook secret
|
||||
</CheckboxWithLabel>
|
||||
</section>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Label</RequiredFormLabel>
|
||||
<Input {...field} placeholder="My Webhook" />
|
||||
<FormDescription>
|
||||
A label to help you identify this webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={createMutation.isPending || !secretStored}
|
||||
type="submit"
|
||||
>
|
||||
Create Webhook
|
||||
{createMutation.isPending && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormField
|
||||
name="version"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Version</FormLabel>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="v2" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">v2</SelectItem>
|
||||
<SelectItem value="1">v1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the data format of the webhook payload (v2 recommended,
|
||||
v1 for legacy users).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{props.type === "create" && (
|
||||
<section>
|
||||
<FormLabel>Webhook Secret</FormLabel>
|
||||
|
||||
<CopyTextButton
|
||||
className="!h-auto my-1 w-full justify-between truncate bg-card px-3 py-3 font-mono"
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.secret}
|
||||
textToShow={shortenString(props.secret)}
|
||||
tooltip="Copy Webhook Secret"
|
||||
/>
|
||||
<FormDescription>
|
||||
Passed as a bearer token in all webhook requests to verify the
|
||||
authenticity of the request.
|
||||
</FormDescription>
|
||||
|
||||
<CheckboxWithLabel className="my-2 text-foreground">
|
||||
<Checkbox
|
||||
checked={!!props.secretStored}
|
||||
onCheckedChange={(v) => {
|
||||
props.setSecretStored(!!v);
|
||||
}}
|
||||
/>
|
||||
I confirm that I've securely stored my webhook secret
|
||||
</CheckboxWithLabel>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
(props.type === "create" && !props.secretStored)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{props.type === "create" ? "Create Webhook" : "Update Webhook"}
|
||||
{mutation.isPending && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteWebhookButton(
|
||||
props: PropsWithChildren<PayWebhooksPageProps & { webhookId: string }>,
|
||||
) {
|
||||
function DeleteWebhookButton(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
@@ -332,8 +518,8 @@ function DeleteWebhookButton(
|
||||
clientId: props.clientId,
|
||||
teamId: props.teamId,
|
||||
webhookId: id,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -343,7 +529,17 @@ function DeleteWebhookButton(
|
||||
});
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
+35
-1
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { type Fee, updateFee } from "@/api/universal-bridge/developer";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,6 +26,8 @@ interface PayConfigProps {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
fees: Fee;
|
||||
projectWalletAddress?: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
@@ -46,6 +49,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
feeBps: values.developerFeeBPS,
|
||||
feeRecipient: values.payoutAddress,
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -103,7 +107,37 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Recipient address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="0x..." />
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
{...field}
|
||||
className="sm:flex-1"
|
||||
placeholder="0x..."
|
||||
/>
|
||||
{props.projectWalletAddress && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!props.projectWalletAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
"payoutAddress",
|
||||
props.projectWalletAddress,
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Use Project Wallet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { getFees } from "@/api/universal-bridge/developer";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
|
||||
import { PayConfig } from "./PayConfig";
|
||||
@@ -31,9 +32,12 @@ export default async function Page(props: {
|
||||
redirect(`/team/${team_slug}`);
|
||||
}
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
|
||||
let fees = await getFees({
|
||||
clientId: project.publishableKey,
|
||||
teamId: team.id,
|
||||
authToken: authToken,
|
||||
}).catch(() => {
|
||||
return {
|
||||
createdAt: "",
|
||||
@@ -64,7 +68,9 @@ export default async function Page(props: {
|
||||
fees={fees}
|
||||
project={project}
|
||||
teamId={team.id}
|
||||
projectWalletAddress={projectWallet?.address}
|
||||
teamSlug={team_slug}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+4
@@ -74,6 +74,10 @@ export type CreateNFTCollectionFunctions = {
|
||||
count: number;
|
||||
};
|
||||
gasless: boolean;
|
||||
onNotEnoughFunds: (data: {
|
||||
requiredAmount: string;
|
||||
balance: string;
|
||||
}) => void;
|
||||
}) => Promise<void>;
|
||||
lazyMintNFTs: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
|
||||
+98
-3
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { encode, getContract, type ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
encode,
|
||||
estimateGasCost,
|
||||
getContract,
|
||||
getGasPrice,
|
||||
type PreparedTransaction,
|
||||
type ThirdwebClient,
|
||||
toEther,
|
||||
} from "thirdweb";
|
||||
import { deployERC721Contract, deployERC1155Contract } from "thirdweb/deploys";
|
||||
import { multicall } from "thirdweb/extensions/common";
|
||||
import {
|
||||
@@ -14,7 +22,8 @@ import {
|
||||
} from "thirdweb/extensions/erc1155";
|
||||
import { grantRole } from "thirdweb/extensions/permissions";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { maxUint256, resolvePromisedValue } from "thirdweb/utils";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import { reportContractDeployed } from "@/analytics/report";
|
||||
@@ -39,6 +48,10 @@ export function CreateNFTPage(props: {
|
||||
const contractAddressRef = useRef<string | undefined>(undefined);
|
||||
const getChain = useGetV5DashboardChain();
|
||||
const sendAndConfirmTx = useSendAndConfirmTx();
|
||||
const sendAndConfirmTxNoPayModal = useSendAndConfirmTx({
|
||||
payModal: false,
|
||||
});
|
||||
|
||||
function getAccount(params: { gasless: boolean }) {
|
||||
if (!activeAccount) {
|
||||
throw new Error("Wallet is not connected");
|
||||
@@ -130,6 +143,7 @@ export function CreateNFTPage(props: {
|
||||
contractName: contractType,
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
is_testnet: chain.testnet,
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
@@ -218,6 +232,10 @@ export function CreateNFTPage(props: {
|
||||
count: number;
|
||||
};
|
||||
gasless: boolean;
|
||||
onNotEnoughFunds: (data: {
|
||||
requiredAmount: string;
|
||||
balance: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const { values, batch } = params;
|
||||
const contract = getDeployedContract({
|
||||
@@ -277,7 +295,46 @@ export function CreateNFTPage(props: {
|
||||
data: encodedTransactions,
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(tx);
|
||||
// if there are more than one batches, on the first batch, we check if the user has enough funds to cover the cost of all the batches ->
|
||||
// calculate the cost of one batch, multiply by the number of batches to get the total cost
|
||||
// if the user does not have enough funds, call the onNotEnoughFunds callback
|
||||
|
||||
const totalBatches = Math.ceil(values.nfts.length / batch.count);
|
||||
|
||||
if (batch.startIndex === 0 && totalBatches > 1 && !params.gasless) {
|
||||
if (!activeAccount) {
|
||||
throw new Error("Wallet is not connected");
|
||||
}
|
||||
|
||||
const costPerBatch = await getTotalTransactionCost({
|
||||
tx: tx,
|
||||
from: activeAccount.address,
|
||||
});
|
||||
|
||||
const totalCost = costPerBatch * BigInt(totalBatches);
|
||||
|
||||
const walletBalance = await getWalletBalance({
|
||||
address: activeAccount.address,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
|
||||
if (walletBalance.value < totalCost) {
|
||||
params.onNotEnoughFunds({
|
||||
balance: toEther(walletBalance.value),
|
||||
requiredAmount: toEther(totalCost),
|
||||
});
|
||||
throw new Error(
|
||||
`Not enough funds: Required ${toEther(totalCost)}, Balance ${toEther(walletBalance.value)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendAndConfirmTxNoPayModal.mutateAsync(tx);
|
||||
} else if (totalBatches > 1) {
|
||||
await sendAndConfirmTxNoPayModal.mutateAsync(tx);
|
||||
} else {
|
||||
await sendAndConfirmTx.mutateAsync(tx);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetAdmins(params: {
|
||||
@@ -390,3 +447,41 @@ function transformSocialUrls(
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}
|
||||
|
||||
async function getTransactionGasCost(tx: PreparedTransaction, from?: string) {
|
||||
try {
|
||||
const gasCost = await estimateGasCost({
|
||||
from,
|
||||
transaction: tx,
|
||||
});
|
||||
|
||||
const bufferCost = gasCost.wei / 10n;
|
||||
|
||||
// Note: get tx.value AFTER estimateGasCost
|
||||
// add 10% extra gas cost to the estimate to ensure user buys enough to cover the tx cost
|
||||
return gasCost.wei + bufferCost;
|
||||
} catch {
|
||||
if (from) {
|
||||
// try again without passing from
|
||||
return await getTransactionGasCost(tx);
|
||||
}
|
||||
// fallback if both fail, use the tx value + 1M * gas price
|
||||
const gasPrice = await getGasPrice({
|
||||
chain: tx.chain,
|
||||
client: tx.client,
|
||||
});
|
||||
|
||||
return 1_000_000n * gasPrice;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTotalTransactionCost(params: {
|
||||
tx: PreparedTransaction;
|
||||
from?: string;
|
||||
}) {
|
||||
const [txValue, txGasCost] = await Promise.all([
|
||||
resolvePromisedValue(params.tx.value),
|
||||
getTransactionGasCost(params.tx, params.from),
|
||||
]);
|
||||
return (txValue || 0n) + txGasCost;
|
||||
}
|
||||
|
||||
+180
-32
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
ImageOffIcon,
|
||||
RefreshCcwIcon,
|
||||
ShoppingBagIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
BuyWidget,
|
||||
TokenProvider,
|
||||
TokenSymbol,
|
||||
useActiveAccount,
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { ChainOverview } from "../../_common/chain-overview";
|
||||
import { StepCard } from "../../_common/step-card";
|
||||
import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell";
|
||||
@@ -71,6 +77,10 @@ export function LaunchNFT(props: {
|
||||
const walletRequiresApproval = activeWallet?.id !== "inApp";
|
||||
const account = useActiveAccount();
|
||||
const contractAddressRef = useRef<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const chainMeta = useV5DashboardChain(
|
||||
Number(formValues.collectionInfo.chain),
|
||||
);
|
||||
|
||||
function updateStatus(
|
||||
index: number,
|
||||
@@ -161,6 +171,18 @@ export function LaunchNFT(props: {
|
||||
const [isGasless, setIsGasless] = useState(canEnableGasless);
|
||||
const showGaslessSection = false; // activeWallet?.id === "inApp";
|
||||
|
||||
const [notEnoughFunds, setNotEnoughFunds] = useState<
|
||||
| false
|
||||
| {
|
||||
values: {
|
||||
requiredAmount: string;
|
||||
balance: string;
|
||||
};
|
||||
isBuySuccess: boolean;
|
||||
showBuyWidget: boolean;
|
||||
}
|
||||
>(false);
|
||||
|
||||
const contractLink = contractAddressRef.current
|
||||
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
|
||||
: null;
|
||||
@@ -204,6 +226,16 @@ export function LaunchNFT(props: {
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
onNotEnoughFunds: (data) => {
|
||||
setNotEnoughFunds({
|
||||
values: {
|
||||
balance: data.balance,
|
||||
requiredAmount: data.requiredAmount,
|
||||
},
|
||||
isBuySuccess: false,
|
||||
showBuyWidget: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
batchesProcessedRef.current += 1;
|
||||
@@ -216,6 +248,16 @@ export function LaunchNFT(props: {
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
onNotEnoughFunds: (data) => {
|
||||
setNotEnoughFunds({
|
||||
values: {
|
||||
balance: data.balance,
|
||||
requiredAmount: data.requiredAmount,
|
||||
},
|
||||
isBuySuccess: false,
|
||||
showBuyWidget: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -274,6 +316,8 @@ export function LaunchNFT(props: {
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
error: errorMessage,
|
||||
step: currentStep.id,
|
||||
is_testnet: chainMeta.testnet,
|
||||
chainId: Number(formValues.collectionInfo.chain),
|
||||
});
|
||||
|
||||
throw error;
|
||||
@@ -283,6 +327,8 @@ export function LaunchNFT(props: {
|
||||
reportAssetCreationSuccessful({
|
||||
assetType: "nft",
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
chainId: Number(formValues.collectionInfo.chain),
|
||||
is_testnet: chainMeta?.testnet ?? false,
|
||||
});
|
||||
|
||||
props.onLaunchSuccess();
|
||||
@@ -357,41 +403,143 @@ export function LaunchNFT(props: {
|
||||
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
|
||||
dialogCloseClassName="hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
{walletRequiresApproval && (
|
||||
<DialogDescription>
|
||||
Each step will prompt a signature request in your wallet
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{/** biome-ignore lint/complexity/useOptionalChain: can't change it to optional chain */}
|
||||
{notEnoughFunds && notEnoughFunds.showBuyWidget ? (
|
||||
<div className="bg-card">
|
||||
<div className="border-b py-2 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setNotEnoughFunds({
|
||||
...notEnoughFunds,
|
||||
showBuyWidget: false,
|
||||
})
|
||||
}
|
||||
className="gap-2 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<BuyWidget
|
||||
client={props.client}
|
||||
chain={chain}
|
||||
amount={String(
|
||||
Number(notEnoughFunds.values.requiredAmount) -
|
||||
Number(notEnoughFunds.values.balance),
|
||||
)}
|
||||
buttonLabel={`Buy ${chainMeta?.nativeCurrency?.symbol || "ETH"}`}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
className="!border-none !w-full !rounded-none"
|
||||
onSuccess={() => {
|
||||
setNotEnoughFunds({
|
||||
...notEnoughFunds,
|
||||
isBuySuccess: true,
|
||||
showBuyWidget: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 p-6 mb-2">
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
{walletRequiresApproval && (
|
||||
<DialogDescription>
|
||||
Each step will prompt a signature request in your wallet
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<MultiStepStatus
|
||||
onRetry={handleRetry}
|
||||
renderError={(step, errorMessage) => {
|
||||
if (
|
||||
props.teamPlan === "free" &&
|
||||
errorMessage.toLowerCase().includes("storage limit")
|
||||
) {
|
||||
return (
|
||||
<StorageErrorPlanUpsell
|
||||
onRetry={() => handleRetry(step)}
|
||||
teamSlug={props.teamSlug}
|
||||
trackingCampaign="create-nft"
|
||||
/>
|
||||
);
|
||||
}
|
||||
<MultiStepStatus
|
||||
onRetry={handleRetry}
|
||||
renderError={(step, errorMessage) => {
|
||||
if (
|
||||
props.teamPlan === "free" &&
|
||||
errorMessage.toLowerCase().includes("storage limit")
|
||||
) {
|
||||
return (
|
||||
<StorageErrorPlanUpsell
|
||||
onRetry={() => handleRetry(step)}
|
||||
teamSlug={props.teamSlug}
|
||||
trackingCampaign="create-nft"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
steps={steps}
|
||||
/>
|
||||
</div>
|
||||
if (notEnoughFunds) {
|
||||
return (
|
||||
<div>
|
||||
{notEnoughFunds.isBuySuccess ? (
|
||||
<p className="text-success-text text-sm mt-1.5 mb-2">
|
||||
Funds bought successfully
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 mb-2 space-y-1">
|
||||
<p className="text-destructive-text text-sm">
|
||||
Not enough funds
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Required Funds:{" "}
|
||||
{notEnoughFunds.values.requiredAmount}{" "}
|
||||
{chainMeta?.nativeCurrency?.symbol || "ETH"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Balance: {notEnoughFunds.values.balance}{" "}
|
||||
{chainMeta?.nativeCurrency?.symbol || "ETH"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-4 border-border border-t bg-card p-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleRetry(step);
|
||||
setNotEnoughFunds(false);
|
||||
}}
|
||||
size="sm"
|
||||
variant={
|
||||
notEnoughFunds.isBuySuccess
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCcwIcon className="size-3.5" />
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
{!notEnoughFunds.isBuySuccess && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNotEnoughFunds({
|
||||
...notEnoughFunds,
|
||||
showBuyWidget: true,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<ShoppingBagIcon className="size-3.5" />
|
||||
Buy Funds
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
steps={steps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-4 border-border border-t bg-card p-6">
|
||||
{!isComplete ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
+6
-1
@@ -178,12 +178,14 @@ export function CreateTokenAssetPage(props: {
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
const chainMetadata = getChain(Number(params.values.chain));
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(params.values.chain),
|
||||
contractName: "DropERC20",
|
||||
contractName: "ERC20Asset",
|
||||
deploymentType: "asset",
|
||||
publisher: account.address,
|
||||
is_testnet: chainMetadata.testnet,
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
@@ -299,12 +301,15 @@ export function CreateTokenAssetPage(props: {
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
const chainMetadata = getChain(Number(values.chain));
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(values.chain),
|
||||
contractName: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
is_testnet: chainMetadata.testnet,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
+4
@@ -230,6 +230,8 @@ export function LaunchTokenStatus(props: {
|
||||
: "ERC20Asset",
|
||||
error: errorMessage,
|
||||
step: currentStep.id,
|
||||
is_testnet: chainMetadata?.testnet,
|
||||
chainId: Number(formValues.chain),
|
||||
});
|
||||
|
||||
updateStatus(i, {
|
||||
@@ -247,6 +249,8 @@ export function LaunchTokenStatus(props: {
|
||||
formValues.saleMode === "drop-erc20:token-drop"
|
||||
? "DropERC20"
|
||||
: "ERC20Asset",
|
||||
chainId: Number(formValues.chain),
|
||||
is_testnet: chainMetadata?.testnet,
|
||||
});
|
||||
|
||||
if (contractAddressRef.current) {
|
||||
|
||||
+48
-18
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -49,6 +50,8 @@ export function TransactionsTableUI(props: {
|
||||
getData: (params: {
|
||||
page: number;
|
||||
status: TransactionStatus | undefined;
|
||||
id: string | undefined;
|
||||
from: string | undefined;
|
||||
}) => Promise<TransactionsResponse>;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
@@ -60,13 +63,15 @@ export function TransactionsTableUI(props: {
|
||||
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 }),
|
||||
queryKey: ["transactions", props.project.id, page, status],
|
||||
queryFn: () => props.getData({ page, status, id, from }),
|
||||
queryKey: ["transactions", props.project.id, page, status, id, from],
|
||||
refetchInterval: autoUpdate ? 4_000 : false,
|
||||
});
|
||||
|
||||
@@ -84,18 +89,18 @@ export function TransactionsTableUI(props: {
|
||||
|
||||
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 lg:flex-row 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 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 items-center justify-end gap-5 border-border border-t pt-4 lg:border-none lg:pt-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<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}
|
||||
@@ -103,14 +108,39 @@ export function TransactionsTableUI(props: {
|
||||
onCheckedChange={(v) => setAutoUpdate(!!v)}
|
||||
/>
|
||||
</div>
|
||||
<StatusSelector
|
||||
setStatus={(v) => {
|
||||
setStatus(v);
|
||||
// reset page
|
||||
</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);
|
||||
}}
|
||||
status={status}
|
||||
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>
|
||||
|
||||
|
||||
+9
-1
@@ -16,11 +16,13 @@ export function TransactionsTable(props: {
|
||||
return (
|
||||
<TransactionsTableUI
|
||||
client={props.client}
|
||||
getData={async ({ page, status }) => {
|
||||
getData={async ({ page, status, id, from }) => {
|
||||
return await getTransactions({
|
||||
page,
|
||||
project: props.project,
|
||||
status,
|
||||
id,
|
||||
from,
|
||||
});
|
||||
}}
|
||||
project={props.project}
|
||||
@@ -34,10 +36,14 @@ 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 }>(
|
||||
{
|
||||
@@ -52,6 +58,8 @@ async function getTransactions({
|
||||
limit: "20",
|
||||
page: page.toString(),
|
||||
status: status ?? undefined,
|
||||
id: id ?? undefined,
|
||||
from: from ?? undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+242
-2
@@ -3,14 +3,17 @@
|
||||
import { encrypt } from "@thirdweb-dev/service-utils";
|
||||
import {
|
||||
createAccessToken,
|
||||
createEoa,
|
||||
createServiceAccount,
|
||||
createVaultClient,
|
||||
rotateServiceAccount,
|
||||
type VaultClient,
|
||||
} from "@thirdweb-dev/vault-sdk";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { updateProjectClient } from "@/hooks/useApi";
|
||||
import { getProjectWalletLabel } from "@/lib/project-wallet";
|
||||
|
||||
const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
|
||||
"Access Token for All Server Wallets";
|
||||
@@ -126,6 +129,92 @@ export async function createVaultAccountAndAccessToken(props: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProjectServerWallet(props: {
|
||||
project: Project;
|
||||
managementAccessToken: string;
|
||||
label?: string;
|
||||
setAsProjectWallet?: boolean;
|
||||
}) {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const walletLabel = props.label?.trim()
|
||||
? props.label.trim()
|
||||
: getProjectWalletLabel(props.project.name);
|
||||
|
||||
const eoa = await createEoa({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: props.managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
metadata: {
|
||||
label: walletLabel,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
type: "server-wallet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eoa.success) {
|
||||
throw new Error(eoa.error?.message || "Failed to create server wallet");
|
||||
}
|
||||
|
||||
engineCloudProxy({
|
||||
body: JSON.stringify({
|
||||
signerAddress: eoa.data.address,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/cache/smart-account",
|
||||
}).catch((err) => {
|
||||
console.warn("failed to cache server wallet", err);
|
||||
});
|
||||
|
||||
if (props.setAsProjectWallet) {
|
||||
await updateDefaultProjectWallet({
|
||||
project: props.project,
|
||||
projectWalletAddress: eoa.data.address,
|
||||
});
|
||||
}
|
||||
|
||||
return eoa.data;
|
||||
}
|
||||
|
||||
export async function updateDefaultProjectWallet(props: {
|
||||
project: Project;
|
||||
projectWalletAddress: string;
|
||||
}) {
|
||||
const services = props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
);
|
||||
const engineCloudService = props.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (engineCloudService) {
|
||||
const engineCloudServiceWithProjectWallet = {
|
||||
...engineCloudService,
|
||||
projectWalletAddress: props.projectWalletAddress,
|
||||
};
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [...services, engineCloudServiceWithProjectWallet],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndEncryptVaultAccessTokens(props: {
|
||||
project: Project;
|
||||
vaultClient: VaultClient;
|
||||
@@ -157,6 +246,13 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
// create a default project server wallet
|
||||
const defaultProjectServerWallet = await createProjectServerWallet({
|
||||
project,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
label: getProjectWalletLabel(project.name),
|
||||
});
|
||||
|
||||
if (projectSecretKey) {
|
||||
// verify that the project secret key is valid
|
||||
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
|
||||
@@ -182,7 +278,9 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
@@ -191,6 +289,7 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -204,7 +303,9 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
@@ -213,6 +314,7 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
encryptedAdminKey: null,
|
||||
encryptedWalletAccessToken: null,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -458,6 +560,98 @@ export async function createWalletAccessToken(props: {
|
||||
],
|
||||
type: "eoa:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:read",
|
||||
},
|
||||
{
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:signTransaction",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:signMessage",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -531,6 +725,52 @@ async function createManagementAccessToken(props: {
|
||||
],
|
||||
type: "eoa:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:read",
|
||||
},
|
||||
{
|
||||
requiredMetadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "solana:create",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
|
||||
+9
-42
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createEoa } from "@thirdweb-dev/vault-sdk";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -17,12 +15,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { initVaultClient } from "../../lib/vault.client";
|
||||
import { createProjectServerWallet } from "../../lib/vault.client";
|
||||
|
||||
export default function CreateServerWallet(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
managementAccessToken: string | undefined;
|
||||
setAsProjectWallet?: boolean;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const [label, setLabel] = useState("");
|
||||
@@ -36,49 +35,17 @@ export default function CreateServerWallet(props: {
|
||||
managementAccessToken: string;
|
||||
label: string;
|
||||
}) => {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const eoa = await createEoa({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
metadata: {
|
||||
label,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
type: "server-wallet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!eoa.success) {
|
||||
throw new Error("Failed to create eoa");
|
||||
}
|
||||
|
||||
// no need to await this, it's not blocking
|
||||
engineCloudProxy({
|
||||
body: JSON.stringify({
|
||||
signerAddress: eoa.data.address,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/cache/smart-account",
|
||||
}).catch((err) => {
|
||||
console.warn("failed to cache server wallet", err);
|
||||
const wallet = await createProjectServerWallet({
|
||||
label,
|
||||
managementAccessToken,
|
||||
project: props.project,
|
||||
setAsProjectWallet: props.setAsProjectWallet,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
setModalOpen(false);
|
||||
|
||||
return eoa;
|
||||
setLabel("");
|
||||
return wallet;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
+75
-35
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
CheckIcon,
|
||||
MoreVerticalIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} 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 {
|
||||
@@ -20,6 +22,7 @@ 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,
|
||||
@@ -50,7 +53,9 @@ import {
|
||||
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";
|
||||
|
||||
@@ -96,15 +101,22 @@ export function ServerWalletsTableUI({
|
||||
</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">
|
||||
<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 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
|
||||
@@ -187,25 +199,10 @@ export function ServerWalletsTableUI({
|
||||
<XIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">No server wallets found</p>
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{wallets.length > 0 && (
|
||||
<div className="flex justify-end items-center p-4 py-5 lg:px-6 border-t">
|
||||
<CreateServerWallet
|
||||
managementAccessToken={managementAccessToken}
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col items-center border-t p-6">
|
||||
<div className="mb-4 text-muted-foreground text-sm">
|
||||
@@ -215,7 +212,7 @@ export function ServerWalletsTableUI({
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions/server-wallets?page=${
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
|
||||
currentPage > 1 ? currentPage - 1 : 1
|
||||
}`}
|
||||
legacyBehavior
|
||||
@@ -232,7 +229,7 @@ export function ServerWalletsTableUI({
|
||||
(pageNumber) => (
|
||||
<PaginationItem key={`page-${pageNumber}`}>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions/server-wallets?page=${pageNumber}`}
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${pageNumber}`}
|
||||
passHref
|
||||
>
|
||||
<PaginationLink isActive={currentPage === pageNumber}>
|
||||
@@ -244,7 +241,7 @@ export function ServerWalletsTableUI({
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Link
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions/server-wallets?page=${
|
||||
href={`/team/${teamSlug}/${project.slug}/transactions?page=${
|
||||
currentPage < totalPages ? currentPage + 1 : totalPages
|
||||
}`}
|
||||
passHref
|
||||
@@ -295,18 +292,34 @@ function ServerWalletTableRow(props: {
|
||||
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>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-foreground",
|
||||
!wallet.metadata.label && "text-muted-foreground",
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
{wallet.metadata.label || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Address */}
|
||||
@@ -399,6 +412,25 @@ function WalletActionsDropdown(props: {
|
||||
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 (
|
||||
<>
|
||||
@@ -428,6 +460,14 @@ function WalletActionsDropdown(props: {
|
||||
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>
|
||||
|
||||
|
||||
+69
-7
@@ -1,3 +1,4 @@
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
@@ -6,10 +7,13 @@ import type { DurationId } from "@/components/analytics/date-range-selector";
|
||||
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
|
||||
import { ServerWalletsTable } from "../transactions/server-wallets/wallet-table/wallet-table";
|
||||
import { InAppWalletAnalytics } from "./analytics/chart";
|
||||
import { InAppWalletsSummary } from "./analytics/chart/Summary";
|
||||
|
||||
@@ -20,6 +24,7 @@ export default async function Page(props: {
|
||||
to?: string;
|
||||
type?: string;
|
||||
interval?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [searchParams, params] = await Promise.all([
|
||||
@@ -40,12 +45,47 @@ export default async function Page(props: {
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
const [vaultClient, project] = await Promise.all([
|
||||
createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
}).catch(() => undefined),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const projectEngineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
|
||||
// Fetch server wallets with pagination (5 per page)
|
||||
const pageSize = 5;
|
||||
const currentPage = Number.parseInt(searchParams.page ?? "1");
|
||||
|
||||
const eoas =
|
||||
vaultClient && managementAccessToken
|
||||
? await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: currentPage - 1,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
})
|
||||
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
|
||||
|
||||
const serverWallets = eoas.data?.items as Wallet[] | undefined;
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
@@ -103,12 +143,34 @@ export default async function Page(props: {
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
<InAppWalletUsersPageContent
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
{/* Server Wallets Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{eoas.error ? null : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
currentPage={currentPage}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
totalRecords={eoas.data.totalRecords}
|
||||
wallets={serverWallets ?? []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Wallets Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User wallets
|
||||
</h2>
|
||||
<InAppWalletUsersPageContent
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectPage>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type {
|
||||
getWebhookSends,
|
||||
WebhookSend,
|
||||
} from "@/api/universal-bridge/developer";
|
||||
import { WebhookSendsUI } from "./webhook-sends";
|
||||
|
||||
const meta = {
|
||||
title: "app/webhooks/WebhookSendsUI",
|
||||
component: WebhookSendsUI,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-7xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof WebhookSendsUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type WebhookSendResult = Awaited<ReturnType<typeof getWebhookSends>>;
|
||||
|
||||
function createMockWebhookSendItem(): WebhookSend {
|
||||
const type = Math.random() > 0.5 ? "onramp" : "bridge";
|
||||
const status =
|
||||
Math.random() > 0.5
|
||||
? "PENDING"
|
||||
: Math.random() > 0.5
|
||||
? "COMPLETED"
|
||||
: "FAILED";
|
||||
|
||||
const responseStatus =
|
||||
Math.random() > 0.5 ? (Math.random() > 0.5 ? 200 : 500) : 404;
|
||||
|
||||
const responseType = Math.random() > 0.5 ? "json" : "text";
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
webhookId: "8fa29fe7-95a9-4b70-97a6-7e80504f5a75",
|
||||
webhookUrl: "https://example.webhook/foo/bar",
|
||||
paymentId:
|
||||
"0x9620fef2c2267b223b8e998b1453b15324acf3ac03e531c9b60c38d6a02843cb",
|
||||
transactionId:
|
||||
type === "bridge"
|
||||
? "0x79fa097789678db4fcb8712d49133a9447e0039c8a8f93be0cfe3664ac304b80"
|
||||
: null,
|
||||
onrampId: type === "onramp" ? crypto.randomUUID() : null,
|
||||
status: status,
|
||||
body: {
|
||||
version: 1,
|
||||
type: type === "bridge" ? "pay.onchain-transaction" : undefined,
|
||||
data:
|
||||
type === "bridge"
|
||||
? {
|
||||
buyWithCryptoStatus: {
|
||||
quote: {
|
||||
fromToken: {
|
||||
chainId: 42161,
|
||||
tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
decimals: 18,
|
||||
priceUSDCents: 452169,
|
||||
name: "Ether",
|
||||
symbol: "ETH",
|
||||
},
|
||||
toToken: {
|
||||
chainId: 8453,
|
||||
tokenAddress: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
decimals: 18,
|
||||
priceUSDCents: 451824,
|
||||
name: "Ether",
|
||||
symbol: "ETH",
|
||||
},
|
||||
fromAmountWei: "101000000000000",
|
||||
fromAmount: "0.000101",
|
||||
toAmountWei: "100000000000000",
|
||||
toAmount: "0.0001",
|
||||
toAmountMin: "0.0001",
|
||||
toAmountMinWei: "100000000000000",
|
||||
estimated: {
|
||||
fromAmountUSDCents: 46,
|
||||
toAmountMinUSDCents: 45,
|
||||
toAmountUSDCents: 45,
|
||||
slippageBPS: 0,
|
||||
feesUSDCents: 0,
|
||||
gasCostUSDCents: 0,
|
||||
durationSeconds: 60,
|
||||
},
|
||||
createdAt: "2025-09-16T23:20:53.228Z",
|
||||
},
|
||||
status: "PENDING",
|
||||
subStatus: "NONE",
|
||||
fromAddress: "0x2a4f24f935eb178e3e7ba9b53a5ee6d8407c0709",
|
||||
toAddress: "0x2a4f24f935eb178e3e7ba9b53a5ee6d8407c0709",
|
||||
bridge: "Universal Bridge",
|
||||
},
|
||||
}
|
||||
: {
|
||||
buyWithFiatStatus: {
|
||||
intentId: "9833815f-77b8-4e3e-b0cf-b8741be16ede",
|
||||
fromAddress: "0x0000000000000000000000000000000000000000",
|
||||
toAddress: "0xEbE5C4774fd4eEA444094367E01d26f0771e248b",
|
||||
status: "PENDING",
|
||||
quote: {
|
||||
createdAt: "2025-06-19T18:32:42.092Z",
|
||||
estimatedOnRampAmount: "0.002",
|
||||
estimatedOnRampAmountWei: "2000000000000000",
|
||||
estimatedToTokenAmount: "0.002",
|
||||
estimatedToTokenAmountWei: "2000000000000000",
|
||||
onRampToken: {
|
||||
chainId: 42161,
|
||||
address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
|
||||
symbol: "ETH",
|
||||
name: "Ether",
|
||||
decimals: 18,
|
||||
priceUsd: 2511.635965,
|
||||
iconUri: "https://assets.relay.link/icons/1/light.png",
|
||||
},
|
||||
toToken: {
|
||||
chainId: 42161,
|
||||
address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
|
||||
symbol: "ETH",
|
||||
name: "Ether",
|
||||
decimals: 18,
|
||||
priceUsd: 2511.635965,
|
||||
iconUri: "https://assets.relay.link/icons/1/light.png",
|
||||
},
|
||||
estimatedDurationSeconds: 60,
|
||||
},
|
||||
failureMessage: "",
|
||||
purchaseData: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
success: true,
|
||||
responseStatus: responseStatus,
|
||||
response:
|
||||
responseType === "json"
|
||||
? '{ "message": "Success" }'
|
||||
: "This is a plaintext response",
|
||||
createdAt: "2025-09-16T23:20:53.981Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createMockWebhookSendResult(length: number): WebhookSendResult {
|
||||
return {
|
||||
data: Array.from({ length }, createMockWebhookSendItem),
|
||||
pagination: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
total: 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mockEmptyWebhookSendResult: WebhookSendResult = {
|
||||
data: [],
|
||||
pagination: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000000));
|
||||
return createMockWebhookSendResult(1);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TenResults: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(10);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OneResult: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(1);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FewResults: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(3);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoResults: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return mockEmptyWebhookSendResult;
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to fetch webhook sends");
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ResendError: Story = {
|
||||
args: {
|
||||
webhookId: "mock-webhook-id",
|
||||
authToken: "mock-auth-token",
|
||||
projectClientId: "mock-project-id",
|
||||
teamId: "mock-team-id",
|
||||
getWebhookSends: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return createMockWebhookSendResult(1);
|
||||
},
|
||||
resendWebhook: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
throw new Error("Failed to resend webhook");
|
||||
},
|
||||
},
|
||||
};
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getWebhookById } from "@/api/universal-bridge/developer";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { WebhookSends } from "./webhook-sends";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const [authToken, params] = await Promise.all([getAuthToken(), props.params]);
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
|
||||
if (!project || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const webhook = await getWebhookById({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
authToken,
|
||||
webhookId: params.id,
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/webhooks/payments`}
|
||||
>
|
||||
Webhooks
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="space-y-0.5 border-b border-dashed pb-6 overflow-hidden ">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{webhook.label || "Untitled Webhook"}
|
||||
</h1>
|
||||
<CopyTextButton
|
||||
textToCopy={webhook.url}
|
||||
textToShow={webhook.url}
|
||||
tooltip="Copy URL"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-sm -translate-x-1.5 truncate max-w-full"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WebhookSends
|
||||
webhookId={params.id}
|
||||
authToken={authToken}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { PlainTextCodeBlock } from "@workspace/ui/components/code/plaintext-code";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { formatDate } from "date-fns";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
DotIcon,
|
||||
RotateCwIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getWebhookSends,
|
||||
resendWebhook,
|
||||
type WebhookSend,
|
||||
} from "@/api/universal-bridge/developer";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
type WebhookSendsProps = {
|
||||
webhookId: string;
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function WebhookSends(props: WebhookSendsProps) {
|
||||
return (
|
||||
<WebhookSendsUI
|
||||
{...props}
|
||||
getWebhookSends={getWebhookSends}
|
||||
resendWebhook={resendWebhook}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookSendsUI(
|
||||
props: WebhookSendsProps & {
|
||||
getWebhookSends: typeof getWebhookSends;
|
||||
resendWebhook: typeof resendWebhook;
|
||||
},
|
||||
) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [_selectedWebhookSend, setSelectedWebhookSend] =
|
||||
useState<WebhookSend | null>(null);
|
||||
const [successFilter, _setSuccessFilter] = useState<
|
||||
"all" | "success" | "failed"
|
||||
>("all");
|
||||
const isMobile = useIsMobile();
|
||||
const [_isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const isDialogOpen = isMobile && _isDialogOpen;
|
||||
|
||||
const webhookSendsQuery = useQuery({
|
||||
queryKey: ["webhook-sends", props.webhookId, page],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
queryFn: () =>
|
||||
props.getWebhookSends({
|
||||
webhookId: props.webhookId,
|
||||
authToken: props.authToken,
|
||||
projectClientId: props.projectClientId,
|
||||
teamId: props.teamId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
success:
|
||||
successFilter === "all" ? undefined : successFilter === "success",
|
||||
}),
|
||||
// preserve the previous data when the page changes
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const totalItems = webhookSendsQuery.data?.pagination.total;
|
||||
const totalPages = totalItems ? Math.ceil(totalItems / pageSize) : 1;
|
||||
const selectedWebhookSend =
|
||||
_selectedWebhookSend || (isMobile ? null : webhookSendsQuery.data?.data[0]);
|
||||
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="pb-4 border-b">
|
||||
<h2 className="text-lg font-semibold">Events</h2>
|
||||
{/* show a filter selector here */}
|
||||
</div>
|
||||
|
||||
{webhookSendsQuery.isError ? (
|
||||
<div className="text-muted-foreground text-base py-20 flex items-center justify-center border-b">
|
||||
Failed to load events
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-b">
|
||||
{/* grid */}
|
||||
<div className="flex flex-col gap-2 lg:grid lg:grid-cols-[1fr_750px] ">
|
||||
{/* left */}
|
||||
<div>
|
||||
{webhookSendsQuery.isFetching &&
|
||||
Array.from({ length: pageSize }).map(() => (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: ok
|
||||
<WebhookSendCompactInfoSkeleton />
|
||||
))}
|
||||
|
||||
{!webhookSendsQuery.isFetching &&
|
||||
webhookSendsQuery.data?.data.map((webhookSend) => (
|
||||
<WebhookSendCompactInfo
|
||||
key={webhookSend.id}
|
||||
webhookSend={webhookSend}
|
||||
isSelected={selectedWebhookSend?.id === webhookSend.id}
|
||||
onSelect={() => {
|
||||
setSelectedWebhookSend(webhookSend);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!webhookSendsQuery.isFetching &&
|
||||
webhookSendsQuery.data?.data.length === 0 && (
|
||||
<div className="text-muted-foreground text-base py-20 flex items-center justify-center">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* right - desktop */}
|
||||
<div className="hidden lg:block lg:pl-4 lg:border-l lg:pt-4">
|
||||
{webhookSendsQuery.isFetching ? (
|
||||
<div className="h-full flex justify-center items-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
) : selectedWebhookSend ? (
|
||||
<WebhookSendInfo
|
||||
webhookSend={selectedWebhookSend}
|
||||
authToken={props.authToken}
|
||||
projectClientId={props.projectClientId}
|
||||
teamId={props.teamId}
|
||||
resendWebhook={props.resendWebhook}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Dialog for mobile */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedWebhookSend(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto p-4">
|
||||
{selectedWebhookSend && (
|
||||
<WebhookSendInfo
|
||||
webhookSend={selectedWebhookSend}
|
||||
authToken={props.authToken}
|
||||
projectClientId={props.projectClientId}
|
||||
teamId={props.teamId}
|
||||
resendWebhook={props.resendWebhook}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{totalPages && totalPages > 1 && (
|
||||
<div className="py-5 border-t">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
totalPages={totalPages}
|
||||
onPageClick={(page) => {
|
||||
setPage(page);
|
||||
setSelectedWebhookSend(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookSendInfo(props: {
|
||||
webhookSend: WebhookSend;
|
||||
authToken: string;
|
||||
projectClientId: string;
|
||||
teamId: string;
|
||||
resendWebhook: typeof resendWebhook;
|
||||
}) {
|
||||
const bodyString = useMemo(() => {
|
||||
try {
|
||||
if (!props.webhookSend.body) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof props.webhookSend.body === "object" &&
|
||||
props.webhookSend.body !== null
|
||||
) {
|
||||
return JSON.stringify(props.webhookSend.body, null, 2);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [props.webhookSend.body]);
|
||||
|
||||
const responseString: { lang: "json" | "text"; code: string } =
|
||||
useMemo(() => {
|
||||
if (!props.webhookSend.response) {
|
||||
return {
|
||||
lang: "text",
|
||||
code: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(props.webhookSend.response);
|
||||
return {
|
||||
lang: "json",
|
||||
code: JSON.stringify(json, null, 2),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
lang: "text",
|
||||
code: props.webhookSend.response,
|
||||
};
|
||||
}
|
||||
}, [props.webhookSend.response]);
|
||||
|
||||
const resendMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (props.webhookSend.onrampId) {
|
||||
const result = await props.resendWebhook({
|
||||
onrampId: props.webhookSend.onrampId,
|
||||
authToken: props.authToken,
|
||||
projectClientId: props.projectClientId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
return result;
|
||||
} else if (props.webhookSend.paymentId) {
|
||||
const result = await props.resendWebhook({
|
||||
paymentId: props.webhookSend.paymentId,
|
||||
authToken: props.authToken,
|
||||
projectClientId: props.projectClientId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
throw new Error("No transaction or onramp ID found");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Webhook resent");
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error("Failed to resend webhook", {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pb-6 relative fade-in-0 duration-300 animate-in flex flex-col min-w-0">
|
||||
<div className="pr-10">
|
||||
<h3 className="text-xl font-semibold mb-1">
|
||||
<span>{getWebhookSendTitle(props.webhookSend)}.</span>
|
||||
<span>{props.webhookSend.status.toLowerCase()}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sent on{" "}
|
||||
{formatDate(
|
||||
new Date(props.webhookSend.createdAt),
|
||||
"MMM d, yyyy HH:mm:ss",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="lg:absolute lg:top-0 lg:right-0 gap-2 rounded-lg bg-card mt-4 lg:mt-0"
|
||||
onClick={() => resendMutation.mutate()}
|
||||
>
|
||||
{resendMutation.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RotateCwIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
Resend
|
||||
</Button>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* succes */}
|
||||
<div>
|
||||
<h3 className="text-sm mb-0.5 font-medium">Delivery Status</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{" "}
|
||||
{props.webhookSend.success ? "Delivered" : "Failed"}{" "}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* transaction id */}
|
||||
{props.webhookSend.transactionId && (
|
||||
<div>
|
||||
<h3 className="text-sm mb-0.5 font-medium">Transaction ID</h3>
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.webhookSend.transactionId}
|
||||
textToShow={props.webhookSend.transactionId}
|
||||
tooltip="Copy Transaction ID"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-sm -translate-x-1.5 truncate max-w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment ID */}
|
||||
{props.webhookSend.paymentId && (
|
||||
<CopyTextField
|
||||
label="Payment ID"
|
||||
value={props.webhookSend.paymentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* onramp id */}
|
||||
{props.webhookSend.onrampId && (
|
||||
<CopyTextField label="Onramp ID" value={props.webhookSend.onrampId} />
|
||||
)}
|
||||
|
||||
{/* event id */}
|
||||
{props.webhookSend.id && (
|
||||
<CopyTextField label="Event ID" value={props.webhookSend.id} />
|
||||
)}
|
||||
|
||||
{/* payload */}
|
||||
<div>
|
||||
<h3 className="text-sm mb-2 font-medium">Payload</h3>
|
||||
<CodeClient
|
||||
code={bodyString || ""}
|
||||
lang="json"
|
||||
scrollableClassName="max-h-[350px]"
|
||||
className="[&_code]:text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* response */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium">Response</h3>
|
||||
<DotIcon className="size-3.5 text-muted-foreground hidden" />
|
||||
<StatusBadge status={props.webhookSend.responseStatus} />
|
||||
</div>
|
||||
|
||||
{responseString.lang === "text" ? (
|
||||
<PlainTextCodeBlock
|
||||
code={responseString.code}
|
||||
scrollableClassName="max-h-[350px]"
|
||||
codeClassName="whitespace-pre-wrap text-xs"
|
||||
/>
|
||||
) : (
|
||||
<CodeClient
|
||||
code={responseString.code}
|
||||
lang="json"
|
||||
scrollableClassName="max-h-[350px]"
|
||||
className="[&_code]:text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyTextField(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm mb-0.5 font-medium"> {props.label}</h3>
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
textToCopy={props.value}
|
||||
textToShow={props.value}
|
||||
tooltip={`Copy ${props.label}`}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground text-sm -translate-x-1.5 truncate max-w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookSendCompactInfo(props: {
|
||||
webhookSend: WebhookSend;
|
||||
isSelected: boolean;
|
||||
onSelect: (webhookSend: WebhookSend) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-2 border-b last:border-b-0 fade-in-0 duration-300 animate-in">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-4 h-auto w-full justify-start text-left hover:bg-card rounded-lg",
|
||||
props.isSelected && "!bg-accent/70",
|
||||
)}
|
||||
onClick={() => props.onSelect(props.webhookSend)}
|
||||
>
|
||||
<StatusBadge status={props.webhookSend.responseStatus} />
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm text-foreground">
|
||||
<span>{getWebhookSendTitle(props.webhookSend)}.</span>
|
||||
<span>{props.webhookSend.status.toLowerCase()}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(
|
||||
new Date(props.webhookSend.createdAt),
|
||||
"MMM d, yyyy HH:mm:ss",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-3.5 ml-auto text-muted-foreground opacity-0 transition-opacity",
|
||||
props.isSelected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookSendCompactInfoSkeleton() {
|
||||
return (
|
||||
<div className="h-[77px] flex gap-3 items-center border-b px-4 last:border-b-0">
|
||||
<Skeleton className="w-[45px] h-[22px] rounded-full" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="w-[240px] h-4" />
|
||||
<Skeleton className="w-[150px] h-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWebhookSendTitle(webhookSend: WebhookSend) {
|
||||
if (typeof webhookSend.body === "object" && webhookSend.body !== null) {
|
||||
if ("type" in webhookSend.body) {
|
||||
const type = webhookSend.body.type;
|
||||
if (typeof type === "string") {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (webhookSend.onrampId) {
|
||||
return "onramp";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function StatusBadge(props: { status: number }) {
|
||||
const variant =
|
||||
props.status >= 200 && props.status < 300 ? "success" : "destructive";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="px-1.5 gap-1.5">
|
||||
{variant === "success" ? (
|
||||
<CircleCheckIcon className="size-3 opacity-70" />
|
||||
) : (
|
||||
<CircleAlertIcon className="size-3 opacity-70" />
|
||||
)}
|
||||
{props.status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
+3
@@ -21,6 +21,9 @@ export default async function Page(props: {
|
||||
<PayWebhooksPage
|
||||
clientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
@@ -30,11 +30,13 @@ function unclaimedFeesStub(token0Amount: bigint, token1Amount: bigint) {
|
||||
address: "0x1234567890123456789012345678901234567890",
|
||||
amount: token0Amount,
|
||||
symbol: "FOO",
|
||||
decimals: 18,
|
||||
},
|
||||
token1: {
|
||||
address: "0x0987654321098765432109876543210987654321",
|
||||
amount: token1Amount,
|
||||
symbol: "BAR",
|
||||
decimals: 18,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+7
-1
@@ -29,11 +29,13 @@ export function ClaimRewardsPage(props: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
token1: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
};
|
||||
chainSlug: string;
|
||||
@@ -98,11 +100,13 @@ export function ClaimRewardsPageUI(props: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
token1: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
};
|
||||
recipient: string;
|
||||
@@ -275,6 +279,7 @@ function TokenReward(props: {
|
||||
address: string;
|
||||
amount: bigint;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
@@ -303,7 +308,8 @@ function TokenReward(props: {
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-bold text-sm">
|
||||
{toTokens(props.token.amount, 18)} {props.token.symbol}
|
||||
{toTokens(props.token.amount, props.token.decimals)}{" "}
|
||||
{props.token.symbol}
|
||||
</p>
|
||||
<Link
|
||||
target="_blank"
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ export default async function Page(props: {
|
||||
const chain = info.clientContract.chain;
|
||||
const assetContractServer = info.serverContract;
|
||||
const serverClient = assetContractServer.client;
|
||||
const chainMetadata = info.chainMetadata;
|
||||
|
||||
const { address: entrypointContractAddress } =
|
||||
await getDeployedEntrypointERC20({
|
||||
@@ -56,6 +57,7 @@ export default async function Page(props: {
|
||||
|
||||
// Note: must use server contract/client here
|
||||
const unclaimedFees = await getUnclaimedFees({
|
||||
chainMetadata,
|
||||
positionManager: getContract({
|
||||
address: reward.positionManager,
|
||||
chain,
|
||||
|
||||
+47
-17
@@ -1,11 +1,14 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getContract, readContract } from "thirdweb";
|
||||
import { getAddress, getContract, readContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { symbol } from "thirdweb/extensions/common";
|
||||
import { decimals } from "thirdweb/extensions/erc20";
|
||||
|
||||
const maxUint128 = 2n ** 128n - 1n;
|
||||
|
||||
export async function getUnclaimedFees(params: {
|
||||
positionManager: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
reward: {
|
||||
tokenId: bigint;
|
||||
recipient: string;
|
||||
@@ -56,33 +59,60 @@ export async function getUnclaimedFees(params: {
|
||||
const token0Address = positionsResult[2];
|
||||
const token1Address = positionsResult[3];
|
||||
|
||||
const [token0Symbol, token1Symbol] = await Promise.all([
|
||||
symbol({
|
||||
contract: getContract({
|
||||
address: token0Address,
|
||||
chain,
|
||||
client,
|
||||
}),
|
||||
}),
|
||||
symbol({
|
||||
contract: getContract({
|
||||
address: token1Address,
|
||||
chain,
|
||||
client,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
const token0Contract = getContract({
|
||||
address: token0Address,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
|
||||
const token1Contract = getContract({
|
||||
address: token1Address,
|
||||
chain,
|
||||
client,
|
||||
});
|
||||
|
||||
const isToken0Native = getAddress(token0Address) === getAddress(ZERO_ADDRESS);
|
||||
const isToken1Native = getAddress(token1Address) === getAddress(ZERO_ADDRESS);
|
||||
|
||||
const nativeSymbol = params.chainMetadata.nativeCurrency.symbol;
|
||||
const nativeDecimals = params.chainMetadata.nativeCurrency.decimals;
|
||||
|
||||
const [token0Symbol, token1Symbol, token0Decimals, token1Decimals] =
|
||||
await Promise.all([
|
||||
isToken0Native
|
||||
? nativeSymbol
|
||||
: symbol({
|
||||
contract: token0Contract,
|
||||
}),
|
||||
isToken1Native
|
||||
? nativeSymbol
|
||||
: symbol({
|
||||
contract: token1Contract,
|
||||
}),
|
||||
isToken0Native
|
||||
? nativeDecimals
|
||||
: decimals({
|
||||
contract: token0Contract,
|
||||
}),
|
||||
isToken1Native
|
||||
? nativeDecimals
|
||||
: decimals({
|
||||
contract: token1Contract,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
token0: {
|
||||
address: token0Address,
|
||||
amount: collectResult[0],
|
||||
symbol: token0Symbol,
|
||||
decimals: token0Decimals,
|
||||
},
|
||||
token1: {
|
||||
address: token1Address,
|
||||
amount: collectResult[1],
|
||||
symbol: token1Symbol,
|
||||
decimals: token1Decimals,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,13 +16,12 @@ export function UniversalBridgeEmbed({
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<BuyAndSwapEmbed
|
||||
chain={chain}
|
||||
buyAmount={amount}
|
||||
tokenAddress={token?.address}
|
||||
pageType="bridge"
|
||||
/>
|
||||
</div>
|
||||
<BuyAndSwapEmbed
|
||||
chain={chain}
|
||||
buyAmount={amount}
|
||||
tokenAddress={token?.address}
|
||||
pageType="bridge"
|
||||
isTestnet={chain.testnet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function PageHeader(props: { containerClassName?: string }) {
|
||||
<div className="border-b border-dashed">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
"container flex max-w-7xl justify-between py-4",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
@@ -30,7 +30,7 @@ export function PageHeader(props: { containerClassName?: string }) {
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<ToggleThemeButton className="bg-transparent" />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -1,17 +1,20 @@
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { DotIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { FaqSection } from "@/components/blocks/faq-section";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { PillLink } from "./components/client/pill-link";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { bridgeAppThirdwebClient } from "./constants";
|
||||
|
||||
const title = "thirdweb Payments: Swap, Bridge, and Onramp";
|
||||
const title = "thirdweb Bridge: Buy, Bridge & Swap Crypto on 85+ Chains";
|
||||
const description =
|
||||
"Swap, bridge, and on-ramp to any EVM chain with thirdweb's Payments.";
|
||||
"Bridge and swap 4500+ tokens across 85+ chains (Ethereum, Base, Optimism, Arbitrum, BNB & more). Best-price routing with near-instant finality";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
@@ -52,37 +55,93 @@ export default async function BridgePage({
|
||||
|
||||
return (
|
||||
<div className="grow flex flex-col relative overflow-hidden">
|
||||
<PageHeader />
|
||||
<div className="relative z-10">
|
||||
<PageHeader />
|
||||
|
||||
<div className="flex grow flex-col items-center justify-center py-36 px-4 min-h-dvh relative overflow-hidden">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed
|
||||
amount={amount as string}
|
||||
chainId={chainId ? Number(chainId) : undefined}
|
||||
token={
|
||||
symbol && decimals && tokenName
|
||||
? {
|
||||
address: tokenAddress as Address,
|
||||
name: tokenName,
|
||||
symbol,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-12 lg:h-20" />
|
||||
|
||||
<div className="bg-background relative z-10">
|
||||
<AppFooter />
|
||||
<div className="relative z-10">
|
||||
<div className="container">
|
||||
<h1 className="text-3xl md:text-6xl font-bold mb-6 tracking-tighter text-center leading-8 lg:leading-none text-balance">
|
||||
Bridge and Swap tokens <br className="max-sm:hidden" /> across any
|
||||
chain, instantly
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center justify-center">
|
||||
<DataPill>85+ Chains Supported</DataPill>
|
||||
<DotIcon className="size-3 text-muted-foreground lg:hidden" />
|
||||
<DataPill>4500+ Tokens Supported</DataPill>
|
||||
<DataPill>9+ Million Routes Available</DataPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-12 lg:h-16" />
|
||||
|
||||
<div className="flex grow items-center justify-center px-4 relative">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed
|
||||
amount={amount as string}
|
||||
chainId={chainId ? Number(chainId) : undefined}
|
||||
token={
|
||||
symbol && decimals && tokenName
|
||||
? {
|
||||
address: tokenAddress as Address,
|
||||
name: tokenName,
|
||||
symbol,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-32" />
|
||||
|
||||
<div className="flex flex-col gap-4 items-center container">
|
||||
<PillLink
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
linkType="integrate-bridge"
|
||||
>
|
||||
Accept Payments in Any Token Right Inside Your App
|
||||
</PillLink>
|
||||
|
||||
<PillLink
|
||||
href="https://thirdweb.com/tokens"
|
||||
linkType="trending-tokens"
|
||||
>
|
||||
Discover Trending Tokens
|
||||
</PillLink>
|
||||
</div>
|
||||
|
||||
<div className="h-32" />
|
||||
|
||||
<div className="container max-w-2xl">
|
||||
<BridgeFaqSection />
|
||||
</div>
|
||||
|
||||
<div className="h-32" />
|
||||
|
||||
<div className="relative">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPill(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="lg:bg-card flex items-center gap-1.5 text-muted-foreground text-xs lg:backdrop-blur-lg lg:border lg:border-border/70 rounded-full lg:px-3 lg:py-1.5 hover:text-foreground transition-colors duration-300">
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
|
||||
"pointer-events-none absolute -inset-x-36 -inset-y-24 text-foreground/20 dark:text-muted-foreground/20 hidden lg:block",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
@@ -94,3 +153,35 @@ function DotsBackgroundPattern(props: { className?: string }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bridgeFaqs: Array<{ title: string; description: string }> = [
|
||||
{
|
||||
title: "What is bridging in crypto?",
|
||||
description:
|
||||
"Crypto bridging (cross-chain bridging) moves tokens between blockchains so you can use assets across networks. In thirdweb Bridge, connect your wallet, choose the source token/network and destination token/network, review the route and price, then confirm. Assets arrive after finality, often under ~10 seconds on fast routes, though timing depends on networks and congestion.",
|
||||
},
|
||||
{
|
||||
title: "How does crypto bridging work?",
|
||||
description:
|
||||
"Bridge smart contracts lock or burn tokens on the source chain and mint or release equivalents on the destination via verified cross-chain providers. thirdweb Bridge automatically finds the fastest, lowest-cost route and may use different mechanisms based on networks and liquidity. Arrival can range from seconds to minutes depending on finality; many routes complete in ~10 seconds",
|
||||
},
|
||||
{
|
||||
title: "What is a crypto asset swap?",
|
||||
description:
|
||||
"A crypto swap exchanges one token for another via a DEX or aggregator. thirdweb Bridge lets you bridge + swap in one step. For example, ETH on Ethereum to USDC on Base, by selecting your start and end tokens/networks and confirming.",
|
||||
},
|
||||
{
|
||||
title: "How can I get stablecoins like USDC or USDT?",
|
||||
description:
|
||||
"Use thirdweb Bridge to convert assets you hold into USDC or USDT on your chosen network: select your current token/network, pick the stablecoin (USDC, USDT, etc) on the destination, and confirm. You can also buy stablecoins with fiat in the Buy flow and bridge if needed. Always verify official token contract addresses.",
|
||||
},
|
||||
{
|
||||
title: "What is the cost of bridging and swapping?",
|
||||
description:
|
||||
"Costs include gas on each chain, bridge/liquidity provider fees, and any DEX swap fees or price impact. thirdweb Bridge compares routes and selects the best price route. Save by using lower-gas times or combining bridge + swap in one flow.",
|
||||
},
|
||||
];
|
||||
|
||||
function BridgeFaqSection() {
|
||||
return <FaqSection faqs={bridgeFaqs} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Bridge, defineChain, toTokens } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
@@ -80,131 +81,137 @@ export default async function PayPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex z-10 flex-col lg:flex-row h-full w-full">
|
||||
<header className="min-w-full lg:min-w-[500px] border-b lg:border-r lg:h-full bg-card flex flex-col gap-4 items-start p-4 lg:p-8">
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-start gap-4">
|
||||
{projectMetadata.image && (
|
||||
<img
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: projectMetadata.image,
|
||||
client: payAppThirdwebClient,
|
||||
}) || ""
|
||||
}
|
||||
alt={projectMetadata.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="rounded-full overflow-hidden"
|
||||
/>
|
||||
<ThemeProvider
|
||||
forcedTheme={theme === "light" ? "light" : "dark"}
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
>
|
||||
<div className="flex z-10 flex-col lg:flex-row h-full w-full">
|
||||
<header className="min-w-full lg:min-w-[500px] border-b lg:border-r lg:h-full bg-card flex flex-col gap-4 items-start p-4 lg:p-8">
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-start gap-4">
|
||||
{projectMetadata.image && (
|
||||
<img
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: projectMetadata.image,
|
||||
client: payAppThirdwebClient,
|
||||
}) || ""
|
||||
}
|
||||
alt={projectMetadata.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">{projectMetadata.name}</h2>
|
||||
</div>
|
||||
{projectMetadata.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{projectMetadata.description}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">{projectMetadata.name}</h2>
|
||||
</div>
|
||||
{projectMetadata.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{projectMetadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block my-4 w-full">
|
||||
{paymentLink.amount && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Details</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{token.iconUri && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: token.iconUri,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={token.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{toTokens(BigInt(paymentLink.amount), token.decimals)}{" "}
|
||||
{token.symbol}
|
||||
</div>
|
||||
{token.prices.USD && (
|
||||
<span>
|
||||
$
|
||||
{(
|
||||
Number(token.prices.USD) *
|
||||
Number(
|
||||
toTokens(BigInt(paymentLink.amount), token.decimals),
|
||||
)
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chain && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Network</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{chain.icon?.url && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: chain.icon.url,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={chain.name}
|
||||
width={chain.icon.width}
|
||||
height={chain.icon.height}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{chain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recipientEnsOrAddress.ensName ||
|
||||
(recipientEnsOrAddress.address && (
|
||||
<div className="hidden lg:block my-4 w-full">
|
||||
{paymentLink.amount && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Seller</span>
|
||||
<span className="text-muted-foreground text-xs">Details</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
{recipientEnsOrAddress.ensName ??
|
||||
shortenAddress(recipientEnsOrAddress.address)}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{token.iconUri && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: token.iconUri,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={token.name}
|
||||
width={25}
|
||||
height={25}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{toTokens(BigInt(paymentLink.amount), token.decimals)}{" "}
|
||||
{token.symbol}
|
||||
</div>
|
||||
{token.prices.USD && (
|
||||
<span>
|
||||
$
|
||||
{(
|
||||
Number(token.prices.USD) *
|
||||
Number(
|
||||
toTokens(BigInt(paymentLink.amount), token.decimals),
|
||||
)
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{chain && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Network</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{chain.icon?.url && (
|
||||
<img
|
||||
src={resolveSchemeWithErrorHandler({
|
||||
uri: chain.icon.url,
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
})}
|
||||
alt={chain.name}
|
||||
width={chain.icon.width}
|
||||
height={chain.icon.height}
|
||||
className="size-5 rounded-full overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{chain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recipientEnsOrAddress.ensName ||
|
||||
(recipientEnsOrAddress.address && (
|
||||
<div className="flex flex-col gap-1 w-full my-4">
|
||||
<span className="text-muted-foreground text-xs">Seller</span>
|
||||
<div className="font-medium flex-row flex justify-between items-center w-full">
|
||||
{recipientEnsOrAddress.ensName ??
|
||||
shortenAddress(recipientEnsOrAddress.address)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto hidden lg:block">
|
||||
<Badge className="flex items-center gap-1.5 bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
Secured by thirdweb
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex justify-center p-12 w-full items-center">
|
||||
<PayPageWidget
|
||||
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
|
||||
chainId={Number(paymentLink.destinationToken.chainId)}
|
||||
clientId={undefined} // Payment links don't need to use the same client ID to be executed
|
||||
image={paymentLink.imageUrl}
|
||||
name={paymentLink.title}
|
||||
paymentLinkId={id}
|
||||
purchaseData={paymentLink.purchaseData}
|
||||
recipientAddress={paymentLink.receiver}
|
||||
redirectUri={redirectUri}
|
||||
theme={theme}
|
||||
token={token}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
<div className="mt-auto hidden lg:block">
|
||||
<Badge className="flex items-center gap-1.5 bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
Secured by thirdweb
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex justify-center p-12 w-full items-center">
|
||||
<PayPageWidget
|
||||
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
|
||||
chainId={Number(paymentLink.destinationToken.chainId)}
|
||||
clientId={undefined} // Payment links don't need to use the same client ID to be executed
|
||||
image={paymentLink.imageUrl}
|
||||
name={paymentLink.title}
|
||||
paymentLinkId={id}
|
||||
purchaseData={paymentLink.purchaseData}
|
||||
recipientAddress={paymentLink.receiver}
|
||||
redirectUri={redirectUri}
|
||||
token={token}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
import { payAppThirdwebClient } from "app/pay/constants";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
import { createThirdwebClient, NATIVE_TOKEN_ADDRESS, toTokens } from "thirdweb";
|
||||
import { AutoConnect, CheckoutWidget } from "thirdweb/react";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
reportPaymentLinkBuySuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function PayPageWidget({
|
||||
chainId,
|
||||
@@ -21,7 +21,6 @@ export function PayPageWidget({
|
||||
name,
|
||||
image,
|
||||
redirectUri,
|
||||
theme,
|
||||
purchaseData,
|
||||
clientId,
|
||||
}: {
|
||||
@@ -34,17 +33,10 @@ export function PayPageWidget({
|
||||
image?: string;
|
||||
redirectUri?: string;
|
||||
clientId: string | undefined;
|
||||
theme?: "light" | "dark";
|
||||
purchaseData: Record<string, unknown> | undefined;
|
||||
}) {
|
||||
const { theme: browserTheme, setTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setTheme(theme);
|
||||
}
|
||||
}, [theme, setTheme]);
|
||||
const chain = useV5DashboardChain(chainId);
|
||||
|
||||
return (
|
||||
@@ -55,6 +47,7 @@ export function PayPageWidget({
|
||||
}
|
||||
/>
|
||||
<CheckoutWidget
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
connectOptions={{
|
||||
wallets: [
|
||||
createWallet("io.metamask"),
|
||||
@@ -87,7 +80,6 @@ export function PayPageWidget({
|
||||
paymentLinkId={paymentLinkId}
|
||||
purchaseData={purchaseData}
|
||||
seller={checksumAddress(recipientAddress)}
|
||||
theme={theme ?? (browserTheme === "light" ? "light" : "dark")}
|
||||
tokenAddress={
|
||||
token.address === NATIVE_TOKEN_ADDRESS
|
||||
? undefined
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user