Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abab4cb1b | ||
|
|
1851a8c1da | ||
|
|
6345634e2c | ||
|
|
a19c0c96ff | ||
|
|
cf202c03dd | ||
|
|
28df6f7e40 | ||
|
|
150a90c387 | ||
|
|
454fc6a5b9 | ||
|
|
72c3499366 | ||
|
|
5612542e1a | ||
|
|
1fa36c1d56 | ||
|
|
fa99acf609 | ||
|
|
15adec4fb7 | ||
|
|
1405289ad0 | ||
|
|
95cc74e807 | ||
|
|
2cb89cb3a1 | ||
|
|
f693ab8869 | ||
|
|
208b052a1b | ||
|
|
514c1a8480 | ||
|
|
a2d75df4c6 | ||
|
|
5d345e7abb | ||
|
|
43638e5d96 | ||
|
|
cc5cdd4f79 | ||
|
|
8823acbeb2 | ||
|
|
035fcf666a | ||
|
|
7775e7c14e | ||
|
|
fa6da43a77 | ||
|
|
733c1e6136 | ||
|
|
439485bc47 | ||
|
|
5c2da5fc5d | ||
|
|
846a5b5028 | ||
|
|
724e7d9238 | ||
|
|
c30b6d7c14 | ||
|
|
45ac0cb683 | ||
|
|
8610b467b6 | ||
|
|
d4a49b2fbd | ||
|
|
35b7a0e991 | ||
|
|
ee951926fb | ||
|
|
5abbae7368 | ||
|
|
b98c228f26 | ||
|
|
5a08176eb0 | ||
|
|
647c285136 | ||
|
|
45ca0334bb | ||
|
|
a0f355717a | ||
|
|
b182302f59 | ||
|
|
942d20a5d9 | ||
|
|
739a2ae3c5 | ||
|
|
4158a796a8 | ||
|
|
a258e7eaf0 | ||
|
|
e386ac9476 | ||
|
|
0574eac02c | ||
|
|
52cbcd2d57 | ||
|
|
387891dde0 | ||
|
|
562c534d37 | ||
|
|
b882e29935 | ||
|
|
9ad1d3cbbf | ||
|
|
aab3da0729 | ||
|
|
78c9d2fe13 | ||
|
|
557d3e3dbd | ||
|
|
06d6504545 | ||
|
|
e201e3d423 | ||
|
|
c8ef941d8e | ||
|
|
39a5b5c8a3 | ||
|
|
6f381d768c | ||
|
|
0c5a51c5bf | ||
|
|
90428b4574 | ||
|
|
e24547bed1 | ||
|
|
3ff8c3fbb2 | ||
|
|
4bd03b06e7 | ||
|
|
b7158c2b54 | ||
|
|
5f26883fbd | ||
|
|
98b6198687 | ||
|
|
880148b793 | ||
|
|
ee1bc3eb07 | ||
|
|
e9ab8cb770 | ||
|
|
9c33981b72 | ||
|
|
c645b42684 | ||
|
|
7583074c06 | ||
|
|
fbafb65f5c | ||
|
|
6b286c7e83 | ||
|
|
159ffbfaee | ||
|
|
0c1bb6ccae | ||
|
|
dceb47de8b | ||
|
|
dfbdbd06e5 | ||
|
|
66bbedbe3a | ||
|
|
2fbf105d15 | ||
|
|
afcaa91873 | ||
|
|
51f955352d | ||
|
|
6b2a90ac89 | ||
|
|
cf53184bb1 | ||
|
|
815b9b1ce1 | ||
|
|
ae1baa5105 | ||
|
|
92266b7459 | ||
|
|
80ad84b803 | ||
|
|
633f0cadf1 | ||
|
|
91b0367b23 | ||
|
|
c86e13b522 | ||
|
|
bc42c2fdfb | ||
|
|
cad9c74bef | ||
|
|
6f2f92ddea | ||
|
|
97c9ab7e1c | ||
|
|
b73fa4b16f | ||
|
|
cbfe9ec459 | ||
|
|
fecd3a6c16 | ||
|
|
8aeb4d6308 | ||
|
|
f1cd2539d1 | ||
|
|
a322e16853 | ||
|
|
e50d77d28f | ||
|
|
7f3b91da37 | ||
|
|
41d50443d9 | ||
|
|
a8ac98bd49 | ||
|
|
0781da9dca | ||
|
|
c5dfad8dbb | ||
|
|
9663079062 | ||
|
|
1f6bb7c329 | ||
|
|
30e13e6b91 | ||
|
|
2dfc245d44 | ||
|
|
64d7bf358f | ||
|
|
a6e9c97c6f | ||
|
|
ee57ded902 | ||
|
|
6d3947ab0c | ||
|
|
632f1d138f | ||
|
|
18f0e575c4 | ||
|
|
e732b11e45 | ||
|
|
f77165e2d1 | ||
|
|
dfd64d2896 | ||
|
|
4e703fdb5d | ||
|
|
af88935680 | ||
|
|
c50c82aacd | ||
|
|
0fa8354dec | ||
|
|
769a927627 | ||
|
|
ceff873cba | ||
|
|
9a432af1cf | ||
|
|
ee6bd4f898 | ||
|
|
ccf6329810 | ||
|
|
821caa63d7 | ||
|
|
09e396d856 | ||
|
|
9573b2f9d2 | ||
|
|
b004a07e5d | ||
|
|
2396b4e461 |
@@ -10,7 +10,13 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["playground-web", "thirdweb-dashboard", "wallet-ui", "portal"],
|
||||
"ignore": [
|
||||
"playground-web",
|
||||
"thirdweb-dashboard",
|
||||
"wallet-ui",
|
||||
"portal",
|
||||
"thirdweb-login"
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"updateInternalDependents": "always",
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
|
||||
@@ -6,8 +6,6 @@ To get started, read the [How this repo works](#how-this-repo-works) section bel
|
||||
|
||||
From there, you can take a look at our [Good First Issues](https://github.com/thirdweb-dev/js/labels/good%20first%20issue) board and find an issue that interests you!
|
||||
|
||||
If you have any questions about the issue, feel free to ask on our [Discord server](https://discord.gg/thirdweb) in the `#contributors` channel; where you'll be able to get help from our team and other contributors.
|
||||
|
||||
<br />
|
||||
|
||||
## How this repo works
|
||||
@@ -85,7 +83,10 @@ If your test depends on a downstream network call, you must mock the call using
|
||||
import { setupServer } from "msw/node";
|
||||
import { downloadMock, uploadMock } from "../../../test/src/mocks/storage.js";
|
||||
|
||||
const server = setupServer(uploadMock("HASH"), downloadMock({ name: "Test NFT" }));
|
||||
const server = setupServer(
|
||||
uploadMock("HASH"),
|
||||
downloadMock({ name: "Test NFT" })
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<br />
|
||||
<a href="https://thirdweb.com">
|
||||
<img src="https://github.com/thirdweb-dev/js/blob/main/legacy_packages/sdk/logo.svg?raw=true" width="200" alt=""/></a>
|
||||
<img src="https://thirdweb.com/brand/thirdweb-icon.svg" width="200" alt=""/></a>
|
||||
<br />
|
||||
</p>
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
<a href="https://github.com/thirdweb-dev/js/actions/workflows/CI.yml">
|
||||
<img alt="Build Status" src="https://github.com/thirdweb-dev/js/actions/workflows/CI.yml/badge.svg"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/thirdweb">
|
||||
<img alt="Join our Discord!" src="https://img.shields.io/discord/834227967404146718.svg?color=7289da&label=discord&logo=discord&style=flat"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>All-in-one web3 SDK for Browser, Node and Mobile apps</strong></p>
|
||||
@@ -31,12 +28,12 @@
|
||||
|
||||
## Library Comparison
|
||||
|
||||
| | thirdweb | Wagmi + Viem | Ethers@6 |
|
||||
| | thirdweb | Wagmi + Viem | Ethers@6 |
|
||||
| ----------------------------------------- | -------- | ------------------ | -------- |
|
||||
| Type safe contract API | ✅ | ✅ | ✅ |
|
||||
| Type safe wallet API | ✅ | ✅ | ✅ |
|
||||
| EVM utils | ✅ | ✅ | ✅ |
|
||||
| RPC for any EVM | ✅ | ⚠️ public RPC only | ❌ |
|
||||
| RPC for any EVM | ✅ | ⚠️ public RPC only | ❌ |
|
||||
| Automatic ABI Resolution | ✅ | ❌ | ❌ |
|
||||
| IPFS Upload/Download | ✅ | ❌ | ❌ |
|
||||
| Embedded wallet (email/ social login) | ✅ | ⚠️ via 3rd party | ❌ |
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
# not required to build, defaults to prod
|
||||
NEXT_PUBLIC_THIRDWEB_DOMAIN="localhost:3000"
|
||||
|
||||
# API host. For local development, please use "https://api.thirdweb-preview.com"
|
||||
# API host. For local development, please use "https://api.thirdweb-dev.com"
|
||||
# otherwise: "https://api.thirdweb.com"
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST="https://api.thirdweb-dev.com"
|
||||
|
||||
# Bridge API. For local development, please use "https://bridge.thirdweb-dev.com"
|
||||
# otherwise: "https://bridge.thirdweb.com"
|
||||
NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST="https://bridge.thirdweb-dev.com"
|
||||
|
||||
# Paper API host
|
||||
NEXT_PUBLIC_THIRDWEB_EWS_API_HOST="https://ews.thirdweb-dev.com"
|
||||
|
||||
@@ -97,4 +101,4 @@ REDIS_URL=""
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
|
||||
# Required for Nebula Chat
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
|
||||
@@ -9,7 +9,8 @@ module.exports = [
|
||||
// -- connect
|
||||
"/connect",
|
||||
"/connect/sign-in",
|
||||
"/connect/pay",
|
||||
"/connect/account-abstraction",
|
||||
"/connect/universal-bridge",
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
"nuqs": "^2.4.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pluralize": "^8.0.0",
|
||||
@@ -89,6 +89,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "^1.7.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 956 KiB |
|
Before Width: | Height: | Size: 695 KiB |
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 709 KiB |
|
Before Width: | Height: | Size: 906 KiB |
|
Before Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 601 KiB |
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 733 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 333 KiB |
@@ -331,6 +331,18 @@ async function redirects() {
|
||||
destination: "/templates/:slug",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /account-abstraction to /connect/account-abstraction
|
||||
{
|
||||
source: "/account-abstraction",
|
||||
destination: "/connect/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /connect/pay to /connect/universal-bridge
|
||||
{
|
||||
source: "/connect/pay",
|
||||
destination: "/connect/universal-bridge",
|
||||
permanent: false,
|
||||
},
|
||||
// PREVIOUS CAMPAIGNS
|
||||
{
|
||||
source: "/unlimited-wallets",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// after running logUnusedAssets.ts,
|
||||
// 1. validate the output
|
||||
// 2. If you think that a file should not be deleted, remove it from the output
|
||||
// 3. paste the output files in `filesToDelete` and run this script to delete them
|
||||
const filesToDelete: string[] = [];
|
||||
|
||||
async function deleteFiles() {
|
||||
for (const filePath of filesToDelete) {
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath);
|
||||
await fs.unlink(fullPath);
|
||||
console.log(`Deleted: ${filePath}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
console.warn(`File not found: ${filePath}`);
|
||||
} else {
|
||||
console.error(`Error deleting ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("--- DONE ---");
|
||||
}
|
||||
|
||||
deleteFiles();
|
||||
@@ -26,9 +26,6 @@ const filesToIgnore = new Set([
|
||||
// macOS stuff
|
||||
"public/assets/product-pages/.DS_Store",
|
||||
"public/assets/.DS_Store",
|
||||
// pdfs
|
||||
"public/Thirdweb_Terms_of_Service.pdf",
|
||||
"public/thirdweb_Privacy_Policy_May_2022.pdf",
|
||||
]);
|
||||
|
||||
function getAllFilesInFolder(folderPath: string) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function acceptInvite(options: {
|
||||
teamId: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
let errorMessage = "Failed to accept invite";
|
||||
try {
|
||||
const result = (await res.json()) as {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
};
|
||||
};
|
||||
errorMessage = result.error.message;
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
@@ -71,15 +71,6 @@ async function proxy<T extends object>(
|
||||
};
|
||||
}
|
||||
|
||||
export async function analyticsServerProxy<T extends object = object>(
|
||||
params: ProxyActionParams,
|
||||
) {
|
||||
return proxy<T>(
|
||||
process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com",
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiServerProxy<T extends object = object>(
|
||||
params: ProxyActionParams,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function sendTeamInvites(options: {
|
||||
teamId: string;
|
||||
invites: Array<{ email: string; role: "OWNER" | "MEMBER" }>;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
results: Array<"fulfilled" | "rejected">;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
}
|
||||
> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
options.invites.map((invite) => sendInvite(options.teamId, invite, token)),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
results: results.map((x) => x.status),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendInvite(
|
||||
teamId: string,
|
||||
invite: { email: string; role: "OWNER" | "MEMBER" },
|
||||
token: string,
|
||||
) {
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamId}/invites`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inviteEmail: invite.email,
|
||||
inviteRole: invite.role,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
return {
|
||||
email: invite.email,
|
||||
ok: false,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
email: invite.email,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { API_SERVER_URL } from "../constants/env";
|
||||
export async function updateAccount(values: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string | null;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fetchAnalytics } from "data/analytics/fetch-analytics";
|
||||
import "server-only";
|
||||
|
||||
import type {
|
||||
AnalyticsQueryParams,
|
||||
EcosystemWalletStats,
|
||||
InAppWalletStats,
|
||||
RpcMethodStats,
|
||||
TransactionStats,
|
||||
@@ -8,15 +10,67 @@ import type {
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { getChains } from "./chain";
|
||||
|
||||
async function fetchAnalytics(
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("You are not authorized to perform this action");
|
||||
}
|
||||
|
||||
const [pathname, searchParams] = input.toString().split("?");
|
||||
if (!pathname) {
|
||||
throw new Error("Invalid input, no pathname provided");
|
||||
}
|
||||
|
||||
// create a new URL object for the analytics server
|
||||
const ANALYTICS_SERVICE_URL = new URL(
|
||||
process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com",
|
||||
);
|
||||
ANALYTICS_SERVICE_URL.pathname = pathname;
|
||||
for (const param of searchParams?.split("&") || []) {
|
||||
const [key, value] = param.split("=");
|
||||
if (!key || !value) {
|
||||
throw new Error("Invalid input, no key or value provided");
|
||||
}
|
||||
ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
decodeURIComponent(key),
|
||||
decodeURIComponent(value),
|
||||
);
|
||||
}
|
||||
// client id DEBUG OVERRIDE
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("clientId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("accountId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// "clientId",
|
||||
// "...",
|
||||
// );
|
||||
|
||||
return fetch(ANALYTICS_SERVICE_URL, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init?.headers,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.clientId) {
|
||||
searchParams.append("clientId", params.clientId);
|
||||
}
|
||||
if (params.accountId) {
|
||||
searchParams.append("accountId", params.accountId);
|
||||
|
||||
searchParams.append("teamId", params.teamId);
|
||||
|
||||
if (params.projectId) {
|
||||
searchParams.append("projectId", params.projectId);
|
||||
}
|
||||
|
||||
// shared params
|
||||
if (params.from) {
|
||||
searchParams.append("from", params.from.toISOString());
|
||||
}
|
||||
@@ -33,13 +87,19 @@ export async function getWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<WalletStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/wallets?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await fetchAnalytics(
|
||||
`v2/sdk/wallet-connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch wallet connections");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch wallet connections, ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -52,7 +112,7 @@ export async function getInAppWalletUsage(
|
||||
): Promise<InAppWalletStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/wallets/in-app?${searchParams.toString()}`,
|
||||
`v2/wallet/connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -60,7 +120,10 @@ export async function getInAppWalletUsage(
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch in-app wallet usage");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch in-app wallet usage, ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -72,10 +135,13 @@ export async function getUserOpUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<UserOpStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/user-ops?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await fetchAnalytics(
|
||||
`v2/bundler/usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
@@ -89,12 +155,46 @@ export async function getUserOpUsage(
|
||||
return json.data as UserOpStats[];
|
||||
}
|
||||
|
||||
export async function getAggregateUserOpUsage(
|
||||
params: Omit<AnalyticsQueryParams, "period">,
|
||||
): Promise<UserOpStats> {
|
||||
const [userOpStats, chains] = await Promise.all([
|
||||
getUserOpUsage({ ...params, period: "all" }),
|
||||
getChains(),
|
||||
]);
|
||||
// Aggregate stats across wallet types
|
||||
return userOpStats.reduce(
|
||||
(acc, curr) => {
|
||||
// Skip testnets from the aggregated stats
|
||||
if (curr.chainId) {
|
||||
const chain = chains.data.find(
|
||||
(c) => c.chainId.toString() === curr.chainId,
|
||||
);
|
||||
if (chain?.testnet) {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
acc.successful += curr.successful;
|
||||
acc.failed += curr.failed;
|
||||
acc.sponsoredUsd += curr.sponsoredUsd;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
date: (params.from || new Date()).toISOString(),
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getClientTransactions(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<TransactionStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/transactions/client?${searchParams.toString()}`,
|
||||
`v2/sdk/contract-transactions?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -118,7 +218,7 @@ export async function getRpcMethodUsage(
|
||||
): Promise<RpcMethodStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/rpc/evm-methods?${searchParams.toString()}`,
|
||||
`v2/rpc/evm-methods?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -139,7 +239,7 @@ export async function getWalletUsers(
|
||||
): Promise<WalletUserStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/wallets/users?${searchParams.toString()}`,
|
||||
`v2/wallet-connects/users?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -147,7 +247,10 @@ export async function getWalletUsers(
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch wallet user stats");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch wallet user stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -155,20 +258,108 @@ export async function getWalletUsers(
|
||||
return json.data as WalletUserStats[];
|
||||
}
|
||||
|
||||
type ActiveStatus = {
|
||||
bundler: boolean;
|
||||
storage: boolean;
|
||||
rpc: boolean;
|
||||
nebula: boolean;
|
||||
sdk: boolean;
|
||||
insight: boolean;
|
||||
pay: boolean;
|
||||
inAppWallet: boolean;
|
||||
ecosystemWallet: boolean;
|
||||
};
|
||||
|
||||
export async function isProjectActive(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<boolean> {
|
||||
): Promise<ActiveStatus> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/active?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await fetchAnalytics(
|
||||
`v2/active-usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch project active status");
|
||||
return false;
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch project active status: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return {
|
||||
bundler: false,
|
||||
storage: false,
|
||||
rpc: false,
|
||||
nebula: false,
|
||||
sdk: false,
|
||||
insight: false,
|
||||
pay: false,
|
||||
inAppWallet: false,
|
||||
ecosystemWallet: false,
|
||||
} as ActiveStatus;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data.isActive as boolean;
|
||||
return json.data as ActiveStatus;
|
||||
}
|
||||
|
||||
export async function getEcosystemWalletUsage(args: {
|
||||
teamId: string;
|
||||
ecosystemSlug: string;
|
||||
ecosystemPartnerId?: string;
|
||||
projectId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const {
|
||||
ecosystemSlug,
|
||||
ecosystemPartnerId,
|
||||
teamId,
|
||||
projectId,
|
||||
from,
|
||||
to,
|
||||
period,
|
||||
} = args;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
// required params
|
||||
searchParams.append("ecosystemSlug", ecosystemSlug);
|
||||
searchParams.append("teamId", teamId);
|
||||
|
||||
// optional params
|
||||
if (ecosystemPartnerId) {
|
||||
searchParams.append("ecosystemPartnerId", ecosystemPartnerId);
|
||||
}
|
||||
if (projectId) {
|
||||
searchParams.append("projectId", projectId);
|
||||
}
|
||||
if (from) {
|
||||
searchParams.append("from", from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.append("to", to.toISOString());
|
||||
}
|
||||
if (period) {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/wallets/connects/${ecosystemSlug}?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch ecosystem wallet stats");
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as EcosystemWalletStats[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { ChainService } from "../../app/(dashboard)/(chain)/types/chain";
|
||||
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "../constants/env";
|
||||
|
||||
export async function getGasSponsoredChains() {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/chains/gas-sponsored`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
next: {
|
||||
revalidate: 15 * 60, //15 minutes
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to fetch gas sponsored chains",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
res.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()).data as number[];
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gas sponsored chains", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getChains() {
|
||||
return fetch(
|
||||
`${API_SERVER_URL}/v1/chains`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{ data: ChainMetadata[] }>;
|
||||
}
|
||||
|
||||
export function getChainServices() {
|
||||
return fetch(
|
||||
`${API_SERVER_URL}/v1/chains/services`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{
|
||||
data: Record<number, Array<ChainService>>;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export type LinkedWallet = {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
walletAddress: string;
|
||||
};
|
||||
|
||||
export async function getLinkedWallets() {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account/wallets`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as {
|
||||
data: LinkedWallet[];
|
||||
};
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,23 +1,9 @@
|
||||
import "server-only";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
bannedAt: Date | null;
|
||||
domains: string[];
|
||||
bundleIds: string[];
|
||||
redirectUrls: string[];
|
||||
lastAccessedAt: Date | null;
|
||||
slug: string;
|
||||
teamId: string;
|
||||
publishableKey: string;
|
||||
// image: string; // TODO
|
||||
};
|
||||
export type Project = ProjectResponse;
|
||||
|
||||
export async function getProjects(teamSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export type TeamInvite = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
email: string;
|
||||
role: "OWNER" | "MEMBER";
|
||||
createdAt: string;
|
||||
status: "pending" | "accepted" | "expired";
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export async function getTeamInvites(
|
||||
teamId: string,
|
||||
options: {
|
||||
count: number;
|
||||
start: number;
|
||||
},
|
||||
) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamId}/invites?skip=${options.start}&take=${options.count}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
result: TeamInvite[];
|
||||
};
|
||||
|
||||
return json.result;
|
||||
}
|
||||
@@ -12,15 +12,16 @@ export type TeamAccountRole =
|
||||
|
||||
export type TeamMember = {
|
||||
account: {
|
||||
creatorWalletAddress: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
} & {
|
||||
deletedAt: Date | null;
|
||||
deletedAt: string | null;
|
||||
accountId: string;
|
||||
teamId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
role: TeamAccountRole;
|
||||
};
|
||||
|
||||
@@ -46,3 +47,26 @@ export async function getMembers(teamSlug: string) {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getMemberById(teamSlug: string, memberId: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamSlug}/members/${memberId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
return (await res.json())?.result as TeamMember;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
import "server-only";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
type EnabledTeamScope =
|
||||
| "pay"
|
||||
| "storage"
|
||||
| "rpc"
|
||||
| "bundler"
|
||||
| "insight"
|
||||
| "embeddedWallets"
|
||||
| "relayer"
|
||||
| "chainsaw"
|
||||
| "nebula";
|
||||
|
||||
export type Team = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
bannedAt?: string;
|
||||
image?: string;
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: false;
|
||||
enabledScopes: EnabledTeamScope[];
|
||||
};
|
||||
|
||||
export type Team = TeamResponse;
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -47,6 +22,24 @@ export async function getTeamBySlug(slug: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function service_getTeamBySlug(slug: string) {
|
||||
const teamRes = await fetch(`${API_SERVER_URL}/v1/teams/${slug}`, {
|
||||
headers: {
|
||||
"x-service-api-key": THIRDWEB_API_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
if (teamRes.ok) {
|
||||
return (await teamRes.json())?.result as Team;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTeamById(id: string) {
|
||||
return getTeamBySlug(id);
|
||||
}
|
||||
|
||||
export async function getTeams() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const Mobile: Story = {
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 py-10">
|
||||
<BadgeContainer label="Base">
|
||||
<DangerSettingCard
|
||||
title="This is a title"
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -17,13 +16,14 @@ export function DangerSettingCard(props: {
|
||||
title: string;
|
||||
className?: string;
|
||||
footerClassName?: string;
|
||||
description: string;
|
||||
description: React.ReactNode;
|
||||
buttonLabel: string;
|
||||
buttonOnClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
isPending: boolean;
|
||||
confirmationDialog: {
|
||||
title: string;
|
||||
description: string;
|
||||
description: React.ReactNode;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
@@ -55,7 +55,7 @@ export function DangerSettingCard(props: {
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2 bg-red-600 font-semibold text-white hover:bg-red-600/80"
|
||||
disabled={props.isPending}
|
||||
disabled={props.isDisabled || props.isPending}
|
||||
>
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
@@ -63,20 +63,22 @@ export function DangerSettingCard(props: {
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="z-[10001]"
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4 gap-4 lg:gap-2">
|
||||
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
@@ -90,7 +92,7 @@ export function DangerSettingCard(props: {
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { AsteriskIcon, InfoIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
export function FormFieldSetup(props: {
|
||||
htmlFor?: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
errorMessage: React.ReactNode | undefined;
|
||||
children: React.ReactNode;
|
||||
tooltip?: React.ReactNode;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThirdwebMiniLogo } from "app/components/ThirdwebMiniLogo";
|
||||
import { DiscordIcon } from "components/icons/brand-icons/DiscordIcon";
|
||||
import { GithubIcon } from "components/icons/brand-icons/GithubIcon";
|
||||
import { InstagramIcon } from "components/icons/brand-icons/InstagramIcon";
|
||||
import { LinkedInIcon } from "components/icons/brand-icons/LinkedinIcon";
|
||||
@@ -61,11 +60,6 @@ export function AppFooter(props: AppFooterProps) {
|
||||
<YoutubeIcon className="size-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" asChild className="size-9">
|
||||
<Link href="https://discord.gg/thirdweb" target="_blank">
|
||||
<DiscordIcon className="size-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" asChild className="size-9">
|
||||
<Link href="https://www.reddit.com/r/thirdweb/" target="_blank">
|
||||
<RedditIcon className="size-5 text-muted-foreground" />
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
@@ -15,8 +22,14 @@ import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "../../../../components/analytics/empty-chart-state";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
header?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
@@ -25,78 +38,99 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
// chart className
|
||||
chartClassName?: string;
|
||||
isPending: boolean;
|
||||
hideLabel?: boolean;
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
props: ThirdwebAreaChartProps<TConfig>,
|
||||
) {
|
||||
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-4 pt-10 pb-4">
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
) : (
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM dd")}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<defs>
|
||||
{configKeys.map((key) => (
|
||||
<linearGradient
|
||||
key={key}
|
||||
id={`fill_${key}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{configKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type="natural"
|
||||
fill={`url(#fill_${key})`}
|
||||
fillOpacity={0.4}
|
||||
stroke={`var(--color-${key})`}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} className="pt-8" />
|
||||
)}
|
||||
</AreaChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
{props.header.description && (
|
||||
<CardDescription>{props.header.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={20}
|
||||
tickFormatter={(value) => formatDate(new Date(value), "MMM dd")}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel={
|
||||
props.hideLabel !== undefined ? props.hideLabel : true
|
||||
}
|
||||
labelFormatter={props.toolTipLabelFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
{configKeys.map((key) => (
|
||||
<linearGradient
|
||||
key={key}
|
||||
id={`fill_${key}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-${key})`}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{configKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
dataKey={key}
|
||||
type="natural"
|
||||
fill={`url(#fill_${key})`}
|
||||
fillOpacity={0.4}
|
||||
stroke={`var(--color-${key})`}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showLegend && (
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="pt-8" />}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,11 @@ import { cn } from "../../../lib/utils";
|
||||
|
||||
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
// metadata
|
||||
title: string;
|
||||
description?: string;
|
||||
header?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
@@ -39,7 +42,6 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
isPending: boolean;
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
titleClassName?: string;
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
@@ -52,15 +54,18 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.titleClassName)}>
|
||||
{props.title}
|
||||
</CardTitle>
|
||||
{props.description && (
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
{props.header.description && (
|
||||
<CardDescription>{props.header.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
@@ -87,7 +92,9 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
}
|
||||
/>
|
||||
{props.showLegend && (
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="pt-5" />}
|
||||
/>
|
||||
)}
|
||||
{configKeys.map((key, idx) => (
|
||||
<Bar
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { TEAM_PLANS } from "utils/pricing";
|
||||
import { remainingDays } from "../../../utils/date-utils";
|
||||
@@ -105,6 +105,23 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
{!isCustomPrice && (
|
||||
<span className="text-muted-foreground">/ month</span>
|
||||
)}
|
||||
|
||||
{billingPlan === "starter" && (
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[320px]"
|
||||
label="We will place a temporary hold of $25 to verify your card, this will be immediately released back to you after verification."
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-auto p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<div>
|
||||
<CircleAlertIcon className="size-5 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{remainingTrialDays > 0 && (
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
export function GenericLoadingPage() {
|
||||
export function GenericLoadingPage({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[500px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[500px] grow items-center justify-center rounded-lg border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,27 +2,29 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ClientOnly } from "components/ClientOnly/ClientOnly";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
export function ColorModeToggle() {
|
||||
export function ToggleThemeButton() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<Skeleton className="size-10 border bg-accent" />}>
|
||||
<ClientOnly
|
||||
ssr={<Skeleton className="size-[34px] rounded-full border bg-accent" />}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
aria-label="Toggle theme"
|
||||
className="h-auto w-auto rounded-full border border-border bg-background p-2"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<Sun strokeWidth={1} className="size-5" />
|
||||
<SunIcon className="size-4" />
|
||||
) : (
|
||||
<Moon strokeWidth={1} className="size-5" />
|
||||
<MoonIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function DatePickerWithRange(props: {
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
labelOverride?: string;
|
||||
popoverAlign?: "start" | "end" | "center";
|
||||
}) {
|
||||
const [screen, setScreen] = React.useState<"from" | "to">("from");
|
||||
const { from, to, setFrom, setTo } = props;
|
||||
@@ -65,7 +66,11 @@ export function DatePickerWithRange(props: {
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Popover */}
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align={props.popoverAlign || "start"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DynamicHeight>
|
||||
<div>
|
||||
{!isValid && (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type LinkProps = React.ComponentProps<typeof Link>;
|
||||
|
||||
export function UnderlineLink(props: LinkProps) {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
className={cn(
|
||||
"underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:text-foreground hover:decoration-foreground hover:decoration-solid",
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
|
||||
@@ -4,6 +4,8 @@ export const DASHBOARD_THIRDWEB_CLIENT_ID =
|
||||
export const DASHBOARD_THIRDWEB_SECRET_KEY =
|
||||
process.env.DASHBOARD_SECRET_KEY || "";
|
||||
|
||||
export const THIRDWEB_API_SECRET = process.env.API_SERVER_SECRET || "";
|
||||
|
||||
export const IPFS_GATEWAY_URL =
|
||||
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
|
||||
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
|
||||
@@ -21,6 +23,9 @@ export const DASHBOARD_STORAGE_URL =
|
||||
export const API_SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
export const BRIDGE_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST || "https://bridge.thirdweb.com";
|
||||
|
||||
/**
|
||||
* Faucet stuff
|
||||
*/
|
||||
|
||||
@@ -117,7 +117,7 @@ function DashboardRouterTopProgressBarInner() {
|
||||
const width = isLoading ? progress : 100;
|
||||
return (
|
||||
<span
|
||||
className="fixed top-0 block h-[2px] bg-blue-500"
|
||||
className="fixed top-0 block h-[3px] bg-foreground"
|
||||
style={{
|
||||
opacity: isLoading ? "100" : "0",
|
||||
width: `${width}%`,
|
||||
|
||||
@@ -42,15 +42,6 @@ export const accountKeys = {
|
||||
[...accountKeys.wallet(walletAddress), "billing-session"] as const,
|
||||
};
|
||||
|
||||
export const apiKeys = {
|
||||
all: ["api"] as const,
|
||||
wallet: (walletAddress: string) => [...apiKeys.all, walletAddress] as const,
|
||||
keys: (walletAddress: string) =>
|
||||
[...apiKeys.wallet(walletAddress), "keys"] as const,
|
||||
key: (id: string, walletAddress: string) =>
|
||||
[...apiKeys.keys(walletAddress), id] as const,
|
||||
};
|
||||
|
||||
export const authorizedWallets = {
|
||||
all: ["authorizedWallets"] as const,
|
||||
wallet: (walletAddress: string) =>
|
||||
@@ -125,4 +116,6 @@ export const engineKeys = {
|
||||
[...engineKeys.all, engineId, "alerts"] as const,
|
||||
notificationChannels: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "notificationChannels"] as const,
|
||||
walletCredentials: (instance: string) =>
|
||||
[...engineKeys.all, instance, "walletCredentials"] as const,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { analyticsServerProxy, apiServerProxy } from "@/actions/proxies";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { accountKeys, apiKeys, authorizedWallets } from "../cache-keys";
|
||||
import { accountKeys, authorizedWallets } from "../cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
|
||||
export const accountStatus = {
|
||||
noCustomer: "noCustomer",
|
||||
noPayment: "noPayment",
|
||||
paymentVerification: "paymentVerification",
|
||||
validPayment: "validPayment",
|
||||
invalidPayment: "invalidPayment",
|
||||
invalidPaymentMethod: "invalidPaymentMethod",
|
||||
} as const;
|
||||
|
||||
export const accountPlan = {
|
||||
free: "free",
|
||||
growth: "growth",
|
||||
@@ -39,6 +29,7 @@ export type Account = {
|
||||
id: string;
|
||||
isStaff: boolean;
|
||||
creatorWalletAddress: string;
|
||||
image?: string | null;
|
||||
name?: string;
|
||||
email?: string;
|
||||
advancedEnabled: boolean;
|
||||
@@ -70,109 +61,6 @@ interface ConfirmEmailInput {
|
||||
confirmationToken: string;
|
||||
}
|
||||
|
||||
type ApiKeyRecoverShareManagement = "AWS_MANAGED" | "USER_MANAGED";
|
||||
type ApiKeyCustomAuthentication = {
|
||||
jwksUri: string;
|
||||
aud: string;
|
||||
};
|
||||
type ApiKeyCustomAuthEndpoint = {
|
||||
authEndpoint: string;
|
||||
customHeaders: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
// MAP to api-server types in PolicyService.ts
|
||||
export type ApiKeyServicePolicy = {
|
||||
allowedChainIds?: number[] | null;
|
||||
allowedContractAddresses?: string[] | null;
|
||||
allowedWallets?: string[] | null;
|
||||
blockedWallets?: string[] | null;
|
||||
bypassWallets?: string[] | null;
|
||||
serverVerifier?: {
|
||||
url: string;
|
||||
headers: { key: string; value: string }[] | null;
|
||||
} | null;
|
||||
limits?: ApiKeyServicePolicyLimits | null;
|
||||
};
|
||||
|
||||
export type ApiKeyServicePolicyLimits = {
|
||||
global?: {
|
||||
// in dollars or ETH
|
||||
maxSpend: string;
|
||||
maxSpendUnit: "usd" | "native";
|
||||
} | null;
|
||||
// ----------------------
|
||||
// TODO implement perUser limits
|
||||
perUserSpend?: {
|
||||
// in dollars or ETH
|
||||
maxSpend: string | null;
|
||||
maxSpendUnit: "usd" | "native";
|
||||
maxSpendPeriod: "day" | "week" | "month";
|
||||
} | null;
|
||||
perUserTransactions?: {
|
||||
maxTransactions: number;
|
||||
maxTransactionsPeriod: "day" | "week" | "month";
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ApiKeyService = {
|
||||
id: string;
|
||||
name: string;
|
||||
targetAddresses: string[];
|
||||
actions: string[];
|
||||
// If updating here, need to update validation logic in `validation.ts` as well for recoveryShareManagement
|
||||
// EMBEDDED WALLET
|
||||
recoveryShareManagement?: ApiKeyRecoverShareManagement;
|
||||
customAuthentication?: ApiKeyCustomAuthentication;
|
||||
customAuthEndpoint?: ApiKeyCustomAuthEndpoint;
|
||||
applicationName?: string;
|
||||
applicationImageUrl?: string;
|
||||
// PAY
|
||||
payoutAddress?: string;
|
||||
};
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
secret?: string;
|
||||
secretMasked: string;
|
||||
accountId: string;
|
||||
creatorWalletAddress: string;
|
||||
walletAddresses: string[];
|
||||
domains: string[];
|
||||
bundleIds: string[];
|
||||
redirectUrls: string[];
|
||||
revokedAt: string;
|
||||
lastAccessedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
services?: ApiKeyService[];
|
||||
};
|
||||
|
||||
interface UpdateKeyServiceInput {
|
||||
name: string;
|
||||
targetAddresses: string[];
|
||||
actions?: string[];
|
||||
}
|
||||
|
||||
export interface CreateKeyInput {
|
||||
name?: string;
|
||||
domains?: string[];
|
||||
bundleIds?: string[];
|
||||
walletAddresses?: string[];
|
||||
services?: UpdateKeyServiceInput[];
|
||||
}
|
||||
|
||||
export interface UpdateKeyInput {
|
||||
id: string;
|
||||
name: string;
|
||||
domains: string[];
|
||||
bundleIds: string[];
|
||||
walletAddresses?: string[];
|
||||
services?: UpdateKeyServiceInput[];
|
||||
redirectUrls: string[];
|
||||
}
|
||||
|
||||
interface UsageStorage {
|
||||
sumFileSizeBytes: number;
|
||||
}
|
||||
@@ -252,132 +140,6 @@ export function useAccountCredits() {
|
||||
});
|
||||
}
|
||||
|
||||
type UserOpUsageQueryResult = (UserOpStats & { chainId?: string })[];
|
||||
|
||||
async function getUserOpUsage(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
|
||||
const searchParams: Record<string, string> = {
|
||||
clientId,
|
||||
};
|
||||
|
||||
if (from) {
|
||||
searchParams.from = from.toISOString();
|
||||
}
|
||||
if (to) {
|
||||
searchParams.to = to.toISOString();
|
||||
}
|
||||
if (period) {
|
||||
searchParams.period = period;
|
||||
}
|
||||
|
||||
const res = await analyticsServerProxy<{ data: UserOpUsageQueryResult }>({
|
||||
pathname: "/v1/user-ops",
|
||||
method: "GET",
|
||||
searchParams: searchParams,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// TODO - remove this hook, fetch this on server
|
||||
export function useUserOpUsageAggregate(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
const { clientId, from, to } = args;
|
||||
const address = useActiveAccount()?.address;
|
||||
const chainStore = useAllChainsData();
|
||||
|
||||
return useQuery<UserOpStats>({
|
||||
queryKey: accountKeys.userOpStats(
|
||||
address || "",
|
||||
clientId,
|
||||
from?.toISOString() || "",
|
||||
to?.toISOString() || "",
|
||||
"all",
|
||||
),
|
||||
queryFn: async () => {
|
||||
const userOpStats = await getUserOpUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period: "all",
|
||||
});
|
||||
|
||||
// Aggregate stats across wallet types
|
||||
return userOpStats.reduce(
|
||||
(acc, curr) => {
|
||||
// Skip testnets from the aggregated stats
|
||||
if (curr.chainId) {
|
||||
const chain = chainStore.idToChain.get(Number(curr.chainId));
|
||||
if (chain?.testnet) {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
acc.successful += curr.successful;
|
||||
acc.failed += curr.failed;
|
||||
acc.sponsoredUsd += curr.sponsoredUsd;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
date: (from || new Date()).toISOString(),
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
},
|
||||
);
|
||||
},
|
||||
enabled: !!clientId && !!address,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO - remove this hook, fetch this on server
|
||||
export function useUserOpUsagePeriod(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period: "day" | "week" | "month" | "year";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.userOpStats(
|
||||
address || "",
|
||||
clientId as string,
|
||||
from?.toISOString() || "",
|
||||
to?.toISOString() || "",
|
||||
period,
|
||||
),
|
||||
queryFn: async () => {
|
||||
return getUserOpUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period,
|
||||
});
|
||||
},
|
||||
enabled: !!clientId && !!address,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
const address = useActiveAccount()?.address;
|
||||
@@ -460,9 +222,6 @@ export function useUpdateNotifications() {
|
||||
}
|
||||
|
||||
export function useConfirmEmail() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: ConfirmEmailInput) => {
|
||||
type Result = {
|
||||
@@ -491,20 +250,6 @@ export function useConfirmEmail() {
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// invalidate related cache, since could be relinking account
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.usage(address || ""),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(address || ""),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -548,205 +293,106 @@ export function useResendEmailConfirmation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
export async function createProjectClient(
|
||||
teamId: string,
|
||||
body: Partial<Project>,
|
||||
) {
|
||||
type Response = {
|
||||
result: {
|
||||
project: Project;
|
||||
secret: string;
|
||||
};
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateKeyInput) => {
|
||||
type Result = {
|
||||
data: ApiKey;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/keys",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
});
|
||||
const res = await apiServerProxy<Response>({
|
||||
pathname: `/v1/teams/${teamId}/projects`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}
|
||||
|
||||
export function useUpdateApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
export async function updateProjectClient(
|
||||
params: {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
},
|
||||
body: Partial<Project>,
|
||||
) {
|
||||
type Response = {
|
||||
result: Project;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: UpdateKeyInput) => {
|
||||
type Result = {
|
||||
data: ApiKey;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: `/v1/keys/${input.id}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
});
|
||||
const res = await apiServerProxy<Response>({
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}
|
||||
|
||||
export function useRevokeApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
export async function deleteProjectClient(params: {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
type Response = {
|
||||
result: true;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
type Result = {
|
||||
data: ApiKey;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: `/v1/keys/${id}/revoke`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
});
|
||||
},
|
||||
const res = await apiServerProxy<Response>({
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}
|
||||
|
||||
export const usePolicies = (serviceId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["policies", serviceId],
|
||||
queryFn: async () => {
|
||||
if (!serviceId) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
type Result = {
|
||||
data: ApiKeyServicePolicy;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/policies",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
searchParams: {
|
||||
serviceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!serviceId,
|
||||
});
|
||||
export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdatePolicies = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: {
|
||||
serviceId: string;
|
||||
data: ApiKeyServicePolicy;
|
||||
}) => {
|
||||
type Result = {
|
||||
data: ApiKeyServicePolicy;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/policies",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
serviceId: input.serviceId,
|
||||
data: input.data,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["policies", variables.serviceId],
|
||||
});
|
||||
export async function rotateSecretKeyClient(projectId: string) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
pathname: "/v2/keys/rotate-secret-key",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export function useRevokeAuthorizedWallet() {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useDashboardContractMetadata(contract: ThirdwebContract) {
|
||||
return useQuery({
|
||||
queryKey: ["contract-metadata-header", contract.chain.id, contract.address],
|
||||
queryFn: () => fetchDashboardContractMetadata(contract),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ type EngineFeature =
|
||||
| "CONTRACT_SUBSCRIPTIONS"
|
||||
| "IP_ALLOWLIST"
|
||||
| "HETEROGENEOUS_WALLET_TYPES"
|
||||
| "SMART_BACKEND_WALLETS";
|
||||
| "SMART_BACKEND_WALLETS"
|
||||
| "WALLET_CREDENTIALS";
|
||||
|
||||
interface EngineSystemHealth {
|
||||
status: string;
|
||||
@@ -860,6 +861,10 @@ export type SetWalletConfigInput =
|
||||
gcpKmsKeyRingId: string;
|
||||
gcpApplicationCredentialEmail: string;
|
||||
gcpApplicationCredentialPrivateKey: string;
|
||||
}
|
||||
| {
|
||||
type: "circle";
|
||||
circleApiKey: string;
|
||||
};
|
||||
|
||||
export function useEngineSetWalletConfig(params: {
|
||||
@@ -869,8 +874,8 @@ export function useEngineSetWalletConfig(params: {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<WalletConfigResponse, void, SetWalletConfigInput>({
|
||||
mutationFn: async (input) => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: SetWalletConfigInput) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}configuration/wallets`, {
|
||||
@@ -884,7 +889,7 @@ export function useEngineSetWalletConfig(params: {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result;
|
||||
return json.result as WalletConfigResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -894,10 +899,17 @@ export function useEngineSetWalletConfig(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateBackendWalletInput = {
|
||||
type: EngineBackendWalletType;
|
||||
label?: string;
|
||||
};
|
||||
export type CreateBackendWalletInput =
|
||||
| {
|
||||
type: Exclude<EngineBackendWalletType, "circle" | "smart:circle">;
|
||||
label?: string;
|
||||
}
|
||||
| {
|
||||
type: "circle" | "smart:circle";
|
||||
label?: string;
|
||||
credentialId: string;
|
||||
isTestnet: boolean;
|
||||
};
|
||||
|
||||
export function useEngineCreateBackendWallet(params: {
|
||||
instanceUrl: string;
|
||||
@@ -1851,3 +1863,118 @@ export function useEngineDeleteNotificationChannel(engineId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface WalletCredential {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
isDefault: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CreateWalletCredentialInput {
|
||||
type: "circle";
|
||||
label: string;
|
||||
entitySecret?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function useEngineWalletCredentials(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { instanceUrl, authToken, page = 1, limit = 100 } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...engineKeys.walletCredentials(instanceUrl), page, limit],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${instanceUrl}wallet-credentials?page=${page}&limit=${limit}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
return (json.result as WalletCredential[]) || [];
|
||||
},
|
||||
enabled: !!instanceUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngineCreateWalletCredential(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateWalletCredentialInput) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}wallet-credentials`, {
|
||||
method: "POST",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result as WalletCredential;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.walletCredentials(instanceUrl),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface UpdateWalletCredentialInput {
|
||||
label?: string;
|
||||
isDefault?: boolean;
|
||||
entitySecret?: string;
|
||||
}
|
||||
|
||||
export function useEngineUpdateWalletCredential(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
...input
|
||||
}: UpdateWalletCredentialInput & { id: string }) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}wallet-credentials/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result as WalletCredential;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.walletCredentials(instanceUrl),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { addContractToMultiChainRegistry } from "components/contract-components/utils";
|
||||
import { MULTICHAIN_REGISTRY_CONTRACT } from "constants/contracts";
|
||||
import {
|
||||
DASHBOARD_ENGINE_RELAYER_URL,
|
||||
DASHBOARD_FORWARDER_ADDRESS,
|
||||
} from "constants/misc";
|
||||
import type { BasicContract } from "contract-ui/types/types";
|
||||
import { getAllMultichainRegistry } from "dashboard-extensions/common/read/getAllMultichainRegistry";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useMemo } from "react";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { remove } from "thirdweb/extensions/thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
export function useMultiChainRegContractList(walletAddress?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard-registry", walletAddress, "multichain-contract-list"],
|
||||
queryFn: async () => {
|
||||
invariant(walletAddress, "walletAddress is required");
|
||||
const contracts = await getAllMultichainRegistry({
|
||||
contract: MULTICHAIN_REGISTRY_CONTRACT,
|
||||
address: walletAddress,
|
||||
});
|
||||
|
||||
return contracts;
|
||||
},
|
||||
|
||||
enabled: !!walletAddress,
|
||||
});
|
||||
}
|
||||
|
||||
interface Options {
|
||||
onlyMainnet?: boolean;
|
||||
}
|
||||
|
||||
export const useAllContractList = (
|
||||
walletAddress: string | undefined,
|
||||
{ onlyMainnet }: Options = { onlyMainnet: false },
|
||||
) => {
|
||||
const multiChainQuery = useMultiChainRegContractList(walletAddress);
|
||||
|
||||
// TODO - instead of using ALL chains, fetch only the ones used here
|
||||
const { idToChain } = useAllChainsData();
|
||||
const contractList = useMemo(() => {
|
||||
const data = multiChainQuery.data || [];
|
||||
|
||||
const mainnets: BasicContract[] = [];
|
||||
const testnets: BasicContract[] = [];
|
||||
|
||||
// biome-ignore lint/complexity/noForEach: FIXME
|
||||
data.forEach((net) => {
|
||||
const chn = idToChain.get(net.chainId);
|
||||
if (chn && chn.status !== "deprecated") {
|
||||
if (chn.testnet) {
|
||||
testnets.push(net);
|
||||
} else {
|
||||
mainnets.push(net);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mainnets.sort((a, b) => a.chainId - b.chainId);
|
||||
|
||||
if (onlyMainnet) {
|
||||
return mainnets;
|
||||
}
|
||||
|
||||
testnets.sort((a, b) => a.chainId - b.chainId);
|
||||
return mainnets.concat(testnets);
|
||||
}, [multiChainQuery.data, onlyMainnet, idToChain]);
|
||||
|
||||
return {
|
||||
...multiChainQuery,
|
||||
data: contractList,
|
||||
};
|
||||
};
|
||||
|
||||
type RemoveContractParams = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
};
|
||||
|
||||
export function useRemoveContractMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const account = useActiveAccount();
|
||||
return useMutation({
|
||||
mutationFn: async (data: RemoveContractParams) => {
|
||||
invariant(data.chainId, "chainId not provided");
|
||||
invariant(account, "No wallet connected");
|
||||
const { contractAddress, chainId } = data;
|
||||
const transaction = remove({
|
||||
contract: MULTICHAIN_REGISTRY_CONTRACT,
|
||||
deployer: account.address,
|
||||
deployment: contractAddress,
|
||||
chainId: BigInt(chainId),
|
||||
});
|
||||
return await sendAndConfirmTransaction({
|
||||
transaction,
|
||||
account,
|
||||
gasless: {
|
||||
experimentalChainlessSupport: true,
|
||||
provider: "engine",
|
||||
relayerUrl: DASHBOARD_ENGINE_RELAYER_URL,
|
||||
relayerForwarderAddress: DASHBOARD_FORWARDER_ADDRESS,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["dashboard-registry"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type AddContractParams = {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
};
|
||||
|
||||
export function useAddContractMutation() {
|
||||
const account = useActiveAccount();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: AddContractParams) => {
|
||||
invariant(account, "cannot add a contract without an address");
|
||||
|
||||
return await addContractToMultiChainRegistry(
|
||||
{
|
||||
address: data.contractAddress,
|
||||
chainId: data.chainId,
|
||||
},
|
||||
account,
|
||||
300000n,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["dashboard-registry"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type ChainlistPaginationProps = {
|
||||
totalPages: number;
|
||||
activePage: number;
|
||||
};
|
||||
|
||||
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
|
||||
activePage,
|
||||
totalPages,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginationButtons
|
||||
activePage={activePage}
|
||||
totalPages={totalPages}
|
||||
onPageClick={(page) => router.push(createPageURL(page))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { SearchIcon, XCircleIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
function cleanUrl(url: string) {
|
||||
if (url.endsWith("?")) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC = () => {
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// reset the input if the query param is removed
|
||||
if (inputRef.current?.value && !searchParams?.get("query")) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
if (term) {
|
||||
params.set("query", term);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
// always delete the page number when searching
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by token address or chain ID"
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{searchParams?.has("query") && (
|
||||
<Button
|
||||
size="icon"
|
||||
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
params.delete("query");
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}}
|
||||
>
|
||||
<XCircleIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type QueryTypeProps = {
|
||||
activeType: "origin" | "destination";
|
||||
};
|
||||
|
||||
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(type: "origin" | "destination") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("type", type);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<ToolTipLabel label="Origin" contentClassName="w-full">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={activeType === "origin" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("origin"));
|
||||
}}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<ArrowUpRightIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<ToolTipLabel label="Destination" contentClassName="w-full">
|
||||
<Button
|
||||
variant={activeType === "destination" ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("destination"));
|
||||
}}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
<ArrowDownLeftIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { Grid2X2Icon, ListIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type RouteListViewProps = {
|
||||
activeView: "grid" | "table";
|
||||
};
|
||||
|
||||
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(view: "grid" | "table") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("view", view);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={activeView === "table" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("table"));
|
||||
}}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<ListIcon strokeWidth={1} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeView === "grid" ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("grid"));
|
||||
}}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
<Grid2X2Icon strokeWidth={1} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { NATIVE_TOKEN_ADDRESS, defineChain, getContract } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { name } from "thirdweb/extensions/common";
|
||||
|
||||
type RouteListCardProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri: string | null;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri: string | null;
|
||||
};
|
||||
|
||||
export async function RouteListCard({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
}: RouteListCardProps) {
|
||||
const [
|
||||
originChain,
|
||||
originTokenName,
|
||||
destinationChain,
|
||||
destinationTokenName,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: name({
|
||||
contract: getContract({
|
||||
address: originTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(originChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: name({
|
||||
contract: getContract({
|
||||
address: destinationTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(destinationChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: originTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: destinationTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full transition-colors hover:border-active-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-8 rounded-full bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-white/10" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="-translate-x-4 size-8 rounded-full bg-white ring-2 ring-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<table className="w-full">
|
||||
<tbody className="text-sm [&_td>*]:min-h-[25px]">
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{originTokenName === "ETH"
|
||||
? originChain.nativeCurrency.name
|
||||
: originTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{destinationTokenName === "ETH"
|
||||
? destinationChain.nativeCurrency.name
|
||||
: destinationTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { NATIVE_TOKEN_ADDRESS, getContract } from "thirdweb";
|
||||
import { defineChain, getChainMetadata } from "thirdweb/chains";
|
||||
import { symbol } from "thirdweb/extensions/common";
|
||||
|
||||
type RouteListRowProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri: string | null;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri: string | null;
|
||||
};
|
||||
|
||||
export async function RouteListRow({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
}: RouteListRowProps) {
|
||||
const [
|
||||
originChain,
|
||||
originTokenSymbol,
|
||||
destinationChain,
|
||||
destinationTokenSymbol,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: symbol({
|
||||
contract: getContract({
|
||||
address: originTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(originChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: symbol({
|
||||
contract: getContract({
|
||||
address: destinationTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(destinationChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: originTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: destinationTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow linkBox className="hover:bg-accent/50">
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-6 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{originTokenSymbol && (
|
||||
<CopyTextButton
|
||||
textToCopy={originTokenAddress}
|
||||
textToShow={
|
||||
originTokenSymbol === "ETH"
|
||||
? originChain.nativeCurrency.symbol
|
||||
: originTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
className="relative z-10 text-base"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{originChain.name}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="size-6 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{destinationTokenSymbol && (
|
||||
<CopyTextButton
|
||||
textToCopy={destinationTokenAddress}
|
||||
textToShow={
|
||||
destinationTokenSymbol === "ETH"
|
||||
? destinationChain.nativeCurrency.symbol
|
||||
: destinationTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
className="relative z-10 text-base"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Address } from "thirdweb";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "../server/routelist-card";
|
||||
import { RouteListRow } from "../server/routelist-row";
|
||||
|
||||
export type SearchParams = Partial<{
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
type: "origin" | "destination";
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 120 is divisible by 2, 3, and 4 so card layout looks nice
|
||||
const DEFAULT_PAGE_SIZE = 120;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
async function getRoutesToRender(params: SearchParams) {
|
||||
const filters: Partial<{
|
||||
limit: number;
|
||||
offset: number;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
filters.originChainId = Number(params.query);
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
}
|
||||
}
|
||||
// Temporary, will update this after the /routes endpoint
|
||||
filters.limit = 10_000;
|
||||
|
||||
const routes = await getRoutes(filters);
|
||||
|
||||
const totalCount = routes.length;
|
||||
|
||||
return {
|
||||
routesToRender: routes,
|
||||
totalCount,
|
||||
filteredCount: routes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function RoutesData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
// pagination
|
||||
const totalPages = Math.ceil(routesToRender.length / DEFAULT_PAGE_SIZE);
|
||||
|
||||
const activePage = Number(props.searchParams.page || DEFAULT_PAGE);
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedRoutes = routesToRender.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{paginatedRoutes.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader className="z-0">
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedRoutes.map((route) => (
|
||||
<RouteListRow
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedRoutes.map((route) => (
|
||||
<li
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
className="h-full"
|
||||
>
|
||||
<RouteListCard
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination totalPages={totalPages} activePage={activePage} />
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{paginatedRoutes.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">{filteredCount}</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">{totalCount}</span>)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">{totalCount}</span> routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { QueryType } from "./components/client/type";
|
||||
import { RouteListView } from "./components/client/view";
|
||||
import {
|
||||
RoutesData,
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Routes: Swap, Bridge, and On-Ramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ChainListPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const activeType = searchParams.type ?? "origin";
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
return (
|
||||
<section className="container mx-auto flex h-full flex-col px-4 py-10">
|
||||
<header className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-row items-center justify-between gap-4 lg:flex-col lg:justify-start">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
Routes
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col ">
|
||||
<div className="flex w-full flex-row gap-4">
|
||||
<SearchInput />
|
||||
<QueryType activeType={activeType} />
|
||||
<RouteListView activeView={activeView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
<RoutesData
|
||||
searchParams={searchParams}
|
||||
activeView={activeView}
|
||||
isLoggedIn={!!authToken}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Address } from "thirdweb";
|
||||
|
||||
export type Route = {
|
||||
originToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri: string;
|
||||
};
|
||||
destinationToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
|
||||
import { BRIDGE_URL, DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
|
||||
import { getAuthToken } from "app/api/lib/getAuthToken";
|
||||
import type { Address } from "thirdweb";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${BRIDGE_URL}/v1/routes`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: token
|
||||
? {
|
||||
authorization: `Bearer ${token}`,
|
||||
}
|
||||
: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
next: { revalidate: 60 * 60 },
|
||||
});
|
||||
|
||||
if (!routesResponse.ok) {
|
||||
throw new Error("Failed to fetch routes");
|
||||
}
|
||||
const routes: { data: Route[] } = await routesResponse.json();
|
||||
|
||||
return routes.data;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { getContractPageSidebarLinks } from "../_utils/getContractPageSidebarLinks";
|
||||
@@ -13,6 +14,8 @@ export function ContractPageLayoutClient(props: {
|
||||
chainMetadata: ChainMetadata;
|
||||
contract: ThirdwebContract;
|
||||
children: React.ReactNode;
|
||||
teamsAndProjects: MinimalTeamsAndProjects | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
const headerMetadataQuery = useQuery({
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import type { DashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import { DeprecatedAlert } from "components/shared/DeprecatedAlert";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ThirdwebClient, ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { MinimalTeamsAndProjects } from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { ContractMetadata } from "./contract-metadata";
|
||||
import { PrimaryDashboardButton } from "./primary-dashboard-button";
|
||||
|
||||
@@ -14,6 +15,8 @@ export function ContractPageLayout(props: {
|
||||
children: React.ReactNode;
|
||||
sidebarLinks: SidebarLink[];
|
||||
dashboardContractMetadata: DashboardContractMetadata | undefined;
|
||||
client: ThirdwebClient;
|
||||
teamsAndProjects: MinimalTeamsAndProjects | undefined;
|
||||
externalLinks:
|
||||
| {
|
||||
name: string;
|
||||
@@ -27,6 +30,8 @@ export function ContractPageLayout(props: {
|
||||
sidebarLinks,
|
||||
dashboardContractMetadata,
|
||||
externalLinks,
|
||||
teamsAndProjects,
|
||||
client,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -49,6 +54,8 @@ export function ContractPageLayout(props: {
|
||||
chainSlug: chainMetadata.slug,
|
||||
contractAddress: contract.address,
|
||||
}}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
<DeprecatedAlert chain={chainMetadata} />
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EVMContractInfo } from "@3rdweb-sdk/react";
|
||||
import {
|
||||
useAddContractMutation,
|
||||
useAllContractList,
|
||||
} from "@3rdweb-sdk/react/hooks/useRegistry";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { EVMContractInfo } from "@3rdweb-sdk/react";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CodeIcon, PlusIcon } from "lucide-react";
|
||||
import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { Chain } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
AddToProjectSelector,
|
||||
type MinimalTeamsAndProjects,
|
||||
} from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card";
|
||||
import { useAddContractToProject } from "../../../../../team/[team_slug]/[project_slug]/hooks/project-contracts";
|
||||
|
||||
const TRACKING_CATEGORY = "add_to_dashboard_upsell";
|
||||
|
||||
@@ -22,6 +34,8 @@ type AddToDashboardCardProps = {
|
||||
chain: Chain;
|
||||
contractInfo: EVMContractInfo;
|
||||
hideCodePageLink?: boolean;
|
||||
teamsAndProjects: MinimalTeamsAndProjects | undefined;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
@@ -29,40 +43,13 @@ export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
chain,
|
||||
contractInfo,
|
||||
hideCodePageLink,
|
||||
teamsAndProjects,
|
||||
client,
|
||||
}) => {
|
||||
const addContract = useAddContractMutation();
|
||||
const trackEvent = useTrack();
|
||||
const walletAddress = useActiveAccount()?.address;
|
||||
const pathname = usePathname();
|
||||
const registry = useAllContractList(walletAddress);
|
||||
|
||||
const { onSuccess: onAddSuccess, onError: onAddError } = useTxNotifications(
|
||||
"Successfully imported",
|
||||
"Failed to import",
|
||||
);
|
||||
|
||||
const isInRegistry =
|
||||
registry.isFetched &&
|
||||
registry.data?.find(
|
||||
(c) =>
|
||||
contractAddress &&
|
||||
// compare address...
|
||||
c.address.toLowerCase() === contractAddress.toLowerCase() &&
|
||||
// ... and chainId
|
||||
c.chainId === chain.id,
|
||||
) &&
|
||||
registry.isSuccess;
|
||||
|
||||
if (
|
||||
!walletAddress ||
|
||||
!contractAddress ||
|
||||
!chain ||
|
||||
pathname?.includes("payments")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInRegistry) {
|
||||
// if user is not logged in
|
||||
if (!teamsAndProjects) {
|
||||
if (hideCodePageLink) {
|
||||
return null;
|
||||
}
|
||||
@@ -84,54 +71,163 @@ export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!chain) {
|
||||
return;
|
||||
}
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "attempt",
|
||||
contractAddress,
|
||||
});
|
||||
addContract.mutate(
|
||||
{
|
||||
contractAddress,
|
||||
chainId: chain.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onAddSuccess();
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "success",
|
||||
contractAddress,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
onAddError(err);
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "error",
|
||||
contractAddress,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{addContract.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlusIcon className="size-4" />
|
||||
)}
|
||||
Import contract
|
||||
</Button>
|
||||
<AddToProjectButton
|
||||
contractAddress={contractAddress}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
chainId={chain.id.toString()}
|
||||
client={client}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function AddToProjectButton(props: {
|
||||
contractAddress: string;
|
||||
teamsAndProjects: MinimalTeamsAndProjects;
|
||||
chainId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" variant="outline">
|
||||
<PlusIcon className="size-4" />
|
||||
Add To Project
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle>Add To Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add the contract in a project's contract list on dashboard
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddToProjectModalContent
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
chainId={props.chainId}
|
||||
contractAddress={props.contractAddress}
|
||||
client={props.client}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToProjectModalContent(props: {
|
||||
teamsAndProjects: MinimalTeamsAndProjects;
|
||||
chainId: string;
|
||||
contractAddress: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const trackEvent = useTrack();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
|
||||
const [importSelection, setImportSelection] = useState({
|
||||
team: props.teamsAndProjects[0]?.team,
|
||||
project: props.teamsAndProjects[0]?.projects[0],
|
||||
});
|
||||
|
||||
const selectedTeam = props.teamsAndProjects.find(
|
||||
(t) => t.team.id === importSelection.team?.id,
|
||||
);
|
||||
|
||||
function handleImport(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "attempt",
|
||||
contractAddress: props.contractAddress,
|
||||
});
|
||||
addContractToProject.mutate(
|
||||
{
|
||||
contractAddress: props.contractAddress,
|
||||
teamId: params.teamId,
|
||||
projectId: params.projectId,
|
||||
chainId: props.chainId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Contract added to the project successfully");
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "success",
|
||||
contractAddress: props.contractAddress,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.message.includes("PROJECT_CONTRACT_ALREADY_EXISTS")) {
|
||||
toast.error("Contract is already added to the project");
|
||||
} else {
|
||||
toast.error("Failed to import contract");
|
||||
}
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "add-to-dashboard",
|
||||
label: "error",
|
||||
contractAddress: props.contractAddress,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isImportEnabled = !!importSelection.project && !!importSelection.team;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 border-border border-t px-6 py-6">
|
||||
<AddToProjectSelector
|
||||
selection={importSelection}
|
||||
onSelectionChange={setImportSelection}
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
{/* No projects alert */}
|
||||
{selectedTeam?.projects.length === 0 && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle> Selected team has no projects </AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnderlineLink
|
||||
href={`/team/${selectedTeam.team.slug}`}
|
||||
className="inline-flex items-center gap-2"
|
||||
target="_blank"
|
||||
>
|
||||
Create Project <ExternalLinkIcon className="size-3.5" />
|
||||
</UnderlineLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end border-border border-t bg-card p-6">
|
||||
<Button
|
||||
disabled={!isImportEnabled}
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
if (!importSelection.team || !importSelection.project) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleImport({
|
||||
teamId: importSelection.team.id,
|
||||
projectId: importSelection.project.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{addContractToProject.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlusIcon className="size-4" />
|
||||
)}
|
||||
Add to Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||