Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abab4cb1b | ||
|
|
1851a8c1da | ||
|
|
6345634e2c | ||
|
|
a19c0c96ff | ||
|
|
cf202c03dd | ||
|
|
28df6f7e40 | ||
|
|
150a90c387 | ||
|
|
454fc6a5b9 | ||
|
|
72c3499366 | ||
|
|
5612542e1a | ||
|
|
1fa36c1d56 | ||
|
|
fa99acf609 | ||
|
|
15adec4fb7 | ||
|
|
1405289ad0 | ||
|
|
95cc74e807 | ||
|
|
2cb89cb3a1 | ||
|
|
f693ab8869 | ||
|
|
208b052a1b | ||
|
|
514c1a8480 | ||
|
|
a2d75df4c6 | ||
|
|
5d345e7abb | ||
|
|
43638e5d96 | ||
|
|
cc5cdd4f79 | ||
|
|
8823acbeb2 | ||
|
|
035fcf666a | ||
|
|
7775e7c14e | ||
|
|
fa6da43a77 | ||
|
|
733c1e6136 | ||
|
|
439485bc47 | ||
|
|
5c2da5fc5d | ||
|
|
846a5b5028 | ||
|
|
724e7d9238 | ||
|
|
c30b6d7c14 | ||
|
|
45ac0cb683 | ||
|
|
8610b467b6 | ||
|
|
d4a49b2fbd | ||
|
|
35b7a0e991 | ||
|
|
ee951926fb | ||
|
|
5abbae7368 |
@@ -10,7 +10,13 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["playground-web", "thirdweb-dashboard", "wallet-ui", "portal"],
|
||||
"ignore": [
|
||||
"playground-web",
|
||||
"thirdweb-dashboard",
|
||||
"wallet-ui",
|
||||
"portal",
|
||||
"thirdweb-login"
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"updateInternalDependents": "always",
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
|
||||
@@ -6,8 +6,6 @@ To get started, read the [How this repo works](#how-this-repo-works) section bel
|
||||
|
||||
From there, you can take a look at our [Good First Issues](https://github.com/thirdweb-dev/js/labels/good%20first%20issue) board and find an issue that interests you!
|
||||
|
||||
If you have any questions about the issue, feel free to ask on our [Discord server](https://discord.gg/thirdweb) in the `#contributors` channel; where you'll be able to get help from our team and other contributors.
|
||||
|
||||
<br />
|
||||
|
||||
## How this repo works
|
||||
@@ -85,7 +83,10 @@ If your test depends on a downstream network call, you must mock the call using
|
||||
import { setupServer } from "msw/node";
|
||||
import { downloadMock, uploadMock } from "../../../test/src/mocks/storage.js";
|
||||
|
||||
const server = setupServer(uploadMock("HASH"), downloadMock({ name: "Test NFT" }));
|
||||
const server = setupServer(
|
||||
uploadMock("HASH"),
|
||||
downloadMock({ name: "Test NFT" })
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<br />
|
||||
<a href="https://thirdweb.com">
|
||||
<img src="https://github.com/thirdweb-dev/js/blob/main/legacy_packages/sdk/logo.svg?raw=true" width="200" alt=""/></a>
|
||||
<img src="https://thirdweb.com/brand/thirdweb-icon.svg" width="200" alt=""/></a>
|
||||
<br />
|
||||
</p>
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
<a href="https://github.com/thirdweb-dev/js/actions/workflows/CI.yml">
|
||||
<img alt="Build Status" src="https://github.com/thirdweb-dev/js/actions/workflows/CI.yml/badge.svg"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/thirdweb">
|
||||
<img alt="Join our Discord!" src="https://img.shields.io/discord/834227967404146718.svg?color=7289da&label=discord&logo=discord&style=flat"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>All-in-one web3 SDK for Browser, Node and Mobile apps</strong></p>
|
||||
@@ -31,12 +28,12 @@
|
||||
|
||||
## Library Comparison
|
||||
|
||||
| | thirdweb | Wagmi + Viem | Ethers@6 |
|
||||
| | thirdweb | Wagmi + Viem | Ethers@6 |
|
||||
| ----------------------------------------- | -------- | ------------------ | -------- |
|
||||
| Type safe contract API | ✅ | ✅ | ✅ |
|
||||
| Type safe wallet API | ✅ | ✅ | ✅ |
|
||||
| EVM utils | ✅ | ✅ | ✅ |
|
||||
| RPC for any EVM | ✅ | ⚠️ public RPC only | ❌ |
|
||||
| RPC for any EVM | ✅ | ⚠️ public RPC only | ❌ |
|
||||
| Automatic ABI Resolution | ✅ | ❌ | ❌ |
|
||||
| IPFS Upload/Download | ✅ | ❌ | ❌ |
|
||||
| Embedded wallet (email/ social login) | ✅ | ⚠️ via 3rd party | ❌ |
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
# not required to build, defaults to prod
|
||||
NEXT_PUBLIC_THIRDWEB_DOMAIN="localhost:3000"
|
||||
|
||||
# API host. For local development, please use "https://api.thirdweb-preview.com"
|
||||
# API host. For local development, please use "https://api.thirdweb-dev.com"
|
||||
# otherwise: "https://api.thirdweb.com"
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST="https://api.thirdweb-dev.com"
|
||||
|
||||
# Bridge API. For local development, please use "https://bridge.thirdweb-dev.com"
|
||||
# otherwise: "https://bridge.thirdweb.com"
|
||||
NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST="https://bridge.thirdweb-dev.com"
|
||||
|
||||
# Paper API host
|
||||
NEXT_PUBLIC_THIRDWEB_EWS_API_HOST="https://ews.thirdweb-dev.com"
|
||||
|
||||
@@ -97,4 +101,4 @@ REDIS_URL=""
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
|
||||
# Required for Nebula Chat
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pluralize": "^8.0.0",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function acceptInvite(options: {
|
||||
teamId: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
let errorMessage = "Failed to accept invite";
|
||||
try {
|
||||
const result = (await res.json()) as {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
};
|
||||
};
|
||||
errorMessage = result.error.message;
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
@@ -71,15 +71,6 @@ async function proxy<T extends object>(
|
||||
};
|
||||
}
|
||||
|
||||
export async function analyticsServerProxy<T extends object = object>(
|
||||
params: ProxyActionParams,
|
||||
) {
|
||||
return proxy<T>(
|
||||
process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com",
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiServerProxy<T extends object = object>(
|
||||
params: ProxyActionParams,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export async function sendTeamInvites(options: {
|
||||
teamId: string;
|
||||
invites: Array<{ email: string; role: "OWNER" | "MEMBER" }>;
|
||||
}): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
results: Array<"fulfilled" | "rejected">;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
}
|
||||
> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
options.invites.map((invite) => sendInvite(options.teamId, invite, token)),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
results: results.map((x) => x.status),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendInvite(
|
||||
teamId: string,
|
||||
invite: { email: string; role: "OWNER" | "MEMBER" },
|
||||
token: string,
|
||||
) {
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamId}/invites`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inviteEmail: invite.email,
|
||||
inviteRole: invite.role,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
return {
|
||||
email: invite.email,
|
||||
ok: false,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
email: invite.email,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fetchAnalytics } from "data/analytics/fetch-analytics";
|
||||
import "server-only";
|
||||
|
||||
import type {
|
||||
AnalyticsQueryParams,
|
||||
EcosystemWalletStats,
|
||||
InAppWalletStats,
|
||||
RpcMethodStats,
|
||||
TransactionStats,
|
||||
@@ -8,15 +10,67 @@ import type {
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { getChains } from "./chain";
|
||||
|
||||
async function fetchAnalytics(
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("You are not authorized to perform this action");
|
||||
}
|
||||
|
||||
const [pathname, searchParams] = input.toString().split("?");
|
||||
if (!pathname) {
|
||||
throw new Error("Invalid input, no pathname provided");
|
||||
}
|
||||
|
||||
// create a new URL object for the analytics server
|
||||
const ANALYTICS_SERVICE_URL = new URL(
|
||||
process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com",
|
||||
);
|
||||
ANALYTICS_SERVICE_URL.pathname = pathname;
|
||||
for (const param of searchParams?.split("&") || []) {
|
||||
const [key, value] = param.split("=");
|
||||
if (!key || !value) {
|
||||
throw new Error("Invalid input, no key or value provided");
|
||||
}
|
||||
ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
decodeURIComponent(key),
|
||||
decodeURIComponent(value),
|
||||
);
|
||||
}
|
||||
// client id DEBUG OVERRIDE
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("clientId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.delete("accountId");
|
||||
// ANALYTICS_SERVICE_URL.searchParams.append(
|
||||
// "clientId",
|
||||
// "...",
|
||||
// );
|
||||
|
||||
return fetch(ANALYTICS_SERVICE_URL, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init?.headers,
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.clientId) {
|
||||
searchParams.append("clientId", params.clientId);
|
||||
}
|
||||
if (params.accountId) {
|
||||
searchParams.append("accountId", params.accountId);
|
||||
|
||||
searchParams.append("teamId", params.teamId);
|
||||
|
||||
if (params.projectId) {
|
||||
searchParams.append("projectId", params.projectId);
|
||||
}
|
||||
|
||||
// shared params
|
||||
if (params.from) {
|
||||
searchParams.append("from", params.from.toISOString());
|
||||
}
|
||||
@@ -33,13 +87,19 @@ export async function getWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<WalletStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/wallets?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await fetchAnalytics(
|
||||
`v2/sdk/wallet-connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch wallet connections");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch wallet connections, ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -52,7 +112,7 @@ export async function getInAppWalletUsage(
|
||||
): Promise<InAppWalletStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/wallets/in-app?${searchParams.toString()}`,
|
||||
`v2/wallet/connects?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -60,7 +120,10 @@ export async function getInAppWalletUsage(
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch in-app wallet usage");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch in-app wallet usage, ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -72,10 +135,13 @@ export async function getUserOpUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<UserOpStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/user-ops?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await fetchAnalytics(
|
||||
`v2/bundler/usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
@@ -89,12 +155,46 @@ export async function getUserOpUsage(
|
||||
return json.data as UserOpStats[];
|
||||
}
|
||||
|
||||
export async function getAggregateUserOpUsage(
|
||||
params: Omit<AnalyticsQueryParams, "period">,
|
||||
): Promise<UserOpStats> {
|
||||
const [userOpStats, chains] = await Promise.all([
|
||||
getUserOpUsage({ ...params, period: "all" }),
|
||||
getChains(),
|
||||
]);
|
||||
// Aggregate stats across wallet types
|
||||
return userOpStats.reduce(
|
||||
(acc, curr) => {
|
||||
// Skip testnets from the aggregated stats
|
||||
if (curr.chainId) {
|
||||
const chain = chains.data.find(
|
||||
(c) => c.chainId.toString() === curr.chainId,
|
||||
);
|
||||
if (chain?.testnet) {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
acc.successful += curr.successful;
|
||||
acc.failed += curr.failed;
|
||||
acc.sponsoredUsd += curr.sponsoredUsd;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
date: (params.from || new Date()).toISOString(),
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getClientTransactions(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<TransactionStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/transactions/client?${searchParams.toString()}`,
|
||||
`v2/sdk/contract-transactions?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -118,7 +218,7 @@ export async function getRpcMethodUsage(
|
||||
): Promise<RpcMethodStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/rpc/evm-methods?${searchParams.toString()}`,
|
||||
`v2/rpc/evm-methods?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -139,7 +239,7 @@ export async function getWalletUsers(
|
||||
): Promise<WalletUserStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v1/wallets/users?${searchParams.toString()}`,
|
||||
`v2/wallet-connects/users?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -147,7 +247,10 @@ export async function getWalletUsers(
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch wallet user stats");
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch wallet user stats: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -155,20 +258,108 @@ export async function getWalletUsers(
|
||||
return json.data as WalletUserStats[];
|
||||
}
|
||||
|
||||
type ActiveStatus = {
|
||||
bundler: boolean;
|
||||
storage: boolean;
|
||||
rpc: boolean;
|
||||
nebula: boolean;
|
||||
sdk: boolean;
|
||||
insight: boolean;
|
||||
pay: boolean;
|
||||
inAppWallet: boolean;
|
||||
ecosystemWallet: boolean;
|
||||
};
|
||||
|
||||
export async function isProjectActive(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<boolean> {
|
||||
): Promise<ActiveStatus> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(`v1/active?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await fetchAnalytics(
|
||||
`v2/active-usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch project active status");
|
||||
return false;
|
||||
const reason = await res?.text();
|
||||
console.error(
|
||||
`Failed to fetch project active status: ${res?.status} - ${res.statusText} - ${reason}`,
|
||||
);
|
||||
return {
|
||||
bundler: false,
|
||||
storage: false,
|
||||
rpc: false,
|
||||
nebula: false,
|
||||
sdk: false,
|
||||
insight: false,
|
||||
pay: false,
|
||||
inAppWallet: false,
|
||||
ecosystemWallet: false,
|
||||
} as ActiveStatus;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data.isActive as boolean;
|
||||
return json.data as ActiveStatus;
|
||||
}
|
||||
|
||||
export async function getEcosystemWalletUsage(args: {
|
||||
teamId: string;
|
||||
ecosystemSlug: string;
|
||||
ecosystemPartnerId?: string;
|
||||
projectId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const {
|
||||
ecosystemSlug,
|
||||
ecosystemPartnerId,
|
||||
teamId,
|
||||
projectId,
|
||||
from,
|
||||
to,
|
||||
period,
|
||||
} = args;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
// required params
|
||||
searchParams.append("ecosystemSlug", ecosystemSlug);
|
||||
searchParams.append("teamId", teamId);
|
||||
|
||||
// optional params
|
||||
if (ecosystemPartnerId) {
|
||||
searchParams.append("ecosystemPartnerId", ecosystemPartnerId);
|
||||
}
|
||||
if (projectId) {
|
||||
searchParams.append("projectId", projectId);
|
||||
}
|
||||
if (from) {
|
||||
searchParams.append("from", from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.append("to", to.toISOString());
|
||||
}
|
||||
if (period) {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/wallets/connects/${ecosystemSlug}?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch ecosystem wallet stats");
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.data as EcosystemWalletStats[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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() {
|
||||
@@ -32,3 +34,21 @@ export async function getGasSponsoredChains() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getChains() {
|
||||
return fetch(
|
||||
`${API_SERVER_URL}/v1/chains`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{ data: ChainMetadata[] }>;
|
||||
}
|
||||
|
||||
export function getChainServices() {
|
||||
return fetch(
|
||||
`${API_SERVER_URL}/v1/chains/services`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{
|
||||
data: Record<number, Array<ChainService>>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export type LinkedWallet = {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
walletAddress: string;
|
||||
};
|
||||
|
||||
export async function getLinkedWallets() {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/account/wallets`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as {
|
||||
data: LinkedWallet[];
|
||||
};
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,23 +1,9 @@
|
||||
import "server-only";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
bannedAt: Date | null;
|
||||
domains: string[];
|
||||
bundleIds: string[];
|
||||
redirectUrls: string[];
|
||||
lastAccessedAt: Date | null;
|
||||
slug: string;
|
||||
teamId: string;
|
||||
publishableKey: string;
|
||||
// image: string; // TODO
|
||||
};
|
||||
export type Project = ProjectResponse;
|
||||
|
||||
export async function getProjects(teamSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
import { API_SERVER_URL } from "../constants/env";
|
||||
|
||||
export type TeamInvite = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
email: string;
|
||||
role: "OWNER" | "MEMBER";
|
||||
createdAt: string;
|
||||
status: "pending" | "accepted" | "expired";
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export async function getTeamInvites(
|
||||
teamId: string,
|
||||
options: {
|
||||
count: number;
|
||||
start: number;
|
||||
},
|
||||
) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamId}/invites?skip=${options.start}&take=${options.count}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
result: TeamInvite[];
|
||||
};
|
||||
|
||||
return json.result;
|
||||
}
|
||||
@@ -12,15 +12,16 @@ export type TeamAccountRole =
|
||||
|
||||
export type TeamMember = {
|
||||
account: {
|
||||
creatorWalletAddress: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
} & {
|
||||
deletedAt: Date | null;
|
||||
deletedAt: string | null;
|
||||
accountId: string;
|
||||
teamId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
role: TeamAccountRole;
|
||||
};
|
||||
|
||||
@@ -46,3 +47,26 @@ export async function getMembers(teamSlug: string) {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getMemberById(teamSlug: string, memberId: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamSlug}/members/${memberId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
return (await res.json())?.result as TeamMember;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
import "server-only";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "../../app/api/lib/getAuthToken";
|
||||
|
||||
type EnabledTeamScope =
|
||||
| "pay"
|
||||
| "storage"
|
||||
| "rpc"
|
||||
| "bundler"
|
||||
| "insight"
|
||||
| "embeddedWallets"
|
||||
| "relayer"
|
||||
| "chainsaw"
|
||||
| "nebula";
|
||||
|
||||
export type Team = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
bannedAt?: string;
|
||||
image?: string;
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
supportPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: false;
|
||||
enabledScopes: EnabledTeamScope[];
|
||||
};
|
||||
|
||||
export type Team = TeamResponse;
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -48,6 +22,20 @@ 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);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -17,13 +16,14 @@ export function DangerSettingCard(props: {
|
||||
title: string;
|
||||
className?: string;
|
||||
footerClassName?: string;
|
||||
description: string;
|
||||
description: React.ReactNode;
|
||||
buttonLabel: string;
|
||||
buttonOnClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
isPending: boolean;
|
||||
confirmationDialog: {
|
||||
title: string;
|
||||
description: string;
|
||||
description: React.ReactNode;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
@@ -55,7 +55,7 @@ export function DangerSettingCard(props: {
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2 bg-red-600 font-semibold text-white hover:bg-red-600/80"
|
||||
disabled={props.isPending}
|
||||
disabled={props.isDisabled || props.isPending}
|
||||
>
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
@@ -63,20 +63,22 @@ export function DangerSettingCard(props: {
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="z-[10001]"
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4 gap-4 lg:gap-2">
|
||||
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
@@ -90,7 +92,7 @@ export function DangerSettingCard(props: {
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThirdwebMiniLogo } from "app/components/ThirdwebMiniLogo";
|
||||
import { DiscordIcon } from "components/icons/brand-icons/DiscordIcon";
|
||||
import { GithubIcon } from "components/icons/brand-icons/GithubIcon";
|
||||
import { InstagramIcon } from "components/icons/brand-icons/InstagramIcon";
|
||||
import { LinkedInIcon } from "components/icons/brand-icons/LinkedinIcon";
|
||||
@@ -61,11 +60,6 @@ export function AppFooter(props: AppFooterProps) {
|
||||
<YoutubeIcon className="size-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" asChild className="size-9">
|
||||
<Link href="https://discord.gg/thirdweb" target="_blank">
|
||||
<DiscordIcon className="size-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" asChild className="size-9">
|
||||
<Link href="https://www.reddit.com/r/thirdweb/" target="_blank">
|
||||
<RedditIcon className="size-5 text-muted-foreground" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { TEAM_PLANS } from "utils/pricing";
|
||||
import { remainingDays } from "../../../utils/date-utils";
|
||||
@@ -105,6 +105,23 @@ export const PricingCard: React.FC<PricingCardProps> = ({
|
||||
{!isCustomPrice && (
|
||||
<span className="text-muted-foreground">/ month</span>
|
||||
)}
|
||||
|
||||
{billingPlan === "starter" && (
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[320px]"
|
||||
label="We will place a temporary hold of $25 to verify your card, this will be immediately released back to you after verification."
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto w-auto p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<div>
|
||||
<CircleAlertIcon className="size-5 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{remainingTrialDays > 0 && (
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
export function GenericLoadingPage() {
|
||||
export function GenericLoadingPage({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[500px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[500px] grow items-center justify-center rounded-lg border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export const DASHBOARD_STORAGE_URL =
|
||||
export const API_SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
export const BRIDGE_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST || "https://bridge.thirdweb.com";
|
||||
|
||||
/**
|
||||
* Faucet stuff
|
||||
*/
|
||||
|
||||
@@ -42,15 +42,6 @@ export const accountKeys = {
|
||||
[...accountKeys.wallet(walletAddress), "billing-session"] as const,
|
||||
};
|
||||
|
||||
export const apiKeys = {
|
||||
all: ["api"] as const,
|
||||
wallet: (walletAddress: string) => [...apiKeys.all, walletAddress] as const,
|
||||
keys: (walletAddress: string) =>
|
||||
[...apiKeys.wallet(walletAddress), "keys"] as const,
|
||||
key: (id: string, walletAddress: string) =>
|
||||
[...apiKeys.keys(walletAddress), id] as const,
|
||||
};
|
||||
|
||||
export const authorizedWallets = {
|
||||
all: ["authorizedWallets"] as const,
|
||||
wallet: (walletAddress: string) =>
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { analyticsServerProxy, apiServerProxy } from "@/actions/proxies";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { accountKeys, apiKeys, authorizedWallets } from "../cache-keys";
|
||||
import { accountKeys, authorizedWallets } from "../cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
|
||||
export const accountStatus = {
|
||||
noCustomer: "noCustomer",
|
||||
noPayment: "noPayment",
|
||||
paymentVerification: "paymentVerification",
|
||||
validPayment: "validPayment",
|
||||
invalidPayment: "invalidPayment",
|
||||
invalidPaymentMethod: "invalidPaymentMethod",
|
||||
} as const;
|
||||
|
||||
export const accountPlan = {
|
||||
free: "free",
|
||||
growth: "growth",
|
||||
@@ -71,109 +61,6 @@ interface ConfirmEmailInput {
|
||||
confirmationToken: string;
|
||||
}
|
||||
|
||||
type ApiKeyRecoverShareManagement = "AWS_MANAGED" | "USER_MANAGED";
|
||||
type ApiKeyCustomAuthentication = {
|
||||
jwksUri: string;
|
||||
aud: string;
|
||||
};
|
||||
type ApiKeyCustomAuthEndpoint = {
|
||||
authEndpoint: string;
|
||||
customHeaders: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
// MAP to api-server types in PolicyService.ts
|
||||
export type ApiKeyServicePolicy = {
|
||||
allowedChainIds?: number[] | null;
|
||||
allowedContractAddresses?: string[] | null;
|
||||
allowedWallets?: string[] | null;
|
||||
blockedWallets?: string[] | null;
|
||||
bypassWallets?: string[] | null;
|
||||
serverVerifier?: {
|
||||
url: string;
|
||||
headers: { key: string; value: string }[] | null;
|
||||
} | null;
|
||||
limits?: ApiKeyServicePolicyLimits | null;
|
||||
};
|
||||
|
||||
export type ApiKeyServicePolicyLimits = {
|
||||
global?: {
|
||||
// in dollars or ETH
|
||||
maxSpend: string;
|
||||
maxSpendUnit: "usd" | "native";
|
||||
} | null;
|
||||
// ----------------------
|
||||
// TODO implement perUser limits
|
||||
perUserSpend?: {
|
||||
// in dollars or ETH
|
||||
maxSpend: string | null;
|
||||
maxSpendUnit: "usd" | "native";
|
||||
maxSpendPeriod: "day" | "week" | "month";
|
||||
} | null;
|
||||
perUserTransactions?: {
|
||||
maxTransactions: number;
|
||||
maxTransactionsPeriod: "day" | "week" | "month";
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ApiKeyService = {
|
||||
id: string;
|
||||
name: string;
|
||||
targetAddresses: string[];
|
||||
actions: string[];
|
||||
// If updating here, need to update validation logic in `validation.ts` as well for recoveryShareManagement
|
||||
// EMBEDDED WALLET
|
||||
recoveryShareManagement?: ApiKeyRecoverShareManagement;
|
||||
customAuthentication?: ApiKeyCustomAuthentication;
|
||||
customAuthEndpoint?: ApiKeyCustomAuthEndpoint;
|
||||
applicationName?: string;
|
||||
applicationImageUrl?: string;
|
||||
// PAY
|
||||
payoutAddress?: string;
|
||||
};
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
secret?: string;
|
||||
secretMasked: string;
|
||||
accountId: string;
|
||||
creatorWalletAddress: string;
|
||||
walletAddresses: string[];
|
||||
domains: string[];
|
||||
bundleIds: string[];
|
||||
redirectUrls: string[];
|
||||
revokedAt: string;
|
||||
lastAccessedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
services?: ApiKeyService[];
|
||||
};
|
||||
|
||||
interface UpdateKeyServiceInput {
|
||||
name: string;
|
||||
targetAddresses: string[];
|
||||
actions?: string[];
|
||||
}
|
||||
|
||||
export interface CreateKeyInput {
|
||||
name?: string;
|
||||
domains?: string[];
|
||||
bundleIds?: string[];
|
||||
walletAddresses?: string[];
|
||||
services?: UpdateKeyServiceInput[];
|
||||
}
|
||||
|
||||
export interface UpdateKeyInput {
|
||||
id: string;
|
||||
name: string;
|
||||
domains: string[];
|
||||
bundleIds: string[];
|
||||
walletAddresses?: string[];
|
||||
services?: UpdateKeyServiceInput[];
|
||||
redirectUrls: string[];
|
||||
}
|
||||
|
||||
interface UsageStorage {
|
||||
sumFileSizeBytes: number;
|
||||
}
|
||||
@@ -253,132 +140,6 @@ export function useAccountCredits() {
|
||||
});
|
||||
}
|
||||
|
||||
type UserOpUsageQueryResult = (UserOpStats & { chainId?: string })[];
|
||||
|
||||
async function getUserOpUsage(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
|
||||
const searchParams: Record<string, string> = {
|
||||
clientId,
|
||||
};
|
||||
|
||||
if (from) {
|
||||
searchParams.from = from.toISOString();
|
||||
}
|
||||
if (to) {
|
||||
searchParams.to = to.toISOString();
|
||||
}
|
||||
if (period) {
|
||||
searchParams.period = period;
|
||||
}
|
||||
|
||||
const res = await analyticsServerProxy<{ data: UserOpUsageQueryResult }>({
|
||||
pathname: "/v1/user-ops",
|
||||
method: "GET",
|
||||
searchParams: searchParams,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// TODO - remove this hook, fetch this on server
|
||||
export function useUserOpUsageAggregate(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
const { clientId, from, to } = args;
|
||||
const address = useActiveAccount()?.address;
|
||||
const chainStore = useAllChainsData();
|
||||
|
||||
return useQuery<UserOpStats>({
|
||||
queryKey: accountKeys.userOpStats(
|
||||
address || "",
|
||||
clientId,
|
||||
from?.toISOString() || "",
|
||||
to?.toISOString() || "",
|
||||
"all",
|
||||
),
|
||||
queryFn: async () => {
|
||||
const userOpStats = await getUserOpUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period: "all",
|
||||
});
|
||||
|
||||
// Aggregate stats across wallet types
|
||||
return userOpStats.reduce(
|
||||
(acc, curr) => {
|
||||
// Skip testnets from the aggregated stats
|
||||
if (curr.chainId) {
|
||||
const chain = chainStore.idToChain.get(Number(curr.chainId));
|
||||
if (chain?.testnet) {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
acc.successful += curr.successful;
|
||||
acc.failed += curr.failed;
|
||||
acc.sponsoredUsd += curr.sponsoredUsd;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
date: (from || new Date()).toISOString(),
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
},
|
||||
);
|
||||
},
|
||||
enabled: !!clientId && !!address,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO - remove this hook, fetch this on server
|
||||
export function useUserOpUsagePeriod(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period: "day" | "week" | "month" | "year";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useQuery({
|
||||
queryKey: accountKeys.userOpStats(
|
||||
address || "",
|
||||
clientId as string,
|
||||
from?.toISOString() || "",
|
||||
to?.toISOString() || "",
|
||||
period,
|
||||
),
|
||||
queryFn: async () => {
|
||||
return getUserOpUsage({
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
period,
|
||||
});
|
||||
},
|
||||
enabled: !!clientId && !!address,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
const address = useActiveAccount()?.address;
|
||||
@@ -461,9 +222,6 @@ export function useUpdateNotifications() {
|
||||
}
|
||||
|
||||
export function useConfirmEmail() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: ConfirmEmailInput) => {
|
||||
type Result = {
|
||||
@@ -492,20 +250,6 @@ export function useConfirmEmail() {
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// invalidate related cache, since could be relinking account
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.usage(address || ""),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountKeys.me(address || ""),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -549,205 +293,106 @@ export function useResendEmailConfirmation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
export async function createProjectClient(
|
||||
teamId: string,
|
||||
body: Partial<Project>,
|
||||
) {
|
||||
type Response = {
|
||||
result: {
|
||||
project: Project;
|
||||
secret: string;
|
||||
};
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateKeyInput) => {
|
||||
type Result = {
|
||||
data: ApiKey;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/keys",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
});
|
||||
const res = await apiServerProxy<Response>({
|
||||
pathname: `/v1/teams/${teamId}/projects`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}
|
||||
|
||||
export function useUpdateApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
export async function updateProjectClient(
|
||||
params: {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
},
|
||||
body: Partial<Project>,
|
||||
) {
|
||||
type Response = {
|
||||
result: Project;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: UpdateKeyInput) => {
|
||||
type Result = {
|
||||
data: ApiKey;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: `/v1/keys/${input.id}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
});
|
||||
const res = await apiServerProxy<Response>({
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}
|
||||
|
||||
export function useRevokeApiKey() {
|
||||
const address = useActiveAccount()?.address;
|
||||
const queryClient = useQueryClient();
|
||||
export async function deleteProjectClient(params: {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
type Response = {
|
||||
result: true;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
type Result = {
|
||||
data: ApiKey;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: `/v1/keys/${id}/revoke`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: apiKeys.keys(address || ""),
|
||||
});
|
||||
},
|
||||
const res = await apiServerProxy<Response>({
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data.result;
|
||||
}
|
||||
|
||||
export const usePolicies = (serviceId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["policies", serviceId],
|
||||
queryFn: async () => {
|
||||
if (!serviceId) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
type Result = {
|
||||
data: ApiKeyServicePolicy;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/policies",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
searchParams: {
|
||||
serviceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!serviceId,
|
||||
});
|
||||
export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdatePolicies = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: {
|
||||
serviceId: string;
|
||||
data: ApiKeyServicePolicy;
|
||||
}) => {
|
||||
type Result = {
|
||||
data: ApiKeyServicePolicy;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
const res = await apiServerProxy<Result>({
|
||||
pathname: "/v1/policies",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
serviceId: input.serviceId,
|
||||
data: input.data,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["policies", variables.serviceId],
|
||||
});
|
||||
export async function rotateSecretKeyClient(projectId: string) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
pathname: "/v2/keys/rotate-secret-key",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export function useRevokeAuthorizedWallet() {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type ChainlistPaginationProps = {
|
||||
totalPages: number;
|
||||
activePage: number;
|
||||
};
|
||||
|
||||
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
|
||||
activePage,
|
||||
totalPages,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginationButtons
|
||||
activePage={activePage}
|
||||
totalPages={totalPages}
|
||||
onPageClick={(page) => router.push(createPageURL(page))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { SearchIcon, XCircleIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
function cleanUrl(url: string) {
|
||||
if (url.endsWith("?")) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC = () => {
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// reset the input if the query param is removed
|
||||
if (inputRef.current?.value && !searchParams?.get("query")) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
if (term) {
|
||||
params.set("query", term);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
// always delete the page number when searching
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by token address or chain ID"
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{searchParams?.has("query") && (
|
||||
<Button
|
||||
size="icon"
|
||||
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
params.delete("query");
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}}
|
||||
>
|
||||
<XCircleIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type QueryTypeProps = {
|
||||
activeType: "origin" | "destination";
|
||||
};
|
||||
|
||||
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(type: "origin" | "destination") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("type", type);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<ToolTipLabel label="Origin" contentClassName="w-full">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={activeType === "origin" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("origin"));
|
||||
}}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<ArrowUpRightIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<ToolTipLabel label="Destination" contentClassName="w-full">
|
||||
<Button
|
||||
variant={activeType === "destination" ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("destination"));
|
||||
}}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
<ArrowDownLeftIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { Grid2X2Icon, ListIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type RouteListViewProps = {
|
||||
activeView: "grid" | "table";
|
||||
};
|
||||
|
||||
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(view: "grid" | "table") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("view", view);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={activeView === "table" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("table"));
|
||||
}}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<ListIcon strokeWidth={1} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeView === "grid" ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("grid"));
|
||||
}}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
<Grid2X2Icon strokeWidth={1} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { NATIVE_TOKEN_ADDRESS, defineChain, getContract } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { name } from "thirdweb/extensions/common";
|
||||
|
||||
type RouteListCardProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri: string | null;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri: string | null;
|
||||
};
|
||||
|
||||
export async function RouteListCard({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
}: RouteListCardProps) {
|
||||
const [
|
||||
originChain,
|
||||
originTokenName,
|
||||
destinationChain,
|
||||
destinationTokenName,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: name({
|
||||
contract: getContract({
|
||||
address: originTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(originChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: name({
|
||||
contract: getContract({
|
||||
address: destinationTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(destinationChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: originTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: destinationTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full transition-colors hover:border-active-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-8 rounded-full bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-white/10" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="-translate-x-4 size-8 rounded-full bg-white ring-2 ring-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<table className="w-full">
|
||||
<tbody className="text-sm [&_td>*]:min-h-[25px]">
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{originTokenName === "ETH"
|
||||
? originChain.nativeCurrency.name
|
||||
: originTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{destinationTokenName === "ETH"
|
||||
? destinationChain.nativeCurrency.name
|
||||
: destinationTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { NATIVE_TOKEN_ADDRESS, getContract } from "thirdweb";
|
||||
import { defineChain, getChainMetadata } from "thirdweb/chains";
|
||||
import { symbol } from "thirdweb/extensions/common";
|
||||
|
||||
type RouteListRowProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri: string | null;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri: string | null;
|
||||
};
|
||||
|
||||
export async function RouteListRow({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
}: RouteListRowProps) {
|
||||
const [
|
||||
originChain,
|
||||
originTokenSymbol,
|
||||
destinationChain,
|
||||
destinationTokenSymbol,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: symbol({
|
||||
contract: getContract({
|
||||
address: originTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(originChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
|
||||
? "ETH"
|
||||
: symbol({
|
||||
contract: getContract({
|
||||
address: destinationTokenAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineChain(destinationChainId),
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
}).catch(() => undefined),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: originTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
uri: destinationTokenIconUri,
|
||||
client: getThirdwebClient(),
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow linkBox className="hover:bg-accent/50">
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
className="size-6 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{originTokenSymbol && (
|
||||
<CopyTextButton
|
||||
textToCopy={originTokenAddress}
|
||||
textToShow={
|
||||
originTokenSymbol === "ETH"
|
||||
? originChain.nativeCurrency.symbol
|
||||
: originTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
className="relative z-10 text-base"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{originChain.name}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
className="size-6 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{destinationTokenSymbol && (
|
||||
<CopyTextButton
|
||||
textToCopy={destinationTokenAddress}
|
||||
textToShow={
|
||||
destinationTokenSymbol === "ETH"
|
||||
? destinationChain.nativeCurrency.symbol
|
||||
: destinationTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
className="relative z-10 text-base"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Address } from "thirdweb";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "../server/routelist-card";
|
||||
import { RouteListRow } from "../server/routelist-row";
|
||||
|
||||
export type SearchParams = Partial<{
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
type: "origin" | "destination";
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 120 is divisible by 2, 3, and 4 so card layout looks nice
|
||||
const DEFAULT_PAGE_SIZE = 120;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
async function getRoutesToRender(params: SearchParams) {
|
||||
const filters: Partial<{
|
||||
limit: number;
|
||||
offset: number;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
filters.originChainId = Number(params.query);
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
}
|
||||
}
|
||||
// Temporary, will update this after the /routes endpoint
|
||||
filters.limit = 10_000;
|
||||
|
||||
const routes = await getRoutes(filters);
|
||||
|
||||
const totalCount = routes.length;
|
||||
|
||||
return {
|
||||
routesToRender: routes,
|
||||
totalCount,
|
||||
filteredCount: routes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function RoutesData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
// pagination
|
||||
const totalPages = Math.ceil(routesToRender.length / DEFAULT_PAGE_SIZE);
|
||||
|
||||
const activePage = Number(props.searchParams.page || DEFAULT_PAGE);
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedRoutes = routesToRender.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{paginatedRoutes.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader className="z-0">
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedRoutes.map((route) => (
|
||||
<RouteListRow
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedRoutes.map((route) => (
|
||||
<li
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
className="h-full"
|
||||
>
|
||||
<RouteListCard
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination totalPages={totalPages} activePage={activePage} />
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{paginatedRoutes.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">{filteredCount}</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">{totalCount}</span>)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">{totalCount}</span> routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { QueryType } from "./components/client/type";
|
||||
import { RouteListView } from "./components/client/view";
|
||||
import {
|
||||
RoutesData,
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Routes: Swap, Bridge, and On-Ramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ChainListPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const activeType = searchParams.type ?? "origin";
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
return (
|
||||
<section className="container mx-auto flex h-full flex-col px-4 py-10">
|
||||
<header className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-row items-center justify-between gap-4 lg:flex-col lg:justify-start">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
Routes
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col ">
|
||||
<div className="flex w-full flex-row gap-4">
|
||||
<SearchInput />
|
||||
<QueryType activeType={activeType} />
|
||||
<RouteListView activeView={activeView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
<RoutesData
|
||||
searchParams={searchParams}
|
||||
activeView={activeView}
|
||||
isLoggedIn={!!authToken}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Address } from "thirdweb";
|
||||
|
||||
export type Route = {
|
||||
originToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri: string;
|
||||
};
|
||||
destinationToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
|
||||
import { BRIDGE_URL, DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
|
||||
import { getAuthToken } from "app/api/lib/getAuthToken";
|
||||
import type { Address } from "thirdweb";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${BRIDGE_URL}/v1/routes`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: token
|
||||
? {
|
||||
authorization: `Bearer ${token}`,
|
||||
}
|
||||
: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
next: { revalidate: 60 * 60 },
|
||||
});
|
||||
|
||||
if (!routesResponse.ok) {
|
||||
throw new Error("Failed to fetch routes");
|
||||
}
|
||||
const routes: { data: Route[] } = await routesResponse.json();
|
||||
|
||||
return routes.data;
|
||||
}
|
||||
-2
@@ -41,8 +41,6 @@ const uploadMetadataFormSchema = z.object({
|
||||
image: fileBufferOrStringSchema.optional(),
|
||||
animationUri: fileBufferOrStringSchema.optional(),
|
||||
external_url: fileBufferOrStringSchema.optional(),
|
||||
customImage: z.string().optional(),
|
||||
customAnimationUrl: z.string().optional(),
|
||||
background_color: z
|
||||
.string()
|
||||
.refine(
|
||||
|
||||
+1
-8
@@ -54,8 +54,6 @@ export type MintFormValues = NFTMetadataInputLimited & {
|
||||
useNextTokenId: boolean;
|
||||
recipient: string;
|
||||
amount: number;
|
||||
customImage: string;
|
||||
customAnimationUrl: string;
|
||||
attributes: { trait_type: string; value: string }[];
|
||||
tokenId?: string;
|
||||
};
|
||||
@@ -65,8 +63,7 @@ const isValidNft = (values: MintFormValues) =>
|
||||
values.description ||
|
||||
values.image ||
|
||||
values.animation_url ||
|
||||
values.external_url ||
|
||||
values.customAnimationUrl;
|
||||
values.external_url;
|
||||
|
||||
const MINTER_ROLE = 1n;
|
||||
|
||||
@@ -334,8 +331,6 @@ function PrimarySalesSection(props: {
|
||||
|
||||
const mintFormSchema = z.object({
|
||||
useNextTokenId: z.boolean(),
|
||||
customImage: z.string().optional(),
|
||||
customAnimationUrl: z.string().optional(),
|
||||
recipient: addressSchema,
|
||||
tokenId: z.coerce.number().min(0, { message: "Invalid tokenId" }).optional(),
|
||||
});
|
||||
@@ -351,8 +346,6 @@ function MintNFTSection(props: {
|
||||
resolver: zodResolver(mintFormSchema),
|
||||
values: {
|
||||
useNextTokenId: false,
|
||||
customImage: "",
|
||||
customAnimationUrl: "",
|
||||
recipient: "",
|
||||
attributes: [{ trait_type: "", value: "" }],
|
||||
amount: 1,
|
||||
|
||||
+12
-6
@@ -14,8 +14,8 @@ import type { UseFormReturn } from "react-hook-form";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
|
||||
type AdvancedNFTMetadataFormGroupValues = {
|
||||
customImage?: string;
|
||||
customAnimationUrl?: string;
|
||||
image?: NFTInput["image"];
|
||||
animation_url?: NFTInput["animation_url"];
|
||||
background_color?: NFTInput["background_color"];
|
||||
external_url?: NFTInput["external_url"];
|
||||
};
|
||||
@@ -56,12 +56,15 @@ export function AdvancedNFTMetadataFormGroup<
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customImage"
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
value={typeof field.value === "string" ? field.value : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT image pre-uploaded, you can set the
|
||||
@@ -74,12 +77,15 @@ export function AdvancedNFTMetadataFormGroup<
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAnimationUrl"
|
||||
name="animation_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Animation URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
value={typeof field.value === "string" ? field.value : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT Animation URL pre-uploaded, you can
|
||||
|
||||
+62
-45
@@ -7,7 +7,6 @@ import {
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Divider,
|
||||
FormControl,
|
||||
Input,
|
||||
Textarea,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
updateTokenURI as updateTokenURI1155,
|
||||
} from "thirdweb/extensions/erc1155";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import type { NFTMetadata } from "thirdweb/utils";
|
||||
import {
|
||||
Button,
|
||||
FormErrorMessage,
|
||||
@@ -68,20 +68,20 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
const transformedQueryData = useMemo(() => {
|
||||
return {
|
||||
name: nft?.metadata.name || "",
|
||||
description: nft?.metadata.description || "",
|
||||
external_url: nft?.metadata.external_url || "",
|
||||
background_color: nft?.metadata.background_color || "",
|
||||
attributes: nft?.metadata.attributes || [],
|
||||
// We override these in the submit if they haven't been changed
|
||||
image: nft?.metadata.image || undefined,
|
||||
animation_url: nft?.metadata.animation_url || undefined,
|
||||
// No need for these, but we need to pass them to the form
|
||||
supply: 0,
|
||||
customImage: "",
|
||||
customAnimationUrl: "",
|
||||
const nftMetadata: Partial<NFTMetadata> = {
|
||||
// basic
|
||||
name: nft.metadata.name || "",
|
||||
description: nft.metadata.description || "",
|
||||
// media
|
||||
image: nft.metadata.image,
|
||||
animation_url: nft.metadata.animation_url,
|
||||
// advanced
|
||||
external_url: nft.metadata.external_url || "",
|
||||
background_color: nft.metadata.background_color || "",
|
||||
attributes: nft.metadata.attributes,
|
||||
};
|
||||
|
||||
return nftMetadata;
|
||||
}, [nft]);
|
||||
|
||||
const {
|
||||
@@ -91,13 +91,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
supply: number;
|
||||
customImage: string;
|
||||
customAnimationUrl: string;
|
||||
}
|
||||
>({
|
||||
} = useForm<NFTMetadataInputLimited>({
|
||||
defaultValues: transformedQueryData,
|
||||
values: transformedQueryData,
|
||||
});
|
||||
@@ -140,6 +134,9 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
};
|
||||
|
||||
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
|
||||
const animationUrlFormValue = watch("animation_url");
|
||||
const imageUrlFormValue = watch("image");
|
||||
|
||||
const mediaFileUrl =
|
||||
watch("animation_url") instanceof File
|
||||
? watch("animation_url")
|
||||
@@ -190,11 +187,8 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
try {
|
||||
const newMetadata = parseAttributes({
|
||||
...data,
|
||||
image: data.image || data.customImage || nft.metadata.image,
|
||||
animation_url:
|
||||
data.animation_url ||
|
||||
data.customAnimationUrl ||
|
||||
nft.metadata.animation_url,
|
||||
image: data.image || nft.metadata.image,
|
||||
animation_url: data.animation_url || nft.metadata.animation_url,
|
||||
});
|
||||
|
||||
const transaction = useUpdateMetadata
|
||||
@@ -249,10 +243,6 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata</Heading>
|
||||
<Divider />
|
||||
</div>
|
||||
<FormControl isRequired isInvalid={!!errors.name}>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input autoFocus {...register("name")} />
|
||||
@@ -263,22 +253,34 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
<div className="flex flex-row flex-wrap gap-3">
|
||||
{nft?.metadata && !mediaFileUrl && (
|
||||
<NFTMediaWithEmptyState
|
||||
metadata={nft.metadata}
|
||||
metadata={{
|
||||
name: nft.metadata.name,
|
||||
animation_url:
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: nft.metadata.animation_url,
|
||||
image:
|
||||
typeof imageUrlFormValue === "string"
|
||||
? imageUrlFormValue
|
||||
: nft.metadata.image,
|
||||
}}
|
||||
width="200px"
|
||||
height="200px"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
showUploadButton
|
||||
showPreview={nft?.metadata ? !!mediaFileUrl : true}
|
||||
setValue={setFile}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
className="shrink-0 rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText={nft?.metadata ? "New Media" : "Media"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormHelperText>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
@@ -319,6 +321,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
index={errors.background_color || errors.external_url ? [0] : undefined}
|
||||
@@ -328,7 +331,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
<FormControl isInvalid={!!errors.background_color}>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
@@ -357,26 +360,40 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.customImage}>
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input max="6" {...register("customImage")} />
|
||||
<Input
|
||||
value={
|
||||
typeof imageUrlFormValue === "string" ? imageUrlFormValue : ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded, you can set the
|
||||
URL or URI here.
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customImage?.message}
|
||||
</FormErrorMessage>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.customAnimationUrl}>
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input max="6" {...register("customAnimationUrl")} />
|
||||
<Input
|
||||
value={
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded, you can
|
||||
set the URL or URI here.
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customAnimationUrl?.message}
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
|
||||
+2
-2
@@ -36,8 +36,8 @@ const UpdateMetadataTab: React.FC<UpdateMetadataTabProps> = ({
|
||||
<Button variant="primary">Update Metadata</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Mint NFT</SheetTitle>
|
||||
<SheetHeader className="mb-5">
|
||||
<SheetTitle className="text-left">Update NFT Metadata</SheetTitle>
|
||||
</SheetHeader>
|
||||
<UpdateNftMetadata
|
||||
twAccount={twAccount}
|
||||
|
||||
+36
-20
@@ -62,13 +62,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
supply: number;
|
||||
customImage: string;
|
||||
customAnimationUrl: string;
|
||||
}
|
||||
>();
|
||||
} = useForm<NFTMetadataInputLimited>();
|
||||
|
||||
const setFile = (file: File) => {
|
||||
if (file.type.includes("image")) {
|
||||
@@ -108,6 +102,9 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
};
|
||||
|
||||
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
|
||||
const animationUrlFormValue = watch("animation_url");
|
||||
const imageUrlFormValue = watch("image");
|
||||
|
||||
const mediaFileUrl =
|
||||
watch("animation_url") instanceof File
|
||||
? watch("animation_url")
|
||||
@@ -261,7 +258,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<AccordionPanel className="!px-0 flex flex-col gap-6">
|
||||
<FormControl isInvalid={!!errors.background_color}>
|
||||
<FormLabel>
|
||||
Background Color <OpenSeaPropertyBadge />
|
||||
@@ -290,26 +287,45 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.customImage}>
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input max="6" {...register("customImage")} />
|
||||
<Input
|
||||
{...register("image")}
|
||||
value={
|
||||
typeof imageUrlFormValue === "string"
|
||||
? imageUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded, you can set
|
||||
the URL or URI here.
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the media file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customImage?.message}
|
||||
</FormErrorMessage>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.customAnimationUrl}>
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input max="6" {...register("customAnimationUrl")} />
|
||||
<Input
|
||||
{...register("animation_url")}
|
||||
value={
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded, you
|
||||
can set the URL or URI here.
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the media
|
||||
file
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customAnimationUrl?.message}
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
|
||||
+36
-20
@@ -63,8 +63,6 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
} = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
supply: number;
|
||||
customImage: string;
|
||||
customAnimationUrl: string;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -106,6 +104,8 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
};
|
||||
|
||||
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
|
||||
const animationUrlFormValue = watch("animation_url");
|
||||
const imageUrlFormValue = watch("image");
|
||||
const mediaFileUrl =
|
||||
watch("animation_url") instanceof File
|
||||
? watch("animation_url")
|
||||
@@ -144,7 +144,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
<form
|
||||
className="mt-6 flex flex-col gap-6"
|
||||
id={MINT_FORM_ID}
|
||||
onSubmit={handleSubmit(async (data) => {
|
||||
onSubmit={handleSubmit(async ({ supply, ...data }) => {
|
||||
if (!address) {
|
||||
toast.error("Please connect your wallet to mint.");
|
||||
return;
|
||||
@@ -153,8 +153,8 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
try {
|
||||
const dataWithCustom = {
|
||||
...data,
|
||||
image: data.image || data.customImage,
|
||||
animation_url: data.animation_url || data.customAnimationUrl,
|
||||
image: data.image,
|
||||
animation_url: data.animation_url,
|
||||
};
|
||||
|
||||
trackEvent({
|
||||
@@ -169,7 +169,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
contract,
|
||||
to: address,
|
||||
nft,
|
||||
supply: BigInt(data.supply),
|
||||
supply: BigInt(supply),
|
||||
});
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
@@ -216,7 +216,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
className="shrink-0 rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText="Media"
|
||||
/>
|
||||
@@ -238,7 +238,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
value={imageUrl}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="rounded border border-border transition-all"
|
||||
className="shrink-0 rounded border border-border transition-all"
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
@@ -313,26 +313,42 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.customImage}>
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input max="6" {...register("customImage")} />
|
||||
<Input
|
||||
value={
|
||||
typeof imageUrlFormValue === "string"
|
||||
? imageUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded, you can set
|
||||
the URL or URI here.
|
||||
If you already have your NFT image pre-uploaded to a URL, you
|
||||
can specify it here instead of uploading the asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customImage?.message}
|
||||
</FormErrorMessage>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.customAnimationUrl}>
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input max="6" {...register("customAnimationUrl")} />
|
||||
<Input
|
||||
value={
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded, you
|
||||
can set the URL or URI here.
|
||||
If you already have your NFT Animation URL pre-uploaded to a
|
||||
URL, you can specify it here instead of uploading the asset
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customAnimationUrl?.message}
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
|
||||
+31
-19
@@ -49,13 +49,7 @@ export const SharedMetadataForm: React.FC<{
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<
|
||||
NFTMetadataInputLimited & {
|
||||
supply: number;
|
||||
customImage: string;
|
||||
customAnimationUrl: string;
|
||||
}
|
||||
>();
|
||||
} = useForm<NFTMetadataInputLimited>();
|
||||
|
||||
const setFile = (file: File) => {
|
||||
if (file.type.includes("image")) {
|
||||
@@ -95,6 +89,8 @@ export const SharedMetadataForm: React.FC<{
|
||||
};
|
||||
|
||||
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
|
||||
const animationUrlFormValue = watch("animation_url");
|
||||
const imageUrlFormValue = watch("image");
|
||||
const mediaFileUrl =
|
||||
watch("animation_url") instanceof File
|
||||
? watch("animation_url")
|
||||
@@ -135,8 +131,8 @@ export const SharedMetadataForm: React.FC<{
|
||||
|
||||
const dataWithCustom = {
|
||||
...data,
|
||||
image: data.image || data.customImage,
|
||||
animation_url: data.animation_url || data.customAnimationUrl,
|
||||
image: data.image,
|
||||
animation_url: data.animation_url,
|
||||
};
|
||||
|
||||
trackEvent({
|
||||
@@ -194,7 +190,7 @@ export const SharedMetadataForm: React.FC<{
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
className="shrink-0 rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText="Media"
|
||||
/>
|
||||
@@ -216,7 +212,7 @@ export const SharedMetadataForm: React.FC<{
|
||||
value={imageUrl}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="rounded border border-border transition-all"
|
||||
className="shrink-0 rounded border border-border transition-all"
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
@@ -243,26 +239,42 @@ export const SharedMetadataForm: React.FC<{
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<FormControl isInvalid={!!errors.customImage}>
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input max="6" {...register("customImage")} />
|
||||
<Input
|
||||
value={
|
||||
typeof imageUrlFormValue === "string"
|
||||
? imageUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("image", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT image pre-uploaded, you can set
|
||||
the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customImage?.message}
|
||||
</FormErrorMessage>
|
||||
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.customAnimationUrl}>
|
||||
<FormControl isInvalid={!!errors.animation_url}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input max="6" {...register("customAnimationUrl")} />
|
||||
<Input
|
||||
value={
|
||||
typeof animationUrlFormValue === "string"
|
||||
? animationUrlFormValue
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue("animation_url", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL pre-uploaded, you
|
||||
can set the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customAnimationUrl?.message}
|
||||
{errors?.animation_url?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainSupportedService,
|
||||
} from "../../../types/chain";
|
||||
import { getChains } from "../../../utils";
|
||||
import { getChainsWithServices } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { ChainListCard } from "../server/chainlist-card";
|
||||
import { ChainListRow } from "../server/chainlist-row";
|
||||
@@ -35,7 +35,7 @@ const DEFAULT_PAGE_SIZE = 24;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
async function getChainsToRender(params: SearchParams) {
|
||||
const chains = await getChains();
|
||||
const chains = await getChainsWithServices();
|
||||
|
||||
// sort the chains
|
||||
const sortedChains = chains.sort((a, b) => {
|
||||
|
||||
@@ -72,29 +72,19 @@ import zytronBanner from "./temp-assets/zytronBanner.png";
|
||||
import zytronCTA from "./temp-assets/zytronCTA.jpg";
|
||||
// END TEMPORARY
|
||||
|
||||
import {
|
||||
getChainServices,
|
||||
getChains,
|
||||
getGasSponsoredChains,
|
||||
} from "@/api/chain";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getGasSponsoredChains } from "../../../@/api/chain";
|
||||
import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainService,
|
||||
ChainServices,
|
||||
} from "./types/chain";
|
||||
import type { ChainMetadataWithServices, ChainServices } from "./types/chain";
|
||||
|
||||
export async function getChains() {
|
||||
export async function getChainsWithServices() {
|
||||
const [chains, chainServices] = await Promise.all([
|
||||
fetch(
|
||||
`${API_SERVER_URL}/v1/chains`,
|
||||
// revalidate every 60 minutes
|
||||
{ next: { revalidate: 60 * 60 } },
|
||||
).then((res) => res.json()) as Promise<{ data: ChainMetadata[] }>,
|
||||
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>>;
|
||||
}>,
|
||||
getChains(),
|
||||
getChainServices(),
|
||||
]);
|
||||
|
||||
if (!chains.data.length) {
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DiscordIcon } from "components/icons/brand-icons/DiscordIcon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { BookOpenIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import accountIcon from "../../../../public/assets/support/account.svg";
|
||||
import contractsIcon from "../../../../public/assets/support/contracts.png";
|
||||
import discordIllustration from "../../../../public/assets/support/discord-illustration.png";
|
||||
import engineIcon from "../../../../public/assets/support/engine.png";
|
||||
import miscIcon from "../../../../public/assets/support/misc.svg";
|
||||
import connectIcon from "../../../../public/assets/support/wallets.png";
|
||||
@@ -193,43 +185,6 @@ export default function SupportPage() {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="container">
|
||||
<Card className="flex flex-row items-center justify-between gap-4 border-[#5865F2] bg-[#5865F2]/20">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-bold text-3xl">
|
||||
Discord Community
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<p className="text-balance">
|
||||
Join our Discord community to connect with other thirdweb
|
||||
developers, ask questions, and get help.
|
||||
</p>
|
||||
<Button
|
||||
className="mr-auto flex flex-row gap-2 bg-[#5865F2] text-white hover:bg-[#5865F2]/80"
|
||||
asChild
|
||||
>
|
||||
<Link href="https://discord.gg/thirdweb">
|
||||
<DiscordIcon className="size-4" />
|
||||
<span>Join Discord</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
Please note that our Discord server is managed by our community
|
||||
moderators and does not offer official support.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</div>
|
||||
<Image
|
||||
src={discordIllustration}
|
||||
alt="discord illustration"
|
||||
className="ml-auto hidden max-w-64 object-cover p-6 md:block"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateAPIKeyDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { LazyCreateProjectDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
@@ -61,27 +61,26 @@ export function AccountHeader(props: {
|
||||
<AccountHeaderDesktopUI {...headerProps} className="max-lg:hidden" />
|
||||
<AccountHeaderMobileUI {...headerProps} className="lg:hidden" />
|
||||
|
||||
<LazyCreateAPIKeyDialog
|
||||
open={createProjectDialogState.isOpen}
|
||||
onOpenChange={() =>
|
||||
setCreateProjectDialogState({
|
||||
isOpen: false,
|
||||
})
|
||||
}
|
||||
onCreateAndComplete={() => {
|
||||
// refresh projects
|
||||
router.refresh();
|
||||
}}
|
||||
teamSlug={
|
||||
createProjectDialogState.isOpen
|
||||
? createProjectDialogState.team.slug
|
||||
: undefined
|
||||
}
|
||||
enableNebulaServiceByDefault={
|
||||
createProjectDialogState.isOpen &&
|
||||
createProjectDialogState.team.enabledScopes.includes("nebula")
|
||||
}
|
||||
/>
|
||||
{createProjectDialogState.isOpen && (
|
||||
<LazyCreateProjectDialog
|
||||
open={true}
|
||||
onOpenChange={() =>
|
||||
setCreateProjectDialogState({
|
||||
isOpen: false,
|
||||
})
|
||||
}
|
||||
onCreateAndComplete={() => {
|
||||
// refresh projects
|
||||
router.refresh();
|
||||
}}
|
||||
teamId={createProjectDialogState.team.id}
|
||||
teamSlug={createProjectDialogState.team.slug}
|
||||
enableNebulaServiceByDefault={
|
||||
createProjectDialogState.isOpen &&
|
||||
createProjectDialogState.team.enabledScopes.includes("nebula")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,25 +11,12 @@ export function AccountDevicesPage() {
|
||||
const authorizedWalletsQuery = useAuthorizedWallets();
|
||||
|
||||
return (
|
||||
<div className="container py-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col justify-between gap-4">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Devices</h1>
|
||||
<p className="text-muted-foreground">
|
||||
List of authorized devices that can perform actions on behalf of
|
||||
your account.
|
||||
</p>
|
||||
<ChakraProviderSetup>
|
||||
<AuthorizedWalletsTable
|
||||
authorizedWallets={authorizedWalletsQuery.data || []}
|
||||
isPending={authorizedWalletsQuery.isPending}
|
||||
isFetched={authorizedWalletsQuery.isFetched}
|
||||
/>
|
||||
</ChakraProviderSetup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChakraProviderSetup>
|
||||
<AuthorizedWalletsTable
|
||||
authorizedWallets={authorizedWalletsQuery.data || []}
|
||||
isPending={authorizedWalletsQuery.isPending}
|
||||
isFetched={authorizedWalletsQuery.isFetched}
|
||||
/>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,21 @@ export default async function Page() {
|
||||
// enforce valid account
|
||||
await getValidAccount("/account/devices");
|
||||
|
||||
return <AccountDevicesPage />;
|
||||
return (
|
||||
<div>
|
||||
<header className="border-border border-b py-10">
|
||||
<div className="container max-w-[950px]">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Devices</h1>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
List of authorized devices that can perform actions on behalf of
|
||||
your account.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container max-w-[950px] py-8">
|
||||
<AccountDevicesPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,11 +70,10 @@ async function HeaderAndNav(props: {
|
||||
path: "/account/settings",
|
||||
name: "Settings",
|
||||
},
|
||||
// TODO - enable these links after they are functional
|
||||
// {
|
||||
// path: "/account/wallets",
|
||||
// name: "Wallets",
|
||||
// },
|
||||
{
|
||||
path: "/account/wallets",
|
||||
name: "Linked Wallets",
|
||||
},
|
||||
{
|
||||
path: "/account/devices",
|
||||
name: "Devices",
|
||||
|
||||
@@ -46,9 +46,9 @@ export function AccountTeamsUI(props: {
|
||||
</div>
|
||||
|
||||
<ToolTipLabel label="Coming Soon">
|
||||
<Button variant="primary" disabled className="gap-2 max-sm:w-full">
|
||||
<Button disabled className="gap-2 max-sm:w-full">
|
||||
<PlusIcon className="size-4" />
|
||||
Create a Team
|
||||
Create Team
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@ function TeamRow(props: {
|
||||
<div className="flex items-center gap-4">
|
||||
<GradientAvatar
|
||||
className="size-8"
|
||||
src={props.team.image}
|
||||
src={props.team.image || ""}
|
||||
id={props.team.id}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getMembers } from "@/api/team-members";
|
||||
import { getMemberById } from "@/api/team-members";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
|
||||
import { getValidAccount } from "./settings/getAccount";
|
||||
|
||||
export default async function Page() {
|
||||
const account = await getValidAccount("/account");
|
||||
const teams = await getTeams();
|
||||
if (!teams) {
|
||||
const [authToken, account, teams] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getValidAccount("/account"),
|
||||
getTeams(),
|
||||
]);
|
||||
|
||||
if (!authToken || !teams) {
|
||||
loginRedirect("/account");
|
||||
}
|
||||
|
||||
const teamsWithRole = (
|
||||
await Promise.all(
|
||||
teams.map(async (team) => {
|
||||
const members = await getMembers(team.slug);
|
||||
if (!members) {
|
||||
return {
|
||||
team,
|
||||
role: "MEMBER" as const,
|
||||
};
|
||||
}
|
||||
const teamsWithRole = await Promise.all(
|
||||
teams.map(async (team) => {
|
||||
const member = await getMemberById(team.slug, account.id);
|
||||
|
||||
const accountMemberInfo = members.find(
|
||||
(m) => m.accountId === account.id,
|
||||
);
|
||||
if (!member) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
team,
|
||||
role: accountMemberInfo?.role || "MEMBER",
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter((x) => !!x);
|
||||
return {
|
||||
team,
|
||||
role: member.role,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container grow py-8">
|
||||
<AccountTeamsUI
|
||||
teamsWithRole={teamsWithRole}
|
||||
client={getThirdwebClient()}
|
||||
/>
|
||||
<div className="flex grow flex-col">
|
||||
<header className="border-border border-b py-10">
|
||||
<div className="container max-w-[950px]">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Overview</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container flex max-w-[950px] grow flex-col py-8">
|
||||
<AccountTeamsUI
|
||||
teamsWithRole={teamsWithRole}
|
||||
client={getThirdwebClient(authToken)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,14 @@ export function AccountSettingsPage(props: {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border border-b py-10">
|
||||
<header className="border-border border-b py-10">
|
||||
<div className="container max-w-[950px]">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container max-w-[950px] grow pt-8 pb-20">
|
||||
<AccountSettingsPageUI
|
||||
hideDeleteAccount
|
||||
|
||||
@@ -1,42 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { LinkedWallet } from "@/api/linked-wallets";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MinusIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDate } from "date-fns";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
|
||||
export function LinkWalletUI(props: {
|
||||
wallets: string[];
|
||||
export function LinkWallet(props: {
|
||||
wallets: LinkedWallet[];
|
||||
accountEmail: string | undefined;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<LinkWalletUI
|
||||
wallets={props.wallets}
|
||||
accountEmail={props.accountEmail}
|
||||
unlinkWallet={async (walletId) => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/account/wallets/${walletId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(res.error);
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkWalletUI(props: {
|
||||
wallets: LinkedWallet[];
|
||||
unlinkWallet: (walletId: string) => Promise<void>;
|
||||
accountEmail: string | undefined;
|
||||
}) {
|
||||
const [deletedWalletIds, setDeletedWalletIds] = useState<string[]>([]);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const walletsToShow = !searchValue
|
||||
let walletsToShow = !searchValue
|
||||
? props.wallets
|
||||
: props.wallets.filter((v) => {
|
||||
return v.toLowerCase().includes(searchValue.toLowerCase());
|
||||
return v.walletAddress
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase());
|
||||
});
|
||||
|
||||
walletsToShow = walletsToShow.filter((v) => !deletedWalletIds.includes(v.id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Linked Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The wallets that are linked to your thirdweb account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TODO - handle linking */}
|
||||
<Button variant="primary" className="gap-2 max-sm:w-full">
|
||||
<PlusIcon className="size-4" />
|
||||
Link a Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search wallet address"
|
||||
value={searchValue}
|
||||
@@ -45,50 +82,141 @@ export function LinkWalletUI(props: {
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<ul className="rounded-lg border bg-card">
|
||||
{walletsToShow.map((v) => {
|
||||
return (
|
||||
<li key={v} className="border-border border-b p-4 last:border-b-0">
|
||||
<WalletRow address={v} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell>Wallet Address</TableCell>
|
||||
<TableCell>Linked on</TableCell>
|
||||
<TableCell>Unlink</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{walletsToShow.map((wallet) => (
|
||||
<TableRow key={wallet.walletAddress}>
|
||||
<TableCell>
|
||||
<WalletAddress
|
||||
address={wallet.walletAddress}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDate(wallet.createdAt, "MMM d, yyyy")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UnlinkButton
|
||||
wallet={wallet}
|
||||
unlinkWallet={props.unlinkWallet}
|
||||
accountEmail={props.accountEmail}
|
||||
onUnlinkSuccess={() => {
|
||||
setDeletedWalletIds([...deletedWalletIds, wallet.id]);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* No Result Found */}
|
||||
{walletsToShow.length === 0 && (
|
||||
<div className="flex h-[200px] items-center justify-center p-4">
|
||||
<div className="flex flex-col gap-3 text-center">
|
||||
<p className="text-sm">No Wallets Found</p>
|
||||
{searchValue && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your search for {`"${searchValue}"`} did not match any wallets
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
{walletsToShow.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-[200px] text-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">No Wallets Found</p>
|
||||
{searchValue && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your search for {`"${searchValue}"`} did not match any
|
||||
wallets
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletRow(props: {
|
||||
address: string;
|
||||
function UnlinkButton(props: {
|
||||
wallet: LinkedWallet;
|
||||
unlinkWallet: (walletId: string) => Promise<void>;
|
||||
onUnlinkSuccess: () => void;
|
||||
accountEmail: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* start */}
|
||||
<WalletAddress
|
||||
address={props.address}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
const [open, setOpen] = useState(false);
|
||||
const unlinkWallet = useMutation({
|
||||
mutationFn: props.unlinkWallet,
|
||||
onSuccess: () => {
|
||||
props.onUnlinkSuccess();
|
||||
setOpen(false);
|
||||
toast.success("Wallet unlinked successfully");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to unlink wallet");
|
||||
},
|
||||
});
|
||||
|
||||
{/* end */}
|
||||
{/* TODO - handle unlink */}
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<MinusIcon className="size-4" />
|
||||
Unlink
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<MinusIcon className="size-4" />
|
||||
Unlink
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
<div className="p-6 pb-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlink Wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="block">
|
||||
Are you sure you want to unlink this wallet?
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: prevents autofocus inside this div to prevent hover card from opening */}
|
||||
<div className="py-2" tabIndex={0}>
|
||||
<WalletAddress
|
||||
address={props.wallet.walletAddress}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-6 py-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This wallet can be linked again by logging in dashboard with this
|
||||
wallet and providing the confirmation code sent to{" "}
|
||||
{props.accountEmail || "email address associated with this account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 rounded-b-lg border-t bg-card p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={unlinkWallet.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
unlinkWallet.mutate(props.wallet.id);
|
||||
}}
|
||||
disabled={unlinkWallet.isPending}
|
||||
>
|
||||
{unlinkWallet.isPending ? (
|
||||
<Spinner className="mr-2 size-4" />
|
||||
) : (
|
||||
<MinusIcon className="mr-2 size-4" />
|
||||
)}
|
||||
Unlink Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { LinkedWallet } from "@/api/linked-wallets";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { LinkWalletUI } from "./LinkWalletUI";
|
||||
@@ -31,31 +33,66 @@ export const Mobile: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const unlinkWalletSuccessStub = async (walletId: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("unlinkWallet", walletId);
|
||||
};
|
||||
|
||||
const unlinkWalletFailureStub = async (walletId: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("unlinkWallet", walletId);
|
||||
throw new Error("Failed to unlink wallet");
|
||||
};
|
||||
|
||||
const accountWalletsStub: LinkedWallet[] = [
|
||||
{
|
||||
walletAddress: "0x51696930092b42243dee1077c8dd237074fb28d4", // jns.eth
|
||||
createdAt: new Date().toISOString(),
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", // no ens
|
||||
createdAt: new Date(
|
||||
Date.now() - 1000 * 60 * 60 * 24 * 365 * 2,
|
||||
).toISOString(),
|
||||
id: "2",
|
||||
},
|
||||
{
|
||||
walletAddress: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", // vitalik.eth
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
|
||||
id: "3",
|
||||
},
|
||||
];
|
||||
|
||||
function Variants() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container mx-auto flex w-full max-w-[1100px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="4 wallets">
|
||||
<BadgeContainer label="Unlink wallet success">
|
||||
<LinkWalletUI
|
||||
wallets={[
|
||||
"0x51696930092b42243dee1077c8dd237074fb28d4", // jns.eth - ens
|
||||
"0xd8da6bf26964af9d7eed9e03e53415d37aa96045", // vitalik.eth - ens, farcaster, lens
|
||||
"0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", // no ens
|
||||
]}
|
||||
accountEmail="[email protected]"
|
||||
unlinkWallet={unlinkWalletSuccessStub}
|
||||
wallets={accountWalletsStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="1 wallet">
|
||||
<BadgeContainer label="Unlink wallet failure">
|
||||
<LinkWalletUI
|
||||
wallets={[
|
||||
"0x51696930092b42243dee1077c8dd237074fb28d4", // jns.eth
|
||||
]}
|
||||
accountEmail="[email protected]"
|
||||
unlinkWallet={unlinkWalletFailureStub}
|
||||
wallets={accountWalletsStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 wallets">
|
||||
<LinkWalletUI wallets={[]} />
|
||||
<LinkWalletUI
|
||||
wallets={[]}
|
||||
unlinkWallet={unlinkWalletSuccessStub}
|
||||
accountEmail="[email protected]"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { LinkWalletUI } from "./LinkWalletUI";
|
||||
import { getLinkedWallets } from "@/api/linked-wallets";
|
||||
import { getValidAccount } from "../settings/getAccount";
|
||||
import { LinkWallet } from "./LinkWalletUI";
|
||||
|
||||
export default async function Page() {
|
||||
const [wallets, account] = await Promise.all([
|
||||
getLinkedWallets(),
|
||||
getValidAccount(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="grow">
|
||||
<LinkWalletUI
|
||||
// TODO - fetch the wallets of current user
|
||||
wallets={[]}
|
||||
/>
|
||||
<div className="flex grow flex-col">
|
||||
<header className="border-border border-b py-10">
|
||||
<div className="container max-w-[950px]">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Linked Wallets
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container max-w-[950px] py-8">
|
||||
<LinkWallet
|
||||
wallets={wallets || []}
|
||||
accountEmail={account.email || ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getAuthToken } from "./getAuthToken";
|
||||
|
||||
// TODO - Fix the `/v1/keys/${apiKeyId}` endpoint in API server
|
||||
|
||||
async function getAPIKey(apiKeyId: string) {
|
||||
// The `/v1/keys/${apiKeyId}`; does not return the "FULL" ApiKey object for some reason
|
||||
// Until this is fixed in API server - we just use the getApiKeys() and find the key by id
|
||||
|
||||
const apiKeys = await getApiKeys();
|
||||
return apiKeys.find((key) => key.id === apiKeyId);
|
||||
|
||||
// const authToken = getAuthToken();
|
||||
// const apiServerURL = new URL(
|
||||
// process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com",
|
||||
// );
|
||||
|
||||
// apiServerURL.pathname = `/v1/keys/${apiKeyId}`;
|
||||
|
||||
// const res = await fetch(apiServerURL, {
|
||||
// method: "GET",
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${authToken}`,
|
||||
// },
|
||||
// });
|
||||
|
||||
// const json = await res.json();
|
||||
|
||||
// if (json.error) {
|
||||
// console.error(json.error);
|
||||
// return undefined;
|
||||
// }
|
||||
|
||||
// return json.data as ApiKey;
|
||||
}
|
||||
|
||||
async function getApiKeys() {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/keys`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json.data as ApiKey[];
|
||||
}
|
||||
|
||||
export function getAPIKeyForProjectId(projectId: string) {
|
||||
if (projectId.startsWith("prj_")) {
|
||||
return getAPIKey(projectId.slice("prj_".length));
|
||||
}
|
||||
|
||||
return getAPIKey(projectId);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { mobileViewport } from "../../../../../stories/utils";
|
||||
import { JoinTeamPageUI } from "./JoinTeamPage";
|
||||
|
||||
const meta = {
|
||||
title: "Team/Join Team",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div>
|
||||
<JoinTeamPageUI
|
||||
teamName="XYZ Inc"
|
||||
invite={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { acceptInvite } from "@/actions/acceptInvite";
|
||||
import type { Team } from "@/api/team";
|
||||
import { ToggleThemeButton } from "@/components/color-mode-toggle";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { DotsBackgroundPattern } from "@/components/ui/background-patterns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckIcon, UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { ThirdwebMiniLogo } from "../../../../components/ThirdwebMiniLogo";
|
||||
|
||||
export function JoinTeamPage(props: {
|
||||
team: Team;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<JoinTeamPageUI
|
||||
teamName={props.team.name}
|
||||
invite={async () => {
|
||||
const res = await acceptInvite({
|
||||
inviteId: props.inviteId,
|
||||
teamId: props.team.id,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(res.errorMessage);
|
||||
throw new Error(res.errorMessage);
|
||||
}
|
||||
|
||||
router.replace(`/team/${props.team.slug}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function JoinTeamPageUI(props: {
|
||||
teamName: string;
|
||||
invite: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<div className="container flex grow flex-col items-center justify-center ">
|
||||
<div className="z-10">
|
||||
<AcceptInviteCardUI teamName={props.teamName} invite={props.invite} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DotsBackgroundPattern />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="border-b bg-background">
|
||||
<header className="container flex w-full flex-row items-center justify-between px-6 py-4">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<ThirdwebMiniLogo className="size-7 md:size-8" />
|
||||
<span className="font-medium text-foreground text-xl tracking-tight">
|
||||
thirdweb
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/support"
|
||||
target="_blank"
|
||||
className="px-2 text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
<ToggleThemeButton />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcceptInviteCardUI(props: {
|
||||
teamName: string;
|
||||
invite: () => Promise<void>;
|
||||
}) {
|
||||
const invite = useMutation({
|
||||
mutationFn: props.invite,
|
||||
});
|
||||
return (
|
||||
<div className="w-full rounded-xl border bg-card shadow-2xl lg:w-[600px]">
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="mb-5 flex size-12 items-center justify-center rounded-full border bg-background">
|
||||
<UsersIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<h1 className="mb-3 font-semibold text-2xl tracking-tight">
|
||||
Join your team on thirdweb
|
||||
</h1>
|
||||
<p className="mb-1.5 font-medium text-muted-foreground">
|
||||
You have been invited to join team{" "}
|
||||
<em className="text-foreground not-italic">{props.teamName}</em>{" "}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
Accepting this invite will add you to the team and give you access to
|
||||
the team's resources
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end border-t p-4 lg:p-6">
|
||||
<Button
|
||||
disabled={invite.isPending}
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
const promise = invite.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Invite accepted",
|
||||
error: (e) => {
|
||||
if (e instanceof Error && e.message) {
|
||||
return e.message;
|
||||
}
|
||||
return "Failed to accept invite";
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{invite.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<CheckIcon className="size-4" />
|
||||
)}
|
||||
Accept Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getTeamBySlug, service_getTeamBySlug } from "@/api/team";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../account/settings/getAccount";
|
||||
import { JoinTeamPage } from "./JoinTeamPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; invite_id: string }>;
|
||||
}) {
|
||||
const { team_slug, invite_id } = await props.params;
|
||||
|
||||
// ensure the user is logged in + onboarded
|
||||
await getValidAccount(`/join/team/${team_slug}/${invite_id}`);
|
||||
|
||||
const [userTeam, inviteTeam] = await Promise.all([
|
||||
getTeamBySlug(team_slug),
|
||||
service_getTeamBySlug(team_slug),
|
||||
]);
|
||||
|
||||
// if the user is already a member of the team, redirect to the team
|
||||
if (userTeam) {
|
||||
redirect(`/team/${team_slug}`);
|
||||
}
|
||||
|
||||
if (!inviteTeam) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <JoinTeamPage team={inviteTeam} inviteId={invite_id} />;
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { redirectToCheckout } from "@/actions/billing";
|
||||
import { getRawAccountAction } from "@/actions/getAccount";
|
||||
import { ToggleThemeButton } from "@/components/color-mode-toggle";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { TURNSTILE_SITE_KEY } from "@/constants/env";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
@@ -202,6 +204,7 @@ function PageContent(props: {
|
||||
onLogout={() => {
|
||||
setScreen({ id: "login" });
|
||||
}}
|
||||
skipShowingPlans={props.redirectPath.startsWith("/join/team")}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -215,36 +218,53 @@ function CustomConnectEmbed(props: {
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const client = useThirdwebClient();
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
|
||||
return (
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
try {
|
||||
await doLogin(params);
|
||||
props.onLogin();
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
doLogout,
|
||||
isLoggedIn: async (x) => {
|
||||
const isLoggedInResult = await isLoggedIn(x);
|
||||
if (isLoggedInResult) {
|
||||
props.onLogin();
|
||||
}
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={wallets}
|
||||
client={client}
|
||||
modalSize="wide"
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
privacyPolicyUrl="/privacy-policy"
|
||||
termsOfServiceUrl="/terms"
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
doLogin: async (params) => {
|
||||
try {
|
||||
const result = await doLogin(params, turnstileToken);
|
||||
if (result.error) {
|
||||
console.error("Failed to login", result.error, result.context);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
props.onLogin();
|
||||
} catch (e) {
|
||||
console.error("Failed to login", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
doLogout,
|
||||
isLoggedIn: async (x) => {
|
||||
const isLoggedInResult = await isLoggedIn(x);
|
||||
if (isLoggedInResult) {
|
||||
props.onLogin();
|
||||
}
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={wallets}
|
||||
client={client}
|
||||
modalSize="wide"
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
className="shadow-lg"
|
||||
privacyPolicyUrl="/privacy-policy"
|
||||
termsOfServiceUrl="/terms"
|
||||
/>
|
||||
<Turnstile
|
||||
options={{
|
||||
// only show if interaction is required
|
||||
appearance: "interaction-only",
|
||||
// match the theme of the rest of the app
|
||||
theme: theme === "light" ? "light" : "dark",
|
||||
}}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ import "server-only";
|
||||
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
|
||||
import { cookies } from "next/headers";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { getAddress } from "thirdweb";
|
||||
import type {
|
||||
GenerateLoginPayloadParams,
|
||||
LoginPayload,
|
||||
VerifyLoginPayloadParams,
|
||||
} from "thirdweb/auth";
|
||||
import { isVercel } from "../../lib/vercel-utils";
|
||||
|
||||
export async function getLoginPayload(
|
||||
params: GenerateLoginPayloadParams,
|
||||
@@ -36,11 +38,93 @@ export async function getLoginPayload(
|
||||
return (await res.json()).data.payload;
|
||||
}
|
||||
|
||||
export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
export async function doLogin(
|
||||
payload: VerifyLoginPayloadParams,
|
||||
turnstileToken: string,
|
||||
) {
|
||||
if (!THIRDWEB_API_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
|
||||
// only validate the turnstile token if we are in a vercel environment
|
||||
if (isVercel()) {
|
||||
if (!turnstileToken) {
|
||||
return {
|
||||
error: "Please complete the captcha.",
|
||||
};
|
||||
}
|
||||
|
||||
// get the request headers
|
||||
const requestHeaders = await headers();
|
||||
if (!requestHeaders) {
|
||||
return {
|
||||
error: "Failed to get request headers. Please try again.",
|
||||
};
|
||||
}
|
||||
// CF header, fallback to req.ip, then X-Forwarded-For
|
||||
const [ip, errors] = (() => {
|
||||
let ip: string | null = null;
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
ip = requestHeaders.get("CF-Connecting-IP") || null;
|
||||
} catch (err) {
|
||||
console.error("failed to get IP address from CF-Connecting-IP", err);
|
||||
errors.push("failed to get IP address from CF-Connecting-IP");
|
||||
}
|
||||
if (!ip) {
|
||||
try {
|
||||
ip = ipAddress(requestHeaders) || null;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"failed to get IP address from ipAddress() function",
|
||||
err,
|
||||
);
|
||||
errors.push("failed to get IP address from ipAddress() function");
|
||||
}
|
||||
}
|
||||
if (!ip) {
|
||||
try {
|
||||
ip = requestHeaders.get("X-Forwarded-For");
|
||||
} catch (err) {
|
||||
console.error("failed to get IP address from X-Forwarded-For", err);
|
||||
errors.push("failed to get IP address from X-Forwarded-For");
|
||||
}
|
||||
}
|
||||
return [ip, errors];
|
||||
})();
|
||||
|
||||
if (!ip) {
|
||||
return {
|
||||
error: "Could not get IP address. Please try again.",
|
||||
context: errors,
|
||||
};
|
||||
}
|
||||
|
||||
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
// Validate the token by calling the "/siteverify" API endpoint.
|
||||
const result = await fetch(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
{
|
||||
body: JSON.stringify({
|
||||
secret: process.env.TURNSTILE_SECRET_KEY,
|
||||
response: turnstileToken,
|
||||
remoteip: ip,
|
||||
}),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const outcome = await result.json();
|
||||
if (!outcome.success) {
|
||||
return {
|
||||
error: "Invalid captcha. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const utmCookies = cookieStore
|
||||
.getAll()
|
||||
@@ -86,7 +170,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
res.statusText,
|
||||
response,
|
||||
);
|
||||
throw new Error("Failed to login - api call failed");
|
||||
return {
|
||||
error: "Failed to login. Please try again later.",
|
||||
};
|
||||
} catch {
|
||||
// just log the basics
|
||||
console.error(
|
||||
@@ -95,7 +181,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
res.statusText,
|
||||
);
|
||||
}
|
||||
throw new Error("Failed to login - api call failed");
|
||||
return {
|
||||
error: "Failed to login. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
@@ -104,7 +192,9 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
|
||||
if (!jwt) {
|
||||
console.error("Failed to login - invalid json", json);
|
||||
throw new Error("Failed to login - invalid json");
|
||||
return {
|
||||
error: "Failed to login. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
// set the token cookie
|
||||
@@ -128,6 +218,10 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
|
||||
// 3 days
|
||||
maxAge: 3 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function doLogout() {
|
||||
|
||||
@@ -25,6 +25,7 @@ function OnboardingUI(props: {
|
||||
// path to redirect from stripe
|
||||
redirectPath: string;
|
||||
redirectToCheckout: RedirectBillingCheckoutAction;
|
||||
skipShowingPlans: boolean;
|
||||
}) {
|
||||
const { account } = props;
|
||||
const [screen, setScreen] = useState<OnboardingScreen>({ id: "onboarding" });
|
||||
@@ -119,7 +120,12 @@ function OnboardingUI(props: {
|
||||
if (account.onboardSkipped) {
|
||||
props.onComplete();
|
||||
} else {
|
||||
setScreen({ id: "plan", team: res.team });
|
||||
if (props.skipShowingPlans) {
|
||||
props.onComplete();
|
||||
skipOnboarding();
|
||||
} else {
|
||||
setScreen({ id: "plan", team: res.team });
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -137,8 +143,8 @@ function OnboardingUI(props: {
|
||||
redirectPath={props.redirectPath}
|
||||
teamSlug={screen.team.slug}
|
||||
skipPlan={async () => {
|
||||
await skipOnboarding().catch(() => {});
|
||||
props.onComplete();
|
||||
skipOnboarding();
|
||||
}}
|
||||
canTrialGrowth={true}
|
||||
redirectToCheckout={props.redirectToCheckout}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { type NebulaContext, promptNebula } from "../api/chat";
|
||||
import { createSession, updateSession } from "../api/session";
|
||||
import type { SessionInfo } from "../api/types";
|
||||
import { examplePrompts } from "../data/examplePrompts";
|
||||
import { newChatPageUrlStore, newSessionsStore } from "../stores";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import { type ChatMessage, Chats } from "./Chats";
|
||||
@@ -181,6 +182,23 @@ export function ChatPageContent(props: {
|
||||
},
|
||||
]);
|
||||
|
||||
const lowerCaseMessage = message.toLowerCase();
|
||||
// handle hardcoded replies first
|
||||
const interceptedReply = examplePrompts.find(
|
||||
(prompt) => prompt.message.toLowerCase() === lowerCaseMessage,
|
||||
)?.interceptedReply;
|
||||
|
||||
if (interceptedReply) {
|
||||
// slight delay to match other response times
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ type: "assistant", text: interceptedReply, request_id: undefined },
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChatStreaming(true);
|
||||
setEnableAutoScroll(true);
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -105,7 +105,7 @@ function DashedBgDiv(props: {
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${props.type === "horizontal" ? "90deg" : "180deg"}, hsl(var(--foreground)/20%) 0 30%, transparent 0 100%)`,
|
||||
backgroundImage: `linear-gradient(${props.type === "horizontal" ? "90deg" : "180deg"}, hsl(var(--active-border)) 0 30%, transparent 0 100%)`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "10px 10px",
|
||||
maskImage: `linear-gradient(${
|
||||
|
||||
@@ -1,13 +1,68 @@
|
||||
type ExamplePrompt = {
|
||||
title: string;
|
||||
message: string;
|
||||
interceptedReply?: string;
|
||||
};
|
||||
|
||||
// Note:
|
||||
// Keep the title as short as possible so 2 of them can fit in a single row on desktop viewport
|
||||
// title is only used for displaying the example - the `message` is sent to server when user clicks on the example - it can be as long and descriptive as needed
|
||||
const whatCanNebulaDoReply = `
|
||||
Nebula is a natural language model with improved blockchain reasoning, autonomous transaction capabilities, and real-time access to the blockchain.
|
||||
[Learn more about Nebula](https://portal.thirdweb.com/nebula)
|
||||
|
||||
Here are some example actions you can perform with Nebula:
|
||||
|
||||
### Bridge & Swap
|
||||
Bridge and swap native currencies
|
||||
- Swap 1 USDC to 1 USDT on the Ethereum Mainnet
|
||||
- Bridge 0.5 ETH from Ethereum Mainnet to Polygon
|
||||
|
||||
### Transfer
|
||||
Send native and ERC-20 currencies
|
||||
- Send 0.1 ETH to vitalik.eth
|
||||
- Transfer 1 USDC to saminacodes.eth on Base
|
||||
|
||||
### Deploy
|
||||
Deploy published contracts
|
||||
- Deploy a Token ERC20 Contract
|
||||
- Deploy a Split contract with two recipients.
|
||||
- Deploy an ERC1155 Contract named 'Hello World' with description 'Hello badges on Ethereum'
|
||||
|
||||
### Understand
|
||||
Retrieve information about smart contracts.
|
||||
- What ERC standards are implemented by contract address 0x59325733eb952a92e069C87F0A6168b29E80627f on Ethereum?
|
||||
- What functions can I use to mint more of my contract's NFTs?
|
||||
- What is the total supply of NFTs for my contract?
|
||||
|
||||
### Interact
|
||||
Query wallet balances, addresses, and token holdings.
|
||||
- How much ETH is in my wallet?
|
||||
- What is the wallet address of vitalik.eth?
|
||||
- Does my wallet hold USDC on Base?
|
||||
|
||||
### Explore
|
||||
Access blockchain-specific data.
|
||||
- What is the last block on zkSync?
|
||||
- What is the current gas price on Avalanche C-Chain?
|
||||
- Can you show me transaction details for 0xdfc450bb39e44bd37c22e0bfd0e5212edbea571e4e534d87b5cbbf06f10b9e04 on Optimism?
|
||||
|
||||
### Research
|
||||
Obtain details about tokens, their addresses, and current prices.
|
||||
- What is the address of USDC on Ethereum?
|
||||
- Is there a UNI token on Arbitrum?
|
||||
- What is the current price of ARB?
|
||||
|
||||
### Build
|
||||
Implement features using Web3 SDKs and tools.
|
||||
- How can I add a connect wallet button to my web app? I want to support users connecting with both email/social wallets and MetaMask and use smart wallets.
|
||||
- Can you show me how to claim an NFT from an ERC721 using TypeScript?
|
||||
- I have an ERC1155 contract from thirdweb. Can you show me how to generate and mint with a signature?
|
||||
`;
|
||||
|
||||
export const examplePrompts: ExamplePrompt[] = [
|
||||
{
|
||||
title: "What can Nebula do?",
|
||||
message: "What can Nebula do?",
|
||||
interceptedReply: whatCanNebulaDoReply,
|
||||
},
|
||||
{
|
||||
title: "Deploy an ERC-20 Token",
|
||||
message:
|
||||
@@ -25,8 +80,4 @@ export const examplePrompts: ExamplePrompt[] = [
|
||||
title: "Transfer 0.001 ETH to thirdweb.eth",
|
||||
message: "Transfer 0.001 ETH to thirdweb.eth",
|
||||
},
|
||||
{
|
||||
title: "Using session keys in Unity",
|
||||
message: "How to use session key in Unity?",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
@@ -20,25 +21,27 @@ const queryClient = new QueryClient();
|
||||
|
||||
export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SyncChainStores />
|
||||
<ThirdwebProvider>
|
||||
<SyncChainDefinitionsToConnectionManager />
|
||||
<TWAutoConnect />
|
||||
<PosthogIdentifier />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
defaultTheme="dark"
|
||||
>
|
||||
<ToasterSetup />
|
||||
<SanctionedAddressesChecker>
|
||||
{props.children}
|
||||
</SanctionedAddressesChecker>
|
||||
</ThemeProvider>
|
||||
</ThirdwebProvider>
|
||||
</QueryClientProvider>
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SyncChainStores />
|
||||
<ThirdwebProvider>
|
||||
<SyncChainDefinitionsToConnectionManager />
|
||||
<TWAutoConnect />
|
||||
<PosthogIdentifier />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
defaultTheme="dark"
|
||||
>
|
||||
<ToasterSetup />
|
||||
<SanctionedAddressesChecker>
|
||||
{props.children}
|
||||
</SanctionedAddressesChecker>
|
||||
</ThemeProvider>
|
||||
</ThirdwebProvider>
|
||||
</QueryClientProvider>
|
||||
</NuqsAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,13 +42,14 @@ async function getProjectsWithAnalytics(
|
||||
projects: Project[],
|
||||
): Promise<Array<ProjectWithAnalytics>> {
|
||||
return Promise.all(
|
||||
projects.map(async (p) => {
|
||||
projects.map(async (project) => {
|
||||
try {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = subDays(today, 30);
|
||||
|
||||
const data = await getWalletConnections({
|
||||
clientId: p.publishableKey,
|
||||
teamId: project.teamId,
|
||||
projectId: project.id,
|
||||
period: "all",
|
||||
from: thirtyDaysAgo,
|
||||
to: today,
|
||||
@@ -60,12 +61,12 @@ async function getProjectsWithAnalytics(
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
...project,
|
||||
monthlyActiveUsers: uniqueWalletsConnected,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...p,
|
||||
...project,
|
||||
monthlyActiveUsers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,21 +5,18 @@ import {
|
||||
getWalletConnections,
|
||||
getWalletUsers,
|
||||
} from "@/api/analytics";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
|
||||
import {
|
||||
type DurationId,
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
import { AnalyticsHeader } from "../../../../components/Analytics/AnalyticsHeader";
|
||||
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
|
||||
import { EmptyState } from "../../../../components/Analytics/EmptyState";
|
||||
@@ -27,16 +24,11 @@ import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getValidAccount } from "app/account/settings/getAccount";
|
||||
import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard";
|
||||
import { Suspense } from "react";
|
||||
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
|
||||
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
|
||||
|
||||
// revalidate every 5 minutes
|
||||
export const maxDuration = 300;
|
||||
|
||||
type SearchParams = {
|
||||
usersChart?: string;
|
||||
from?: string;
|
||||
@@ -54,7 +46,6 @@ export default async function TeamOverviewPage(props: {
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const account = await getValidAccount(`/team/${params.team_slug}`);
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
@@ -77,7 +68,7 @@ export default async function TeamOverviewPage(props: {
|
||||
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16">
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<OverviewPageContent
|
||||
account={account}
|
||||
teamId={team.id}
|
||||
range={range}
|
||||
interval={interval}
|
||||
searchParams={searchParams}
|
||||
@@ -89,12 +80,12 @@ export default async function TeamOverviewPage(props: {
|
||||
}
|
||||
|
||||
async function OverviewPageContent(props: {
|
||||
account: Account;
|
||||
teamId: string;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { account, range, interval, searchParams } = props;
|
||||
const { teamId, range, interval, searchParams } = props;
|
||||
|
||||
const [
|
||||
walletConnections,
|
||||
@@ -107,47 +98,47 @@ async function OverviewPageContent(props: {
|
||||
] = await Promise.all([
|
||||
// Aggregated wallet connections
|
||||
getWalletConnections({
|
||||
accountId: account.id,
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// Time series data for wallet users
|
||||
getWalletUsers({
|
||||
accountId: account.id,
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
// In-app wallet usage
|
||||
getInAppWalletUsage({
|
||||
accountId: account.id,
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// User operations usage
|
||||
getUserOpUsage({
|
||||
accountId: account.id,
|
||||
teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
getUserOpUsage({
|
||||
accountId: account.id,
|
||||
teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: "all",
|
||||
}),
|
||||
// Client transactions
|
||||
getClientTransactions({
|
||||
accountId: account.id,
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}),
|
||||
getClientTransactions({
|
||||
accountId: account.id,
|
||||
teamId: teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: "all",
|
||||
|
||||
+40
-3
@@ -1,29 +1,66 @@
|
||||
import { getEcosystemWalletUsage } from "@/api/analytics";
|
||||
import {
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { RangeSelector } from "components/analytics/range-selector";
|
||||
import { getEcosystemWalletUsage } from "data/analytics/wallets/ecosystem";
|
||||
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
|
||||
import { EcosystemWalletsSummary } from "./Summary";
|
||||
|
||||
export async function EcosystemAnalyticsPage({
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
interval,
|
||||
range,
|
||||
}: { ecosystemSlug: string; interval: "day" | "week"; range?: Range }) {
|
||||
}: {
|
||||
ecosystemSlug: string;
|
||||
teamId: string;
|
||||
interval: "day" | "week";
|
||||
range?: Range;
|
||||
}) {
|
||||
if (!range) {
|
||||
range = getLastNDaysRange("last-120");
|
||||
}
|
||||
|
||||
const stats = await getEcosystemWalletUsage({
|
||||
const allTimeStatsPromise = getEcosystemWalletUsage({
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
from: new Date(2022, 0, 1),
|
||||
to: new Date(),
|
||||
period: "all",
|
||||
});
|
||||
|
||||
const monthlyStatsPromise = getEcosystemWalletUsage({
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||
to: new Date(),
|
||||
period: "month",
|
||||
});
|
||||
|
||||
const statsPromise = getEcosystemWalletUsage({
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}).catch(() => null);
|
||||
|
||||
const [allTimeStats, monthlyStats, stats] = await Promise.all([
|
||||
allTimeStatsPromise,
|
||||
monthlyStatsPromise,
|
||||
statsPromise,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EcosystemWalletsSummary
|
||||
allTimeStats={allTimeStats ?? []}
|
||||
monthlyStats={monthlyStats ?? []}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<RangeSelector range={range} interval={interval} />
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
+25
-20
@@ -1,7 +1,8 @@
|
||||
import type { Range } from "components/analytics/date-range-selector";
|
||||
import { fetchApiServer } from "data/analytics/fetch-api-server";
|
||||
import { FetchError } from "utils/error";
|
||||
import type { Ecosystem } from "../../../types";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTeamBySlug } from "../../../../../../../../../@/api/team";
|
||||
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
|
||||
import { fetchEcosystem } from "../../../utils/fetchEcosystem";
|
||||
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
@@ -19,28 +20,32 @@ export default async function Page(props: {
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const ecosystem = await getEcosystem(params.slug);
|
||||
const ecosystemLayoutPath = `/team/${params.team_slug}/~/ecosystem`;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
const [ecosystem, team] = await Promise.all([
|
||||
fetchEcosystem(params.slug, authToken),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!ecosystem) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
return (
|
||||
<EcosystemAnalyticsPage
|
||||
ecosystemSlug={ecosystem.slug}
|
||||
teamId={team.id}
|
||||
interval={searchParams.interval || "week"}
|
||||
range={searchParams.range}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getEcosystem(ecosystemSlug: string) {
|
||||
const res = await fetchApiServer(`/v1/ecosystem-wallet/${ecosystemSlug}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
throw new FetchError(
|
||||
res,
|
||||
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystems",
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem };
|
||||
return data.result;
|
||||
}
|
||||
|
||||
+25
-33
@@ -1,8 +1,7 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { getEcosystemWalletUsage } from "data/analytics/wallets/ecosystem";
|
||||
import { cookies } from "next/headers";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import {} from "@/constants/cookie";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAddress } from "thirdweb";
|
||||
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
|
||||
import { fetchEcosystem } from "../../../utils/fetchEcosystem";
|
||||
import { EcosystemHeader } from "./ecosystem-header.client";
|
||||
|
||||
@@ -15,12 +14,7 @@ export async function EcosystemLayoutSlug({
|
||||
params: { slug: string };
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const authToken = activeAccount
|
||||
? (await cookies()).get(COOKIE_PREFIX_TOKEN + getAddress(activeAccount))
|
||||
?.value
|
||||
: null;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
@@ -32,34 +26,32 @@ export async function EcosystemLayoutSlug({
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
const allTimeStatsPromise = getEcosystemWalletUsage({
|
||||
ecosystemSlug: ecosystem.slug,
|
||||
from: new Date(2022, 0, 1),
|
||||
to: new Date(),
|
||||
period: "all",
|
||||
});
|
||||
|
||||
const monthlyStatsPromise = getEcosystemWalletUsage({
|
||||
ecosystemSlug: ecosystem.slug,
|
||||
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||
to: new Date(),
|
||||
period: "month",
|
||||
});
|
||||
|
||||
const [allTimeStats, monthlyStats] = await Promise.all([
|
||||
allTimeStatsPromise,
|
||||
monthlyStatsPromise,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-10">
|
||||
<div className="flex grow flex-col">
|
||||
<EcosystemHeader
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={ecosystemLayoutPath}
|
||||
allTimeStats={allTimeStats || []}
|
||||
monthlyStats={monthlyStats || []}
|
||||
/>
|
||||
{children}
|
||||
|
||||
<SidebarLayout
|
||||
sidebarLinks={[
|
||||
{
|
||||
label: "Overview",
|
||||
href: `${ecosystemLayoutPath}/${ecosystem.slug}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
label: "Analytics",
|
||||
href: `${ecosystemLayoutPath}/${ecosystem.slug}/analytics`,
|
||||
},
|
||||
{
|
||||
label: "Design (coming soon)",
|
||||
href: `${ecosystemLayoutPath}/${ecosystem.slug}#`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+61
-88
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { CopyButton } from "@/components/ui/CopyButton";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,21 +13,18 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
ExternalLinkIcon,
|
||||
PlusCircleIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { EcosystemWalletStats } from "types/analytics";
|
||||
import { useEcosystemList } from "../../../hooks/use-ecosystem-list";
|
||||
import type { Ecosystem } from "../../../types";
|
||||
import { EcosystemWalletsSummary } from "../analytics/components/Summary";
|
||||
import { useEcosystem } from "../hooks/use-ecosystem";
|
||||
|
||||
function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
@@ -111,8 +109,6 @@ function EcosystemSelect(props: {
|
||||
export function EcosystemHeader(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
allTimeStats: EcosystemWalletStats[];
|
||||
monthlyStats: EcosystemWalletStats[];
|
||||
}) {
|
||||
const { data: fetchedEcosystem } = useEcosystem({
|
||||
slug: props.ecosystem.slug,
|
||||
@@ -135,95 +131,72 @@ export function EcosystemHeader(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<EcosystemAlertBanner ecosystem={ecosystem} />
|
||||
<header className="flex flex-col gap-12">
|
||||
<div className="flex flex-col justify-between gap-4 md:grid-cols-4 md:flex-row">
|
||||
<div className="flex items-center gap-4">
|
||||
{!ecosystem.imageUrl ? (
|
||||
<Skeleton className="size-24" />
|
||||
) : (
|
||||
ecosystemImageLink && (
|
||||
<div className="relative size-24 overflow-hidden rounded-md">
|
||||
<Image
|
||||
<div className="border-b py-8">
|
||||
<div className="container flex flex-col gap-8">
|
||||
<EcosystemAlertBanner ecosystem={ecosystem} />
|
||||
<header className="flex flex-col gap-12">
|
||||
<div className="flex flex-col justify-between gap-4 md:grid-cols-4 md:flex-row">
|
||||
<div className="flex items-center gap-4">
|
||||
{!ecosystem.imageUrl ? (
|
||||
<Skeleton className="size-24" />
|
||||
) : (
|
||||
ecosystemImageLink && (
|
||||
<Img
|
||||
src={ecosystemImageLink}
|
||||
sizes="100px"
|
||||
alt={ecosystem.name}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover object-center"
|
||||
className="size-24 rounded-full border object-contain object-center"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{!ecosystem.name ? (
|
||||
<Skeleton className="h-12 w-[225px]" />
|
||||
) : (
|
||||
<h2 className="font-bold text-4xl text-foreground">
|
||||
{ecosystem.name}
|
||||
</h2>
|
||||
)
|
||||
)}
|
||||
{!ecosystem.slug ? (
|
||||
<Skeleton className="h-6 w-[300px]" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-muted-foreground">
|
||||
ecosystem.{ecosystem.slug}
|
||||
</p>
|
||||
<CopyButton text={`ecosystem.${ecosystem.slug}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href={`https://${ecosystem.slug}.ecosystem.thirdweb.com`}
|
||||
className="text-link-foreground"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<div className="flex flex-col gap-2">
|
||||
{!ecosystem.name ? (
|
||||
<Skeleton className="h-12 w-[225px]" />
|
||||
) : (
|
||||
<h2 className="font-semibold text-3xl text-foreground tracking-tight">
|
||||
{ecosystem.name}
|
||||
</h2>
|
||||
)}
|
||||
{!ecosystem.slug ? (
|
||||
<Skeleton className="h-6 w-[300px]" />
|
||||
) : (
|
||||
<div>
|
||||
<CopyTextButton
|
||||
textToCopy={`ecosystem.${ecosystem.slug}`}
|
||||
textToShow={`ecosystem.${ecosystem.slug}`}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Ecosytem slug"
|
||||
variant="ghost"
|
||||
className="-translate-x-2 px-2 py-0.5 text-muted-foreground"
|
||||
/>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="-translate-x-2 h-auto w-auto gap-2 rounded-xl px-2 py-0.5 text-muted-foreground"
|
||||
>
|
||||
{`${ecosystem.slug}.ecosystem.thirdweb.com`}
|
||||
</a>
|
||||
<Link
|
||||
href={`https://${ecosystem.slug}.ecosystem.thirdweb.com`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{`${ecosystem.slug}.ecosystem.thirdweb.com`}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 md:items-end">
|
||||
<EcosystemSelect
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={props.ecosystemLayoutPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 md:items-end">
|
||||
<EcosystemSelect
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={props.ecosystemLayoutPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<EcosystemWalletsSummary
|
||||
allTimeStats={props.allTimeStats}
|
||||
monthlyStats={props.monthlyStats}
|
||||
/>
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
name: "Overview",
|
||||
path: `${props.ecosystemLayoutPath}/${ecosystem.slug}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Analytics",
|
||||
path: `${props.ecosystemLayoutPath}/${ecosystem.slug}/analytics`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Configuration",
|
||||
path: `${props.ecosystemLayoutPath}/${ecosystem.slug}/configuration`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Design (Coming Soon)",
|
||||
path: `${props.ecosystemLayoutPath}/${ecosystem.slug}#`,
|
||||
isDisabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</header>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../../login/loginRedirect";
|
||||
import { EcosystemPermissionsPage } from "./components/client/EcosystemPermissionsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/~/ecosystem/${params.slug}/configuration`,
|
||||
);
|
||||
}
|
||||
|
||||
return <EcosystemPermissionsPage params={params} authToken={authToken} />;
|
||||
}
|
||||
+5
-1
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
|
||||
export default function Loading() {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
|
||||
+5
-15
@@ -3,22 +3,12 @@ import { EcosystemWalletPricingCard } from "./components/pricing-card";
|
||||
|
||||
export async function EcosystemCreatePage(props: { teamSlug: string }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6 md:mx-auto md:max-w-lg lg:max-w-4xl">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-3xl text-foreground tracking-tight">
|
||||
Create an Ecosystem
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create wallets that work across every chain and every app.
|
||||
</p>
|
||||
</header>
|
||||
<main className="grid w-full gap-6 lg:grid-cols-2">
|
||||
<section className="flex items-start">
|
||||
<EcosystemWalletPricingCard />
|
||||
</section>
|
||||
<section className="mb-12 lg:px-4">
|
||||
<div className="flex grow flex-col items-center justify-center">
|
||||
<main className="container max-w-5xl py-20">
|
||||
<div className="flex flex-col rounded-lg border bg-card lg:flex-row">
|
||||
<CreateEcosystemForm teamSlug={props.teamSlug} />
|
||||
</section>
|
||||
<EcosystemWalletPricingCard />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+147
-125
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -14,7 +15,7 @@ import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,138 +46,159 @@ export function CreateEcosystemForm(props: { teamSlug: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
const res = await createEcosystem({
|
||||
teamSlug: props.teamSlug,
|
||||
...values,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 401: {
|
||||
toast.error("Please login to create an ecosystem");
|
||||
break;
|
||||
}
|
||||
case 403:
|
||||
{
|
||||
toast.error(
|
||||
"You are not authorized to create an ecosystem, please ask your team owner to create it.",
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 409: {
|
||||
toast.error("An ecosystem with that name already exists.");
|
||||
break;
|
||||
}
|
||||
// any other status code treat as a random failure
|
||||
default: {
|
||||
toast.error(
|
||||
"Failed to create ecosystem, please try again later.",
|
||||
);
|
||||
}
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex grow flex-col"
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
const res = await createEcosystem({
|
||||
teamSlug: props.teamSlug,
|
||||
...values,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 401: {
|
||||
toast.error("Please login to create an ecosystem");
|
||||
break;
|
||||
}
|
||||
})}
|
||||
className="flex flex-col items-stretch gap-8"
|
||||
>
|
||||
<div className="grid gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Ecosystem Name</RequiredFormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input placeholder="Aperture Laboratories" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Ecosystem Logo</RequiredFormLabel>
|
||||
<FormControl>
|
||||
<ImageUpload
|
||||
accept="image/png, image/jpeg"
|
||||
onUpload={(files) => {
|
||||
if (files[0]) {
|
||||
form.setValue("logo", files[0]);
|
||||
form.clearErrors("logo");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The logo will be displayed as the ecosystem's wallet icon.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Integration permissions</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue="PARTNER_WHITELIST"
|
||||
className="flex flex-wrap gap-4 py-2"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItemButton
|
||||
value="PARTNER_WHITELIST"
|
||||
id="PARTNER_WHITELIST"
|
||||
>
|
||||
Allowlist
|
||||
</RadioGroupItemButton>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItemButton value="ANYONE" id="ANYONE">
|
||||
Public
|
||||
</RadioGroupItemButton>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Link
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/ecosystem-permissions"
|
||||
>
|
||||
Learn more about ecosystem permissions
|
||||
</Link>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
case 403: {
|
||||
toast.error(
|
||||
"You are not authorized to create an ecosystem, please ask your team owner to create it.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 409: {
|
||||
toast.error("An ecosystem with that name already exists.");
|
||||
break;
|
||||
}
|
||||
// any other status code treat as a random failure
|
||||
default: {
|
||||
toast.error(
|
||||
"Failed to create ecosystem, please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="grid gap-6 p-4 lg:p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Create Ecosystem
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create wallets that work across every chain and every app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Ecosystem Name</RequiredFormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Aperture Laboratories"
|
||||
{...field}
|
||||
className="bg-background"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<RequiredFormLabel>Ecosystem Logo</RequiredFormLabel>
|
||||
<FormControl>
|
||||
<ImageUpload
|
||||
className="bg-background"
|
||||
accept="image/png, image/jpeg"
|
||||
onUpload={(files) => {
|
||||
if (files[0]) {
|
||||
form.setValue("logo", files[0]);
|
||||
form.clearErrors("logo");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The logo will be displayed as the ecosystem's wallet icon.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Integration permissions</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue="PARTNER_WHITELIST"
|
||||
className="flex flex-wrap gap-4 py-2"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItemButton
|
||||
value="PARTNER_WHITELIST"
|
||||
id="PARTNER_WHITELIST"
|
||||
className="bg-background"
|
||||
>
|
||||
Allowlist
|
||||
</RadioGroupItemButton>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroupItemButton
|
||||
value="ANYONE"
|
||||
id="ANYONE"
|
||||
className="bg-background"
|
||||
>
|
||||
Public
|
||||
</RadioGroupItemButton>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Link
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/ecosystem-permissions"
|
||||
>
|
||||
Learn more about ecosystem permissions
|
||||
</Link>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
<div className="mt-auto flex justify-end border-t p-4 lg:p-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
className="gap-2"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting && (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
Create Ecosystem
|
||||
{form.formState.isSubmitting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowRightIcon className="size-4" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-11
@@ -7,42 +7,44 @@ export function EcosystemWalletPricingCard(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-background shadow",
|
||||
"flex max-w-sm grow flex-col overflow-hidden border-t lg:border-t-0 lg:border-l",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Image src={headerImage} alt="" sizes="50vw" className="w-full" />
|
||||
<div className="relative border-border border-t p-4 pb-8 md:p-6 md:pb-8">
|
||||
<h4 className="font-bold text-4xl text-foreground">
|
||||
|
||||
<div className="relative flex grow flex-col border-border border-t p-4 lg:p-6">
|
||||
<h4 className="flex items-center gap-2 font-semibold text-4xl text-foreground tracking-tight">
|
||||
$250{" "}
|
||||
<span className="font-normal text-lg text-muted-foreground">
|
||||
per month
|
||||
</span>
|
||||
</h4>
|
||||
<ul className="mt-6 flex flex-col gap-4 text-muted-foreground text-sm md:text-base">
|
||||
|
||||
<ul className="mt-5 flex flex-col gap-3 text-muted-foreground text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 md:mt-0.5" /> Share
|
||||
<CheckIcon className="size-4 text-green-500 md:mt-0.5" /> Share
|
||||
assets across apps within your ecosystem
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 md:mt-0.5" /> Connect
|
||||
<CheckIcon className="size-4 text-green-500 md:mt-0.5" /> Connect
|
||||
with social, phone, email, or passkey
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 md:mt-0.5" /> A
|
||||
<CheckIcon className="size-4 text-green-500 md:mt-0.5" /> A
|
||||
standalone wallet UI for all your users
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 md:mt-0.5" /> Custom
|
||||
<CheckIcon className="size-4 text-green-500 md:mt-0.5" /> Custom
|
||||
permissions and billing across your ecosystem
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 md:mt-0.5" /> 30,000
|
||||
<CheckIcon className="size-4 text-green-500 md:mt-0.5" /> 30,000
|
||||
active wallets included*
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-4 text-right text-muted-foreground text-xs italic">
|
||||
$0.02 per additional wallet
|
||||
<p className="mt-auto pt-5 text-right text-muted-foreground text-xs italic">
|
||||
* $0.02 per additional wallet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="container flex grow flex-col py-8">{props.children}</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
|
||||
export default function Loading() {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { BookMarkedIcon, ExternalLinkIcon, MoveRightIcon } from "lucide-react";
|
||||
import { cookies } from "next/headers";
|
||||
import {} from "@/constants/cookie";
|
||||
import { ArrowRightIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import headerImage from "./assets/header.png";
|
||||
import { fetchEcosystemList } from "./utils/fetchEcosystemList";
|
||||
|
||||
@@ -14,97 +13,74 @@ export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const { team_slug } = await props.params;
|
||||
return (
|
||||
<EcosystemLandingPage
|
||||
ecosystemLayoutPath={`/team/${team_slug}/~/ecosystem`}
|
||||
/>
|
||||
);
|
||||
const ecosystemLayoutPath = `/team/${team_slug}/~/ecosystem`;
|
||||
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
|
||||
console.error("failed to fetch ecosystems", err);
|
||||
return [];
|
||||
});
|
||||
if (ecosystems[0]) {
|
||||
redirect(`${ecosystemLayoutPath}/${ecosystems[0].slug}`);
|
||||
}
|
||||
|
||||
return <EcosystemLandingPage ecosystemLayoutPath={ecosystemLayoutPath} />;
|
||||
}
|
||||
|
||||
async function EcosystemLandingPage(props: {
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const authToken = activeAccount
|
||||
? cookiesManager.get(`${COOKIE_PREFIX_TOKEN}${activeAccount}`)?.value
|
||||
: null;
|
||||
|
||||
// if user is logged in and has an ecosystem, redirect to the first ecosystem
|
||||
if (authToken) {
|
||||
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
|
||||
console.error("failed to fetch ecosystems", err);
|
||||
return [];
|
||||
});
|
||||
if (ecosystems[0]) {
|
||||
redirect(`${props.ecosystemLayoutPath}/${ecosystems[0].slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise we fall through to the page
|
||||
|
||||
return (
|
||||
<main className="container mx-auto flex max-w-2xl flex-col gap-8 pb-6">
|
||||
<Image
|
||||
src={headerImage}
|
||||
alt="Ecosystems"
|
||||
sizes="100vw"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-left">
|
||||
<h2 className="text-balance font-bold text-2xl text-foreground sm:text-3xl">
|
||||
One wallet, a whole ecosystem of apps and games
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
With Ecosystem Wallets, your users can access their assets across
|
||||
hundreds of apps and games within your ecosystem. You can control
|
||||
which apps join your ecosystem and how their users interact with your
|
||||
wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
|
||||
{!authToken ? (
|
||||
<ToolTipLabel label="Connect your wallet to create an ecosystem">
|
||||
<Button variant="primary" className="opacity-50">
|
||||
Create Ecosystem
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
) : (
|
||||
<Button variant="primary" asChild>
|
||||
<Link href={`${props.ecosystemLayoutPath}/create`}>
|
||||
Create Ecosystem
|
||||
<div className="container flex grow flex-col items-center justify-center py-20">
|
||||
{/* Card */}
|
||||
<div className="flex max-w-lg flex-col rounded-lg border bg-card">
|
||||
<Image
|
||||
src={headerImage}
|
||||
alt="Ecosystems"
|
||||
sizes="100vw"
|
||||
className="w-full border-b"
|
||||
/>
|
||||
|
||||
{/* body */}
|
||||
<div className="px-4 py-6 lg:px-6">
|
||||
<h2 className="mb-2 text-balance font-semibold text-2xl text-foreground leading-tight tracking-tighter">
|
||||
One wallet, a whole ecosystem of apps and games
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
With Ecosystem Wallets, your users can access their assets across
|
||||
hundreds of apps and games within your ecosystem. You can control
|
||||
which apps join your ecosystem and how their users interact with
|
||||
your wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex flex-col justify-end gap-4 border-t px-4 py-6 lg:flex-row lg:px-6">
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
target="_blank"
|
||||
className="flex flex-row gap-2"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
target="_blank"
|
||||
className="flex flex-row gap-2"
|
||||
>
|
||||
Read The Docs
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild className="gap-2">
|
||||
<Link href={`${props.ecosystemLayoutPath}/create`}>
|
||||
Create Ecosystem
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2 h-0 w-full border-t" />
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
className="group mb-6"
|
||||
>
|
||||
<Card className="flex flex-col gap-3 p-6 md:p-8 md:px-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookMarkedIcon className="size-5 text-muted-foreground" />
|
||||
<h4 className="font-bold text-lg">Learn more</h4>
|
||||
</div>
|
||||
<p className="text-md text-muted-foreground">
|
||||
Learn how to create and manage Ecosystem Wallets in our docs.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center gap-2 text-md text-primary transition-all group-hover:gap-3 group-hover:text-primary/80">
|
||||
View Docs <MoveRightIcon className="size-4" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-page";
|
||||
@@ -20,8 +19,7 @@ export default async function Page(props: {
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const [account, authToken, team] = await Promise.all([
|
||||
getValidAccount(),
|
||||
const [authToken, team] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
@@ -35,7 +33,7 @@ export default async function Page(props: {
|
||||
if (hasNebulaAccess) {
|
||||
return (
|
||||
<NebulaAnalyticsPage
|
||||
accountId={account.id}
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
@@ -167,10 +167,11 @@ export function TeamProjectsPage(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LazyCreateAPIKeyDialog
|
||||
<LazyCreateProjectDialog
|
||||
open={isCreateProjectDialogOpen}
|
||||
onOpenChange={setIsCreateProjectDialogOpen}
|
||||
teamSlug={props.team.slug}
|
||||
teamId={props.team.id}
|
||||
onCreateAndComplete={() => {
|
||||
// refresh projects
|
||||
router.refresh();
|
||||
|
||||
+8
-9
@@ -1,5 +1,5 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { SidebarLink } from "./SidebarLink";
|
||||
@@ -57,14 +57,13 @@ function RenderLinkGroup(props: {
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-4">
|
||||
<GradientAvatar
|
||||
src={props.titleAvatarIcon.src}
|
||||
className="size-[18px]"
|
||||
id={props.titleAvatarIcon.id}
|
||||
client={props.client}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{props.title}</p>
|
||||
<div className="px-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-card font-normal text-muted-foreground"
|
||||
>
|
||||
{props.title}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
|
||||
+6
-2
@@ -36,10 +36,14 @@ export function PlanInfoCard(props: {
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{validPlan} Plan
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Go to "Manage Billing" to view your invoices, update your payment
|
||||
method, or edit your billing details.
|
||||
</p>
|
||||
{trialEndsInFuture && <Badge>Trial</Badge>}
|
||||
</div>
|
||||
{trialEndsAfterDays > 0 && (
|
||||
@@ -53,7 +57,7 @@ export function PlanInfoCard(props: {
|
||||
{/* manage team billing */}
|
||||
<BillingPortalButton
|
||||
teamSlug={team.slug}
|
||||
variant="outline"
|
||||
variant="primary"
|
||||
redirectPath={`/team/${team.slug}/~/settings/billing`}
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function Page(props: {
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
return redirect("/team");
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+9
-2
@@ -48,6 +48,9 @@ function Story() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
client={getThirdwebClient()}
|
||||
leaveTeam={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
<ComponentVariants />
|
||||
<Toaster richColors />
|
||||
@@ -61,8 +64,12 @@ function ComponentVariants() {
|
||||
<div className="mx-auto max-w-[1100px]">
|
||||
<h2 className="mb-5 text-3xl"> Component variations </h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<LeaveTeamCard enabled={true} teamName="foo" />
|
||||
<LeaveTeamCard enabled={false} teamName="foo" />
|
||||
<LeaveTeamCard
|
||||
teamName="foo"
|
||||
leaveTeam={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
<DeleteTeamCard enabled={true} teamName="foo" />
|
||||
<DeleteTeamCard enabled={false} teamName="foo" />
|
||||
</div>
|
||||
|
||||
+14
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Team } from "@/api/team";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -10,6 +11,7 @@ import { updateTeam } from "./updateTeam";
|
||||
export function TeamGeneralSettingsPage(props: {
|
||||
team: Team;
|
||||
client: ThirdwebClient;
|
||||
accountId: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
|
||||
@@ -30,6 +32,18 @@ export function TeamGeneralSettingsPage(props: {
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
leaveTeam={async () => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/teams/${props.team.id}/members/${props.accountId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
router.replace("/team");
|
||||
}}
|
||||
updateTeamImage={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
|
||||
+16
-34
@@ -20,6 +20,7 @@ export function TeamGeneralSettingsPageUI(props: {
|
||||
updateTeamImage: (file: File | undefined) => Promise<void>;
|
||||
updateTeamField: UpdateTeamField;
|
||||
client: ThirdwebClient;
|
||||
leaveTeam: () => Promise<void>;
|
||||
}) {
|
||||
const hasPermissionToDelete = false; // TODO
|
||||
return (
|
||||
@@ -38,7 +39,7 @@ export function TeamGeneralSettingsPageUI(props: {
|
||||
client={props.client}
|
||||
/>
|
||||
<TeamIdCard team={props.team} />
|
||||
<LeaveTeamCard enabled={false} teamName={props.team.name} />
|
||||
<LeaveTeamCard teamName={props.team.name} leaveTeam={props.leaveTeam} />
|
||||
<DeleteTeamCard
|
||||
enabled={hasPermissionToDelete}
|
||||
teamName={props.team.name}
|
||||
@@ -156,12 +157,12 @@ function TeamSlugFormControl(props: {
|
||||
|
||||
function TeamAvatarFormControl(props: {
|
||||
updateTeamImage: (file: File | undefined) => Promise<void>;
|
||||
avatar: string | undefined;
|
||||
avatar: string | null;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const teamAvatarUrl = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: props.avatar,
|
||||
uri: props.avatar || undefined,
|
||||
});
|
||||
|
||||
const [teamAvatar, setTeamAvatar] = useState<File | undefined>();
|
||||
@@ -238,20 +239,15 @@ function TeamIdCard(props: {
|
||||
}
|
||||
|
||||
export function LeaveTeamCard(props: {
|
||||
enabled: boolean;
|
||||
teamName: string;
|
||||
leaveTeam: () => Promise<void>;
|
||||
}) {
|
||||
const title = "Leave Team";
|
||||
const description =
|
||||
"Revoke your access to this Team. Any resources you've added to the Team will remain.";
|
||||
|
||||
// TODO
|
||||
const leaveTeam = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Deleting team");
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
mutationFn: props.leaveTeam,
|
||||
});
|
||||
|
||||
function handleLeave() {
|
||||
@@ -262,32 +258,18 @@ export function LeaveTeamCard(props: {
|
||||
});
|
||||
}
|
||||
|
||||
if (props.enabled) {
|
||||
return (
|
||||
<DangerSettingCard
|
||||
title={title}
|
||||
description={description}
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleLeave}
|
||||
isPending={leaveTeam.isPending}
|
||||
confirmationDialog={{
|
||||
title: `Are you sure you want to leave team "${props.teamName}" ?`,
|
||||
description:
|
||||
"This will revoke your access to this Team. Any resources you've added to the Team will remain.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
header={{
|
||||
title,
|
||||
description,
|
||||
<DangerSettingCard
|
||||
title={title}
|
||||
description={description}
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleLeave}
|
||||
isPending={leaveTeam.isPending}
|
||||
confirmationDialog={{
|
||||
title: `Are you sure you want to leave team "${props.teamName}" ?`,
|
||||
description:
|
||||
"This will revoke your access to this Team. Any resources you've added to the Team will remain.",
|
||||
}}
|
||||
bottomText="To leave this Team, ensure at least one more Member has the Owner role."
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import type { TeamAccountRole } from "@/api/team-members";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {} from "@/components/ui/select";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { teamStub } from "../../../../../../../stories/stubs";
|
||||
import { mobileViewport } from "../../../../../../../stories/utils";
|
||||
import { InviteSection } from "./InviteSection";
|
||||
|
||||
const meta = {
|
||||
title: "Team/Settings/Members/InviteSection",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const TEAM_CONFIGS = [
|
||||
{ id: "free", label: "Free Team", team: teamStub("foo", "free") },
|
||||
{ id: "starter", label: "Starter Team", team: teamStub("foo", "starter") },
|
||||
{ id: "growth", label: "Growth Team", team: teamStub("bazz", "growth") },
|
||||
{ id: "pro", label: "Pro Team", team: teamStub("bar", "pro") },
|
||||
] as const;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
type InviteParams = Array<{ email: string; role: TeamAccountRole }>;
|
||||
|
||||
const INVITE_HANDLERS = {
|
||||
success: async (params: InviteParams) => {
|
||||
await sleep(500);
|
||||
return { results: params.map(() => "fulfilled" as const) };
|
||||
},
|
||||
failure: async (params: InviteParams) => {
|
||||
await sleep(500);
|
||||
return { results: params.map(() => "rejected" as const) };
|
||||
},
|
||||
mixed: async (params: InviteParams) => {
|
||||
await sleep(500);
|
||||
return {
|
||||
results: params.map((_, index) =>
|
||||
index % 2 === 0 ? ("fulfilled" as const) : ("rejected" as const),
|
||||
),
|
||||
};
|
||||
},
|
||||
} as const;
|
||||
|
||||
function RadioOption({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
}: { id: string; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={value} id={id} />
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Story() {
|
||||
const [selectedTeam, setSelectedTeam] = useState({
|
||||
id: "free",
|
||||
team: TEAM_CONFIGS[0].team,
|
||||
});
|
||||
const [hasEditPermission, setHasEditPermission] = useState("true");
|
||||
const [inviteResult, setInviteResult] =
|
||||
useState<keyof typeof INVITE_HANDLERS>("success");
|
||||
|
||||
const showPermissionControls =
|
||||
selectedTeam.id !== "free" && selectedTeam.id !== "pro";
|
||||
const showInviteControls =
|
||||
showPermissionControls && hasEditPermission === "true";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
|
||||
<div className="mb-8 flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-medium">Team Type</h3>
|
||||
<RadioGroup
|
||||
value={selectedTeam.id}
|
||||
onValueChange={(value: typeof selectedTeam.id) => {
|
||||
const config = TEAM_CONFIGS.find(({ id }) => id === value);
|
||||
if (config) setSelectedTeam({ id: value, team: config.team });
|
||||
}}
|
||||
className="flex gap-4"
|
||||
>
|
||||
{TEAM_CONFIGS.map(({ id, label }) => (
|
||||
<RadioOption key={id} id={id} label={label} value={id} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{showPermissionControls && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-medium">Edit Permission</h3>
|
||||
<RadioGroup
|
||||
value={hasEditPermission}
|
||||
onValueChange={setHasEditPermission}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<RadioOption
|
||||
id="permission-true"
|
||||
label="Has Permission"
|
||||
value="true"
|
||||
/>
|
||||
<RadioOption
|
||||
id="permission-false"
|
||||
label="No Permission"
|
||||
value="false"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInviteControls && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-medium">Invite Result</h3>
|
||||
<RadioGroup
|
||||
value={inviteResult}
|
||||
onValueChange={(value: typeof inviteResult) => {
|
||||
setInviteResult(value as keyof typeof INVITE_HANDLERS);
|
||||
}}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<RadioOption
|
||||
id="result-success"
|
||||
label="All Success"
|
||||
value="success"
|
||||
/>
|
||||
<RadioOption
|
||||
id="result-failure"
|
||||
label="All Failure"
|
||||
value="failure"
|
||||
/>
|
||||
<RadioOption
|
||||
id="result-mixed"
|
||||
label="Mixed Results"
|
||||
value="mixed"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10">
|
||||
<InviteSection
|
||||
team={selectedTeam.team}
|
||||
userHasEditPermission={hasEditPermission === "true"}
|
||||
inviteTeamMembers={INVITE_HANDLERS[inviteResult]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+273
-83
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamAccountRole } from "@/api/team-members";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -12,26 +19,75 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExternalLinkIcon, LinkIcon, UserPlus } from "lucide-react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ExternalLinkIcon, PlusIcon, Trash2Icon, UserPlus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan";
|
||||
|
||||
const inviteFormSchema = z.object({
|
||||
invites: z
|
||||
.array(
|
||||
z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: z.enum(["OWNER", "MEMBER"] as const),
|
||||
}),
|
||||
)
|
||||
.min(1, "No invites added"),
|
||||
});
|
||||
|
||||
type InviteFormValues = z.infer<typeof inviteFormSchema>;
|
||||
|
||||
export function InviteSection(props: {
|
||||
team: Team;
|
||||
userHasEditPermission: boolean;
|
||||
inviteTeamMembers: (
|
||||
params: Array<{
|
||||
email: string;
|
||||
role: TeamAccountRole;
|
||||
}>,
|
||||
) => Promise<{
|
||||
results: Array<"fulfilled" | "rejected">;
|
||||
}>;
|
||||
}) {
|
||||
const teamPlan = getValidTeamPlan(props.team);
|
||||
let bottomSection: React.ReactNode = null;
|
||||
const inviteEnabled = false; // teamPlan !== "free" && props.userHasEditPermission;
|
||||
const maxAllowedInvitesAtOnce = 10;
|
||||
// invites are enabled if user has edit permission and team plan is not "free"
|
||||
const inviteEnabled = teamPlan !== "free" && props.userHasEditPermission;
|
||||
|
||||
const form = useForm<InviteFormValues>({
|
||||
resolver: zodResolver(inviteFormSchema),
|
||||
defaultValues: {
|
||||
invites: [
|
||||
{
|
||||
email: "",
|
||||
role: "MEMBER",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const sendInvites = useMutation({
|
||||
mutationFn: async (data: InviteFormValues) => {
|
||||
const res = await props.inviteTeamMembers(data.invites);
|
||||
|
||||
return {
|
||||
inviteStatuses: res.results,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (teamPlan === "free") {
|
||||
bottomSection = (
|
||||
<div className="lg:px6 flex items-center justify-between gap-4 border-border border-t px-4 py-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This feature is not available on the Free Plan.{" "}
|
||||
This feature is not available on the {teamPlan} plan.{" "}
|
||||
<Link
|
||||
href="https://thirdweb.com/pricing"
|
||||
target="_blank"
|
||||
@@ -61,108 +117,242 @@ export function InviteSection(props: {
|
||||
);
|
||||
} else {
|
||||
bottomSection = (
|
||||
<div className="flex items-center border-border border-t px-4 py-4 lg:justify-end lg:px-6">
|
||||
<div className="flex items-center border-border border-t px-4 py-4 lg:justify-between lg:px-6">
|
||||
{teamPlan === "pro" && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Team members are billed according to your plan.{" "}
|
||||
<Link
|
||||
href="https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Reach out to sales <ExternalLinkIcon className="inline size-3" />.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(teamPlan === "starter" || teamPlan === "growth") && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Team members are billed according to your plan.{" "}
|
||||
<Link
|
||||
href="https://thirdweb.com/pricing"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
View pricing <ExternalLinkIcon className="inline size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 max-sm:w-full"
|
||||
disabled
|
||||
type="submit"
|
||||
disabled={sendInvites.isPending}
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
Invite
|
||||
{sendInvites.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
{form.watch("invites").length > 1 ? "Send Invites" : "Send Invite"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(data: InviteFormValues) {
|
||||
if (!inviteEnabled) return;
|
||||
sendInvites.mutate(data, {
|
||||
onSuccess(data) {
|
||||
const inviteStatuses = data.inviteStatuses;
|
||||
|
||||
const failedInvites = inviteStatuses.filter((r) => r === "rejected");
|
||||
const inviteOrInvites =
|
||||
data.inviteStatuses.length > 1 ? "invites" : "invite";
|
||||
|
||||
if (failedInvites.length > 0) {
|
||||
// all invites failed
|
||||
if (failedInvites.length === data.inviteStatuses.length) {
|
||||
toast.error(`Failed to send ${inviteOrInvites}`);
|
||||
}
|
||||
// some invites failed
|
||||
else {
|
||||
toast.error(
|
||||
`Failed to send ${failedInvites.length} of ${data.inviteStatuses.length} ${inviteOrInvites}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// all invites succeeded
|
||||
else {
|
||||
toast.success(
|
||||
`Successfully sent ${data.inviteStatuses.length === 1 ? "" : data.inviteStatuses.length} ${inviteOrInvites}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// when form updates - reset mutation result
|
||||
const subscription = form.watch(() => {
|
||||
sendInvites.reset();
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form.watch, sendInvites.reset]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 font-semibold text-2xl tracking-tight">Invite</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<section>
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-lg tracking-tight">Invite</h2>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
{/* Invite via Link */}
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-4 lg:px-6",
|
||||
!inviteEnabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-3 lg:flex-row lg:items-center">
|
||||
<p className="text-foreground text-sm">
|
||||
Invite new members via email or link
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 max-sm:bg-card"
|
||||
disabled={!inviteEnabled}
|
||||
>
|
||||
<LinkIcon className="size-3" />
|
||||
Copy Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 px-4 lg:my-0 lg:px-6">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
{/* Invite via Email Send */}
|
||||
<div className="px-4 py-6 lg:px-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className={cn(
|
||||
"mb-1 block text-sm",
|
||||
!inviteEnabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="[email protected]"
|
||||
disabled={!inviteEnabled}
|
||||
className="disabled:bg-muted"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Invite new members to your team by email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="role"
|
||||
className={cn(
|
||||
"mb-1 block text-sm",
|
||||
!inviteEnabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Role
|
||||
</Label>
|
||||
<RoleSelector disabled={!inviteEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-6 lg:px-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{form.watch("invites").map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<div key={index} className="flex items-start gap-4">
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invites.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel
|
||||
className={cn(
|
||||
!inviteEnabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
disabled={!inviteEnabled}
|
||||
className="disabled:bg-muted"
|
||||
/>
|
||||
</FormControl>
|
||||
{sendInvites.data && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-sm leading-none",
|
||||
sendInvites.data.inviteStatuses[index] ===
|
||||
"rejected" && "text-destructive-text",
|
||||
sendInvites.data.inviteStatuses[index] ===
|
||||
"fulfilled" && "text-success-text",
|
||||
)}
|
||||
>
|
||||
{sendInvites.data.inviteStatuses[index] ===
|
||||
"rejected"
|
||||
? "Failed to send invite"
|
||||
: "Invite sent"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bottomSection}
|
||||
</div>
|
||||
</section>
|
||||
{!sendInvites.data && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invites.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
!inviteEnabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Role
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RoleSelector
|
||||
disabled={!inviteEnabled}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.watch("invites").length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-8 bg-background text-destructive-text disabled:cursor-not-allowed disabled:opacity-100"
|
||||
onClick={() => {
|
||||
const currentInvites = form.getValues("invites");
|
||||
form.setValue(
|
||||
"invites",
|
||||
currentInvites.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 gap-2"
|
||||
disabled={
|
||||
!inviteEnabled ||
|
||||
form.watch("invites").length >= maxAllowedInvitesAtOnce
|
||||
}
|
||||
onClick={() => {
|
||||
const currentInvites = form.watch("invites");
|
||||
form.setValue("invites", [
|
||||
...currentInvites,
|
||||
{ email: "", role: "MEMBER" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Another
|
||||
</Button>
|
||||
</div>
|
||||
{bottomSection}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleSelector(props: {
|
||||
disabled?: boolean;
|
||||
value: TeamAccountRole;
|
||||
onChange: (v: TeamAccountRole) => void;
|
||||
}) {
|
||||
const roles: TeamAccountRole[] = ["OWNER", "MEMBER"];
|
||||
const [role, setRole] = useState<TeamAccountRole>("MEMBER");
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
value={props.value}
|
||||
onValueChange={(v) => {
|
||||
setRole(v as TeamAccountRole);
|
||||
props.onChange(v as TeamAccountRole);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import type { TeamInvite } from "@/api/team-invites";
|
||||
import type { TeamAccountRole } from "@/api/team-members";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { teamStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
BadgeContainer,
|
||||
mobileViewport,
|
||||
} from "../../../../../../../stories/utils";
|
||||
import { ManageInvitesSection } from "./ManageInvitesSection";
|
||||
|
||||
const meta = {
|
||||
title: "Team/Settings/Members/Invites",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const freeTeam = teamStub("foo", "free");
|
||||
|
||||
function createInviteStub(
|
||||
email: string,
|
||||
role: TeamAccountRole,
|
||||
createdDays: number,
|
||||
status: TeamInvite["status"],
|
||||
): TeamInvite {
|
||||
const createdAt = new Date();
|
||||
createdAt.setDate(createdAt.getDate() - createdDays);
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 10);
|
||||
|
||||
const invite: TeamInvite = {
|
||||
id: `invite-id-${email}`,
|
||||
email,
|
||||
role,
|
||||
createdAt: createdAt.toISOString(),
|
||||
status,
|
||||
teamId: "team-id-foo",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
|
||||
return invite;
|
||||
}
|
||||
|
||||
const invitesStub: TeamInvite[] = [
|
||||
createInviteStub("[email protected]", "OWNER", 1, "pending"),
|
||||
createInviteStub("[email protected]", "MEMBER", 3, "expired"),
|
||||
createInviteStub("[email protected]", "OWNER", 2, "pending"),
|
||||
];
|
||||
|
||||
const deleteInviteStub = async (inviteId: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("deleted", inviteId);
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
|
||||
<div className="flex flex-col gap-10">
|
||||
<BadgeContainer label="Has permission">
|
||||
<ManageInvitesSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={true}
|
||||
teamInvites={invitesStub}
|
||||
client={getThirdwebClient()}
|
||||
deleteInvite={deleteInviteStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No permission">
|
||||
<ManageInvitesSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={false}
|
||||
teamInvites={invitesStub}
|
||||
client={getThirdwebClient()}
|
||||
deleteInvite={deleteInviteStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamInvite } from "@/api/team-invites";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDate } from "date-fns";
|
||||
import { EllipsisIcon, MailIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
FiltersSection,
|
||||
type MemberSortId,
|
||||
type RoleFilterValue,
|
||||
} from "./_common";
|
||||
|
||||
export function ManageInvitesSection(props: {
|
||||
team: Team;
|
||||
userHasEditPermission: boolean;
|
||||
teamInvites: TeamInvite[];
|
||||
client: ThirdwebClient;
|
||||
deleteInvite: (inviteId: string) => Promise<void>;
|
||||
}) {
|
||||
let topSection: React.ReactNode = null;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deletedInviteIds, setDeletedInviteIds] = useState<string[]>([]);
|
||||
const [role, setRole] = useState<RoleFilterValue>("ALL ROLES");
|
||||
const [sortBy, setSortBy] = useState<MemberSortId>("date");
|
||||
|
||||
const invitesToShow = useMemo(() => {
|
||||
let value = props.teamInvites;
|
||||
|
||||
if (searchTerm) {
|
||||
value = value.filter((inv) =>
|
||||
inv.email.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
value = value.filter((inv) => !deletedInviteIds.includes(inv.id));
|
||||
|
||||
if (role !== "ALL ROLES") {
|
||||
value = value.filter((m) => m.role === role);
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case "date":
|
||||
value = value.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
break;
|
||||
case "a-z":
|
||||
value = value.sort((a, b) => a.email.localeCompare(b.email));
|
||||
break;
|
||||
case "z-a":
|
||||
value = value.sort((a, b) => b.email.localeCompare(a.email));
|
||||
break;
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [role, props.teamInvites, sortBy, deletedInviteIds, searchTerm]);
|
||||
|
||||
if (!props.userHasEditPermission) {
|
||||
topSection = (
|
||||
<div className="border-border border-b p-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You don't have permission to manage invites
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<FiltersSection
|
||||
// don't use invitesToShow here
|
||||
disabled={props.teamInvites.length === 0}
|
||||
role={role}
|
||||
setRole={setRole}
|
||||
setSortBy={setSortBy}
|
||||
sortBy={sortBy}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
searchPlaceholder="Search Email"
|
||||
/>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
{/* Top section */}
|
||||
{topSection}
|
||||
|
||||
{invitesToShow.length > 0 && (
|
||||
<ul>
|
||||
{invitesToShow.map((invite) => {
|
||||
return (
|
||||
<li
|
||||
key={invite.id}
|
||||
className="border-border border-b last:border-b-0"
|
||||
>
|
||||
<InviteRow
|
||||
invite={invite}
|
||||
client={props.client}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
deleteInvite={props.deleteInvite}
|
||||
onInviteDeleted={() => {
|
||||
setDeletedInviteIds([...deletedInviteIds, invite.id]);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{invitesToShow.length === 0 && (
|
||||
<div className="flex justify-center px-4 py-20">
|
||||
<p className="text-muted-foreground text-sm">No Invites</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteRow(props: {
|
||||
invite: TeamInvite;
|
||||
userHasEditPermission: boolean;
|
||||
client: ThirdwebClient;
|
||||
deleteInvite: (inviteId: string) => Promise<void>;
|
||||
onInviteDeleted: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col justify-between gap-3 px-4 py-4 lg:flex-row lg:items-center">
|
||||
<div className="flex items-center gap-3 lg:gap-4">
|
||||
{/* Mail Icon */}
|
||||
<div className="hidden size-6 items-center justify-center rounded-full border bg-background lg:flex lg:size-9">
|
||||
<MailIcon className="size-3 text-muted-foreground lg:size-4" />
|
||||
</div>
|
||||
|
||||
{/* Email + Timestamps */}
|
||||
<div>
|
||||
<p className="mb-2 font-semibold text-sm lg:mb-1">
|
||||
{props.invite.email}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 text-muted-foreground text-xs lg:flex-row lg:items-center lg:gap-2">
|
||||
<span>
|
||||
Invited on {formatDate(props.invite.createdAt, "MMM d, yyyy")}
|
||||
</span>
|
||||
<div className="hidden size-1 rounded-full bg-muted-foreground/50 lg:block" />
|
||||
<span>
|
||||
Invitation expires on{" "}
|
||||
{formatDate(props.invite.expiresAt, "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 lg:justify-start lg:gap-6">
|
||||
{/* Role */}
|
||||
<p className="text-muted-foreground text-xs capitalize lg:text-sm">
|
||||
{props.invite.role.toLowerCase()}
|
||||
</p>
|
||||
|
||||
{/* Status */}
|
||||
<Badge
|
||||
className="absolute top-4 right-4 capitalize lg:static lg:px-3 lg:py-1.5"
|
||||
variant={
|
||||
props.invite.status === "pending"
|
||||
? "outline"
|
||||
: props.invite.status === "accepted"
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{props.invite.status}
|
||||
</Badge>
|
||||
|
||||
{/* Options */}
|
||||
{props.userHasEditPermission && (
|
||||
<ManageInviteButton
|
||||
invite={props.invite}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
deleteInvite={props.deleteInvite}
|
||||
onInviteDeleted={props.onInviteDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageInviteButton(props: {
|
||||
invite: TeamInvite;
|
||||
userHasEditPermission: boolean;
|
||||
deleteInvite: (inviteId: string) => Promise<void>;
|
||||
onInviteDeleted: () => void;
|
||||
}) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => props.deleteInvite(props.invite.id),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="!h-auto !w-auto p-1.5"
|
||||
disabled={!props.userHasEditPermission}
|
||||
>
|
||||
<EllipsisIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Delete Invite
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle className="text-xl">Delete Invite</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this invite?
|
||||
<span className="mt-1 block text-muted-foreground">
|
||||
The invite link sent to {props.invite.email} will no longer work
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="gap-2 bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
const promise = deleteMutation.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Invite deleted successfully",
|
||||
error: "Failed to delete invite",
|
||||
});
|
||||
promise.then(() => {
|
||||
setShowDeleteDialog(false);
|
||||
props.onInviteDeleted();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending && <Spinner className="size-4" />}
|
||||
Delete Invite
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+152
-166
@@ -2,19 +2,28 @@
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamAccountRole, TeamMember } from "@/api/team-members";
|
||||
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EllipsisIcon, SearchIcon } from "lucide-react";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { EllipsisIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { FiltersSection, type MemberSortId } from "./_common";
|
||||
|
||||
type RoleFilterValue = "ALL ROLES" | TeamAccountRole;
|
||||
|
||||
@@ -22,14 +31,27 @@ export function ManageMembersSection(props: {
|
||||
team: Team;
|
||||
userHasEditPermission: boolean;
|
||||
members: TeamMember[];
|
||||
client: ThirdwebClient;
|
||||
deleteMember: (memberId: string) => Promise<void>;
|
||||
}) {
|
||||
let topSection: React.ReactNode = null;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deletedMembersIds, setDeletedMembersIds] = useState<string[]>([]);
|
||||
const [role, setRole] = useState<RoleFilterValue>("ALL ROLES");
|
||||
const [sortBy, setSortBy] = useState<MemberSortId>("date");
|
||||
|
||||
const membersToShow = useMemo(() => {
|
||||
let value = props.members;
|
||||
|
||||
if (searchTerm) {
|
||||
value = value.filter((m) =>
|
||||
m.account.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
value = value.filter((m) => !deletedMembersIds.includes(m.accountId));
|
||||
|
||||
if (role !== "ALL ROLES") {
|
||||
value = value.filter((m) => m.role === role);
|
||||
}
|
||||
@@ -37,7 +59,8 @@ export function ManageMembersSection(props: {
|
||||
switch (sortBy) {
|
||||
case "date":
|
||||
value = value.sort(
|
||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
break;
|
||||
case "a-z":
|
||||
@@ -53,7 +76,7 @@ export function ManageMembersSection(props: {
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [role, props.members, sortBy]);
|
||||
}, [role, props.members, sortBy, deletedMembersIds, searchTerm]);
|
||||
|
||||
if (!props.userHasEditPermission) {
|
||||
topSection = (
|
||||
@@ -63,43 +86,10 @@ export function ManageMembersSection(props: {
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
topSection = (
|
||||
<div className="flex items-center justify-between border-border border-b p-4">
|
||||
<div className="flex items-center gap-3 lg:gap-4">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
className="border-muted-foreground data-[state=checked]:border-inverted"
|
||||
disabled={
|
||||
!props.userHasEditPermission || membersToShow.length === 0
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="select-all"
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Select All ({membersToShow.length})
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="!h-auto !w-auto p-1.5"
|
||||
disabled={!props.userHasEditPermission || membersToShow.length === 0}
|
||||
>
|
||||
<EllipsisIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Team Members</h2>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
<FiltersSection
|
||||
// don't use membersToShow here
|
||||
disabled={props.members.length === 0}
|
||||
@@ -107,9 +97,12 @@ export function ManageMembersSection(props: {
|
||||
setRole={setRole}
|
||||
setSortBy={setSortBy}
|
||||
sortBy={sortBy}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
searchPlaceholder="Search Team Members"
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
<div className="h-3" />
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
@@ -127,6 +120,14 @@ export function ManageMembersSection(props: {
|
||||
<MemberRow
|
||||
member={member}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
client={props.client}
|
||||
deleteMember={props.deleteMember}
|
||||
onMemberDeleted={() => {
|
||||
setDeletedMembersIds([
|
||||
...deletedMembersIds,
|
||||
member.accountId,
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -148,27 +149,30 @@ export function ManageMembersSection(props: {
|
||||
function MemberRow(props: {
|
||||
member: TeamMember;
|
||||
userHasEditPermission: boolean;
|
||||
client: ThirdwebClient;
|
||||
deleteMember: (memberId: string) => Promise<void>;
|
||||
onMemberDeleted: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-4">
|
||||
<div className="flex items-center gap-3 lg:gap-4">
|
||||
{/* Checkbox */}
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
className="border-muted-foreground data-[state=checked]:border-inverted"
|
||||
disabled={!props.userHasEditPermission}
|
||||
<GradientAvatar
|
||||
className="size-6 border lg:size-9"
|
||||
src={props.member.account.image || ""}
|
||||
id={props.member.account.creatorWalletAddress}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
{/* PFP */}
|
||||
<div className="size-8 rounded-full border border-border bg-muted lg:size-9" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-semibold text-sm">
|
||||
{props.member.account.name || props.member.account.email}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-0.5 font-semibold text-sm">
|
||||
{props.member.account.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm opacity-70">
|
||||
{props.member.account.email}
|
||||
</p>
|
||||
{props.member.account.name && (
|
||||
<p className="text-muted-foreground text-xs lg:text-sm">
|
||||
{props.member.account.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,119 +181,101 @@ function MemberRow(props: {
|
||||
{props.member.role.toLowerCase()}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="!h-auto !w-auto p-1.5"
|
||||
disabled={!props.userHasEditPermission}
|
||||
>
|
||||
<EllipsisIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
{props.userHasEditPermission && (
|
||||
<ManageMemberButton
|
||||
member={props.member}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
deleteMember={props.deleteMember}
|
||||
onMemberDeleted={props.onMemberDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersSection(props: {
|
||||
disabled: boolean;
|
||||
role: RoleFilterValue;
|
||||
setRole: (role: RoleFilterValue) => void;
|
||||
setSortBy: (sortBy: MemberSortId) => void;
|
||||
sortBy: MemberSortId;
|
||||
function ManageMemberButton(props: {
|
||||
member: TeamMember;
|
||||
userHasEditPermission: boolean;
|
||||
deleteMember: (memberId: string) => Promise<void>;
|
||||
onMemberDeleted: () => void;
|
||||
}) {
|
||||
const { role, setRole, setSortBy, sortBy } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||
{/* Search */}
|
||||
<div className="relative grow">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 transform text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search Team members"
|
||||
className="bg-card pl-9"
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</div>
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
<div className="grid grid-cols-2 items-center gap-3 lg:flex">
|
||||
<RoleSelector disabled={props.disabled} role={role} setRole={setRole} />
|
||||
<SortMembersBy
|
||||
disabled={props.disabled}
|
||||
setSortBy={setSortBy}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MemberSortId = "date" | "a-z" | "z-a";
|
||||
|
||||
function SortMembersBy(props: {
|
||||
disabled?: boolean;
|
||||
setSortBy: (sortBy: MemberSortId) => void;
|
||||
sortBy: MemberSortId;
|
||||
}) {
|
||||
const { sortBy, setSortBy } = props;
|
||||
const valueToLabel: Record<MemberSortId, string> = {
|
||||
date: "Date",
|
||||
"a-z": "Name (A-Z)",
|
||||
"z-a": "Name (Z-A)",
|
||||
};
|
||||
|
||||
const sortByIds: MemberSortId[] = ["date", "a-z", "z-a"];
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => props.deleteMember(props.member.accountId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => {
|
||||
setSortBy(v as "date" | "a-z" | "z-a");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-card capitalize disabled:bg-muted lg:w-[150px]"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortByIds.map((id) => (
|
||||
<SelectItem key={id} value={id} className="capitalize">
|
||||
{valueToLabel[id]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleSelector(props: {
|
||||
disabled?: boolean;
|
||||
role: RoleFilterValue;
|
||||
setRole: (role: RoleFilterValue) => void;
|
||||
}) {
|
||||
const { role, setRole } = props;
|
||||
const roles: RoleFilterValue[] = ["OWNER", "MEMBER", "ALL ROLES"];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(v) => {
|
||||
setRole(v as RoleFilterValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-card capitalize disabled:bg-muted lg:w-[150px]"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role} value={role} className="capitalize">
|
||||
{role.toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="!h-auto !w-auto p-1.5"
|
||||
disabled={!props.userHasEditPermission}
|
||||
>
|
||||
<EllipsisIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Remove Member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="p-6">
|
||||
<DialogTitle className="text-xl">Remove Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove{" "}
|
||||
<span
|
||||
className={
|
||||
props.member.account.name
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{props.member.account.name || "this member"}
|
||||
</span>{" "}
|
||||
from the team?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="gap-2 bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
const promise = deleteMutation.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Member deleted successfully",
|
||||
error: "Failed to delete member",
|
||||
});
|
||||
promise.then(() => {
|
||||
setShowDeleteDialog(false);
|
||||
props.onMemberDeleted();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending && <Spinner className="size-4" />}
|
||||
Remove Member
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+52
-68
@@ -1,17 +1,16 @@
|
||||
import type { TeamAccountRole, TeamMember } from "@/api/team-members";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { teamStub } from "../../../../../../../stories/stubs";
|
||||
import {
|
||||
BadgeContainer,
|
||||
mobileViewport,
|
||||
} from "../../../../../../../stories/utils";
|
||||
import { InviteSection } from "./InviteSection";
|
||||
import { ManageMembersSection } from "./ManageMembersSection";
|
||||
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
|
||||
|
||||
const meta = {
|
||||
title: "Team/Settings/Members",
|
||||
title: "Team/Settings/Members/Manage",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
@@ -35,11 +34,10 @@ export const Mobile: Story = {
|
||||
};
|
||||
|
||||
const freeTeam = teamStub("foo", "free");
|
||||
const proTeam = teamStub("bar", "pro");
|
||||
const growthTeam = teamStub("bazz", "growth");
|
||||
|
||||
function createMemberStub(
|
||||
id: string,
|
||||
name: string,
|
||||
role: TeamAccountRole,
|
||||
createdHours: number,
|
||||
): TeamMember {
|
||||
@@ -49,88 +47,74 @@ function createMemberStub(
|
||||
const member: TeamMember = {
|
||||
account: {
|
||||
email: `user-${id}@foo.com`,
|
||||
name: id,
|
||||
name: name,
|
||||
creatorWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
image: null,
|
||||
},
|
||||
accountId: `account-id-${id}`,
|
||||
createdAt: date,
|
||||
createdAt: date.toISOString(),
|
||||
deletedAt: null,
|
||||
role: role,
|
||||
teamId: "team-id-foo-bar",
|
||||
updatedAt: new Date(),
|
||||
updatedAt: date.toISOString(),
|
||||
};
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
const membersStub: TeamMember[] = [
|
||||
createMemberStub("first-member", "OWNER", 1),
|
||||
createMemberStub("third-member", "MEMBER", 3),
|
||||
createMemberStub("second-member", "OWNER", 2),
|
||||
createMemberStub("first-member", "First Member", "OWNER", 1),
|
||||
createMemberStub("third-member", "Third Member", "MEMBER", 3),
|
||||
createMemberStub("second-member", "Second Member", "OWNER", 2),
|
||||
];
|
||||
|
||||
const membersStubNoName: TeamMember[] = [
|
||||
createMemberStub("first-member", "", "OWNER", 1),
|
||||
createMemberStub("third-member", "", "MEMBER", 3),
|
||||
createMemberStub("second-member", "", "OWNER", 2),
|
||||
];
|
||||
|
||||
const deleteMemberStub = async (memberId: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("deleted", memberId);
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
|
||||
<TeamMembersSettingsPage
|
||||
team={proTeam}
|
||||
userHasEditPermission={true}
|
||||
members={membersStub}
|
||||
/>
|
||||
<div className="flex flex-col gap-10">
|
||||
<BadgeContainer label="Has permission">
|
||||
<ManageMembersSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={true}
|
||||
members={membersStub}
|
||||
client={getThirdwebClient()}
|
||||
deleteMember={deleteMemberStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<CompVariants />
|
||||
<BadgeContainer label="No permission">
|
||||
<ManageMembersSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={false}
|
||||
members={membersStub}
|
||||
client={getThirdwebClient()}
|
||||
deleteMember={deleteMemberStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Has permission, Members dont have names">
|
||||
<ManageMembersSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={true}
|
||||
members={membersStubNoName}
|
||||
client={getThirdwebClient()}
|
||||
deleteMember={deleteMemberStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompVariants() {
|
||||
return (
|
||||
<div className="mt-20 border-border border-t py-10">
|
||||
<div className="">
|
||||
<h2 className="py-4 font-semibold text-3xl"> Invite Variants </h2>
|
||||
|
||||
{/* Invite */}
|
||||
<div className="flex flex-col gap-10">
|
||||
<BadgeContainer label="Free Team">
|
||||
<InviteSection team={freeTeam} userHasEditPermission={false} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Pro Team, User does not have permission">
|
||||
<InviteSection team={proTeam} userHasEditPermission={false} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Pro, User has permission">
|
||||
<InviteSection team={proTeam} userHasEditPermission={true} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Growth, User has permission">
|
||||
<InviteSection team={growthTeam} userHasEditPermission={true} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
<div className="my-10" />
|
||||
|
||||
<h2 className="py-4 font-semibold text-3xl">Team Members Variants</h2>
|
||||
|
||||
<div className="flex flex-col gap-10">
|
||||
<BadgeContainer label="Has permission">
|
||||
<ManageMembersSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={true}
|
||||
members={membersStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No permission">
|
||||
<ManageMembersSection
|
||||
team={freeTeam}
|
||||
userHasEditPermission={false}
|
||||
members={membersStub}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+99
-16
@@ -1,38 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { sendTeamInvites } from "@/actions/sendTeamInvite";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamInvite } from "@/api/team-invites";
|
||||
import type { TeamMember } from "@/api/team-members";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { InviteSection } from "./InviteSection";
|
||||
import { ManageInvitesSection } from "./ManageInvitesSection";
|
||||
import { ManageMembersSection } from "./ManageMembersSection";
|
||||
|
||||
export function TeamMembersSettingsPage(props: {
|
||||
team: Team;
|
||||
userHasEditPermission: boolean;
|
||||
members: TeamMember[];
|
||||
client: ThirdwebClient;
|
||||
teamInvites: TeamInvite[];
|
||||
}) {
|
||||
const [manageTab, setManageTab] = useState<"members" | "invites">("members");
|
||||
const router = useDashboardRouter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert variant="info">
|
||||
<AlertCircleIcon className="size-5 text-red-400" />
|
||||
<AlertTitle>
|
||||
Inviting and Managing Team Members is not available yet
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
This feature will be available in Q1 2025
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="h-10" />
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Members</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage team members and invitations
|
||||
</p>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
<InviteSection
|
||||
team={props.team}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
inviteTeamMembers={async (params) => {
|
||||
const res = await sendTeamInvites({
|
||||
teamId: props.team.id,
|
||||
invites: params,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.errorMessage);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
|
||||
return {
|
||||
results: res.results,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="h-10" />
|
||||
<ManageMembersSection
|
||||
team={props.team}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
members={props.members}
|
||||
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
isActive: manageTab === "members",
|
||||
name: "Team Members",
|
||||
onClick: () => setManageTab("members"),
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
isActive: manageTab === "invites",
|
||||
name: "Pending Invites",
|
||||
onClick: () => setManageTab("invites"),
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
{manageTab === "members" && (
|
||||
<ManageMembersSection
|
||||
team={props.team}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
members={props.members}
|
||||
client={props.client}
|
||||
deleteMember={async (memberAccountId) => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/teams/${props.team.id}/members/${memberAccountId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{manageTab === "invites" && (
|
||||
<ManageInvitesSection
|
||||
team={props.team}
|
||||
userHasEditPermission={props.userHasEditPermission}
|
||||
client={props.client}
|
||||
deleteInvite={async (inviteId) => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/teams/${props.team.id}/invites/${inviteId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
}}
|
||||
teamInvites={props.teamInvites}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import type { TeamAccountRole } from "@/api/team-members";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
export type RoleFilterValue = "ALL ROLES" | TeamAccountRole;
|
||||
export type MemberSortId = "date" | "a-z" | "z-a";
|
||||
|
||||
export function FiltersSection(props: {
|
||||
disabled: boolean;
|
||||
role: RoleFilterValue;
|
||||
setRole: (role: RoleFilterValue) => void;
|
||||
setSortBy: (sortBy: MemberSortId) => void;
|
||||
sortBy: MemberSortId;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (searchTerm: string) => void;
|
||||
searchPlaceholder: string;
|
||||
}) {
|
||||
const { role, setRole, setSortBy, sortBy, searchTerm, setSearchTerm } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
{/* Search */}
|
||||
<div className="relative grow">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 transform text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={props.searchPlaceholder}
|
||||
className="bg-card pl-9"
|
||||
disabled={props.disabled}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 items-center gap-3 lg:flex">
|
||||
<RoleSelector disabled={props.disabled} role={role} setRole={setRole} />
|
||||
<SortMembersBy
|
||||
disabled={props.disabled}
|
||||
setSortBy={setSortBy}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortMembersBy(props: {
|
||||
disabled?: boolean;
|
||||
setSortBy: (sortBy: MemberSortId) => void;
|
||||
sortBy: MemberSortId;
|
||||
}) {
|
||||
const { sortBy, setSortBy } = props;
|
||||
const valueToLabel: Record<MemberSortId, string> = {
|
||||
date: "Date",
|
||||
"a-z": "Name (A-Z)",
|
||||
"z-a": "Name (Z-A)",
|
||||
};
|
||||
|
||||
const sortByIds: MemberSortId[] = ["date", "a-z", "z-a"];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => {
|
||||
setSortBy(v as "date" | "a-z" | "z-a");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-card capitalize disabled:bg-muted lg:w-[150px]"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortByIds.map((id) => (
|
||||
<SelectItem key={id} value={id} className="capitalize">
|
||||
{valueToLabel[id]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleSelector(props: {
|
||||
disabled?: boolean;
|
||||
role: RoleFilterValue;
|
||||
setRole: (role: RoleFilterValue) => void;
|
||||
}) {
|
||||
const { role, setRole } = props;
|
||||
const roles: RoleFilterValue[] = ["OWNER", "MEMBER", "ALL ROLES"];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(v) => {
|
||||
setRole(v as RoleFilterValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-card capitalize disabled:bg-muted lg:w-[150px]"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role} value={role} className="capitalize">
|
||||
{role.toLowerCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getTeamInvites } from "@/api/team-invites";
|
||||
import { getMembers } from "@/api/team-members";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
|
||||
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
|
||||
|
||||
export default async function Page(props: {
|
||||
@@ -10,13 +13,23 @@ export default async function Page(props: {
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const pagePath = `/team/${params.team_slug}/~/settings/members`;
|
||||
|
||||
const [account, team, members] = await Promise.all([
|
||||
getValidAccount(`/team/${params.team_slug}/~/settings/members`),
|
||||
const [authToken, account, team, members, teamInvites] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getValidAccount(pagePath),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getMembers(params.team_slug),
|
||||
getTeamInvites(params.team_slug, {
|
||||
count: 100,
|
||||
start: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
redirect(pagePath);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
@@ -33,11 +46,17 @@ export default async function Page(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pendingOrExpiredInvites = teamInvites.filter(
|
||||
(invite) => invite.status === "pending" || invite.status === "expired",
|
||||
);
|
||||
|
||||
return (
|
||||
<TeamMembersSettingsPage
|
||||
team={team}
|
||||
members={members}
|
||||
userHasEditPermission={accountMemberInfo.role === "OWNER"}
|
||||
client={getThirdwebClient(authToken)}
|
||||
teamInvites={pendingOrExpiredInvites}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { TeamGeneralSettingsPage } from "./general/TeamGeneralSettingsPage";
|
||||
|
||||
@@ -9,13 +10,23 @@ export default async function Page(props: {
|
||||
team_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const team = await getTeamBySlug((await props.params).team_slug);
|
||||
const token = await getAuthToken();
|
||||
const params = await props.params;
|
||||
|
||||
const [team, account, token] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getValidAccount(`/team/${params.team_slug}/settings`),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!team || !token) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<TeamGeneralSettingsPage team={team} client={getThirdwebClient(token)} />
|
||||
<TeamGeneralSettingsPage
|
||||
team={team}
|
||||
client={getThirdwebClient(token)}
|
||||
accountId={account.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-4
@@ -92,12 +92,14 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
return (
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<InAppWalletUsersChartCard
|
||||
teamId={team.id}
|
||||
accountId={account.id}
|
||||
from={currentPeriodStart}
|
||||
to={currentPeriodEnd}
|
||||
/>
|
||||
|
||||
<TotalSponsoredCard
|
||||
teamId={team.id}
|
||||
accountId={account.id}
|
||||
from={currentPeriodStart}
|
||||
to={currentPeriodEnd}
|
||||
@@ -138,6 +140,7 @@ export const Usage: React.FC<UsageProps> = ({
|
||||
type ChartCardProps = {
|
||||
from: Date;
|
||||
to: Date;
|
||||
teamId: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
@@ -176,7 +179,7 @@ async function AsyncInAppWalletUsersChartCard(
|
||||
period: "day",
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
accountId: props.accountId,
|
||||
teamId: props.teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
return (
|
||||
@@ -222,17 +225,17 @@ async function AsyncTotalSponsoredChartCard(
|
||||
title: string;
|
||||
},
|
||||
) {
|
||||
const { accountId, from, to } = props;
|
||||
const { teamId, from, to } = props;
|
||||
const [userOpUsageTimeSeries, userOpUsage] = await Promise.all([
|
||||
// User operations usage
|
||||
getUserOpUsage({
|
||||
accountId: accountId,
|
||||
teamId,
|
||||
from: from,
|
||||
to: to,
|
||||
period: "week",
|
||||
}),
|
||||
getUserOpUsage({
|
||||
accountId: accountId,
|
||||
teamId,
|
||||
from: from,
|
||||
to: to,
|
||||
period: "all",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user