Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
363bcad530 Version Packages (#6094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-29 10:42:04 -08:00
Jonas DanielsandGitHub ee3822c785 pass team instead of project to rateLimit (#6092) 2025-01-29 10:36:11 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
1cff849b69 Version Packages (#6089)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-28 18:22:36 -08:00
Jonas DanielsandGitHub b5e327e5ec add billingPlanVersion to TeamResponse (#6084) 2025-01-28 18:20:57 -08:00
757 changed files with 13871 additions and 26750 deletions
+1 -7
View File
@@ -10,13 +10,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"playground-web",
"thirdweb-dashboard",
"wallet-ui",
"portal",
"thirdweb-login"
],
"ignore": ["playground-web", "thirdweb-dashboard", "wallet-ui", "portal"],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"updateInternalDependents": "always",
"onlyUpdatePeerDependentsWhenOutOfRange": true
+3 -4
View File
@@ -6,6 +6,8 @@ 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
@@ -83,10 +85,7 @@ 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());
+6 -3
View File
@@ -1,7 +1,7 @@
<p align="center">
<br />
<a href="https://thirdweb.com">
<img src="https://thirdweb.com/brand/thirdweb-icon.svg" width="200" alt=""/></a>
<img src="https://github.com/thirdweb-dev/js/blob/main/legacy_packages/sdk/logo.svg?raw=true" width="200" alt=""/></a>
<br />
</p>
@@ -11,6 +11,9 @@
<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>
@@ -28,12 +31,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 | ❌ |
+2 -6
View File
@@ -6,14 +6,10 @@
# not required to build, defaults to prod
NEXT_PUBLIC_THIRDWEB_DOMAIN="localhost:3000"
# API host. For local development, please use "https://api.thirdweb-dev.com"
# API host. For local development, please use "https://api.thirdweb-preview.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"
@@ -101,4 +97,4 @@ REDIS_URL=""
ANALYTICS_SERVICE_URL=""
# Required for Nebula Chat
NEXT_PUBLIC_NEBULA_URL=""
NEXT_PUBLIC_NEBULA_URL=""
+1 -2
View File
@@ -9,8 +9,7 @@ module.exports = [
// -- connect
"/connect",
"/connect/sign-in",
"/connect/account-abstraction",
"/connect/universal-bridge",
"/connect/pay",
// -- nebula
"/nebula",
// --insight
+12 -13
View File
@@ -46,12 +46,12 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tooltip": "1.1.7",
"@sentry/nextjs": "8.53.0",
"@sentry/nextjs": "8.51.0",
"@shazow/whatsabi": "^0.19.0",
"@tanstack/react-query": "5.66.0",
"@tanstack/react-query": "5.65.0",
"@tanstack/react-table": "^8.20.6",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "^1.6.0",
"@vercel/functions": "^1.5.2",
"@vercel/og": "^0.6.5",
"abitype": "1.0.8",
"chakra-react-select": "^4.7.6",
@@ -72,9 +72,9 @@
"next-plausible": "^3.12.4",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.0",
"openapi-types": "^12.1.3",
"p-limit": "^6.2.0",
"papaparse": "^5.5.2",
"papaparse": "^5.5.1",
"pluralize": "^8.0.0",
"posthog-js": "1.67.1",
"qrcode": "^1.5.3",
@@ -83,18 +83,17 @@
"react-day-picker": "^8.10.1",
"react-dom": "19.0.0",
"react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0",
"react-error-boundary": "^4.1.2",
"react-hook-form": "7.54.2",
"react-markdown": "^9.0.1",
"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",
"sonner": "^1.7.2",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.18.3",
"swagger-ui-react": "^5.18.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
@@ -107,7 +106,7 @@
"@chromatic-com/storybook": "3.2.4",
"@next/bundle-analyzer": "15.1.6",
"@next/eslint-plugin-next": "15.1.6",
"@playwright/test": "1.50.1",
"@playwright/test": "1.50.0",
"@storybook/addon-essentials": "8.5.2",
"@storybook/addon-interactions": "8.5.2",
"@storybook/addon-links": "8.5.2",
@@ -118,7 +117,7 @@
"@storybook/react": "8.5.2",
"@storybook/test": "8.5.2",
"@types/color": "4.2.0",
"@types/node": "22.13.0",
"@types/node": "22.10.10",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
@@ -130,12 +129,12 @@
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.19",
"checkly": "^4.19.1",
"checkly": "^4.18.0",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"eslint-plugin-storybook": "^0.11.1",
"knip": "5.43.6",
"knip": "5.43.5",
"next-sitemap": "^4.2.3",
"postcss": "8.5.1",
"storybook": "8.5.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

-12
View File
@@ -331,18 +331,6 @@ 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",
@@ -1,28 +0,0 @@
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,6 +26,9 @@ 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) {
@@ -1,53 +0,0 @@
"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,
};
}
+9
View File
@@ -71,6 +71,15 @@ 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,
) {
@@ -1,68 +0,0 @@
"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,7 +5,6 @@ import { API_SERVER_URL } from "../constants/env";
export async function updateAccount(values: {
name?: string;
email?: string;
image?: string | null;
}) {
const token = await getAuthToken();
+29 -220
View File
@@ -1,8 +1,6 @@
import "server-only";
import { fetchAnalytics } from "data/analytics/fetch-analytics";
import type {
AnalyticsQueryParams,
EcosystemWalletStats,
InAppWalletStats,
RpcMethodStats,
TransactionStats,
@@ -10,67 +8,15 @@ 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();
searchParams.append("teamId", params.teamId);
if (params.projectId) {
searchParams.append("projectId", params.projectId);
if (params.clientId) {
searchParams.append("clientId", params.clientId);
}
if (params.accountId) {
searchParams.append("accountId", params.accountId);
}
// shared params
if (params.from) {
searchParams.append("from", params.from.toISOString());
}
@@ -87,19 +33,13 @@ export async function getWalletConnections(
params: AnalyticsQueryParams,
): Promise<WalletStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/sdk/wallet-connects?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
const res = await fetchAnalytics(`v1/wallets?${searchParams.toString()}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch wallet connections, ${res?.status} - ${res.statusText} - ${reason}`,
);
console.error("Failed to fetch wallet connections");
return [];
}
@@ -112,7 +52,7 @@ export async function getInAppWalletUsage(
): Promise<InAppWalletStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/wallet/connects?${searchParams.toString()}`,
`v1/wallets/in-app?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -120,10 +60,7 @@ export async function getInAppWalletUsage(
);
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch in-app wallet usage, ${res?.status} - ${res.statusText} - ${reason}`,
);
console.error("Failed to fetch in-app wallet usage");
return [];
}
@@ -135,13 +72,10 @@ export async function getUserOpUsage(
params: AnalyticsQueryParams,
): Promise<UserOpStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/bundler/usage?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
const res = await fetchAnalytics(`v1/user-ops?${searchParams.toString()}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (res?.status !== 200) {
const reason = await res?.text();
@@ -155,46 +89,12 @@ 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(
`v2/sdk/contract-transactions?${searchParams.toString()}`,
`v1/transactions/client?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -218,7 +118,7 @@ export async function getRpcMethodUsage(
): Promise<RpcMethodStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/rpc/evm-methods?${searchParams.toString()}`,
`v1/rpc/evm-methods?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -239,7 +139,7 @@ export async function getWalletUsers(
): Promise<WalletUserStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/wallet-connects/users?${searchParams.toString()}`,
`v1/wallets/users?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -247,10 +147,7 @@ export async function getWalletUsers(
);
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch wallet user stats: ${res?.status} - ${res.statusText} - ${reason}`,
);
console.error("Failed to fetch wallet user stats");
return [];
}
@@ -258,108 +155,20 @@ 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<ActiveStatus> {
): Promise<boolean> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/active-usage?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
const res = await fetchAnalytics(`v1/active?${searchParams.toString()}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (res?.status !== 200) {
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;
console.error("Failed to fetch project active status");
return false;
}
const json = await res.json();
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[];
return json.data.isActive as boolean;
}
-54
View File
@@ -1,54 +0,0 @@
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>>;
}>;
}
@@ -1,32 +0,0 @@
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;
}
+16 -2
View File
@@ -1,9 +1,23 @@
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 = ProjectResponse;
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 async function getProjects(teamSlug: string) {
const token = await getAuthToken();
-46
View File
@@ -1,46 +0,0 @@
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;
}
+4 -28
View File
@@ -12,16 +12,15 @@ export type TeamAccountRole =
export type TeamMember = {
account: {
creatorWalletAddress: string;
name: string;
email: string | null;
image: string | null;
};
deletedAt: string | null;
} & {
deletedAt: Date | null;
accountId: string;
teamId: string;
createdAt: string;
updatedAt: string;
createdAt: Date;
updatedAt: Date;
role: TeamAccountRole;
};
@@ -47,26 +46,3 @@ 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;
}
+28 -21
View File
@@ -1,9 +1,34 @@
import "server-only";
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
import type { TeamResponse } from "@thirdweb-dev/service-utils";
import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
export type Team = TeamResponse;
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 async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
@@ -22,24 +47,6 @@ 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 py-10">
<div className="container flex max-w-[1000px] flex-col gap-8 lg:p-10">
<BadgeContainer label="Base">
<DangerSettingCard
title="This is a title"
@@ -6,6 +6,7 @@ import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -16,14 +17,13 @@ export function DangerSettingCard(props: {
title: string;
className?: string;
footerClassName?: string;
description: React.ReactNode;
description: string;
buttonLabel: string;
buttonOnClick: () => void;
isDisabled?: boolean;
isPending: boolean;
confirmationDialog: {
title: string;
description: React.ReactNode;
description: string;
};
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.isDisabled || props.isPending}
disabled={props.isPending}
>
{props.isPending && <Spinner className="size-3" />}
{props.buttonLabel}
@@ -63,22 +63,20 @@ export function DangerSettingCard(props: {
</DialogTrigger>
<DialogContent
className="z-[10001] overflow-hidden p-0"
className="z-[10001]"
dialogOverlayClassName="z-[10000]"
>
<div className="p-6">
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
</div>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
<DialogFooter className="mt-4 gap-4 lg:gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
@@ -92,7 +90,7 @@ export function DangerSettingCard(props: {
{props.isPending && <Spinner className="size-3" />}
{props.buttonLabel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@@ -1,11 +1,10 @@
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: React.ReactNode;
label: string;
errorMessage: React.ReactNode | undefined;
children: React.ReactNode;
tooltip?: React.ReactNode;
@@ -1,6 +1,7 @@
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";
@@ -60,6 +61,11 @@ 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,12 +1,5 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
@@ -22,14 +15,8 @@ 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 }>;
@@ -38,99 +25,78 @@ 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 (
<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>
<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) => (
<Area
<linearGradient
key={key}
dataKey={key}
type="natural"
fill={`url(#fill_${key})`}
fillOpacity={0.4}
stroke={`var(--color-${key})`}
stackId="a"
/>
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>
{props.showLegend && (
<ChartLegend content={<ChartLegendContent />} className="pt-8" />
)}
</AreaChart>
)}
</ChartContainer>
</div>
);
}
@@ -27,11 +27,8 @@ import { cn } from "../../../lib/utils";
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
// metadata
header?: {
title: string;
description?: string;
titleClassName?: string;
};
title: string;
description?: string;
// chart config
config: TConfig;
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
@@ -42,6 +39,7 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
isPending: boolean;
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
hideLabel?: boolean;
titleClassName?: string;
};
export function ThirdwebBarChart<TConfig extends ChartConfig>(
@@ -54,18 +52,15 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
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")}>
<CardHeader>
<CardTitle className={cn("mb-2", props.titleClassName)}>
{props.title}
</CardTitle>
{props.description && (
<CardDescription>{props.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<ChartContainer config={props.config} className={props.chartClassName}>
{props.isPending ? (
<LoadingChartState />
@@ -92,9 +87,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
}
/>
{props.showLegend && (
<ChartLegend
content={<ChartLegendContent className="pt-5" />}
/>
<ChartLegend content={<ChartLegendContent />} />
)}
{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, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
import { CheckIcon, CircleDollarSignIcon } from "lucide-react";
import type React from "react";
import { TEAM_PLANS } from "utils/pricing";
import { remainingDays } from "../../../utils/date-utils";
@@ -105,23 +105,6 @@ 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,20 +1,10 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { cn } from "../../../lib/utils";
export function GenericLoadingPage({
className,
}: {
className?: string;
}) {
export function GenericLoadingPage() {
return (
<div
className={cn(
"flex min-h-[500px] grow items-center justify-center rounded-lg border border-border",
className,
)}
>
<div className="flex min-h-[500px] grow items-center justify-center rounded-lg border border-border">
<Spinner className="size-10" />
</div>
);
@@ -8,7 +8,7 @@ import {
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { Check, Copy } from "lucide-react";
import { Check, Copy, ExternalLinkIcon } from "lucide-react";
import { useMemo } from "react";
import { type ThirdwebClient, isAddress } from "thirdweb";
import { ZERO_ADDRESS } from "thirdweb";
@@ -148,6 +148,21 @@ export function WalletAddress(props: {
);
})
)}
<Button
asChild
variant="upsell"
className="flex flex-row items-center gap-2 text-sm"
size="sm"
>
<a
target="_blank"
href="https://blog.thirdweb.com/changelog/introducing-the-social-sdk?ref=dashboard-social-wallet"
rel="noreferrer"
>
Learn more
<ExternalLinkIcon className="size-4" />
</a>
</Button>
</div>
</HoverCardContent>
</HoverCard>
@@ -2,29 +2,27 @@
import { Button } from "@/components/ui/button";
import { ClientOnly } from "components/ClientOnly/ClientOnly";
import { MoonIcon, SunIcon } from "lucide-react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Skeleton } from "./ui/skeleton";
export function ToggleThemeButton() {
export function ColorModeToggle() {
const { setTheme, theme } = useTheme();
return (
<ClientOnly
ssr={<Skeleton className="size-[34px] rounded-full border bg-accent" />}
>
<ClientOnly ssr={<Skeleton className="size-10 border bg-accent" />}>
<Button
variant="outline"
variant="ghost"
size="icon"
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" ? (
<SunIcon className="size-4" />
<Sun strokeWidth={1} className="size-5" />
) : (
<MoonIcon className="size-4" />
<Moon strokeWidth={1} className="size-5" />
)}
</Button>
</ClientOnly>
@@ -27,7 +27,6 @@ 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;
@@ -66,11 +65,7 @@ export function DatePickerWithRange(props: {
</PopoverTrigger>
{/* Popover */}
<PopoverContent
className="w-auto p-0"
align={props.popoverAlign || "start"}
sideOffset={10}
>
<PopoverContent className="w-auto p-0" align="start">
<DynamicHeight>
<div>
{!isValid && (
@@ -1,16 +0,0 @@
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,
)}
/>
);
}
@@ -1,20 +0,0 @@
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 z-50 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 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 z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
"fixed 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",
-5
View File
@@ -4,8 +4,6 @@ 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}";
@@ -23,9 +21,6 @@ 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
*/
+1 -1
View File
@@ -117,7 +117,7 @@ function DashboardRouterTopProgressBarInner() {
const width = isLoading ? progress : 100;
return (
<span
className="fixed top-0 block h-[3px] bg-foreground"
className="fixed top-0 block h-[2px] bg-blue-500"
style={{
opacity: isLoading ? "100" : "0",
width: `${width}%`,
@@ -42,6 +42,15 @@ 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) =>
@@ -86,8 +95,7 @@ export const engineKeys = {
backendWallet: (address: string, chainId: number) =>
["backendWallet", address, chainId] as const,
backendWalletBalance: (address: string, chainId: number) =>
["backendWallet", "balance", address, chainId] as const,
backendWalletBalanceAll: () => ["backendWallet", "balance"] as const,
[...engineKeys.backendWallet(address, chainId), "balance"] as const,
corsUrls: (instance: string) =>
[...engineKeys.all, instance, "corsUrls"] as const,
ipAllowlist: (instance: string) =>
@@ -116,6 +124,4 @@ 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,12 +1,22 @@
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { analyticsServerProxy, apiServerProxy } from "@/actions/proxies";
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 { accountKeys, authorizedWallets } from "../cache-keys";
import type { UserOpStats } from "types/analytics";
import { accountKeys, apiKeys, 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",
@@ -29,7 +39,6 @@ export type Account = {
id: string;
isStaff: boolean;
creatorWalletAddress: string;
image?: string | null;
name?: string;
email?: string;
advancedEnabled: boolean;
@@ -61,6 +70,109 @@ 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;
}
@@ -140,6 +252,132 @@ 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;
@@ -222,6 +460,9 @@ export function useUpdateNotifications() {
}
export function useConfirmEmail() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: ConfirmEmailInput) => {
type Result = {
@@ -250,6 +491,20 @@ 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 || ""),
}),
]);
},
});
}
@@ -293,106 +548,240 @@ export function useResendEmailConfirmation() {
});
}
export async function createProjectClient(
teamId: string,
body: Partial<Project>,
) {
type Response = {
result: {
project: Project;
secret: string;
};
};
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 async function updateProjectClient(
params: {
projectId: string;
teamId: string;
},
body: Partial<Project>,
) {
type Response = {
result: Project;
};
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 async function deleteProjectClient(params: {
projectId: string;
teamId: string;
export function useApiKeys(props: {
isLoggedIn: boolean;
}) {
type Response = {
result: true;
};
const address = useActiveAccount()?.address;
return useQuery({
queryKey: apiKeys.keys(address || ""),
queryFn: async () => {
type Result = {
data: ApiKey[];
error?: { message: string };
};
const res = await apiServerProxy<Response>({
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`,
method: "DELETE",
const res = await apiServerProxy<Result>({
pathname: "/v1/keys",
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
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: !!address && props.isLoggedIn,
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data.result;
}
export type RotateSecretKeyAPIReturnType = {
data: {
secret: string;
secretMasked: string;
secretHash: string;
};
export function useCreateApiKey() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
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 || ""),
});
},
});
}
export function useUpdateApiKey() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
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 || ""),
});
},
});
}
export function useRevokeApiKey() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
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 || ""),
});
},
});
}
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 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",
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],
});
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
}
};
export function useRevokeAuthorizedWallet() {
const address = useActiveAccount()?.address;
@@ -15,7 +15,6 @@ export function useDashboardContractMetadata(contract: ThirdwebContract) {
return useQuery({
queryKey: ["contract-metadata-header", contract.chain.id, contract.address],
queryFn: () => fetchDashboardContractMetadata(contract),
refetchOnWindowFocus: false,
});
}
@@ -93,8 +93,7 @@ type EngineFeature =
| "CONTRACT_SUBSCRIPTIONS"
| "IP_ALLOWLIST"
| "HETEROGENEOUS_WALLET_TYPES"
| "SMART_BACKEND_WALLETS"
| "WALLET_CREDENTIALS";
| "SMART_BACKEND_WALLETS";
interface EngineSystemHealth {
status: string;
@@ -861,10 +860,6 @@ export type SetWalletConfigInput =
gcpKmsKeyRingId: string;
gcpApplicationCredentialEmail: string;
gcpApplicationCredentialPrivateKey: string;
}
| {
type: "circle";
circleApiKey: string;
};
export function useEngineSetWalletConfig(params: {
@@ -874,8 +869,8 @@ export function useEngineSetWalletConfig(params: {
const { instanceUrl, authToken } = params;
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: SetWalletConfigInput) => {
return useMutation<WalletConfigResponse, void, SetWalletConfigInput>({
mutationFn: async (input) => {
invariant(instanceUrl, "instance is required");
const res = await fetch(`${instanceUrl}configuration/wallets`, {
@@ -889,7 +884,7 @@ export function useEngineSetWalletConfig(params: {
throw new Error(json.error.message);
}
return json.result as WalletConfigResponse;
return json.result;
},
onSuccess: () => {
return queryClient.invalidateQueries({
@@ -899,17 +894,10 @@ export function useEngineSetWalletConfig(params: {
});
}
export type CreateBackendWalletInput =
| {
type: Exclude<EngineBackendWalletType, "circle" | "smart:circle">;
label?: string;
}
| {
type: "circle" | "smart:circle";
label?: string;
credentialId: string;
isTestnet: boolean;
};
export type CreateBackendWalletInput = {
type: EngineBackendWalletType;
label?: string;
};
export function useEngineCreateBackendWallet(params: {
instanceUrl: string;
@@ -1863,118 +1851,3 @@ 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),
});
},
});
}
@@ -0,0 +1,149 @@
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"],
});
},
});
}
@@ -1,37 +0,0 @@
"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))}
/>
);
};
@@ -1,74 +0,0 @@
"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>
);
};
@@ -1,55 +0,0 @@
"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>
);
};
@@ -1,50 +0,0 @@
"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>
);
};
@@ -1,126 +0,0 @@
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>
);
}
@@ -1,144 +0,0 @@
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>
);
}
@@ -1,161 +0,0 @@
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>
</>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -1,70 +0,0 @@
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>
);
}
@@ -1,14 +0,0 @@
import type { Address } from "thirdweb";
export type Route = {
originToken: {
address: Address;
chainId: number;
iconUri: string;
};
destinationToken: {
address: Address;
chainId: number;
iconUri: string;
};
};
@@ -1,60 +0,0 @@
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,32 +1,13 @@
"use client";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
THIRDWEB_ENGINE_FAUCET_WALLET,
TURNSTILE_SITE_KEY,
} from "@/constants/env";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { Turnstile } from "@marsidev/react-turnstile";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
@@ -36,21 +17,9 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type ThirdwebClient,
prepareTransaction,
toUnits,
toWei,
} from "thirdweb";
import { toUnits } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import type { Chain } from "thirdweb/chains";
import {
useActiveAccount,
useActiveWalletChain,
useSendTransaction,
useSwitchActiveWalletChain,
useWalletBalance,
} from "thirdweb/react";
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
import { z } from "zod";
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
@@ -168,35 +137,6 @@ export function FaucetButton({
const form = useForm<z.infer<typeof claimFaucetSchema>>();
// loading state
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
return (
<Button variant="outline" className="w-full gap-2">
Checking Faucet <Spinner className="size-3" />
</Button>
);
}
// faucet is empty
if (isFaucetEmpty) {
return (
<div className="w-full">
<div className="mb-3 text-center text-muted-foreground text-sm">
Faucet is empty right now
</div>
<SendFundsToFaucetModalButton
chain={definedChain}
isLoggedIn={!!twAccount}
client={client}
chainMeta={chain}
onFaucetRefill={() => {
faucetWalletBalanceQuery.refetch();
}}
/>
</div>
);
}
// Force users to log in to claim the faucet
if (!address || !twAccount) {
return (
@@ -224,6 +164,24 @@ export function FaucetButton({
);
}
// loading state
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
return (
<Button variant="outline" className="w-full gap-2">
Checking Faucet <Spinner className="size-3" />
</Button>
);
}
// faucet is empty
if (isFaucetEmpty) {
return (
<Button variant="outline" disabled className="!opacity-100 w-full">
Faucet is empty right now
</Button>
);
}
// Can not claim
if (canClaimFaucetQuery.data && canClaimFaucetQuery.data.canClaim === false) {
return (
@@ -292,165 +250,3 @@ export function FaucetButton({
</div>
);
}
const faucetFormSchema = z.object({
amount: z.coerce.number().refine((value) => value > 0, {
message: "Amount must be greater than 0",
}),
});
function SendFundsToFaucetModalButton(props: {
chain: Chain;
isLoggedIn: boolean;
client: ThirdwebClient;
chainMeta: ChainMetadata;
onFaucetRefill: () => void;
}) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="default" className="w-full">
Refill Faucet
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Refill Faucet</DialogTitle>
<DialogDescription>Send funds to faucet wallet</DialogDescription>
</DialogHeader>
<SendFundsToFaucetModalContent {...props} />
</DialogContent>
</Dialog>
);
}
function SendFundsToFaucetModalContent(props: {
chain: Chain;
isLoggedIn: boolean;
client: ThirdwebClient;
chainMeta: ChainMetadata;
onFaucetRefill: () => void;
}) {
const account = useActiveAccount();
const activeChain = useActiveWalletChain();
const switchActiveWalletChain = useSwitchActiveWalletChain();
const sendTxMutation = useSendTransaction({
payModal: false,
});
const switchChainMutation = useMutation({
mutationFn: async () => {
await switchActiveWalletChain(props.chain);
},
});
const form = useForm<z.infer<typeof faucetFormSchema>>({
resolver: zodResolver(faucetFormSchema),
defaultValues: {
amount: 0.1,
},
});
function onSubmit(values: z.infer<typeof faucetFormSchema>) {
const sendNativeTokenTx = prepareTransaction({
chain: props.chain,
client: props.client,
to: THIRDWEB_ENGINE_FAUCET_WALLET,
value: toWei(values.amount.toString()),
});
const promise = sendTxMutation.mutateAsync(sendNativeTokenTx);
toast.promise(promise, {
success: `Sent ${values.amount} ${props.chainMeta.nativeCurrency.symbol} to faucet`,
error: `Failed to send ${values.amount} ${props.chainMeta.nativeCurrency.symbol} to faucet`,
});
promise.then(() => {
props.onFaucetRefill();
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex min-w-0 flex-col gap-5"
>
<div className="min-w-0">
<p className="mb-2 text-foreground text-sm"> Faucet Wallet </p>
<CopyTextButton
copyIconPosition="right"
variant="outline"
className="w-full justify-between bg-card py-2 font-mono"
textToCopy={THIRDWEB_ENGINE_FAUCET_WALLET}
textToShow={THIRDWEB_ENGINE_FAUCET_WALLET}
tooltip={undefined}
/>
</div>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type="number"
className="h-auto bg-card text-2xl md:text-2xl [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<div className="-translate-y-1/2 absolute top-1/2 right-4 text-muted-foreground text-sm">
{props.chainMeta.nativeCurrency.symbol}
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!account && (
<CustomConnectWallet
chain={props.chain}
loginRequired={false}
isLoggedIn={props.isLoggedIn}
connectButtonClassName="!w-full"
detailsButtonClassName="!w-full"
/>
)}
{account && activeChain && (
<div>
{activeChain.id === props.chain.id ? (
<Button
key="submit"
type="submit"
className="mt-4 w-full gap-2"
disabled={sendTxMutation.isPending}
>
{sendTxMutation.isPending && <Spinner className="size-4" />}
Send funds to faucet
</Button>
) : (
<Button
key="switch"
className="mt-4 w-full gap-2"
disabled={switchChainMutation.isPending}
onClick={() => switchChainMutation.mutate()}
>
Switch to {props.chainMeta.name}{" "}
{switchChainMutation.isPending && (
<Spinner className="size-4" />
)}
</Button>
)}
</div>
)}
</form>
</Form>
);
}
@@ -1,9 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ThirdwebClient, ThirdwebContract } from "thirdweb";
import type { 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";
@@ -14,8 +13,6 @@ export function ContractPageLayoutClient(props: {
chainMetadata: ChainMetadata;
contract: ThirdwebContract;
children: React.ReactNode;
teamsAndProjects: MinimalTeamsAndProjects | undefined;
client: ThirdwebClient;
}) {
const metadataQuery = useContractPageMetadata(props.contract);
const headerMetadataQuery = useQuery({

Some files were not shown because too many files have changed in this diff Show More