Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
1abab4cb1b Version Packages (#6294)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-25 02:33:10 -08:00
Joaquim VergesandGitHub 1851a8c1da [thirdweb] feat: Add activeWallet prop to PayEmbed (#6335) 2025-02-25 02:22:49 -08:00
Joaquim VergesandGitHub 6345634e2c [thirdweb] fix: Remove icon property check in currency tests (#6334) 2025-02-25 01:35:00 -08:00
a19c0c96ff [SDK] Feature: Add SiweOptions for useConnectModal (#6317)
Co-authored-by: Joaquim Verges <[email protected]>
2025-02-25 01:34:44 -08:00
gregandGitHub cf202c03dd [Dashboard] Fix: Use Secret Key to Fetch Routes on Server (#6333) 2025-02-24 23:21:12 -07:00
Joaquim VergesandGitHub 28df6f7e40 [SDK] fix: Force pre-EIP1559 transactions on Homeverse chains (#6332) 2025-02-24 21:37:38 -08:00
gregandGitHub 150a90c387 Feature: Routes page (#6318) 2025-02-24 22:16:02 -07:00
Giselle Chacon NessiandGitHub 454fc6a5b9 Update billing management text (#6258) 2025-02-24 19:33:36 -08:00
Giselle Chacon NessiandGitHub 72c3499366 removed discord cta (#6259) 2025-02-24 19:31:58 -08:00
Jonas DanielsandGitHub 5612542e1a migrate analytics params from clientId/accountId to teamId/projectId (#6331) 2025-02-24 19:30:33 -08:00
Phillip HoandGitHub 1fa36c1d56 chore: allow passing teamId to service-utils auth (#6329) 2025-02-25 07:36:14 +08:00
Jonas DanielsandGitHub fa99acf609 migrate user op analytics to server components (#6328) 2025-02-24 15:34:14 -08:00
Joaquim VergesandGitHub 15adec4fb7 [SDK] feat: Enhance fiat onramp with multi-step flow and currency support (#6323) 2025-02-24 15:19:35 -08:00
Jonas DanielsandGitHub 1405289ad0 migrate analytics endpoints from v1 to v2 (#6326) 2025-02-24 12:47:29 -08:00
kilavvyandGitHub 95cc74e807 fix: typos in documentation files (#6321)
Signed-off-by: kilavvy <[email protected]>
2025-02-24 12:21:13 -08:00
gregandGitHub 2cb89cb3a1 [SDK] Fix: Upload storage timeout (#6300) 2025-02-23 21:26:05 -08:00
saminaandGitHub f693ab8869 [Portal] Minor docs updates for Nebula (#6320) 2025-02-23 20:18:38 +13:00
MananTank 208b052a1b [TOOL-3476] Dashboard: Add Transfer Project in Project settings page (#6316)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the project management features by introducing team member retrieval, project transfer capabilities, and UI improvements in the `ProjectGeneralSettingsPage`. It also updates the handling of team roles and member information.

### Detailed summary
- Updated `AccountTeamsUI` to handle empty image string for avatars.
- Added `getMemberById` function to fetch team member details.
- Replaced `getMembers` with `getMemberById` in `Page` component.
- Enhanced `DangerSettingCard` to include `isDisabled` prop.
- Refactored `ProjectGeneralSettingsPage` to manage team roles and project transfers.
- Introduced `TransferProject` component for transferring projects between teams.
- Updated storybook stories to include owner and member account scenarios.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-22 18:23:44 +00:00
MananTank 514c1a8480 [TOOL-3496] Dashboard: Ecosystem pages UI improvements (#6311)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/yNOf1svJ8o3zjO7zQouZ/f1d0c79f-f711-42e3-9c5e-2b57bfacc05b.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/yNOf1svJ8o3zjO7zQouZ/39702f47-477c-4177-bf3b-ac41c05b5093.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/yNOf1svJ8o3zjO7zQouZ/ce5cc358-a16a-4a06-90a1-a11ff47f67e4.png)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring and enhancing the loading components and ecosystem management pages in the `dashboard` application. It introduces improvements in code structure, UI components, and error handling.

### Detailed summary
- Removed unused files from `utils`, `data`, and `ecosystem` directories.
- Updated `GenericLoadingPage` to accept a `className` prop for styling.
- Refactored loading components to use the new `GenericLoadingPage` structure.
- Enhanced `EcosystemCreatePage` layout and styling.
- Improved `EcosystemAnalyticsPage` to fetch and display wallet usage statistics.
- Replaced direct API calls with token-based authentication in ecosystem pages.
- Updated `EcosystemHeader` to utilize new UI components and improve layout.
- Refactored ecosystem creation form for better user experience and error handling.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-22 01:38:35 +00:00
jnsdls a2d75df4c6 improve Turnstile captcha error messages (#6315) 2025-02-22 01:29:04 +00:00
MananTank 5d345e7abb [TOOL-3497] Dashboard: Add Linked Wallets page in account layout, minor UI tweaks in account layout (#6313)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the account management features in the dashboard by introducing linked wallets functionality, refining UI elements, and improving code organization.

### Detailed summary
- Added `/account/wallets` route with "Linked Wallets" label.
- Updated button text from "Create a Team" to "Create Team".
- Changed `<div>` to `<header>` in `AccountSettingsPage.tsx`.
- Enhanced `Devices` page structure with headers and descriptions.
- Implemented `getLinkedWallets` API function.
- Updated `Page` component to fetch linked wallets and account info.
- Refactored `LinkWallet` and `LinkWalletUI` components for better state management and UI.
- Added stubs for unlinking wallets in `LinkWalletsUI.stories.tsx`.
- Improved wallet unlinking functionality with confirmation dialogs.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-22 01:21:02 +00:00
jnsdls 43638e5d96 only validate Turnstile captcha on Vercel environments (#6314) 2025-02-22 00:16:34 +00:00
jnsdls cc5cdd4f79 enable team invites for starter and growth plans (#6312)
blocked by: https://github.com/thirdweb-dev/api-server/pull/1412
2025-02-21 23:43:33 +00:00
jnsdls 8823acbeb2 add Turnstile captcha to login flow (#6310)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on integrating `Turnstile` CAPTCHA for enhanced security during the login process in the `LoginPage`. It modifies the `doLogin` function to include token validation and updates the UI to present the `Turnstile` challenge.

### Detailed summary
- Added `TURNSTILE_SITE_KEY` and `Turnstile` component to `LoginPage.tsx`.
- Introduced state management for `turnstileToken`.
- Updated `doLogin` function to accept `turnstileToken` and validate it.
- Enhanced error handling for login failures.
- Updated UI to display `Turnstile` CAPTCHA.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-21 21:20:45 +00:00
MananTank 035fcf666a [TOOL-3493] Dashboard: Fix search in team members page, minor ui tweaks (#6309)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the member management functionality by adding search capabilities to filter team members and invites based on a search term. It introduces new state variables and updates components to utilize these features.

### Detailed summary
- Added `searchTerm`, `setSearchTerm`, and `searchPlaceholder` to relevant components.
- Updated the `Input` component to use `searchTerm` and `setSearchTerm`.
- Implemented filtering logic for invites and members based on `searchTerm`.
- Replaced `GradientAvatar` with `Badge` in `TeamSettingsSidebar`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-21 16:17:03 +00:00
MananTank 7775e7c14e [TOOL-3473] Add Team Member invites (#6301)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the team management features in the dashboard, including invite management, member roles, and UI improvements for better user experience.

### Detailed summary
- Added `skipShowingPlans` prop in `LoginPage`.
- Updated `AnnouncementBanner` to include new layout segments.
- Enhanced `TeamMember` type with new fields.
- Introduced `DotsBackgroundPattern` component for UI.
- Implemented `service_getTeamBySlug` API function.
- Added `leaveTeam` functionality in various components.
- Improved onboarding logic to skip plans.
- Created `JoinTeamPage` with invite handling.
- Added invite management features in `ManageInvitesSection`.
- Enhanced member management with delete functionality.
- Updated UI components for better accessibility and usability.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-21 07:28:35 +00:00
MananTank fa6da43a77 [TOOL-3488] Dashboard: Add disclaimer for starter plan about verification hold on pricing card (#6308) 2025-02-21 07:20:19 +00:00
MananTank 733c1e6136 [TOOL-3483] Dashboard: Fix Nebula subdomain rewrite for Vercel preview links (#6303)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the middleware logic to handle subdomains more flexibly, accommodating both the standard `nebula` subdomain and a new format used in Vercel previews.

### Detailed summary
- Changed the condition to check for `subdomain` being either `nebula` or starting with `nebula---`.
- Updated the path rewriting logic to prepend `nebula-app` to the existing paths when the condition is met.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-20 21:24:40 +00:00
Jonas DanielsandGitHub 439485bc47 [Apps] login (#6295) 2025-02-20 12:06:03 -08:00
MananTank 5c2da5fc5d [TOOL-3485] Dashboard: Improve USD currency formatting (#6304)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the formatting of monetary values across various components by replacing manual formatting with a utility function `toUSD`. This enhances consistency and readability in displaying currency values.

### Detailed summary
- Added `toUSD` utility function for consistent currency formatting.
- Replaced manual currency formatting in `PaymentsSuccessRate.tsx`, `Payouts.tsx`, `PayCustomersTable.tsx`, `TotalVolumePieChart.tsx`, and `TotalSponsoredChartCard.tsx`.
- Modified random number generation in `storyUtils.ts` to use non-integer values.
- Updated `format-utils.ts` to use a new compact number formatter.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-20 20:00:55 +00:00
Waren GonzagaandGitHub 846a5b5028 Update page.mdx (#6302)
Signed-off-by: Waren Gonzaga <[email protected]>
2025-02-20 10:48:58 -08:00
MananTank 724e7d9238 [TOOL-3419] Nebula: Add hardcoded reply for Nebula Capabilities (#6298)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the user interface and functionality of various components in the dashboard application, particularly in handling links, chat interactions, and example prompts for a feature called Nebula.

### Detailed summary
- Updated `className` for improved styling in `markdown-renderer.tsx`.
- Changed `backgroundImage` gradient in `EmptyStateChatPageContent.tsx`.
- Added `target` attribute for external links in `Sidebar.tsx`.
- Introduced `examplePrompts` with new responses in `ChatPageContent.tsx`.
- Added `interceptedReply` to `ExamplePrompt` interface in `examplePrompts.ts`.
- Provided detailed descriptions of Nebula's capabilities in `examplePrompts.ts`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-19 18:51:27 +00:00
MananTank c30b6d7c14 [TOOL-3470] Playground: Fix eslint image warnings (#6297) 2025-02-19 18:47:34 +00:00
0xFirekeeper 45ac0cb683 [Docs] Improve Unity Build Instructions (#6287)
Closes TOOL-3456

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating descriptions and improving the clarity of build instructions for Unity and troubleshooting OAuth redirects.

### Detailed summary
- Fixed a typo in the `description` for the Unreal Engine link.
- Changed section title from `General` to `All Target Platforms`.
- Clarified build settings for IL2CPP Code Generation.
- Added details about OAuth redirect troubleshooting, including best practices for bundle IDs.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-19 07:56:18 +00:00
MananTank 8610b467b6 [TOOL-3447] Dashboard: Remove /v1/keys usage (#6293)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on refactoring the project and team management functionalities in the application, specifically updating the handling of API keys, project settings, and team responses, alongside improving redirection logic and type definitions.

### Detailed summary
- Removed `getAPIKeys.ts`.
- Updated types for `TeamResponse` and `ProjectResponse`.
- Changed redirection logic in various components to use the correct team/project slugs.
- Updated `ProjectGeneralSettingsPage` to use the new project structure.
- Refactored API key handling to support new project structure.
- Replaced instances of `LazyCreateAPIKeyDialog` with `LazyCreateProjectDialog`.
- Updated service utilities to include new project-related types.
- Improved error handling and user feedback in settings pages.

> The following files were skipped due to too many changes: `apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts`, `apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx`, `apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-19 00:51:37 +00:00
kumaryash90 d4a49b2fbd [SDK] fix: Custom create2 gas config update (#6286)
TOOL-3458

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new gas price configuration for a blockchain network named `Ronin` within the `create-2-factory.ts` file.

### Detailed summary
- Added a new entry for the year `"2020"` with:
  - `name`: `"Ronin"`
  - `gasPrice`: `110n * 10n ** 9n`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-18 21:27:58 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
35b7a0e991 Version Packages (#6290)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-18 10:43:34 -08:00
MananTank ee951926fb [TOOL-3453] Dashboard: Fix NFT Metadata issues when adding media URLs instead of uploading (#6288)
=

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the NFT metadata handling in the dashboard application, specifically renaming and refactoring properties related to custom images and animations, improving form handling, and enhancing user interface elements.

### Detailed summary
- Updated documentation link in `next-env.d.ts`.
- Changed `customImage` and `customAnimationUrl` to `image` and `animation_url` in multiple components.
- Updated titles and headers for improved clarity in forms.
- Enhanced form controls with better error handling and user feedback.
- Adjusted layout and spacing in several components for better usability.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-02-18 18:28:32 +00:00
Joaquim VergesandGitHub 5abbae7368 [thirdweb] fix: Respect buyWithFiat setting in PayEmbed (#6289) 2025-02-18 10:28:03 -08:00
288 changed files with 9732 additions and 4702 deletions
+7 -1
View File
@@ -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
+4 -3
View File
@@ -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());
+3 -6
View File
@@ -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 -2
View File
@@ -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=""
+1 -1
View File
@@ -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.
+1
View File
@@ -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,
};
}
-9
View File
@@ -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,
};
}
+220 -29
View File
@@ -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[];
}
+20
View File
@@ -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;
}
+2 -16
View File
@@ -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();
+46
View File
@@ -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;
}
+28 -4
View File
@@ -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;
}
+17 -29
View File
@@ -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%)",
}}
/>
);
}
+3
View File
@@ -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>
);
};
@@ -0,0 +1,126 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { NATIVE_TOKEN_ADDRESS, defineChain, getContract } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { name } from "thirdweb/extensions/common";
type RouteListCardProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri: string | null;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri: string | null;
};
export async function RouteListCard({
originChainId,
originTokenAddress,
originTokenIconUri,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
}: RouteListCardProps) {
const [
originChain,
originTokenName,
destinationChain,
destinationTokenName,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: name({
contract: getContract({
address: originTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(originChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: name({
contract: getContract({
address: destinationTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(destinationChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
originTokenIconUri
? resolveSchemeWithErrorHandler({
uri: originTokenIconUri,
client: getThirdwebClient(),
})
: undefined,
destinationTokenIconUri
? resolveSchemeWithErrorHandler({
uri: destinationTokenIconUri,
client: getThirdwebClient(),
})
: undefined,
]);
return (
<div className="relative h-full">
<Card className="h-full w-full transition-colors hover:border-active-border">
<CardHeader className="flex flex-row items-center justify-between p-4">
<div className="flex flex-row items-center gap-2">
{resolvedOriginTokenIconUri ? (
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
className="size-8 rounded-full bg-white"
/>
) : (
<div className="size-8 rounded-full bg-white/10" />
)}
{resolvedDestinationTokenIconUri ? (
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
className="-translate-x-4 size-8 rounded-full bg-white ring-2 ring-card"
/>
) : (
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
)}
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4">
<table className="w-full">
<tbody className="text-sm [&_td>*]:min-h-[25px]">
<tr>
<th className="text-left font-normal text-base">
{originTokenName === "ETH"
? originChain.nativeCurrency.name
: originTokenName}
</th>
<td className="text-right text-base text-muted-foreground">
{originChain.name}
</td>
</tr>
<tr>
<th className="text-left font-normal text-base">
{destinationTokenName === "ETH"
? destinationChain.nativeCurrency.name
: destinationTokenName}
</th>
<td className="text-right text-base text-muted-foreground">
{destinationChain.name}
</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,144 @@
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { TableCell, TableRow } from "@/components/ui/table";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { NATIVE_TOKEN_ADDRESS, getContract } from "thirdweb";
import { defineChain, getChainMetadata } from "thirdweb/chains";
import { symbol } from "thirdweb/extensions/common";
type RouteListRowProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri: string | null;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri: string | null;
};
export async function RouteListRow({
originChainId,
originTokenAddress,
originTokenIconUri,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
}: RouteListRowProps) {
const [
originChain,
originTokenSymbol,
destinationChain,
destinationTokenSymbol,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: symbol({
contract: getContract({
address: originTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(originChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: symbol({
contract: getContract({
address: destinationTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(destinationChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
originTokenIconUri
? resolveSchemeWithErrorHandler({
uri: originTokenIconUri,
client: getThirdwebClient(),
})
: undefined,
destinationTokenIconUri
? resolveSchemeWithErrorHandler({
uri: destinationTokenIconUri,
client: getThirdwebClient(),
})
: undefined,
]);
return (
<TableRow linkBox className="hover:bg-accent/50">
<TableCell>
<div className="flex flex-row items-center gap-4">
<div className="flex items-center gap-1">
{resolvedOriginTokenIconUri ? (
// For now we're using a normal img tag because the domain for these images is unknown
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
className="size-6 rounded-full bg-muted-foreground"
/>
) : (
<div className="size-6 rounded-full bg-muted-foreground" />
)}
{originTokenSymbol && (
<CopyTextButton
textToCopy={originTokenAddress}
textToShow={
originTokenSymbol === "ETH"
? originChain.nativeCurrency.symbol
: originTokenSymbol
}
tooltip="Copy Token Address"
className="relative z-10 text-base"
variant="ghost"
copyIconPosition="right"
/>
)}
</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{originChain.name}
</TableCell>
<TableCell>
<div className="flex flex-row items-center gap-4">
<div className="flex items-center gap-1">
{resolvedDestinationTokenIconUri ? (
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
className="size-6 rounded-full bg-muted-foreground"
/>
) : (
<div className="size-6 rounded-full bg-muted-foreground" />
)}
{destinationTokenSymbol && (
<CopyTextButton
textToCopy={destinationTokenAddress}
textToShow={
destinationTokenSymbol === "ETH"
? destinationChain.nativeCurrency.symbol
: destinationTokenSymbol
}
tooltip="Copy Token Address"
className="relative z-10 text-base"
variant="ghost"
copyIconPosition="right"
/>
)}
</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{destinationChain.name}
</TableCell>
</TableRow>
);
}
@@ -0,0 +1,161 @@
import {
Table,
TableBody,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Address } from "thirdweb";
import { getRoutes } from "../../../utils";
import { ChainlistPagination } from "../client/pagination";
import { RouteListCard } from "../server/routelist-card";
import { RouteListRow } from "../server/routelist-row";
export type SearchParams = Partial<{
includeDeprecated: boolean;
query: string;
page: number;
type: "origin" | "destination";
// maybe later we'll have a page size param?
// pageSize: number;
view: "table" | "grid";
}>;
// 120 is divisible by 2, 3, and 4 so card layout looks nice
const DEFAULT_PAGE_SIZE = 120;
const DEFAULT_PAGE = 1;
async function getRoutesToRender(params: SearchParams) {
const filters: Partial<{
limit: number;
offset: number;
originChainId?: number;
originTokenAddress?: Address;
destinationChainId?: number;
destinationTokenAddress?: Address;
}> = {};
if (params.type === "origin" || typeof params.type === "undefined") {
if (params.query?.startsWith("0x")) {
filters.originTokenAddress = params.query as Address;
} else if (params.query) {
filters.originChainId = Number(params.query);
}
} else if (params.type === "destination") {
if (params.query?.startsWith("0x")) {
filters.destinationTokenAddress = params.query as Address;
} else if (params.query) {
filters.destinationChainId = Number(params.query);
}
}
// Temporary, will update this after the /routes endpoint
filters.limit = 10_000;
const routes = await getRoutes(filters);
const totalCount = routes.length;
return {
routesToRender: routes,
totalCount,
filteredCount: routes.length,
};
}
export async function RoutesData(props: {
searchParams: SearchParams;
activeView: "table" | "grid";
isLoggedIn: boolean;
}) {
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
props.searchParams,
);
// pagination
const totalPages = Math.ceil(routesToRender.length / DEFAULT_PAGE_SIZE);
const activePage = Number(props.searchParams.page || DEFAULT_PAGE);
const pageSize = DEFAULT_PAGE_SIZE;
const startIndex = (activePage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedRoutes = routesToRender.slice(startIndex, endIndex);
return (
<>
<main>
{/* empty state */}
{paginatedRoutes.length === 0 ? (
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
<p className="text-2xl">No Results found</p>
</div>
) : props.activeView === "table" ? (
<TableContainer>
<Table>
<TableHeader className="z-0">
<TableRow>
<TableHead>Origin Token</TableHead>
<TableHead>Origin Chain</TableHead>
<TableHead>Destination Token</TableHead>
<TableHead>Destination Chain</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedRoutes.map((route) => (
<RouteListRow
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
/>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{paginatedRoutes.map((route) => (
<li
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
className="h-full"
>
<RouteListCard
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
/>
</li>
))}
</ul>
)}
</main>
<div className="h-10" />
{totalPages > 1 && (
<ChainlistPagination totalPages={totalPages} activePage={activePage} />
)}
<div className="h-4" />
<p className="text-balance text-center text-muted-foreground text-sm">
Showing{" "}
<span className="text-accent-foreground">{paginatedRoutes.length}</span>{" "}
out of{" "}
{filteredCount !== totalCount ? (
<>
<span className="text-accent-foreground">{filteredCount}</span>{" "}
routes that match filters. (Total:{" "}
<span className="text-accent-foreground">{totalCount}</span>)
</>
) : (
<>
<span className="text-accent-foreground">{totalCount}</span> routes.
</>
)}
</p>
</>
);
}
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;
}
@@ -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(
@@ -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,
@@ -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
@@ -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>
@@ -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}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
);
}
+4 -5
View File
@@ -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}
/>
+35 -29
View File
@@ -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&apos;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} />;
}
+49 -29
View File
@@ -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>
);
}
+99 -5
View File
@@ -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?",
},
];
+22 -19
View File
@@ -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",
@@ -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" />
@@ -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;
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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} />;
}
@@ -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" />;
}
@@ -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>
);
@@ -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>
);
}
@@ -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();
@@ -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">
@@ -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 (
@@ -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>
@@ -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;
@@ -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}
/>
);
}
@@ -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>
);
}
@@ -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
@@ -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>
);
}
@@ -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>
</>
);
}
@@ -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>
</>
);
}
@@ -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>
);
}
@@ -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}
/>
);
}
@@ -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