Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
d14a78c06c Version Packages (#6499)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-19 17:38:22 -07:00
Jonas DanielsandGitHub f81fd46e9c add SMS country selection for in-app wallets (#6502) 2025-03-19 17:30:41 -07:00
Joaquim VergesandGitHub d6ce3aedd8 [SDK] fix: Remove fixed gas limit for Saga Mainnet (#6501) 2025-03-19 14:07:22 -07:00
Joaquim VergesandGitHub 6672549edd [SDK] feat: Add fixed gas limit for Saga Mainnet (#6500) 2025-03-19 13:17:56 -07:00
Joaquim VergesandGitHub 639b542d27 [SDK] docs: Add example for shouldUpdateSessionKey function (#6491) 2025-03-19 12:53:43 -07:00
Joaquim VergesandGitHub b5fb5dd0cc [SDK] feat: Support account overrides in engineAccount() (#6496) 2025-03-19 12:53:27 -07:00
Joaquim VergesandGitHub 8b036dcfa7 [SDK] feat: Add Saga Mainnet to gas-free chains (#6498) 2025-03-19 12:52:35 -07:00
Joaquim VergesandGitHub 56bc4ae335 [Playground] feat: Add PayEmbed component playground (#6493) 2025-03-18 18:43:05 -07:00
0xFirekeeper 71f83a884b .NET Docs - Synchronous EngineWallet.Create (#6469)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on modifying the instantiation of the `engineWallet` variable by removing the `await` keyword from the `EngineWallet.Create` method call, indicating a change in how the method is executed.

### Detailed summary
- Removed `await` from the `EngineWallet.Create` method call for `engineWallet`.
- The method now executes synchronously instead of asynchronously.

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

<!-- end pr-codex -->
2025-03-18 19:29:08 +00:00
saminacodes 2d5ff74335 [Portal] added agglayer and erc-20 guide (#6492)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the documentation for deploying ERC-20 and Agglayer module contracts, providing step-by-step guides and visual aids for users.

### Detailed summary
- Added a new section for "Tutorials" in the `sidebar` with a link to "Deploy ERC-20 Core."
- Introduced a tutorial on deploying an ERC-20 Core contract, including steps and images.
- Created a guide for adding the Agglayer module to modular contracts, detailing prerequisites and steps.
- Included images for each step in both tutorials for better understanding.

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

<!-- end pr-codex -->
2025-03-18 05:17:21 +00:00
Joaquim VergesandGitHub cbc3bf1d36 [Dashboard] feat: Add allowed operations to partner access control (#6478) 2025-03-17 18:23:52 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
20ead112e2 Version Packages (#6460)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-17 16:59:32 -07:00
saminacodes 06d25bb7a9 [Portal] redrafted insight use case page (#6489)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the documentation for various blockchain use cases, enhancing clarity and structure. It introduces a new section summarizing insights and specific blueprints for tracking on-chain activities.

### Detailed summary
- Removed detailed explanations for various use cases in sections for `Gaming`, `DeFi`, `NFTs`, and `Wallets`.
- Added a new section titled `Insight Use Cases`.
- Provided a concise table summarizing use cases and corresponding blueprints for tracking blockchain data.

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

<!-- end pr-codex -->
2025-03-17 23:34:29 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
91f8b2dbf2 Version Packages (#6460)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-17 16:34:12 -07:00
Joaquim VergesandGitHub 9901351fb2 [SDK] fix: Handle serialized bigints for packing userops (#6490) 2025-03-17 16:24:41 -07:00
cb86fa2929 [Portal] Update UB Docs (#6482)
Signed-off-by: samina <[email protected]>
Co-authored-by: gregfromstl <[email protected]>
2025-03-17 12:05:46 -07:00
EimanGitHubJoaquim VergesJonas Danielsgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
53718208cd updated description of using session keys with Engine (#6474)
Signed-off-by: Eiman <[email protected]>
Signed-off-by: Joaquim Verges <[email protected]>
Co-authored-by: Joaquim Verges <[email protected]>
Co-authored-by: Jonas Daniels <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-03-15 15:25:14 -07:00
Jonas DanielsandGitHub ef2613aca5 Pin GitHub Actions to specific commit hashes (#6479) 2025-03-15 15:06:34 -07:00
saminacodes f2e61b83cb [Portal] updated billing docs (#6477)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the billing information section in the documentation, specifically enhancing the instructions for viewing past invoices and modifying the related content.

### Detailed summary
- Updated the navigation steps for viewing past invoices from **Settings > Billing > Manage Billing** to **Settings > Invoices**.
- Changed the instructions for viewing invoices to emphasize breakdown by plan and usage.
- Removed an unused `invoices-settings.png` import.

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

<!-- end pr-codex -->
2025-03-15 00:21:42 +00:00
Jonas DanielsandGitHub ab0d33016d add Stripe integration for team invoices (#6475) 2025-03-14 16:09:35 -07:00
Joe-ThirdwebandGitHub f54a0cb39b [Dashboard] Feature: Adding custom chain info to Hemi Sepolia (#6154)
Signed-off-by: Joe-Thirdweb <[email protected]>
2025-03-14 13:27:16 -07:00
Joaquim VergesandGitHub 90010ee723 [Dashboard] feat: Add access control and server verifier to partner configuration (#6473) 2025-03-13 23:20:00 -07:00
MananTank 269fbe5ecf [TOOL-3691] Dashbaord: Keep project settings submit button always enabled (#6467)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on simplifying the logic for determining if form fields are "dirty" before enabling the save button in the `ProjectGeneralSettingsPage`. It removes checks for dirty states, allowing the save button to be enabled regardless of whether the fields have been modified.

### Detailed summary
- Removed `isNameDirty` check for the `name` field in `ProjectNameSetting`.
- Removed `isDomainsDirty` check for the `domains` field in `AllowedDomainsSetting`.
- Removed `isBundleIdsDirty` check for the `bundleIds` field in `AllowedBundleIDsSetting`.
- Updated save button to always have `disabled: false` for all settings.
- Kept `isPending` functionality unchanged based on `props.isUpdatingProject`.

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

<!-- end pr-codex -->
2025-03-13 22:48:40 +00:00
Jonas DanielsandGitHub 7ed8fc2c43 update dependencies (#6471) 2025-03-13 15:35:06 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9f2f2e794a chore(deps-dev): bump eslint-plugin-storybook from 0.11.3 to 0.11.4 (#6454)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 09:29:21 -07:00
Reza RahemtolaGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
eefd2649fd [Docs] Fix: Typo in react/getting-started guide (#6470)
Signed-off-by: Reza Rahemtola <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-03-13 09:24:38 -07:00
MananTank 62de6dd04c [TOOL-3684] Dashboard: Add FTUX in project overview page (#6466)
<!--

## 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 several enhancements and fixes across various components in the dashboard application, including new features, property adjustments, and structural changes for improved functionality and usability.

### Detailed summary
- Added `showRangeSelector` property to `AnalyticsHeader`.
- Updated `RotateSecretKeyButton` to be exported.
- Removed `isEnabled` property and replaced it with `isDisabled` across multiple components.
- Introduced `IntegrateAPIKeyCodeTabs` and related sections in `ProjectFTUX`.
- Enhanced project integration sections with new UI components and improved code examples.
- Refactored `NotificationButtonInner` and other components to remove `isEnabled` checks.

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

<!-- end pr-codex -->
2025-03-13 16:10:03 +00:00
MananTank 6fc6c6e681 Portal: Fix knip lint error - Delete AAChainList (#6468) 2025-03-12 19:26:29 +00:00
Joe-ThirdwebandGitHub 58cd79426d [Portal] Fix: Updating AA supported chains (#6459)
Signed-off-by: Joe-Thirdweb <[email protected]>
2025-03-12 00:09:02 -07:00
kumaryash90 185e8f48df Fix cross chain tab event queries (#6465)
<!--

## 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 modifying the `blockRange` parameter in the `page.tsx` file to adjust the maximum block number limit for a specific condition.

### Detailed summary
- Updated `blockRange` from `latestBlockNumber < 2000000n` to `latestBlockNumber < 100000n`, changing the maximum limit from `2000000n` to `100000n`.

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

<!-- end pr-codex -->
2025-03-12 05:02:08 +00:00
jnsdls d8865b74ef add copy invite link feature to team invites (#6463) 2025-03-12 01:58:07 +00:00
MananTank 1e8d1e4b52 [NEB-113] Save context chains in local storage (#6452)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the `ChatPageContent` component by implementing functionality to save and retrieve the last used chain IDs from local storage, ensuring that the user's selection persists across sessions.

### Detailed summary
- Added `saveLastUsedChainIds` function to store chain IDs in local storage.
- Implemented `getLastUsedChainIds` function to retrieve chain IDs from local storage.
- Updated `updatedContextFilters` to use last used chain IDs if available.
- Ensured wallet address is set only from a connected wallet.

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

<!-- end pr-codex -->
2025-03-11 17:36:20 +00:00
MananTank 91194ce63a [TOOL-3681] Portal: Fix code identation in reference pages (#6461)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving code readability and fixing a typo in the console log message, as well as adjusting the CSS class for better text formatting in a component.

### Detailed summary
- In `packages/thirdweb/src/react/core/utils/wallet.ts`, the console log message was changed from `"Walelt name"` to `"wallet name"` for clarity.
- In `apps/portal/src/components/Document/Code.tsx`, the CSS class was modified from `rounded-lg border bg-card font-mono text-sm leading-relaxed` to `whitespace-pre rounded-lg border bg-card font-mono text-sm leading-relaxed` to enhance text formatting.

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

<!-- end pr-codex -->
2025-03-11 17:27:01 +00:00
MananTank a1141f316f [TOOL-3673] Dashboard: change Turnstile widget appearance on missing turnstile token (#6453) 2025-03-11 12:53:44 +00:00
kumaryash90 1273cc7b83 Compute addresses with ref contracts (#5699)
TOOL-2796 (XPROT-934)

## Problem solved

This was an issue when installing modules published with ref contracts. Module installation goes through infra deployment functions which use computed addresses.

SDK should process ref contracts when computing published contract addresses.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the handling of published contract references in the `thirdweb` library, particularly in the deployment process. It introduces a new function to compute addresses for these references and updates existing functions and tests accordingly.

### Detailed summary
- Added `processRefDeployments` function to process contract references in constructor params.
- Introduced `computeRefDeployments` function for computing addresses of published contract references.
- Updated the handling of `salt` values to default to an empty string instead of `"thirdweb"`.
- Modified `computeDeploymentInfoFromMetadata` to use processed constructor parameters.
- Adjusted tests to reflect changes in contract deployment parameters and added new tests for address computation of deployed contracts with references.

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

<!-- end pr-codex -->
2025-03-11 00:51:50 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
691110286d Version Packages (#6448)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-10 00:27:12 -07:00
Joaquim VergesandGitHub 4d197a822d [SDK] test: Add factory address to smart wallet test (#6450) 2025-03-10 00:20:16 -07:00
Joaquim VergesandGitHub f655657fce [SDK] fix: EIP1193 provider value filtering in transactions (#6449) 2025-03-10 00:01:00 -07:00
Joaquim VergesandGitHub 109f250068 [SDK] fix: Handle authorizationList tx prop in ethers5 adapter (#6447) 2025-03-09 23:06:21 -07:00
saminaandGitHub f7cf09b3cb [Portal] Account Docs (#6442) 2025-03-09 22:39:20 -07:00
gregfromstl dd06be8def [Dashboard] Feature: update dashboard routes page to handle more routes (#6446)
<!--

## 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 renaming and updating the `RoutesPage` functionality, enhancing token properties, and adjusting UI elements in the routing components for better clarity and usability.

### Detailed summary
- Renamed `ChainListPage` to `RoutesPage`.
- Updated token properties to include `name` and `symbol` for both origin and destination tokens.
- Increased `filters.limit` from `10,000` to `50,000`.
- Modified UI classes for improved styling and consistency.

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

<!-- end pr-codex -->
2025-03-10 04:57:15 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
bf8da3588f Version Packages (#6445)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-09 21:45:12 -07:00
gregandGitHub f6cbdb7e30 BREAKING: Removes 3D model support on MediaRenderer (#6443) 2025-03-09 20:46:12 -07:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
d350f8cb13 Version Packages (#6440)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-09 18:44:17 -07:00
MananTank 4b200503b8 [TOOL-3639] Dashboard: Pay > Universal Bridge renaming (#6431)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on renaming and rebranding the `Pay` feature to `Universal Bridge`, updating related paths, descriptions, and components accordingly.

### Detailed summary
- Renamed `Pay` to `Universal Bridge` in various components and files.
- Updated links and routes to reflect the new branding.
- Changed descriptions to align with the new feature purpose.
- Replaced `TrackedLinkTW` with `TrackedUnderlineLink` in multiple components.

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

<!-- end pr-codex -->
2025-03-08 15:16:08 +00:00
MananTank f8eb86da47 [TOOL-3640] Dashboard: Add sponsored transactions table in team usage page (#6434)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the functionality and user experience of the dashboard by adding new features, improving existing components, and refining data handling, particularly around sponsored transactions and usage analytics.

### Detailed summary
- Added `placeholder` prop to `SingleNetworkSelector`.
- Integrated `SponsoredTransactionsTable` in `AccountAbstractionAnalytics`.
- Enhanced `Usage` component with `client` and `projects` props.
- Improved `ExportToCSVButton` to handle different data types.
- Introduced `SponsoredTransactionsTable` for displaying sponsored transaction data.
- Updated `SponsoredTransactionsTableUI` with pagination and filter options.
- Added error handling and loading states in various components.
- Refactored proxy actions to support optional search parameters and text parsing.
- Introduced `getAuthToken` for authentication in several pages.

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

<!-- end pr-codex -->
2025-03-08 12:57:58 +00:00
gregandGitHub 2ae778ed7f Feature: Adds OTP cooldown timer (#6439) 2025-03-07 15:55:52 -08:00
0xFirekeeper 1c135b0f65 Improve .NET/Unity Documentation (#6438)
Also adds Insight and Engine
Closes TOOL-3570

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing documentation and sidebar navigation for the Thirdweb .NET SDK, introducing new wallet integrations, and updating metadata and descriptions for clarity.

### Detailed summary
- Updated `PrivateKeyWallet` description to markdown format.
- Changed `title` in `nebula/quickstart` metadata.
- Added utility functions description in `utils/page.mdx`.
- Removed `Constants.DECIMALS_18`.
- Added `EngineWallet` documentation with usage example.
- Updated sidebar links for better organization and added new sections.

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

<!-- end pr-codex -->
2025-03-07 22:26:33 +00:00
226 changed files with 10334 additions and 4406 deletions
+4 -4
View File
@@ -6,15 +6,15 @@ runs:
steps:
# we use bun for some test suites
- name: Setup bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: 1.0.35
# pnpm for our dependencies
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
version: 9
version: 9.11.0
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
check-latest: true
+10 -10
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Optimize CI
id: check_skip
uses: withgraphite/graphite-ci-action@main
uses: withgraphite/graphite-ci-action@9cb601a55e114099561b6d755505de377d45db40 # v0.0.9 ("main")
with:
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
@@ -38,7 +38,7 @@ jobs:
name: Build Packages
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
@@ -54,13 +54,13 @@ jobs:
runs-on: ubuntu-latest-8
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
- name: Setup Biome
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@c016c38f26f2c4a6eb3662679143614a254263fd # v2.3.0
with:
version: latest
@@ -74,13 +74,13 @@ jobs:
runs-on: ubuntu-latest-8
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
- name: Set up foundry
uses: foundry-rs/foundry-toolchain@v1
uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # v1.3.1
with:
cache: false
version: nightly-c4a984fbf2c48b793c8cd53af84f56009dd1070c
@@ -88,7 +88,7 @@ jobs:
- run: pnpm test
- name: Code Coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
with:
directory: packages/
flags: packages
@@ -106,7 +106,7 @@ jobs:
bundler: [vite, webpack, esbuild]
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
@@ -172,13 +172,13 @@ jobs:
runs-on: ubuntu-latest-8
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
- name: Report bundle size
uses: andresz1/size-limit-action@master
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
+1 -1
View File
@@ -16,4 +16,4 @@ jobs:
github.event.pull_request.author_association == 'COLLABORATOR' ||
github.event.pull_request.author_association == 'CONTRIBUTOR'
steps:
- uses: toshimaru/[email protected]
- uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
+4 -4
View File
@@ -42,11 +42,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,4 +72,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+1 -1
View File
@@ -17,6 +17,6 @@ jobs:
pull-requests: write
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout branch
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
# Do not use the GITHUB_TOKEN by default
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout branch
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
# Do not use the GITHUB_TOKEN by default
@@ -36,7 +36,7 @@ jobs:
- name: Create release Pull Request or publish to NPM
id: changesets
uses: changesets/action@v1
uses: changesets/action@06245a4e0a36c064a573d4150030f5ec548e4fcc #v1.4.0
with:
publish: pnpm release
version: pnpm version-packages
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v.9.1.0
with:
stale-issue-message: 'This issue has been inactive for 7 days. It is now marked as stale and will be closed in 2 days if no further activity occurs.'
stale-pr-message: 'This PR has been inactive for 7 days. It is now marked as stale and will be closed in 2 days if no further activity occurs.'
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install
uses: ./.github/composite-actions/install
@@ -30,7 +30,7 @@ jobs:
run: pnpm typedoc
- name: Update Gist
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GIST_TOKEN }}
script: |
+3
View File
@@ -102,3 +102,6 @@ ANALYTICS_SERVICE_URL=""
# Required for Nebula Chat
NEXT_PUBLIC_NEBULA_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
+3
View File
@@ -27,5 +27,8 @@ const config: StorybookConfig = {
},
},
staticDirs: ["../public"],
features: {
experimentalRSC: true,
},
};
export default config;
+28 -27
View File
@@ -45,9 +45,9 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@sentry/nextjs": "9.3.0",
"@sentry/nextjs": "9.5.0",
"@shazow/whatsabi": "0.20.0",
"@tanstack/react-query": "5.67.1",
"@tanstack/react-query": "5.67.3",
"@tanstack/react-table": "^8.21.2",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "2.0.0",
@@ -61,18 +61,18 @@
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "12.4.10",
"framer-motion": "12.5.0",
"fuse.js": "7.1.0",
"idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.5.0",
"ioredis": "^5.6.0",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.477.0",
"next": "15.2.0",
"lucide-react": "0.482.0",
"next": "15.2.2",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.4",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.0",
"nuqs": "^2.4.1",
"p-limit": "^6.2.0",
"papaparse": "^5.5.2",
"pluralize": "^8.0.0",
@@ -94,7 +94,8 @@
"shiki": "1.27.0",
"sonner": "2.0.1",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.20.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.20.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
@@ -104,21 +105,21 @@
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.5",
"@next/bundle-analyzer": "15.2.0",
"@next/eslint-plugin-next": "15.2.0",
"@playwright/test": "1.50.1",
"@storybook/addon-essentials": "8.6.3",
"@storybook/addon-interactions": "8.6.3",
"@storybook/addon-links": "8.6.3",
"@storybook/addon-onboarding": "8.6.3",
"@storybook/addon-viewport": "8.6.3",
"@storybook/blocks": "8.6.3",
"@storybook/nextjs": "8.6.3",
"@storybook/react": "8.6.3",
"@storybook/test": "8.6.3",
"@chromatic-com/storybook": "3.2.6",
"@next/bundle-analyzer": "15.2.2",
"@next/eslint-plugin-next": "15.2.2",
"@playwright/test": "1.51.1",
"@storybook/addon-essentials": "8.6.4",
"@storybook/addon-interactions": "8.6.4",
"@storybook/addon-links": "8.6.4",
"@storybook/addon-onboarding": "8.6.4",
"@storybook/addon-viewport": "8.6.4",
"@storybook/blocks": "8.6.4",
"@storybook/nextjs": "8.6.4",
"@storybook/react": "8.6.4",
"@storybook/test": "8.6.4",
"@types/color": "4.2.0",
"@types/node": "22.13.9",
"@types/node": "22.13.10",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
@@ -129,16 +130,16 @@
"@types/swagger-ui-react": "^4.19.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.19",
"autoprefixer": "^10.4.21",
"checkly": "5.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"eslint-plugin-storybook": "0.11.3",
"knip": "5.45.0",
"eslint-plugin-storybook": "0.11.6",
"knip": "5.46.0",
"next-sitemap": "^4.2.3",
"postcss": "8.5.3",
"storybook": "8.6.3",
"storybook": "8.6.4",
"tailwindcss": "3.4.17",
"typescript": "5.8.2"
}
+7
View File
@@ -354,6 +354,13 @@ async function redirects() {
destination: "/",
permanent: false,
},
// pay > universal-bridge redirect
{
source: "/team/:team_slug/:project_slug/connect/pay/:path*",
destination:
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use server";
import "server-only";
import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
+15 -11
View File
@@ -5,13 +5,14 @@ import { API_SERVER_URL } from "../constants/env";
type ProxyActionParams = {
pathname: string;
searchParams?: Record<string, string>;
searchParams?: Record<string, string | undefined>;
method: "GET" | "POST" | "PUT" | "DELETE";
body?: string;
headers?: Record<string, string>;
parseAsText?: boolean;
};
type ProxyActionResult<T extends object> =
type ProxyActionResult<T> =
| {
status: number;
ok: true;
@@ -23,7 +24,7 @@ type ProxyActionResult<T extends object> =
error: string;
};
async function proxy<T extends object>(
async function proxy<T>(
baseUrl: string,
params: ProxyActionParams,
): Promise<ProxyActionResult<T>> {
@@ -34,7 +35,10 @@ async function proxy<T extends object>(
url.pathname = params.pathname;
if (params.searchParams) {
for (const key in params.searchParams) {
url.searchParams.append(key, params.searchParams[key] as string);
const value = params.searchParams[key];
if (value) {
url.searchParams.append(key, value);
}
}
}
@@ -67,19 +71,15 @@ async function proxy<T extends object>(
return {
status: res.status,
ok: true,
data: await res.json(),
data: params.parseAsText ? await res.text() : await res.json(),
};
}
export async function apiServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function apiServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(API_SERVER_URL, params);
}
export async function payServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function payServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(
process.env.NEXT_PUBLIC_PAY_URL
? `https://${process.env.NEXT_PUBLIC_PAY_URL}`
@@ -87,3 +87,7 @@ export async function payServerProxy<T extends object = object>(
params,
);
}
export async function analyticsServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(process.env.ANALYTICS_SERVICE_URL || "", params);
}
@@ -0,0 +1,60 @@
import "server-only";
import Stripe from "stripe";
import type { Team } from "../api/team";
let existingStripe: Stripe | undefined;
function getStripe() {
if (!existingStripe) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (!STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
existingStripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2025-02-24.acacia",
});
}
return existingStripe;
}
export async function getTeamInvoices(
team: Team,
options?: { cursor?: string },
) {
try {
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Get the list of invoices for the customer
const invoices = await getStripe().invoices.list({
customer: customerId,
limit: 10,
starting_after: options?.cursor,
});
return invoices;
} catch (error) {
console.error("Error fetching billing history:", error);
// If the error is that the customer doesn't exist, return an empty array
// instead of throwing an error
if (
error instanceof Stripe.errors.StripeError &&
error.message.includes("No such customer")
) {
return {
data: [],
has_more: false,
};
}
throw new Error("Failed to fetch billing history");
}
}
+53
View File
@@ -0,0 +1,53 @@
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "../constants/env";
export type SMSCountryTiers = {
tier1: string[];
tier2: string[];
tier3: string[];
tier4: string[];
tier5: string[];
};
export async function getSMSCountryTiers() {
if (!THIRDWEB_API_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(`${API_SERVER_URL}/v1/sms/list-country-tiers`, {
headers: {
"Content-Type": "application/json",
"x-service-api-key": THIRDWEB_API_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
});
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
};
}
}
+2 -1
View File
@@ -3,7 +3,8 @@ 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";
export type Team = TeamResponse;
export type Team = TeamResponse & { stripeCustomerId: string | null };
export async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
@@ -2,11 +2,12 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { DownloadIcon } from "lucide-react";
import Papa from "papaparse";
import { toast } from "sonner";
import { cn } from "../../lib/utils";
export function ExportToCSVButton(props: {
getData: () => Promise<{ header: string[]; rows: string[][] }>;
getData: () => Promise<{ header: string[]; rows: string[][] } | string>;
fileName: string;
disabled?: boolean;
className?: string;
@@ -14,7 +15,12 @@ export function ExportToCSVButton(props: {
const exportMutation = useMutation({
mutationFn: async () => {
const data = await props.getData();
exportToCSV(props.fileName, data);
if (typeof data === "string") {
exportToCSV(props.fileName, data);
} else {
const fileContent = convertToCSVFormat(data);
exportToCSV(props.fileName, fileContent);
}
},
onError: () => {
toast.error("Failed to download CSV");
@@ -29,7 +35,7 @@ export function ExportToCSVButton(props: {
return (
<Button
variant="outline"
disabled={props.disabled}
disabled={props.disabled || exportMutation.isPending}
className={cn("flex items-center gap-2 border text-xs", props.className)}
onClick={async () => {
exportMutation.mutate();
@@ -50,15 +56,15 @@ export function ExportToCSVButton(props: {
);
}
function exportToCSV(
fileName: string,
data: { header: string[]; rows: string[][] },
) {
const { header, rows } = data;
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
.map((e) => e.join(","))
.join("\n")}`;
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
return Papa.unparse({
fields: data.header,
data: data.rows,
});
}
function exportToCSV(fileName: string, fileContent: string) {
const csvContent = `data:text/csv;charset=utf-8,${fileContent}`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
@@ -47,7 +47,7 @@ export function Img(props: imgElementProps) {
}, []);
return (
<div className="relative">
<div className="relative shrink-0">
<img
{...restProps}
// avoid setting empty src string to prevent request to the entire page
@@ -104,6 +104,7 @@ export function SingleNetworkSelector(props: {
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData();
@@ -180,7 +181,11 @@ export function SingleNetworkSelector(props: {
props.onChange(Number(chainId));
}}
closeOnSelect={true}
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
placeholder={
isLoadingChains
? "Loading Chains..."
: props.placeholder || "Select Chain"
}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
@@ -96,7 +96,6 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
onClick: () => setEnvironment(env.environment),
isActive: activeEnvironment === env.environment,
name: env.title,
isEnabled: true,
}))}
tabClassName="text-sm gap-2 !text-sm"
tabIconClassName="size-4"
@@ -8,7 +8,7 @@ import {
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { Check, Copy } from "lucide-react";
import { Check, Copy, XIcon } from "lucide-react";
import { useMemo } from "react";
import { type ThirdwebClient, isAddress } from "thirdweb";
import { ZERO_ADDRESS } from "thirdweb";
@@ -16,6 +16,7 @@ import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { ToolTipLabel } from "../ui/tooltip";
import { Img } from "./Img";
export function WalletAddress(props: {
@@ -44,7 +45,16 @@ export function WalletAddress(props: {
const { onCopy, hasCopied } = useClipboard(address, 2000);
if (!isAddress(address)) {
return <span>Invalid Address ({address})</span>;
return (
<ToolTipLabel label={address} hoverable>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<div className="flex size-6 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
</div>
Invalid Address
</span>
</ToolTipLabel>
);
}
// special case for zero address
@@ -91,13 +91,11 @@ export function DatePickerWithRange(props: {
name: "From",
onClick: () => setScreen("from"),
isActive: screen === "from",
isEnabled: true,
},
{
name: "To",
onClick: () => setScreen("to"),
isActive: screen === "to",
isEnabled: true,
},
]}
/>
@@ -1,5 +1,4 @@
import * as parserBabel from "prettier/plugins/babel";
import * as estree from "prettier/plugins/estree";
import { format } from "prettier/standalone";
import { type BundledLanguage, codeToHtml } from "shiki";
@@ -21,10 +20,11 @@ export async function getCodeHtml(
ignoreFormattingErrors?: boolean;
},
) {
const estreePlugin = await import("prettier/plugins/estree");
const formattedCode = isPrettierSupportedLang(lang)
? await format(code, {
parser: "babel-ts",
plugins: [parserBabel, estree],
plugins: [parserBabel, estreePlugin.default],
printWidth: 60,
}).catch((e) => {
if (!options?.ignoreFormattingErrors) {
+3 -3
View File
@@ -79,7 +79,7 @@ export function TabButtons(props: {
name: React.ReactNode;
onClick: () => void;
isActive: boolean;
isEnabled?: boolean;
isDisabled?: boolean;
icon?: React.FC<{ className?: string }>;
}[];
tabClassName?: string;
@@ -119,11 +119,11 @@ export function TabButtons(props: {
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2 font-medium text-sm hover:bg-accent lg:px-3 lg:text-base",
!tab.isActive &&
"text-muted-foreground hover:text-foreground",
!tab.isEnabled && "cursor-not-allowed opacity-50",
tab.isDisabled && "cursor-not-allowed opacity-50",
props.tabClassName,
tab.isActive && props.activeTabClassName,
)}
onClick={tab.isEnabled ? tab.onClick : undefined}
onClick={!tab.isDisabled ? tab.onClick : undefined}
>
{tab.icon && (
<tab.icon className={cn("size-6", props.tabIconClassName)} />
@@ -1,59 +1,42 @@
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 { defineChain } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { name } from "thirdweb/extensions/common";
type RouteListCardProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri: string | null;
originTokenIconUri?: string | null;
originTokenSymbol: string;
originTokenName: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri: string | null;
destinationTokenIconUri?: string | null;
destinationTokenSymbol: string;
destinationTokenName: string;
};
export async function RouteListCard({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenName,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenName,
}: 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,
@@ -78,17 +61,17 @@ export async function RouteListCard({
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
className="size-8 rounded-full bg-white"
className="size-8 rounded-full border border-muted-foreground"
/>
) : (
<div className="size-8 rounded-full bg-white/10" />
<div className="size-8 rounded-full bg-muted-foreground" />
)}
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
className="-translate-x-4 size-8 rounded-full bg-white ring-2 ring-card"
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
/>
) : (
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
@@ -2,59 +2,41 @@ 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;
originTokenIconUri?: string | null;
originTokenSymbol?: string;
originTokenName?: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri: string | null;
destinationTokenIconUri?: string | null;
destinationTokenSymbol?: string;
destinationTokenName?: string;
};
export async function RouteListRow({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenSymbol,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenSymbol,
}: 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,
@@ -80,7 +62,7 @@ export async function RouteListRow({
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
className="size-6 rounded-full bg-muted-foreground"
className="size-6 rounded-full border border-muted-foreground"
/>
) : (
<div className="size-6 rounded-full bg-muted-foreground" />
@@ -115,7 +97,7 @@ export async function RouteListRow({
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
className="size-6 rounded-full bg-muted-foreground"
className="size-6 rounded-full border border-muted-foreground"
/>
) : (
<div className="size-6 rounded-full bg-muted-foreground" />
@@ -50,7 +50,7 @@ async function getRoutesToRender(params: SearchParams) {
}
}
// Temporary, will update this after the /routes endpoint
filters.limit = 10_000;
filters.limit = 50_000;
const routes = await getRoutes(filters);
@@ -107,9 +107,13 @@ export async function RoutesData(props: {
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenSymbol={route.originToken.symbol}
originTokenName={route.originToken.name}
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenSymbol={route.destinationToken.symbol}
destinationTokenName={route.destinationToken.name}
/>
))}
</TableBody>
@@ -126,9 +130,13 @@ export async function RoutesData(props: {
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenSymbol={route.originToken.symbol}
originTokenName={route.originToken.name}
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenSymbol={route.destinationToken.symbol}
destinationTokenName={route.destinationToken.name}
/>
</li>
))}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -22,7 +22,7 @@ export const metadata: Metadata = {
},
};
export default async function ChainListPage(props: {
export default async function RoutesPage(props: {
searchParams: Promise<SearchParams>;
}) {
const authToken = await getAuthToken();
@@ -4,11 +4,15 @@ export type Route = {
originToken: {
address: Address;
chainId: number;
iconUri: string;
iconUri?: string;
name: string;
symbol: string;
};
destinationToken: {
address: Address;
chainId: number;
iconUri: string;
iconUri?: string;
name: string;
symbol: string;
};
};
@@ -71,7 +71,6 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
name: mode,
isActive: mode === listingMode,
onClick: () => setListingMode(mode),
isEnabled: true,
}))}
tabClassName="text-sm gap-2 !text-sm"
tabContainerClassName="gap-0.5"
@@ -120,7 +120,7 @@ export default async function Page(props: {
const events = await getContractEvents({
contract: twCloneFactoryContract,
events: [ProxyDeployedEvent],
blockRange: latestBlockNumber < 2000000n ? latestBlockNumber : 2000000n,
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
});
const event = events.find(
(e) =>
@@ -45,7 +45,7 @@ export const products = [
link: "https://portal.thirdweb.com/connect/account-abstraction/overview",
},
{
name: "Pay",
name: "Universal Bridge",
id: "pay",
icon: PayIcon,
description: "Point of sale solution for bridging, onramping & swapping",
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

@@ -10,6 +10,8 @@ import ancient8Banner from "./temp-assets/ancient8-banner.png";
import assetChainBanner from "./temp-assets/assetChainBanner.png";
import assetChainCTABG from "./temp-assets/assetChainCTABanner.png";
import hemiSepoliaBanner from "./temp-assets/HemiBanner.png";
import hemiSepoliaCTA from "./temp-assets/HemiCTA.png";
import appChainBanner from "./temp-assets/appChainBanner.jpg";
// TEMPORARY
import appchainCTA from "./temp-assets/appchainCTA.png";
@@ -452,6 +454,18 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//HemiSepolia
743111: {
headerImgUrl: hemiSepoliaBanner.src,
about:
"The Hemi Network is a modular Layer-2 protocol for superior scaling, security, and interoperability, powered by Bitcoin and Ethereum.",
cta: {
backgroundImageUrl: hemiSepoliaCTA.src,
title: "One Network, Powered by Bitcoin and Ethereum",
buttonLink: "https://hemi.xyz/",
buttonText: "Learn More",
},
},
//Hashfire
4227: {
headerImgUrl: hashfireBanner.src,
@@ -100,7 +100,6 @@ const DeployOptions = (props: {
key,
name: value.title,
isActive: activeTab === key,
isEnabled: true,
onClick: () => setActiveTab(key as TabId),
}))}
tabClassName="font-medium"
@@ -22,6 +22,11 @@ export async function getRawAccount() {
},
});
if (!res.ok) {
console.error("Error fetching account", res.status, res.statusText);
return undefined;
}
const json = await res.json();
if (json.error) {
+11 -2
View File
@@ -19,6 +19,7 @@ import {
} from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { isVercel } from "../../lib/vercel-utils";
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
import { getSDKTheme } from "../components/sdk-component-theme";
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
@@ -246,14 +247,17 @@ function CustomConnectEmbed(props: {
}) {
const { theme } = useTheme();
const client = useThirdwebClient();
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileToken, setTurnstileToken] = useState<string | undefined>(
undefined,
);
const [alwaysShowTurnstile, setAlwaysShowTurnstile] = useState(false);
return (
<div className="flex flex-col items-center gap-4">
<Turnstile
options={{
// only show if interaction is required
appearance: "interaction-only",
appearance: alwaysShowTurnstile ? "always" : "interaction-only",
// match the theme of the rest of the app
theme: theme === "light" ? "light" : "dark",
}}
@@ -265,6 +269,11 @@ function CustomConnectEmbed(props: {
auth={{
getLoginPayload,
doLogin: async (params) => {
if (isVercel() && !turnstileToken) {
setAlwaysShowTurnstile(true);
throw new Error("Please complete the captcha.");
}
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
+1 -1
View File
@@ -40,7 +40,7 @@ export async function getLoginPayload(
export async function doLogin(
payload: VerifyLoginPayloadParams,
turnstileToken: string,
turnstileToken: string | undefined,
) {
if (!THIRDWEB_API_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
@@ -89,13 +89,11 @@ export function LoginOrSignup(props: {
name: "Create account",
onClick: () => setTab("signup"),
isActive: tab === "signup",
isEnabled: true,
},
{
name: "I already have an account",
onClick: () => setTab("login"),
isActive: tab === "login",
isEnabled: true,
},
]}
/>
@@ -227,13 +227,11 @@ function InviteModalContent(props: {
name: "Starter",
onClick: () => setPlanToShow("starter"),
isActive: planToShow === "starter",
isEnabled: true,
},
{
name: "Growth",
onClick: () => setPlanToShow("growth"),
isActive: planToShow === "growth",
isEnabled: true,
},
]}
/>
@@ -1,13 +1,13 @@
"use client";
import { getBillingCheckoutUrl } from "@/actions/billing";
import { apiServerProxy } from "@/actions/proxies";
import { sendTeamInvites } from "@/actions/sendTeamInvite";
import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { apiServerProxy } from "../../../../@/actions/proxies";
import { useTrack } from "../../../../hooks/analytics/useTrack";
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
@@ -96,6 +96,7 @@ export function ChatPageContent(props: {
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
_setContextFilters(v);
setHasUserUpdatedContextFilters(true);
saveLastUsedChainIds(v?.chainIds || undefined);
}, []);
const isNewSession = !props.session;
@@ -118,7 +119,21 @@ export function ChatPageContent(props: {
walletAddress: null,
};
// Only set wallet address from connected wallet
updatedContextFilters.walletAddress = address || null;
// if we have last used chains in storage, continue using them
try {
const lastUsedChainIds = getLastUsedChainIds();
if (lastUsedChainIds) {
updatedContextFilters.chainIds = lastUsedChainIds;
return updatedContextFilters;
}
} catch {
// ignore local storage errors
}
// else - use the active chain
updatedContextFilters.chainIds = activeChain
? [activeChain.id.toString()]
: [];
@@ -493,3 +508,31 @@ function WalletDisconnectedDialog(props: {
</Dialog>
);
}
const NEBULA_LAST_USED_CHAIN_IDS_KEY = "nebula-last-used-chain-ids";
function saveLastUsedChainIds(chainIds: string[] | undefined) {
try {
if (chainIds && chainIds.length > 0) {
localStorage.setItem(
NEBULA_LAST_USED_CHAIN_IDS_KEY,
JSON.stringify(chainIds),
);
} else {
localStorage.removeItem(NEBULA_LAST_USED_CHAIN_IDS_KEY);
}
} catch {
// ignore local storage errors
}
}
function getLastUsedChainIds(): string[] | null {
try {
const lastUsedChainIdsStr = localStorage.getItem(
NEBULA_LAST_USED_CHAIN_IDS_KEY,
);
return lastUsedChainIdsStr ? JSON.parse(lastUsedChainIdsStr) : null;
} catch {
return null;
}
}
@@ -1,10 +1,11 @@
"use client";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
@@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
@@ -44,22 +44,21 @@ function BillingAlertBanner(props: {
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
<p className="mt-1 mb-4 text-sm">{props.description}</p>
<BillingPortalButton
buttonProps={{
size: "sm",
className: cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
),
}}
teamSlug={props.teamSlug}
getBillingPortalUrl={props.getBillingPortalUrl}
<Button
asChild
className={cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
)}
>
{props.ctaLabel}
</BillingPortalButton>
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
{props.ctaLabel}
</Link>
</Button>
</div>
);
}
@@ -73,7 +72,6 @@ export function PastDueBannerUI(props: {
ctaLabel="View Invoices"
variant="warning"
title="Unpaid Invoices"
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
You have unpaid invoices. Service may be suspended if not paid
@@ -94,7 +92,6 @@ export function ServiceCutOffBannerUI(props: {
ctaLabel="Pay Now"
variant="error"
title="Service Suspended"
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
Your service has been suspended due to unpaid invoices. Pay now to
@@ -63,7 +63,12 @@ export default async function TeamOverviewPage(props: {
return (
<div className="flex grow flex-col">
<div className="border-b">
<AnalyticsHeader title="Analytics" interval={interval} range={range} />
<AnalyticsHeader
title="Analytics"
interval={interval}
range={range}
showRangeSelector={true}
/>
</div>
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16">
<Suspense fallback={<GenericLoadingPage />}>
@@ -0,0 +1,50 @@
import {} from "@/components/ui/breadcrumb";
import { getAuthToken } from "../../../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
import { AddPartnerForm } from "../components/client/add-partner-form.client";
import { fetchEcosystem } from "../hooks/fetchEcosystem";
export default async function AddPartnerPage({
params,
}: {
params: Promise<{ slug: string; team_slug: string }>;
}) {
const { slug, team_slug } = await params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}`);
}
const teamSlug = team_slug;
const ecosystemSlug = slug;
try {
const ecosystem = await fetchEcosystem({
teamIdOrSlug: teamSlug,
slug: ecosystemSlug,
authToken,
});
return (
<div className="flex flex-col">
<div className="container">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Add New Partner
</h1>
<AddPartnerForm ecosystem={ecosystem} authToken={authToken} />
</div>
</div>
);
} catch (error) {
console.error("Error fetching ecosystem:", error);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">Error</h1>
<p>Could not load ecosystem. Please try again.</p>
</div>
</div>
);
}
}
@@ -1,43 +1,23 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import type { Ecosystem } from "../../../../../types";
import { AddPartnerForm } from "./add-partner-form.client";
export function AddPartnerDialogButton(props: {
teamSlug: string;
ecosystem: Ecosystem;
authToken: string;
}) {
const [open, setOpen] = useState(false);
const addPartnerUrl = `/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/add-partner`;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle className="font-semibold text-2xl tracking-tight">
Add Partner
</DialogTitle>
</DialogHeader>
<AddPartnerForm
ecosystem={props.ecosystem}
onPartnerAdded={() => setOpen(false)}
authToken={props.authToken}
/>
</DialogContent>
</Dialog>
<Link href={addPartnerUrl} passHref>
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
</Link>
);
}
@@ -21,7 +21,11 @@ export function EcosystemPermissionsPage({
/>
<AuthOptionsSection ecosystem={ecosystem} authToken={authToken} />
{ecosystem?.permission === "PARTNER_WHITELIST" && (
<EcosystemPartnersSection ecosystem={ecosystem} authToken={authToken} />
<EcosystemPartnersSection
teamSlug={params.team_slug}
ecosystem={ecosystem}
authToken={authToken}
/>
)}
</div>
);
@@ -1,32 +1,22 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
"use client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { z } from "zod";
import type { Ecosystem } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import type { Ecosystem, Partner } from "../../../../../types";
import { useAddPartner } from "../../hooks/use-add-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
export function AddPartnerForm({
ecosystem,
onPartnerAdded,
authToken,
}: { authToken: string; ecosystem: Ecosystem; onPartnerAdded: () => void }) {
const form = useForm<z.input<typeof partnerFormSchema>>({
resolver: zodResolver(partnerFormSchema),
});
}: {
ecosystem: Ecosystem;
authToken: string;
}) {
const router = useDashboardRouter();
const params = useParams();
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const { mutateAsync: addPartner, isPending } = useAddPartner(
{
@@ -34,7 +24,13 @@ export function AddPartnerForm({
},
{
onSuccess: () => {
onPartnerAdded();
toast.success("Partner added successfully", {
description: "The partner has been added to your ecosystem.",
});
// Redirect to the redirect page that will take us back to the configuration page
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
router.push(redirectPath);
},
onError: (error) => {
const message =
@@ -46,95 +42,28 @@ export function AddPartnerForm({
},
);
const handleSubmit = (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
addPartner({
ecosystem,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
accessControl: finalAccessControl,
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
addPartner({
ecosystem,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
});
})}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> App Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "lg:-translate-y-4 hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription>
Space or comma-separated list of regex domains (e.g.
*.example.com)
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription>
Space or comma-separated list of bundle IDs
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button disabled={isPending} type="submit" className="w-full gap-2">
{isPending && <Spinner className="size-4" />}
Add
</Button>
</form>
</Form>
<PartnerForm
onSubmit={handleSubmit}
isSubmitting={isPending}
submitLabel="Add"
/>
);
}
@@ -0,0 +1,677 @@
"use client";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { type Control, useFieldArray } from "react-hook-form";
import { SingleNetworkSelector } from "../../../../../../../../../../../@/components/blocks/NetworkSelectors";
import type { PartnerFormValues } from "./partner-form.client";
type AllowedOperationsSectionProps = {
control: Control<PartnerFormValues>;
enabled: boolean;
onToggle: (checked: boolean) => void;
};
export function AllowedOperationsSection({
control,
enabled,
onToggle,
}: AllowedOperationsSectionProps) {
// Setup field array for allowed operations
const allowedOperationsFields = useFieldArray({
control,
name: "accessControl.allowedOperations",
});
return (
<div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-center justify-between gap-6 ">
<div>
<Label htmlFor="allowed-operations-switch" className="text-base">
Allowed Operations
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Configure which signing operations are allowed for this partner
</p>
</div>
<Switch
id="allowed-operations-switch"
checked={enabled}
onCheckedChange={onToggle}
/>
</div>
{enabled && (
<div className="rounded-lg">
<div className="flex flex-col gap-6">
{allowedOperationsFields.fields.map((field, index) => (
<div
key={field.id}
className="rounded-md border border-border p-4"
>
<div className="mb-4 flex items-center justify-between">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.signMethod`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Allowed Signature Method</FormLabel>
<FormDescription className="text-xs">
Select the types of signatures that are allowed for
this partner
</FormDescription>
<Select
onValueChange={(value) => {
// Update the signature method
field.onChange(value);
// Reset the operation data based on the selected method
if (value === "eth_signTransaction") {
// Remove any existing fields for other methods
allowedOperationsFields.update(index, {
signMethod: value,
allowedTransactions: [],
});
} else if (value === "eth_signTypedData_v4") {
allowedOperationsFields.update(index, {
signMethod: value,
allowedTypedData: [],
});
} else if (value === "personal_sign") {
allowedOperationsFields.update(index, {
signMethod: value,
allowedPersonalSigns: [],
});
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a signature method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="eth_signTransaction">
eth_signTransaction
</SelectItem>
<SelectItem value="eth_signTypedData_v4">
eth_signTypedData_v4
</SelectItem>
<SelectItem value="personal_sign">
personal_sign
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
aria-label="Remove operation"
onClick={() => {
allowedOperationsFields.remove(index);
}}
className="!w-auto ml-4 h-10 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
{/* Render different fields based on the signature method */}
{allowedOperationsFields.fields[index]?.signMethod ===
"eth_signTransaction" && (
<TransactionRestrictions control={control} index={index} />
)}
{allowedOperationsFields.fields[index]?.signMethod ===
"eth_signTypedData_v4" && (
<TypedDataRestrictions control={control} index={index} />
)}
{allowedOperationsFields.fields[index]?.signMethod ===
"personal_sign" && (
<PersonalSignRestrictions control={control} index={index} />
)}
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
allowedOperationsFields.append({
signMethod: "eth_signTransaction",
allowedTransactions: [],
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add operation
</Button>
</div>
</div>
)}
</div>
);
}
// Component for eth_signTransaction restrictions
function TransactionRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const transactionsArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedTransactions`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Transactions</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific transactions patterns that are allowed for this
partner. Optional, default is all transactions.
</FormDescription>
<FormDescription className="mb-4 text-warning-text text-xs">
Attention - 'eth_signTransaction' signatures are only for EOA
transactions. To specify allowed Smart Account transactions, you need to
select the 'eth_personalSign' signature method, with type 'userOp' (user
operations).
</FormDescription>
<div className="flex flex-col gap-4">
{transactionsArray.fields.map((field, txIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.contractAddress`}
render={({ field }) => (
<FormItem>
<FormLabel>Contract Address (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.selector`}
render={({ field }) => (
<FormItem>
<FormLabel>Function Selector (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.maxValue`}
render={({ field }) => (
<FormItem>
<FormLabel>Max Value in Wei (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove transaction"
onClick={() => {
transactionsArray.remove(txIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
transactionsArray.append({
chainId: 1,
contractAddress: "",
selector: "",
maxValue: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add transaction
</Button>
</div>
</div>
);
}
// Component for eth_signTypedData_v4 restrictions
function TypedDataRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const typedDataArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedTypedData`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Typed Data</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific typed data patterns that are allowed for this
partner. Optional, default is any typed data.
</FormDescription>
<div className="flex flex-col gap-4">
{typedDataArray.fields.map((field, dataIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.domain`}
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="Domain name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.verifyingContract`}
render={({ field }) => (
<FormItem>
<FormLabel>Verifying Contract (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain (optional)</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.primaryType`}
render={({ field }) => (
<FormItem>
<FormLabel>Primary Type (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="Type" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove typed data"
onClick={() => {
typedDataArray.remove(dataIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
typedDataArray.append({
domain: "",
verifyingContract: undefined,
chainId: undefined,
primaryType: undefined,
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add typed data
</Button>
</div>
</div>
);
}
// Component for personal_sign restrictions
function PersonalSignRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const personalSignArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedPersonalSigns`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Personal Sign Data</Label>
<FormDescription className="mb-4 text-xs">
Specify the types of personal signatures that are allowed for this
partner. Optional, default is all personal signatures.
</FormDescription>
<div className="flex flex-col gap-4">
{personalSignArray.fields.map((field, signIndex) => (
<div key={field.id} className="rounded-md border border-border p-3">
<div className="mb-4 flex items-center justify-between">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.messageType`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Message Type</FormLabel>
<Select
onValueChange={(value) => {
// Update the message type
field.onChange(value);
// Reset the message data based on the selected type
if (value === "userOp") {
personalSignArray.update(signIndex, {
messageType: value,
allowedTransactions: [],
});
} else if (value === "other") {
personalSignArray.update(signIndex, {
messageType: value,
message: "",
});
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select message type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="userOp">userOp</SelectItem>
<SelectItem value="other">other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
aria-label="Remove personal sign"
onClick={() => {
personalSignArray.remove(signIndex);
}}
className="!w-auto ml-4 h-10 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
{personalSignArray.fields[signIndex]?.messageType === "userOp" ? (
<UserOpTransactions
control={control}
opIndex={index}
signIndex={signIndex}
/>
) : (
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.message`}
render={({ field }) => (
<FormItem>
<FormLabel>Message (optional)</FormLabel>
<FormControl>
<Input placeholder="Message pattern" {...field} />
</FormControl>
<FormDescription className="text-xs">
Specify a message pattern that is allowed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
personalSignArray.append({
messageType: "other",
message: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add personal sign
</Button>
</div>
</div>
);
}
// Component for userOp transactions within personal_sign
function UserOpTransactions({
control,
opIndex,
signIndex,
}: {
control: Control<PartnerFormValues>;
opIndex: number;
signIndex: number;
}) {
const userOpTransactionsArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">
Allowed User Operations (smart account transactions)
</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific User Operations that are allowed for this partner.
Optional, default is all User Operations.
</FormDescription>
<div className="flex flex-col gap-4">
{userOpTransactionsArray.fields.map((field, txIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.contractAddress`}
render={({ field }) => (
<FormItem>
<FormLabel>Contract Address (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.selector`}
render={({ field }) => (
<FormItem>
<FormLabel>Function Selector (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.maxValue`}
render={({ field }) => (
<FormItem>
<FormLabel>Max Value in Wei (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove transaction"
onClick={() => {
userOpTransactionsArray.remove(txIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
userOpTransactionsArray.append({
chainId: 1,
contractAddress: "",
selector: "",
maxValue: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add transaction
</Button>
</div>
</div>
);
}
@@ -0,0 +1,374 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import type { z } from "zod";
import type { Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { AllowedOperationsSection } from "./allowed-operations-section";
export type PartnerFormValues = z.infer<typeof partnerFormSchema>;
type PartnerFormProps = {
partner?: Partner; // Optional for add form
onSubmit: (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => void;
isSubmitting: boolean;
submitLabel: string;
};
export function PartnerForm({
partner,
onSubmit,
isSubmitting,
submitLabel,
}: PartnerFormProps) {
// Check if partner has accessControl and serverVerifier
const hasAccessControl = partner ? !!partner.accessControl : false;
const hasServerVerifier =
hasAccessControl && !!partner?.accessControl?.serverVerifier;
const hasAllowedOperations =
hasAccessControl && !!partner?.accessControl?.allowedOperations?.length;
const form = useForm<PartnerFormValues>({
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: partner?.name || "",
domains: partner?.allowlistedDomains.join(",") || "",
bundleIds: partner?.allowlistedBundleIds.join(",") || "",
// Set the actual accessControl data if it exists
accessControl: partner?.accessControl,
// Set the UI control properties based on existing data
accessControlEnabled: hasAccessControl,
serverVerifierEnabled: hasServerVerifier,
allowedOperationsEnabled: hasAllowedOperations,
},
mode: "onChange", // Validate on change for better user experience
});
// Watch the boolean flags for UI state
const accessControlEnabled = form.watch("accessControlEnabled");
const serverVerifierEnabled = form.watch("serverVerifierEnabled");
const allowedOperationsEnabled = form.watch("allowedOperationsEnabled");
// Setup field array for headers
const customHeaderFields = useFieldArray({
control: form.control,
name: "accessControl.serverVerifier.headers",
});
const handleSubmit = form.handleSubmit(
(values) => {
// Construct the final accessControl object based on the enabled flags
let finalAccessControl: Partner["accessControl"] | null = null;
if (values.accessControlEnabled) {
finalAccessControl = {} as Partner["accessControl"];
if (finalAccessControl && values.serverVerifierEnabled) {
finalAccessControl.serverVerifier = {
url: values.accessControl?.serverVerifier?.url || "",
headers: values.accessControl?.serverVerifier?.headers || [],
};
}
if (finalAccessControl && values.allowedOperationsEnabled) {
finalAccessControl.allowedOperations =
values.accessControl?.allowedOperations || [];
}
// if no values have been set, remove the accessControl object
if (
finalAccessControl &&
Object.keys(finalAccessControl).length === 0
) {
finalAccessControl = null;
}
}
onSubmit(values, finalAccessControl);
},
(errors) => {
// Log validation errors for debugging
console.error("Form validation errors:", errors);
},
);
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex grow flex-col gap-5">
<FormField
control={form.control}
name="name"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4 lg:col-span-4">
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.domains?.message &&
"block translate-y-0 text-destructive opacity-100",
)}
>
{form.formState.errors.domains?.message ??
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.bundleIds?.message &&
"block text-destructive",
)}
>
{form.formState.errors.bundleIds?.message ??
"Space or comma-separated list of bundle IDs"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Access Control Section */}
<div className="mb-4 flex items-center justify-between gap-6">
<div>
<Label htmlFor="access-control-switch" className="text-base">
Access Control
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Enable access control for this partner
</p>
</div>
<Switch
id="access-control-switch"
checked={accessControlEnabled}
onCheckedChange={(checked) => {
form.setValue("accessControlEnabled", checked);
// If disabling access control, also disable server verifier and allowed operations
if (!checked) {
form.setValue("serverVerifierEnabled", false);
form.setValue("allowedOperationsEnabled", false);
}
}}
/>
</div>
{accessControlEnabled && (
<>
<div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-center justify-between gap-6">
<div>
<Label
htmlFor="server-verifier-switch"
className="text-base"
>
Server Verifier
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Configure a server verifier for access control
</p>
</div>
<Switch
id="server-verifier-switch"
checked={serverVerifierEnabled}
onCheckedChange={(checked) => {
form.setValue("serverVerifierEnabled", checked);
// Initialize serverVerifier fields if enabling
if (
checked &&
!form.getValues("accessControl.serverVerifier")
) {
form.setValue("accessControl.serverVerifier", {
url: "",
headers: [],
});
}
}}
/>
</div>
{serverVerifierEnabled && (
<div className="mt-4 grid grid-cols-1 gap-6">
<FormField
control={form.control}
name="accessControl.serverVerifier.url"
render={({ field }) => (
<FormItem>
<FormLabel>Server Verifier URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-verifier"
/>
</FormControl>
<FormDescription
className={cn(
"text-xs",
form.formState.errors.accessControl
?.serverVerifier?.url && "text-destructive",
)}
>
{form.formState.errors.accessControl?.serverVerifier
?.url?.message ||
"Enter the URL of your server where verification requests will be sent"}
</FormDescription>
</FormItem>
)}
/>
<div>
<Label className="mb-3 inline-block">
Custom Headers
</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, headerIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...form.register(
`accessControl.serverVerifier.headers.${headerIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...form.register(
`accessControl.serverVerifier.headers.${headerIdx}.value`,
)}
/>
<Button
variant="outline"
aria-label="Remove header"
onClick={() => {
customHeaderFields.remove(headerIdx);
}}
className="!w-auto px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
<p className="mt-3 text-muted-foreground text-xs">
Set custom headers to be sent along with verification
requests
</p>
</div>
</div>
)}
</div>
{/* Allowed Operations Section */}
<AllowedOperationsSection
control={form.control}
enabled={allowedOperationsEnabled}
onToggle={(checked) => {
form.setValue("allowedOperationsEnabled", checked);
// Initialize allowedOperations array if enabling
if (
checked &&
!form.getValues("accessControl.allowedOperations")
) {
form.setValue("accessControl.allowedOperations", []);
}
}}
/>
</>
)}
</div>
<Button
disabled={isSubmitting}
type="submit"
className="mt-4 w-full gap-2"
>
{isSubmitting && <Spinner className="size-4" />}
{submitLabel}
</Button>
</form>
</Form>
);
}
@@ -1,44 +1,24 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { z } from "zod";
import type { Ecosystem, Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { useUpdatePartner } from "../../hooks/use-update-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
export function UpdatePartnerForm({
ecosystem,
partner,
onSuccess,
authToken,
}: {
ecosystem: Ecosystem;
partner: Partner;
onSuccess: () => void;
authToken: string;
}) {
const form = useForm<z.input<typeof partnerFormSchema>>({
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: partner.name,
domains: partner.allowlistedDomains.join(","),
bundleIds: partner.allowlistedBundleIds.join(","),
},
});
const router = useDashboardRouter();
const params = useParams();
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
{
@@ -46,123 +26,48 @@ export function UpdatePartnerForm({
},
{
onSuccess: () => {
form.reset();
onSuccess();
toast.success("Partner updated successfully", {
description: "The partner details have been updated.",
});
// Redirect to the redirect page that will take us back to the configuration page
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
router.push(redirectPath);
},
onError: (error) => {
const message =
error instanceof Error
? error.message
: "Failed to add ecosystem partner";
: "Failed to update ecosystem partner";
toast.error(message);
},
},
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
updatePartner({
ecosystem,
partnerId: partner.id,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
});
})}
className="flex flex-col gap-4"
>
<div className="flex grow flex-col gap-5">
<FormField
control={form.control}
name="name"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4 lg:col-span-4">
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.domains?.message &&
"block translate-y-0 text-destructive opacity-100", // If there are errors show them rather than the tip
)}
>
{form.formState.errors.domains?.message ??
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.bundleIds?.message &&
"block text-destructive",
)}
>
{form.formState.errors.bundleIds?.message ??
"Space or comma-separated list of bundle IDs"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
const handleSubmit = (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
updatePartner({
ecosystem,
partnerId: partner.id,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
accessControl: finalAccessControl,
});
};
<Button
disabled={isPending}
type="submit"
className="mt-4 w-full gap-2"
>
{isPending && <Spinner className="size-4" />}
Update
</Button>
</form>
</Form>
return (
<PartnerForm
partner={partner}
onSubmit={handleSubmit}
isSubmitting={isPending}
submitLabel="Update"
/>
);
}
@@ -1,42 +0,0 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState } from "react";
import type { Ecosystem, Partner } from "../../../../../types";
import { UpdatePartnerForm } from "./update-partner-form.client";
export function UpdatePartnerModal({
children,
ecosystem,
partner,
authToken,
}: {
children: React.ReactNode;
ecosystem: Ecosystem;
partner: Partner;
authToken: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Update {partner.name}</DialogTitle>
</DialogHeader>
<UpdatePartnerForm
ecosystem={ecosystem}
partner={partner}
onSuccess={() => setOpen(false)}
authToken={authToken}
/>
</DialogContent>
</Dialog>
);
}
@@ -3,9 +3,10 @@ import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton";
import { PartnersTable } from "./partners-table";
export function EcosystemPartnersSection({
teamSlug,
ecosystem,
authToken,
}: { ecosystem: Ecosystem; authToken: string }) {
}: { teamSlug: string; ecosystem: Ecosystem; authToken: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-6 lg:px-6">
<div className="flex flex-col items-start justify-between max-sm:mb-5 lg:flex-row">
@@ -21,10 +22,18 @@ export function EcosystemPartnersSection({
own app.
</p>
</div>
<AddPartnerDialogButton ecosystem={ecosystem} authToken={authToken} />
<AddPartnerDialogButton
teamSlug={teamSlug}
ecosystem={ecosystem}
authToken={authToken}
/>
</div>
<PartnersTable ecosystem={ecosystem} authToken={authToken} />
<PartnersTable
ecosystem={ecosystem}
authToken={authToken}
teamSlug={teamSlug}
/>
</div>
);
}
@@ -12,20 +12,28 @@ import {
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Link } from "../../../../../../../../../../../tw-components/link";
import type { Ecosystem, Partner } from "../../../../../types";
import { usePartners } from "../../../hooks/use-partners";
import { useDeletePartner } from "../../hooks/use-delete-partner";
import { UpdatePartnerModal } from "../client/update-partner-modal.client";
export function PartnersTable({
ecosystem,
authToken,
}: { ecosystem: Ecosystem; authToken: string }) {
const { partners, isPending } = usePartners({ ecosystem, authToken });
teamSlug,
}: {
ecosystem: Ecosystem;
authToken: string;
teamSlug: string;
}) {
const { partners, isPending } = usePartners({
ecosystem,
authToken,
});
if (isPending) {
return (
@@ -59,6 +67,7 @@ export function PartnersTable({
partner={partner}
ecosystem={ecosystem}
authToken={authToken}
teamSlug={teamSlug}
/>
))}
</TableBody>
@@ -70,8 +79,10 @@ export function PartnersTable({
function PartnerRow(props: {
partner: Partner;
ecosystem: Ecosystem;
teamSlug: string;
authToken: string;
}) {
const router = useDashboardRouter();
const { mutateAsync: deletePartner, isPending: isDeleting } =
useDeletePartner(
{
@@ -117,22 +128,21 @@ function PartnerRow(props: {
<td className="table-cell py-3 align-middle">
<div className="flex justify-end gap-3 pr-3">
<UpdatePartnerModal
partner={props.partner}
ecosystem={props.ecosystem}
authToken={props.authToken}
<Button
type="button"
variant="outline"
size="icon"
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
disabled={isDeleting}
onClick={() => {
router.push(
`/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/partners/${props.partner.id}/edit`,
);
}}
>
<Button
type="button"
variant="outline"
size="icon"
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
disabled={isDeleting}
>
<Pencil className="size-4" />
<span className="sr-only">Edit</span>
</Button>
</UpdatePartnerModal>
<Pencil className="size-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
title={`Are you sure you want to delete the partner ${props.partner.name}?`}
description={
@@ -1,33 +1,248 @@
import { isAddress, isHex } from "thirdweb/utils";
import z from "zod";
const isDomainRegex =
/^(?:\*|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*(?:\*(?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*)?)|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|localhost(?::\d{1,5})?)$/;
export const partnerFormSchema = z.object({
name: z
// Define schemas for the different operation types
const allowedTransactionSchema = z.object({
chainId: z.number().refine(
(data) => {
if (data !== undefined) {
return data > 0;
}
return true;
},
{
message: "Invalid chain ID",
},
),
contractAddress: z
.string()
.trim()
.min(3, {
message: "Name must be at least 3 characters",
})
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
domains: z
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isAddress(data);
}
return true;
},
{
message: "Invalid contract address",
},
),
selector: z
.string()
.trim()
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
})
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
bundleIds: z
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isHex(data) && data.length === 10; // 0x + 4 bytes for the selector (8 chars)
}
return true;
},
{
message: "Invalid function selector",
},
),
maxValue: z
.string()
.trim()
.transform((s) =>
s
.split(/,| /)
.filter((d) => d.length > 0)
.join(","),
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return BigInt(data) >= 0;
}
return true;
},
{
message: "Invalid max value",
},
),
});
const allowedTypedDataSchema = z
.object({
domain: z.string().refine((data) => data.length > 0, {
message: "Domain is required",
}),
verifyingContract: z
.string()
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isAddress(data);
}
return true;
},
{
message: "Invalid verifying contract address",
},
),
chainId: z
.number()
.optional()
.refine(
(data) => {
if (data !== undefined) {
return data > 0;
}
return true;
},
{
message: "Invalid chain ID",
},
),
primaryType: z.string().optional(),
})
.transform((data) => {
return {
...data,
verifyingContract:
data.verifyingContract && data.verifyingContract.length > 0
? data.verifyingContract
: undefined,
primaryType:
data.primaryType && data.primaryType.length > 0
? data.primaryType
: undefined,
};
});
const personalSignRestrictionSchema = z.discriminatedUnion("messageType", [
z.object({
messageType: z.literal("userOp"),
allowedTransactions: z
.array(allowedTransactionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
messageType: z.literal("other"),
message: z.string().optional(),
}),
]);
const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
z.object({
signMethod: z.literal("eth_signTransaction"),
allowedTransactions: z
.array(allowedTransactionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
signMethod: z.literal("eth_signTypedData_v4"),
allowedTypedData: z
.array(allowedTypedDataSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
signMethod: z.literal("personal_sign"),
allowedPersonalSigns: z
.array(personalSignRestrictionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
]);
export const partnerFormSchema = z
.object({
name: z
.string()
.trim()
.min(3, {
message: "Name must be at least 3 characters",
})
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
domains: z
.string()
.trim()
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
})
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
bundleIds: z
.string()
.trim()
.transform((s) =>
s
.split(/,| /)
.filter((d) => d.length > 0)
.join(","),
),
accessControlEnabled: z.boolean().default(false),
serverVerifierEnabled: z.boolean().default(false),
allowedOperationsEnabled: z.boolean().default(false),
accessControl: z
.object({
serverVerifier: z
.object({
url: z.string().optional(),
headers: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
})
.optional(),
allowedOperations: z
.array(allowedOperationsSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
})
.optional(),
})
.refine(
(data) => {
// If serverVerifier is enabled, validate the URL
if (data.accessControlEnabled && data.serverVerifierEnabled) {
return (
data.accessControl?.serverVerifier?.url &&
data.accessControl.serverVerifier.url.trim() !== ""
);
}
// Otherwise, no validation needed
return true;
},
{
message:
"Server Verifier URL is required when Server Verifier is enabled",
path: ["accessControl", "serverVerifier", "url"],
},
)
.refine(
(data) => {
// If serverVerifier is enabled, validate the URL format
if (
data.accessControlEnabled &&
data.serverVerifierEnabled &&
data.accessControl?.serverVerifier?.url
) {
try {
new URL(data.accessControl.serverVerifier.url);
return true;
} catch {
return false;
}
}
// Otherwise, no validation needed
return true;
},
{
message: "Please enter a valid URL",
path: ["accessControl", "serverVerifier", "url"],
},
);
@@ -0,0 +1,34 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../../../../types";
/**
* Fetches ecosystem data from the server
*/
export async function fetchEcosystem(args: {
teamIdOrSlug: string;
slug: string;
authToken: string;
}): Promise<Ecosystem> {
const { teamIdOrSlug, slug, authToken } = args;
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
revalidate: 0,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystem",
);
}
return (await res.json()).result as Ecosystem;
}
@@ -0,0 +1,32 @@
import type { Ecosystem, Partner } from "../../../../types";
export async function fetchPartnerDetails(args: {
authToken: string;
ecosystem: Ecosystem;
partnerId: string;
}): Promise<Partner> {
const { authToken, ecosystem, partnerId } = args;
try {
const response = await fetch(
`${ecosystem.url}/${ecosystem.id}/partner/${partnerId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch partner details: ${response.status} - ${response.statusText}`,
);
}
return await response.json();
} catch (error) {
console.error("Error fetching partner:", error);
throw error;
}
}
@@ -0,0 +1,34 @@
import type { Ecosystem, Partner } from "../../../../types";
/**
* Fetches partners for an ecosystem
*/
export async function fetchPartners({
ecosystem,
authToken,
}: {
ecosystem: Ecosystem;
authToken: string;
}): Promise<Partner[]> {
const res = await fetch(`${ecosystem.url}/${ecosystem.id}/partners`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
revalidate: 0,
},
});
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch partners",
);
}
const partners = (await res.json()) as Partner[];
return partners.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
@@ -10,6 +10,7 @@ type AddPartnerParams = {
name: string;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
accessControl?: Partner["accessControl"] | null;
};
export function useAddPartner(
@@ -41,6 +42,7 @@ export function useAddPartner(
name: params.name,
allowlistedDomains: params.allowlistedDomains,
allowlistedBundleIds: params.allowlistedBundleIds,
accessControl: params.accessControl ?? undefined,
// TODO - remove the requirement for permissions in API endpoint
permissions: ["FULL_CONTROL_V1"],
}),
@@ -11,6 +11,12 @@ type UpdatePartnerParams = {
name: string;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
accessControl?: {
serverVerifier?: {
url: string;
headers?: { key: string; value: string }[];
} | null;
} | null;
};
export function useUpdatePartner(
@@ -42,6 +48,7 @@ export function useUpdatePartner(
name: params.name,
allowlistedDomains: params.allowlistedDomains,
allowlistedBundleIds: params.allowlistedBundleIds,
accessControl: params.accessControl,
}),
},
);
@@ -0,0 +1,96 @@
import {} from "@/components/ui/breadcrumb";
import { getAuthToken } from "../../../../../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";
import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client";
import { fetchEcosystem } from "../../../hooks/fetchEcosystem";
import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails";
export default async function EditPartnerPage({
params,
}: {
params: Promise<{ slug: string; team_slug: string; partner_id: string }>;
}) {
const { slug, team_slug, partner_id } = await params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}/configuration`);
}
const teamSlug = team_slug;
const ecosystemSlug = slug;
const partnerId = partner_id;
try {
const ecosystem = await fetchEcosystem({
teamIdOrSlug: teamSlug,
slug: ecosystemSlug,
authToken,
});
try {
// TODO re-enable this once IAW service is re deployed
const partner = await fetchPartnerDetails({
ecosystem,
partnerId,
authToken,
});
// const partners = await fetchPartners({
// ecosystem,
// authToken,
// });
// const partner = partners.find((p) => p.id === partnerId);
if (!partner) {
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Error
</h1>
<p>Could not load partner details. Please try again.</p>
</div>
</div>
);
}
return (
<div className="flex flex-col">
<div className="container">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Edit Partner: {partner.name}
</h1>
<UpdatePartnerForm
ecosystem={ecosystem}
partner={partner}
authToken={authToken}
/>
</div>
</div>
);
} catch (partnerError) {
console.error("Error fetching partner:", partnerError);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Error
</h1>
<p>Could not load partner details. Please try again.</p>
</div>
</div>
);
}
} catch (ecosystemError) {
console.error("Error fetching ecosystem:", ecosystemError);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">Error</h1>
<p>Could not load ecosystem. Please try again.</p>
</div>
</div>
);
}
}
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type { Ecosystem, Partner } from "../../../types";
import { fetchPartners } from "../configuration/hooks/fetchPartners";
export function usePartners({
ecosystem,
@@ -8,21 +9,7 @@ export function usePartners({
const partnersQuery = useQuery({
queryKey: ["ecosystem", ecosystem.id, "partners"],
queryFn: async () => {
const res = await fetch(`${ecosystem.url}/${ecosystem.id}/partners`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystems",
);
}
return (await res.json()) as Partner[];
return fetchPartners({ ecosystem, authToken });
},
retry: false,
});
@@ -55,4 +55,57 @@ export type Partner = {
permissions: [PartnerPermission];
createdAt: string;
updatedAt: string;
accessControl?: {
serverVerifier?: {
url: string;
headers?: { key: string; value: string }[];
};
allowedOperations?: AllowedOperations[];
};
};
type AllowedArgument = {
offset: number;
type: "address" | "uint256" | "bytes32" | "bool" | "string";
comparisonOperator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
value: string;
};
type AllowedTransaction = {
chainId: number;
contractAddress?: string;
selector?: string;
arguments?: AllowedArgument[];
maxValue?: string;
};
type AllowedTypedData = {
domain: string;
verifyingContract?: string;
chainId?: number;
primaryType?: string;
};
type PersonalSignRestriction =
| {
messageType: "userOp";
allowedTransactions?: AllowedTransaction[];
}
| {
messageType: "other";
message?: string;
};
type AllowedOperations =
| {
signMethod: "eth_signTransaction";
allowedTransactions?: AllowedTransaction[];
}
| {
signMethod: "eth_signTypedData_v4";
allowedTypedData?: AllowedTypedData[];
}
| {
signMethod: "personal_sign";
allowedPersonalSigns?: PersonalSignRestriction[];
};
@@ -86,7 +86,6 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
key,
name,
isActive: activeTab === key,
isEnabled: true,
onClick: () => setActiveTab(key),
icon:
(key === "aws-kms" && !isAwsKmsConfigured) ||
@@ -55,7 +55,7 @@ export function SettingsLayout(props: {
/>
<div
className={cn(
"flex grow flex-col",
"flex max-w-full grow flex-col",
// if showing full nav on mobile - hide the page content
showFullNavOnMobile && "max-sm:hidden",
)}
@@ -12,13 +12,18 @@ export function getTeamSettingsLinks(teamSlug: string) {
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
name: "Billing",
href: `${prefix}/billing`,
group: "team",
},
{
name: "Billing",
href: `${prefix}/billing`,
name: "Invoices",
href: `${prefix}/invoices`,
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
group: "team",
},
{
@@ -9,6 +9,7 @@ import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { differenceInDays, isAfter } from "date-fns";
import { format } from "date-fns/format";
import { CircleAlertIcon } from "lucide-react";
import Link from "next/link";
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
export function PlanInfoCard(props: {
@@ -53,7 +54,14 @@ export function PlanInfoCard(props: {
)}
</div>
<div className="flex flex-row gap-2">
<div className="flex flex-row items-center gap-2">
{/* go to invoices page */}
<Button asChild variant="outline">
<Link href={`/team/${team.slug}/~/settings/invoices`}>
View Invoices
</Link>
</Button>
{/* manage team billing */}
<BillingPortalButton
teamSlug={team.slug}
@@ -0,0 +1,200 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ChevronLeftIcon,
ChevronRight,
CreditCard,
Download,
Receipt,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { useTransition } from "react";
import type Stripe from "stripe";
import { searchParams } from "../search-params";
export function BillingHistory(props: {
invoices: Stripe.Invoice[];
hasMore: boolean;
}) {
console.log(props.invoices.map((i) => i.id));
const [isLoading, startTransition] = useTransition();
const [cursor, setCursor] = useQueryState(
"cursor",
searchParams.cursor.withOptions({
startTransition,
history: "push",
shallow: false,
}),
);
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const getStatusBadge = (invoice: Stripe.Invoice) => {
switch (invoice.status) {
case "paid":
return (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
Paid
</Badge>
);
case "open":
// we treate "uncollectible" as unpaid
case "uncollectible": {
// if the invoice due date is in the past, we want to display it as past due
if (invoice.due_date && invoice.due_date < Date.now()) {
return (
<Badge
variant="outline"
className="border-red-200 bg-red-50 text-red-800"
>
Past Due
</Badge>
);
}
return <Badge variant="outline">Open</Badge>;
}
case "void":
return <Badge variant="secondary">Void</Badge>;
default:
return <Badge variant="outline">Unknown</Badge>;
}
};
if (props.invoices.length === 0) {
return (
<div className="py-6 text-center">
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 font-medium text-lg">No billing history</h3>
<p className="mt-1 text-muted-foreground text-sm">
Your invoice history will appear here once you have made payments.
</p>
</div>
);
}
return (
<div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">#{invoice.number}</TableCell>
<TableCell>{formatDate(invoice.created)}</TableCell>
<TableCell>
{invoice.status === "paid"
? formatCurrency(invoice.amount_paid, invoice.currency)
: formatCurrency(invoice.amount_due, invoice.currency)}
</TableCell>
<TableCell>{getStatusBadge(invoice)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{invoice.status === "open" &&
invoice.hosted_invoice_url && (
<Button variant="default" size="sm" asChild>
<a
href={invoice.hosted_invoice_url}
target="_blank"
rel="noopener noreferrer"
>
<CreditCard className="mr-2 h-4 w-4" />
Pay Now
</a>
</Button>
)}
{invoice.invoice_pdf && (
<Button variant="ghost" size="sm" asChild>
<a
href={invoice.invoice_pdf}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
PDF
</a>
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
<hr className="my-4" />
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => {
// use browser history to go back
// this is KINDA hacky but it works (as long as the user doesn't send the URL to someone else...)
window.history.back();
}}
disabled={isLoading || !cursor}
>
{isLoading ? (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<ChevronLeftIcon className="mr-2 h-4 w-4" />
)}
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const lastInvoice = props.invoices[props.invoices.length - 1];
if (lastInvoice && props.hasMore) {
setCursor(lastInvoice.id);
}
}}
disabled={!props.hasMore || isLoading}
>
Next
{isLoading && props.hasMore ? (
<div className="ml-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<ChevronRight className="ml-2 h-4 w-4" />
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { getTeamInvoices } from "@/actions/stripe-actions";
import { getTeamBySlug } from "@/api/team";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { BillingHistory } from "./components/billing-history";
import { searchParamLoader } from "./search-params";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<SearchParams>;
}) {
const [params, searchParams] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
]);
const pagePath = `/team/${params.team_slug}/settings/invoices`;
const [, team] = await Promise.all([
// only called to verify login status etc
getValidAccount(pagePath),
getTeamBySlug(params.team_slug),
]);
if (!team) {
redirect("/team");
}
const invoices = await getTeamInvoices(team, {
cursor: searchParams.cursor ?? undefined,
});
return (
<Card>
<CardHeader>
<CardTitle>Invoice History</CardTitle>
<CardDescription className="mt-2">
View your past invoices and payment history
</CardDescription>
</CardHeader>
<CardContent>
<BillingHistory invoices={invoices.data} hasMore={invoices.has_more} />
</CardContent>
</Card>
);
}
@@ -0,0 +1,7 @@
import { createLoader, parseAsString } from "nuqs/server";
export const searchParams = {
cursor: parseAsString,
};
export const searchParamLoader = createLoader(searchParams);
@@ -18,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { BASE_URL } from "@/constants/env";
import { useMutation } from "@tanstack/react-query";
import { formatDate } from "date-fns";
import { EllipsisIcon, MailIcon } from "lucide-react";
@@ -117,6 +118,7 @@ export function ManageInvitesSection(props: {
className="border-border border-b last:border-b-0"
>
<InviteRow
teamSlug={props.team.slug}
invite={invite}
client={props.client}
userHasEditPermission={props.userHasEditPermission}
@@ -143,6 +145,7 @@ export function ManageInvitesSection(props: {
}
function InviteRow(props: {
teamSlug: string;
invite: TeamInvite;
userHasEditPermission: boolean;
client: ThirdwebClient;
@@ -198,6 +201,7 @@ function InviteRow(props: {
{/* Options */}
{props.userHasEditPermission && (
<ManageInviteButton
teamSlug={props.teamSlug}
invite={props.invite}
userHasEditPermission={props.userHasEditPermission}
deleteInvite={props.deleteInvite}
@@ -210,6 +214,7 @@ function InviteRow(props: {
}
function ManageInviteButton(props: {
teamSlug: string;
invite: TeamInvite;
userHasEditPermission: boolean;
deleteInvite: (inviteId: string) => Promise<void>;
@@ -235,6 +240,18 @@ function ManageInviteButton(props: {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{props.invite.status === "pending" && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${BASE_URL}/join/team/${props.teamSlug}/${props.invite.id}`,
);
toast.success("Invite link copied to clipboard");
}}
>
Copy Invite Link
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
@@ -62,13 +62,11 @@ export function TeamMembersSettingsPage(props: {
isActive: manageTab === "members",
name: "Team Members",
onClick: () => setManageTab("members"),
isEnabled: true,
},
{
isActive: manageTab === "invites",
name: "Pending Invites",
onClick: () => setManageTab("invites"),
isEnabled: true,
},
]}
/>
@@ -0,0 +1,157 @@
"use client";
import { analyticsServerProxy } from "@/actions/proxies";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import {
type SponsoredTransaction,
SponsoredTransactionsTableUI,
} from "./SponsoredTransactionsTableUI";
type GetSponsoredTransactionsParams = {
teamId: string;
limit: number;
offset: number;
from: string;
to: string;
// optional
projectId?: string;
chainId?: string;
};
const getSponsoredTransactions = async (
params: GetSponsoredTransactionsParams,
) => {
const res = await analyticsServerProxy<{
data: SponsoredTransaction[];
meta: {
total: number;
};
}>({
pathname: "/v2/bundler/sponsored-transactions",
method: "GET",
searchParams: {
teamId: params.teamId,
limit: params.limit.toString(),
offset: params.offset.toString(),
projectId: params.projectId,
chainId: params.chainId,
from: params.from,
to: params.to,
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
};
async function getSponsoredTransactionsCSV(params: {
teamId: string;
from: string;
to: string;
}) {
const res = await analyticsServerProxy<string>({
method: "GET",
pathname: "/v2/bundler/sponsored-transactions/export",
parseAsText: true,
searchParams: {
teamId: params.teamId,
from: params.from,
to: params.to,
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
}
export function SponsoredTransactionsTable(
props: {
client: ThirdwebClient;
teamId: string;
teamSlug: string;
from: string;
to: string;
} & (
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
}
| {
variant: "project";
projectId: string;
}
),
) {
const pageSize = 10;
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<{
chainId?: string;
projectId?: string;
}>({
projectId: props.variant === "project" ? props.projectId : undefined,
});
const params = {
teamId: props.teamId,
limit: pageSize,
offset: (page - 1) * pageSize,
from: props.from,
to: props.to,
chainId: filters.chainId,
projectId: filters.projectId,
};
const sponsoredTransactionsQuery = useQuery({
queryKey: ["sponsored-transactions", params],
queryFn: () => {
return getSponsoredTransactions(params);
},
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
const totalPages = sponsoredTransactionsQuery.data
? Math.ceil(sponsoredTransactionsQuery.data.meta.total / pageSize)
: 0;
return (
<SponsoredTransactionsTableUI
filters={filters}
variant={props.variant}
projects={props.variant === "team" ? props.projects : []}
setFilters={(v) => {
setFilters(v);
setPage(1);
}}
client={props.client}
isError={sponsoredTransactionsQuery.isError}
getCSV={() => {
return getSponsoredTransactionsCSV({
teamId: props.teamId,
from: props.from,
to: props.to,
});
}}
isPending={sponsoredTransactionsQuery.isFetching}
sponsoredTransactions={sponsoredTransactionsQuery.data?.data ?? []}
pageNumber={page}
setPageNumber={setPage}
pageSize={pageSize}
teamSlug={props.teamSlug}
totalPages={totalPages}
/>
);
}
@@ -0,0 +1,175 @@
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { ThirdwebProvider } from "thirdweb/react";
import { projectStub } from "../../../../../../../../stories/stubs";
import {
type SponsoredTransaction,
SponsoredTransactionsTableUI,
} from "./SponsoredTransactionsTableUI";
const meta = {
title: "Usage/SponsoredTransactionsTable",
component: Variant,
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<div className="container max-w-[1154px] py-10">
<ThirdwebProvider>
<Story />
</ThirdwebProvider>
</div>
),
],
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
const mockProjects = [
projectStub("foo", "team-1"),
projectStub("bar", "team-1"),
projectStub("baz", "team-1"),
];
function createSponsoredTransactionStub() {
const chainIdsToPick = ["1", "137", "8453", "42161", "10", "100"];
const randomVal = Math.random();
const feeMode = randomVal < 0.2 ? "low" : randomVal < 0.4 ? "zero" : "high";
const feeUsd =
feeMode === "low"
? Math.random() / 100
: feeMode === "zero"
? 0
: Math.random();
return {
timestamp: new Date(
Date.now() - Math.random() * 1000 * 60 * 60 * 24 * 30,
).toISOString(),
teamId: crypto.randomUUID(),
projectId:
mockProjects[Math.floor(Math.random() * mockProjects.length)]?.id || "",
chainId: chainIdsToPick[
Math.floor(Math.random() * chainIdsToPick.length)
] as string,
transactionFee: randomVal,
transactionFeeUsd: feeUsd,
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
transactionHash:
"0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
userOpHash:
"0x0987654321fedcba0987654321fedcba0987654321fedcba0987654321fedcba",
} satisfies SponsoredTransaction;
}
const client = getThirdwebClient();
export const Default: Story = {
args: {
totalPages: 4,
sponsoredTransactions: new Array(10)
.fill(null)
.map(createSponsoredTransactionStub),
isPending: false,
projectLoadingFails: false,
isError: false,
},
};
export const Loading: Story = {
args: {
totalPages: 4,
sponsoredTransactions: [],
isPending: true,
projectLoadingFails: false,
isError: false,
},
};
export const NoTransactions: Story = {
args: {
totalPages: 1,
sponsoredTransactions: [],
isPending: false,
projectLoadingFails: false,
isError: false,
},
};
export const NoPagination: Story = {
args: {
totalPages: 1,
sponsoredTransactions: new Array(5)
.fill(null)
.map(createSponsoredTransactionStub),
isPending: false,
projectLoadingFails: false,
isError: false,
},
};
export const ErrorLoadingTransactions: Story = {
args: {
totalPages: 1,
sponsoredTransactions: [],
isPending: false,
projectLoadingFails: false,
isError: true,
},
};
function Variant(props: {
sponsoredTransactions: SponsoredTransaction[];
isPending: boolean;
projectLoadingFails: boolean;
totalPages: number;
isError: boolean;
}) {
const [variant, setVariant] = useState<"team" | "project">("team");
const [pageNumber, setPageNumber] = useState(1);
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<RadioGroup
value={variant}
onValueChange={(value) => setVariant(value as "team" | "project")}
className="flex items-center gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="team" id="team" />
<Label htmlFor="team">Team</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="project" id="project" />
<Label htmlFor="project">Project</Label>
</div>
</RadioGroup>
</div>
<SponsoredTransactionsTableUI
projects={mockProjects}
getCSV={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return "foo,bar\n1,2\n3,4";
}}
filters={{}}
setFilters={() => {}}
isError={props.isError}
client={client}
totalPages={props.totalPages}
isPending={props.isPending}
pageNumber={pageNumber}
pageSize={10}
setPageNumber={setPageNumber}
sponsoredTransactions={props.sponsoredTransactions}
teamSlug="foo"
variant={variant}
/>
</div>
);
}
@@ -0,0 +1,481 @@
"use client";
import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { PaginationButtons } from "@/components/pagination-buttons";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ChainIcon } from "components/icons/ChainIcon";
import { format, formatDistance } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { ExternalLinkIcon, XIcon } from "lucide-react";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
export type SponsoredTransaction = {
timestamp: string;
teamId: string;
projectId: string;
chainId: string;
transactionFee: number;
transactionFeeUsd: number;
walletAddress: string;
transactionHash: string;
userOpHash: string;
};
export function SponsoredTransactionsTableUI(
props: {
sponsoredTransactions: SponsoredTransaction[];
isPending: boolean;
isError: boolean;
teamSlug: string;
client: ThirdwebClient;
pageSize: number;
setPageNumber: (pageNumber: number) => void;
pageNumber: number;
totalPages: number;
filters: { chainId?: string; projectId?: string };
setFilters: (filters: { chainId?: string; projectId?: string }) => void;
} & (
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
getCSV: () => Promise<string>;
}
| {
variant: "project";
}
),
) {
const showPagination = props.totalPages > 1;
return (
<div className="overflow-hidden rounded-lg border bg-card">
{/* header */}
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
<h2 className="font-semibold text-lg">Sponsored Transactions</h2>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{/* Filters */}
<div className="flex gap-2">
{props.variant === "team" && (
<ProjectFilter
projectId={props.filters.projectId}
setProjectId={(projectId) =>
props.setFilters({ ...props.filters, projectId })
}
projects={props.projects}
client={props.client}
/>
)}
<ChainFilter
chainId={props.filters.chainId}
setChainId={(chainId) =>
props.setFilters({ ...props.filters, chainId })
}
/>
</div>
{props.variant === "team" && (
<>
<div className="hidden h-4 w-[1px] border bg-border lg:block" />
<ExportToCSVButton
className="bg-background"
getData={props.getCSV}
fileName="sponsored-transactions"
/>
</>
)}
</div>
</div>
<TableContainer className={"rounded-none border-none"}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Transaction Hash</TableHead>
{props.variant === "team" && <TableHead>Project</TableHead>}
<TableHead>Chain</TableHead>
<TableHead>Wallet</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Fee</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!props.isPending
? props.sponsoredTransactions.map((transaction) => (
<TableRow key={transaction.transactionHash}>
{/* Tx Hash */}
<TableCell>
<TransactionHashCell
hash={transaction.transactionHash}
chainId={transaction.chainId}
/>
</TableCell>
{/* Project */}
{props.variant === "team" && (
<TableCell>
<ProjectCell
teamSlug={props.teamSlug}
project={props.projects.find(
(p) => p.id === transaction.projectId,
)}
client={props.client}
/>
</TableCell>
)}
{/* Chain */}
<TableCell>
<ChainCell chainId={transaction.chainId} />
</TableCell>
{/* Wallet */}
<TableCell>
<WalletAddress address={transaction.walletAddress} />
</TableCell>
{/* Time */}
<TableCell>
<ToolTipLabel
hoverable
label={format(new Date(transaction.timestamp), "PPpp")}
>
<span>
{formatDistance(
new Date(transaction.timestamp),
new Date(),
{
addSuffix: true,
},
)}
</span>
</ToolTipLabel>
</TableCell>
{/* Fee */}
<TableCell>
<TransactionFeeCell
usdValue={transaction.transactionFeeUsd}
/>
</TableCell>
</TableRow>
))
: Array.from({ length: props.pageSize }).map((_, index) => (
<SkeletonRow
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={`skeleton-${index}`}
variant={props.variant}
/>
))}
</TableBody>
</Table>
</TableContainer>
{!props.isPending && (
// biome-ignore lint/complexity/noUselessFragments: better readability
<>
{props.isError ? (
<div className="px-6 py-24">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-2">
<XIcon className="size-5" />
</div>
</div>
<p className="text-center text-destructive-text text-sm">
Failed to load sponsored transactions
</p>
</div>
) : props.sponsoredTransactions.length === 0 ? (
<div className="px-6 py-24">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-2">
<XIcon className="size-5 text-muted-foreground" />
</div>
</div>
<p className="text-center text-muted-foreground text-sm">
No sponsored transactions
</p>
</div>
) : null}
</>
)}
{showPagination && (
<div className="flex justify-end gap-3 rounded-b-lg border-t p-4">
<PaginationButtons
activePage={props.pageNumber}
totalPages={props.totalPages}
onPageClick={(page) => props.setPageNumber(page)}
/>
</div>
)}
</div>
);
}
function SkeletonRow(props: { variant: "team" | "project" }) {
return (
<TableRow className="h-[72.5px]">
{/* Tx Hash */}
<TableCell>
<Skeleton className="h-7 w-[160px]" />
</TableCell>
{/* Project */}
{props.variant === "team" && (
<TableCell>
<Skeleton className="h-7 w-[145px]" />
</TableCell>
)}
{/* Chain */}
<TableCell>
<Skeleton className="h-7 w-[130px]" />
</TableCell>
{/* Wallet */}
<TableCell>
<Skeleton className="h-7 w-[130px]" />
</TableCell>
{/* Time */}
<TableCell>
<Skeleton className="h-7 w-[80px]" />
</TableCell>
{/* Fee */}
<TableCell>
<Skeleton className="h-7 w-[40px]" />
</TableCell>
</TableRow>
);
}
function TransactionHashCell(props: { hash: string; chainId: string }) {
const { idToChain } = useAllChainsData();
const chain = idToChain.get(Number(props.chainId));
const explorerUrl = chain?.explorers?.[0]?.url;
const txHashToShow = `${props.hash.slice(0, 6)}...${props.hash.slice(-4)}`;
if (explorerUrl) {
return (
<Button size="sm" variant="ghost" asChild>
<Link
href={`${explorerUrl}/tx/${props.hash}`}
target="_blank"
className="-translate-x-2 gap-2 font-mono"
>
{txHashToShow}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
</Button>
);
}
return (
<CopyTextButton
textToCopy={props.hash}
textToShow={txHashToShow}
tooltip="Transaction Hash"
copyIconPosition="right"
className="-translate-x-2 font-mono"
variant="ghost"
/>
);
}
function ChainCell(props: { chainId: string }) {
const { idToChain, allChains } = useAllChainsData();
const chain = idToChain.get(Number(props.chainId));
if (allChains.length === 0) {
return <Skeleton className="w-[100px]" />;
}
return (
<div className="relative flex w-max items-center gap-2">
<ChainIcon ipfsSrc={chain?.icon?.url} className="size-6" />
<Link
target="_blank"
href={`/${chain ? chain.slug : props.chainId}`}
className="before:absolute before:inset-0 hover:underline hover:underline-offset-4"
>
{chain ? chain.name : `Chain #${props.chainId}`}
</Link>
</div>
);
}
function ProjectCell(props: {
teamSlug: string;
project:
| {
id: string;
name: string;
image: string | null;
slug: string;
}
| undefined;
client: ThirdwebClient;
}) {
// just typeguard - never actually happens
if (!props.project) {
return <Skeleton className="h-6 w-[130px]" />;
}
return (
<div className="relative flex w-max items-center gap-2">
<ProjectAvatar
src={props.project.image || ""}
className="size-6 shrink-0"
client={props.client}
/>
<Link
href={`/team/${props.teamSlug}/${props.project.slug}`}
className="before:absolute before:inset-0 hover:underline hover:underline-offset-4"
target="_blank"
>
{props.project.name}
</Link>
</div>
);
}
function ChainFilter(props: {
chainId: string | undefined;
setChainId: (chainId: string | undefined) => void;
}) {
const isChainFilterActive = props.chainId !== undefined;
return (
<div className="flex bg-background">
{isChainFilterActive && (
<ToolTipLabel label="Remove chain filter">
<Button
variant="outline"
size="icon"
className="rounded-r-none border-r-0 px-3 text-muted-foreground"
onClick={() => props.setChainId(undefined)}
>
<XIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
<SingleNetworkSelector
className={cn(isChainFilterActive && "rounded-l-none")}
chainId={props.chainId ? Number(props.chainId) : undefined}
onChange={(chainId) => props.setChainId(chainId.toString())}
popoverContentClassName="!w-[80vw] md:!w-[350px]"
align="end"
placeholder="All Chains"
disableChainId
/>
</div>
);
}
function ProjectFilter(props: {
projectId: string | undefined;
setProjectId: (projectId: string | undefined) => void;
projects: { id: string; name: string; image: string | null }[];
client: ThirdwebClient;
}) {
const isProjectFilterActive = props.projectId !== undefined;
return (
<div className="flex bg-background">
{isProjectFilterActive && (
<ToolTipLabel label="Remove project filter">
<Button
variant="outline"
size="icon"
className="rounded-r-none border-r-0 px-3 text-muted-foreground"
onClick={() => props.setProjectId(undefined)}
>
<XIcon className="size-4" />
</Button>
</ToolTipLabel>
)}
<SelectWithSearch
onValueChange={(value) => props.setProjectId(value)}
options={props.projects.map((project) => ({
label: project.name,
value: project.id,
}))}
value={props.projectId}
placeholder="All Projects"
popoverContentClassName="!w-[80vw] md:!w-[350px]"
align="end"
className={cn(
"min-w-[160px]",
isProjectFilterActive && "rounded-l-none",
)}
renderOption={(option) => {
const project = props.projects.find((p) => p.id === option.value);
if (!project) {
return <></>;
}
return (
<div className="flex items-center gap-2">
<ProjectAvatar
src={project.image || ""}
className="size-6 shrink-0"
client={props.client}
/>
<span>{project.name}</span>
</div>
);
}}
/>
</div>
);
}
// for values >= 0.01, show 2 decimal places
const normalValueUSDFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "standard",
maximumFractionDigits: 2,
});
function formatTransactionFee(usdValue: number) {
if (usdValue >= 0.01 || usdValue === 0) {
return normalValueUSDFormatter.format(usdValue);
}
return "< $0.01";
}
function TransactionFeeCell(props: { usdValue: number }) {
return (
<ToolTipLabel label={`$${props.usdValue}`}>
<span>{formatTransactionFee(props.usdValue)}</span>
</ToolTipLabel>
);
}
@@ -10,8 +10,10 @@ import { InAppWalletUsersChartCardUI } from "components/embedded-wallets/Analyti
import { subMonths } from "date-fns";
import Link from "next/link";
import { Suspense, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { toPercent, toSize } from "utils/number";
import { TotalSponsoredChartCardUI } from "../../../../_components/TotalSponsoredCard";
import { SponsoredTransactionsTable } from "./SponsoredTransactionsTable";
import { UsageCard } from "./UsageCard";
type UsageProps = {
@@ -20,6 +22,13 @@ type UsageProps = {
subscriptions: TeamSubscription[];
account: Account;
team: Team;
client: ThirdwebClient;
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
};
export const Usage: React.FC<UsageProps> = ({
@@ -27,6 +36,8 @@ export const Usage: React.FC<UsageProps> = ({
subscriptions,
account,
team,
client,
projects,
}) => {
// TODO - get this from team instead of account
const storageMetrics = useMemo(() => {
@@ -105,6 +116,16 @@ export const Usage: React.FC<UsageProps> = ({
to={currentPeriodEnd}
/>
<SponsoredTransactionsTable
client={client}
teamSlug={team.slug}
teamId={team.id}
from={currentPeriodStart.toISOString()}
to={currentPeriodEnd.toISOString()}
projects={projects}
variant="team"
/>
<UsageCard
{...rpcMetrics}
name="RPC"
@@ -1,7 +1,10 @@
import { getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { redirect } from "next/navigation";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { getAccountUsage } from "./getAccountUsage";
import { Usage } from "./overview/components/Usage";
@@ -11,19 +14,23 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
const account = await getValidAccount(`/team/${params.team_slug}/~/usage`);
const team = await getTeamBySlug(params.team_slug);
const [account, team, authToken] = await Promise.all([
getValidAccount(`/team/${params.team_slug}/~/usage`),
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team) {
redirect("/team");
}
const [accountUsage, subscriptions] = await Promise.all([
const [accountUsage, subscriptions, projects] = await Promise.all([
getAccountUsage(),
getTeamSubscriptions(team.slug),
getProjects(team.slug),
]);
if (!accountUsage || !subscriptions) {
if (!accountUsage || !subscriptions || !authToken) {
return (
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
Something went wrong. Please try again later.
@@ -31,6 +38,8 @@ export default async function Page(props: {
);
}
const client = getThirdwebClient(authToken);
return (
<Usage
// TODO - remove accountUsage when we have all the data available from team
@@ -38,6 +47,13 @@ export default async function Page(props: {
subscriptions={subscriptions}
account={account}
team={team}
client={client}
projects={projects.map((project) => ({
id: project.id,
name: project.name,
image: project.image,
slug: project.slug,
}))}
/>
);
}
@@ -0,0 +1,36 @@
"use client";
import { TabButtons } from "@/components/ui/tabs";
import { useState } from "react";
type TabKey = "ts" | "react" | "react-native" | "dotnet" | "unity" | "unreal";
const tabNames: Record<TabKey, string> = {
ts: "TypeScript",
react: "React",
"react-native": "React Native",
dotnet: ".NET",
unity: "Unity",
unreal: "Unreal Engine",
};
export function IntegrateAPIKeyCodeTabs(props: {
tabs: Record<TabKey, React.ReactNode>;
}) {
const [tab, setTab] = useState<TabKey>("ts");
return (
<div>
<TabButtons
tabClassName="!text-sm"
tabs={Object.entries(tabNames).map(([key, name]) => ({
name,
onClick: () => setTab(key as TabKey),
isActive: tab === key,
}))}
/>
<div className="h-3" />
{props.tabs[tab]}
</div>
);
}
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react";
import { projectStub } from "../../../../../../stories/stubs";
import { ProjectFTUX } from "./ProjectFTUX";
const meta = {
title: "Project/ProjectFTUX",
component: ProjectFTUX,
decorators: [
(Story) => (
<div className="container py-8 pb-20">
<Story />
</div>
),
],
} satisfies Meta<typeof ProjectFTUX>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
project: {
...projectStub("foo", "bar"),
secretKeys: [
{
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
hash: "1234567890",
masked: "1234567890",
},
],
},
teamSlug: "bar",
},
};
@@ -0,0 +1,517 @@
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CodeServer } from "@/components/ui/code/code.server";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import {
ChevronRightIcon,
CircleAlertIcon,
ExternalLinkIcon,
} from "lucide-react";
import { ContractIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
import { InsightIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
import { DotNetIcon } from "../../../../../../components/icons/brand-icons/DotNetIcon";
import { GithubIcon } from "../../../../../../components/icons/brand-icons/GithubIcon";
import { ReactIcon } from "../../../../../../components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "../../../../../../components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "../../../../../../components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "../../../../../../components/icons/brand-icons/UnrealIcon";
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
import { SecretKeySection } from "./SecretKeySection";
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
return (
<div className="flex flex-col gap-10">
<IntegrateAPIKeySection
project={props.project}
teamSlug={props.teamSlug}
/>
<ProductsSection
teamSlug={props.teamSlug}
projectSlug={props.project.slug}
/>
<SDKSection />
<StarterKitsSection />
</div>
);
}
// Integrate API key section ------------------------------------------------------------
function IntegrateAPIKeySection({
project,
teamSlug,
}: {
project: Project;
teamSlug: string;
}) {
const secretKeyMasked = project.secretKeys[0]?.masked;
const clientId = project.publishableKey;
return (
<section>
<h2 className="mb-3 font-semibold text-xl tracking-tight">
Integrate API key
</h2>
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-6 ">
<div>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application.
</p>
<CopyTextButton
textToCopy={clientId}
className="!h-auto w-full max-w-[350px] justify-between truncate bg-background px-3 py-3 font-mono"
textToShow={clientId}
copyIconPosition="right"
tooltip="Copy Client ID"
/>
</div>
{secretKeyMasked && (
<SecretKeySection
secretKeyMasked={secretKeyMasked}
projectId={project.id}
/>
)}
</div>
<div className="h-5" />
<IntegrationCodeExamples project={project} teamSlug={teamSlug} />
</div>
</section>
);
}
function IntegrationCodeExamples(props: {
project: Project;
teamSlug: string;
}) {
return (
<IntegrateAPIKeyCodeTabs
tabs={{
ts: (
<CodeServer
code={typescriptCodeExample(props.project)}
lang="ts"
className="bg-background"
/>
),
react: (
<CodeServer
code={reactCodeExample(props.project)}
lang="ts"
className="bg-background"
/>
),
"react-native": (
<CodeServer
code={reactCodeExample(props.project)}
lang="ts"
className="bg-background"
/>
),
unity: (
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>
Configure Client ID in Thirdweb Manager prefab
</AlertTitle>
<AlertDescription className="leading-loose">
Configure "Client ID" and "Bundle ID" in{" "}
<UnderlineLink
href="https://portal.thirdweb.com/unity/v5/thirdwebmanager"
target="_blank"
>
Thirdweb Manager prefab
</UnderlineLink>
<span className="block text-sm">
Make sure to configure your app's bundle ID in "Allowed Bundle
IDs" in{" "}
<UnderlineLink
target="_blank"
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
Project settings
</UnderlineLink>
</span>
</AlertDescription>
</Alert>
),
dotnet: (
<div className="flex flex-col gap-3">
<CodeServer
code={dotNotCodeExample(props.project)}
lang="csharp"
className="bg-background"
/>
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>
Configure your app's bundle ID in "Allowed Bundle IDs" in
Project
</AlertTitle>
<AlertDescription className="leading-loose">
Go to{" "}
<UnderlineLink
target="_blank"
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
Project settings
</UnderlineLink>{" "}
and add your app's bundle ID to the "Allowed Bundle IDs" list.
</AlertDescription>
</Alert>
</div>
),
unreal: (
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>
Configure Client ID in Thirdweb Unreal Plugin{" "}
</AlertTitle>
<AlertDescription className="leading-loose">
Configure "Client ID" and "Bundle ID" in{" "}
<UnderlineLink
href="https://portal.thirdweb.com/unreal-engine/getting-started"
target="_blank"
>
thirdweb plugin settings
</UnderlineLink>
<span className="block text-sm">
Make sure to configure your app's bundle ID in "Allowed Bundle
IDs" in{" "}
<UnderlineLink
target="_blank"
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
Project settings
</UnderlineLink>
</span>
</AlertDescription>
</Alert>
),
}}
/>
);
}
const typescriptCodeExample = (project: Project) => `\
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({
// use clientId for client side usage
clientId: "${project.publishableKey}",
// use secretKey for server side usage
secretKey: "${project.secretKeys[0]?.masked}", // replace this with full secret key
});`;
const reactCodeExample = (project: Project) => `\
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({
clientId: "${project.publishableKey}",
});`;
const dotNotCodeExample = (project: Project) => `\
using Thirdweb;
// For web applications
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}");
// For native applications - Replace "yourBundleId" with your app's bundle ID
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}", bundleId: "yourBundleId");
// For backend applications (Note: below shown secret key is not the full secret key)
var client = ThirdwebClient.Create(secretKey: "${project.secretKeys[0]?.masked}");`;
// products section ------------------------------------------------------------
function ProductsSection(props: {
teamSlug: string;
projectSlug: string;
}) {
const products: Array<{
title: string;
description: string;
href: string;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
}> = [
{
title: "Engine",
description:
"Scale your application with a backend server to read, write, and deploy contracts at production-grade.",
href: `/team/${props.teamSlug}/~/engine`,
icon: EngineIcon,
trackingLabel: "engine",
},
{
title: "Contracts",
description:
"Deploy your own contracts or leverage existing solutions for onchain implementation",
href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`,
icon: ContractIcon,
trackingLabel: "contracts",
},
{
title: "Insight",
description:
"Add indexing capabilities to retrieve real-time onchain data",
href: `/team/${props.teamSlug}/${props.projectSlug}/insight`,
icon: InsightIcon,
trackingLabel: "insight",
},
{
title: "Nebula",
description:
"Integrate a blockchain AI model to improve your users insight into your application and the blockchain",
href: `/team/${props.teamSlug}/~/nebula`,
icon: NebulaIcon,
trackingLabel: "nebula",
},
];
return (
<div className="">
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Complete your full-stack application
</h2>
<p className="text-muted-foreground">
Tools to build frontend, backend, and onchain with built-in
infrastructure and analytics.
</p>
<div className="h-4" />
{/* Feature Cards */}
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<ProductCard
key={product.title}
title={product.title}
description={product.description}
href={product.href}
icon={product.icon}
trackingLabel={product.trackingLabel}
/>
))}
</section>
</div>
);
}
function ProductCard(props: {
title: string;
description: string;
href: string;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
}) {
return (
<div className="relative flex flex-col rounded-lg border bg-card p-4 hover:border-active-border">
<div className="mb-3 flex size-9 items-center justify-center rounded-full border p-1">
<props.icon className="size-5 text-muted-foreground" />
</div>
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
<TrackedLinkTW
href={props.href}
target="_blank"
className="before:absolute before:inset-0"
category="project-ftux"
label={props.trackingLabel}
>
{props.title}
</TrackedLinkTW>
</h3>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
);
}
// sdk section ------------------------------------------------------------
type SDKCardProps = {
name: string;
href: string;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
};
const sdks: SDKCardProps[] = [
{
name: "TypeScript",
href: "https://portal.thirdweb.com/sdk/typescript",
icon: TypeScriptIcon,
trackingLabel: "typescript",
},
{
name: "React",
href: "https://portal.thirdweb.com/react/v5",
icon: ReactIcon,
trackingLabel: "react",
},
{
name: "React Native",
href: "https://portal.thirdweb.com/react-native/v5",
icon: ReactIcon,
trackingLabel: "react_native",
},
{
name: "Unity",
href: "https://portal.thirdweb.com/unity/v5",
icon: UnityIcon,
trackingLabel: "unity",
},
{
name: "Unreal Engine",
href: "https://portal.thirdweb.com/unreal-engine",
icon: UnrealIcon,
trackingLabel: "unreal",
},
{
name: ".NET",
href: "https://portal.thirdweb.com/dotnet",
icon: DotNetIcon,
trackingLabel: "dotnet",
},
];
function SDKSection() {
return (
<section>
<h2 className="mb-3 font-semibold text-xl tracking-tight">Client SDKs</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{sdks.map((sdk) => (
<SDKCard
key={sdk.name}
name={sdk.name}
href={sdk.href}
icon={sdk.icon}
trackingLabel={sdk.trackingLabel}
/>
))}
</div>
</section>
);
}
function SDKCard(props: SDKCardProps) {
return (
<div className="relative flex items-center gap-3 rounded-lg border bg-card p-4 hover:border-active-border">
<div className="flex size-9 items-center justify-center rounded-full border">
<props.icon className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<p className="font-medium text-base">
<TrackedLinkTW
href={props.href}
className="before:absolute before:inset-0"
target="_blank"
category="project-ftux"
label={props.trackingLabel}
>
{props.name}
</TrackedLinkTW>
</p>
<p className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
View Docs
<ExternalLinkIcon className="size-3" />
</p>
</div>
</div>
);
}
// starter kits section ------------------------------------------------------------
type StartedKitCardProps = {
name: string;
href: string;
trackingLabel: string;
};
const startedKits: StartedKitCardProps[] = [
{
name: "Next Starter",
href: "https://github.com/thirdweb-example/next-starter",
trackingLabel: "next_starter",
},
{
name: "Vite Starter",
href: "https://github.com/thirdweb-example/vite-starter",
trackingLabel: "vite_starter",
},
{
name: "Expo Starter",
href: "https://github.com/thirdweb-example/expo-starter",
trackingLabel: "expo_starter",
},
{
name: "Node Starter",
href: "https://github.com/thirdweb-example/node-starter",
trackingLabel: "node_starter",
},
];
function StarterKitsSection() {
return (
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="font-semibold text-xl tracking-tight">Starter Kits</h2>
<TrackedLinkTW
href="https://github.com/thirdweb-example"
target="_blank"
category="project-ftux"
label="view_all_templates"
className="inline-flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
>
View all <ChevronRightIcon className="size-3" />
</TrackedLinkTW>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{startedKits.map((kit) => (
<StarterKitCard
key={kit.name}
name={kit.name}
href={kit.href}
trackingLabel={kit.trackingLabel}
/>
))}
</div>
</section>
);
}
function StarterKitCard(props: StartedKitCardProps) {
return (
<div className="relative flex items-center gap-3 rounded-lg border bg-card p-4 hover:border-active-border">
<div className="flex size-9 items-center justify-center rounded-full border">
<GithubIcon className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<TrackedLinkTW
href={props.href}
target="_blank"
category="project-ftux"
label={props.trackingLabel}
className="before:absolute before:inset-0"
>
{props.name}
</TrackedLinkTW>
<p className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
View Repo
<ExternalLinkIcon className="size-3" />
</p>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import { rotateSecretKeyClient } from "@3rdweb-sdk/react/hooks/useApi";
import { useState } from "react";
import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage";
export function SecretKeySection(props: {
secretKeyMasked: string;
projectId: string;
}) {
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
return (
<div>
<h3>Secret Key</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies and authenticates your application from a backend. <br />{" "}
This is not the full secret key, Refer to your saved secret key at the
time of creation for the full secret key.
</p>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm lg:w-[350px]">
{secretKeyMasked}
</div>
<RotateSecretKeyButton
rotateSecretKey={async () => {
return rotateSecretKeyClient(props.projectId);
}}
onSuccess={(data) => {
setSecretKeyMasked(data.data.secretMasked);
}}
/>
</div>
</div>
);
}
@@ -2,7 +2,7 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TabPathLinks } from "@/components/ui/tabs";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { CircleAlertIcon } from "lucide-react";
import { useActiveWalletChain } from "thirdweb/react";
@@ -29,21 +29,20 @@ export function AccountAbstractionLayout(props: {
return (
<div>
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
Account Abstraction
</h1>
<p className="text-muted-foreground text-sm">
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
into your apps.{" "}
<TrackedLinkTW
<TrackedUnderlineLink
target="_blank"
label="docs-wallets"
category={TRACKING_CATEGORY}
href="https://portal.thirdweb.com/wallets/smart-wallet"
className="text-link-foreground hover:text-foreground"
>
View Documentation
</TrackedLinkTW>
</TrackedUnderlineLink>
</p>
<div className="h-6" />
<div className="flex flex-col gap-6">
@@ -1,12 +1,14 @@
import { getUserOpUsage } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import {
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { AccountAbstractionAnalytics } from "components/smart-wallets/AccountAbstractionAnalytics";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { searchParamLoader } from "./search-params";
interface PageParams {
@@ -19,11 +21,16 @@ export default async function Page(props: {
searchParams: Promise<SearchParams>;
children: React.ReactNode;
}) {
const [params, searchParams] = await Promise.all([
const [params, searchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
@@ -53,5 +60,13 @@ export default async function Page(props: {
period: interval,
});
return <AccountAbstractionAnalytics userOpStats={userOpStats} />;
return (
<AccountAbstractionAnalytics
userOpStats={userOpStats}
client={getThirdwebClient(authToken)}
teamId={project.teamId}
projectId={project.id}
teamSlug={params.team_slug}
/>
);
}
@@ -1,5 +1,5 @@
import { getInAppWalletUsage } from "@/api/analytics";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { subDays } from "date-fns";
import { TRACKING_CATEGORY } from "../_constants";
@@ -31,22 +31,21 @@ export async function InAppWalletsHeader({
return (
<div>
<h1 className="mb-3 font-semibold text-2xl tracking-tight md:text-3xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
In-App Wallets
</h1>
<p className="mt-3 mb-7 max-w-[700px] text-muted-foreground">
<p className="mb-7 max-w-[700px] text-muted-foreground text-sm leading-relaxed">
A wallet infrastructure that enables apps to create, manage, and control
their users wallets. Email login, social login, and bring-your-own auth
supported.{" "}
<TrackedLinkTW
<TrackedUnderlineLink
target="_blank"
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
label="learn-more"
category={TRACKING_CATEGORY}
className="text-link-foreground hover:text-foreground"
>
Learn more
</TrackedLinkTW>
</TrackedUnderlineLink>
</p>
<InAppWalletsSummary
allTimeStats={allTimeStats || []}
@@ -1,4 +1,5 @@
import { getProject } from "@/api/projects";
import { getSMSCountryTiers } from "@/api/sms";
import { getTeamBySlug } from "@/api/team";
import { redirect } from "next/navigation";
import { InAppWalletSettingsPage } from "../../../../../../../components/embedded-wallets/Configure";
@@ -9,9 +10,10 @@ export default async function Page(props: {
}) {
const { team_slug, project_slug } = await props.params;
const [team, project] = await Promise.all([
const [team, project, smsCountryTiers] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
getSMSCountryTiers(),
]);
if (!team) {
@@ -29,6 +31,7 @@ export default async function Page(props: {
trackingCategory="in-app-wallet-project-settings"
teamSlug={team_slug}
validTeamPlan={getValidTeamPlan(team)}
smsCountryTiers={smsCountryTiers}
/>
);
}
@@ -20,8 +20,8 @@ export default async function Layout(props: {
href: `/team/${team_slug}/${project_slug}/connect/account-abstraction`,
},
{
label: "Pay",
href: `/team/${team_slug}/${project_slug}/connect/pay`,
label: "Universal Bridge",
href: `/team/${team_slug}/${project_slug}/connect/universal-bridge`,
},
{
label: "Playground",
@@ -1,7 +1,7 @@
import { getProject } from "@/api/projects";
import { TabPathLinks } from "@/components/ui/tabs";
import Link from "next/link";
import { redirect } from "next/navigation";
import { UnderlineLink } from "../../../../../../@/components/ui/UnderlineLink";
export default async function Layout(props: {
params: Promise<{
@@ -17,26 +17,25 @@ export default async function Layout(props: {
redirect(`/team/${params.team_slug}`);
}
const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/pay`;
const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge`;
return (
<div className="flex w-full flex-col gap-6">
<div className="flex flex-col items-start justify-between gap-6 lg:flex-row">
<div className="max-w-[700px]">
<h1 className="mb-3 font-bold text-3xl tracking-tight md:text-4xl">
Pay
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
Universal Bridge
</h1>
<p className="text-muted-foreground text-sm leading-relaxed md:text-base">
Pay allows your users to purchase cryptocurrencies and execute
transactions with their credit card or debit card, or with any token
via cross-chain routing.{" "}
<Link
<p className="text-muted-foreground text-sm leading-relaxed">
Universal Bridge allows your users to bridge, swap, and purchase
cryptocurrencies and execute transactions with any fiat options or
tokens via cross-chain routing.{" "}
<UnderlineLink
target="_blank"
href="https://portal.thirdweb.com/connect/pay/overview"
className="!text-link-foreground"
>
Learn more
</Link>
</UnderlineLink>
</p>
</div>
</div>
@@ -43,25 +43,21 @@ function Story() {
name: "60 Days",
onClick: () => setTab("60-day"),
isActive: tab === "60-day",
isEnabled: true,
},
{
name: "30 Days",
onClick: () => setTab("30-day"),
isActive: tab === "30-day",
isEnabled: true,
},
{
name: "7 Days",
onClick: () => setTab("7-day"),
isActive: tab === "7-day",
isEnabled: true,
},
{
name: "Pending",
onClick: () => setTab("pending"),
isActive: tab === "pending",
isEnabled: true,
},
]}
/>
@@ -31,8 +31,8 @@ import {
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
import { EmptyState } from "../../components/Analytics/EmptyState";
import { PieChartCard } from "../../components/Analytics/PieChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
@@ -85,11 +85,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
title={project.name}
interval={interval}
range={range}
showRangeSelector={isActive}
/>
</div>
{!isActive ? (
<div className="container p-6">
<EmptyState />
<div className="container py-8 pb-20">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex grow flex-col py-6">
@@ -217,7 +217,7 @@ export function ProjectGeneralSettingsPageUI(props: {
const paths = {
aaConfig: `${projectLayout}/connect/account-abstraction/settings`,
inAppConfig: `${projectLayout}/connect/in-app-wallets/settings`,
payConfig: `${projectLayout}/connect/pay/settings`,
payConfig: `${projectLayout}/connect/universal-bridge/settings`,
afterDeleteRedirectTo: `/team/${props.teamSlug}`,
};
@@ -406,7 +406,6 @@ function ProjectNameSetting(props: {
handleSubmit: () => void;
}) {
const { form, handleSubmit } = props;
const isNameDirty = form.getFieldState("name").isDirty;
return (
<SettingsCard
@@ -419,8 +418,8 @@ function ProjectNameSetting(props: {
errorText={form.getFieldState("name").error?.message}
saveButton={{
onClick: handleSubmit,
disabled: !isNameDirty,
isPending: props.isUpdatingProject && isNameDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
bottomText="Please use 64 characters at maximum"
>
@@ -501,7 +500,6 @@ function AllowedDomainsSetting(props: {
handleSubmit: () => void;
}) {
const { form, handleSubmit } = props;
const isDomainsDirty = form.getFieldState("domains").isDirty;
const helperText = (
<ul className="flex list-disc flex-col gap-1.5 py-1 pl-3 text-muted-foreground text-sm [&>li]:pl-1">
@@ -544,8 +542,8 @@ function AllowedDomainsSetting(props: {
errorText={form.getFieldState("domains", form.formState).error?.message}
saveButton={{
onClick: handleSubmit,
disabled: !isDomainsDirty,
isPending: props.isUpdatingProject && isDomainsDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
bottomText="This is only applicable for web applications"
>
@@ -606,13 +604,12 @@ function AllowedBundleIDsSetting(props: {
handleSubmit: () => void;
}) {
const { form, handleSubmit } = props;
const isBundleIdsDirty = form.getFieldState("bundleIds").isDirty;
return (
<SettingsCard
saveButton={{
onClick: handleSubmit,
disabled: !isBundleIdsDirty,
isPending: props.isUpdatingProject && isBundleIdsDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
noPermissionText={undefined}
header={{
@@ -715,7 +712,7 @@ function EnabledServicesSetting(props: {
errorText={undefined}
saveButton={{
onClick: handleSubmit,
disabled: !form.formState.isDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
bottomText=""
@@ -984,7 +981,7 @@ function DeleteProject(props: {
);
}
function RotateSecretKeyButton(props: {
export function RotateSecretKeyButton(props: {
rotateSecretKey: RotateSecretKey;
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
}) {
@@ -1219,8 +1216,6 @@ function TransferProject(props: {
selectedTeamId === props.currentTeamId ||
!props.isOwnerAccount;
console.log({ selectedTeam: selectedTeamWithRole, isDisabled });
const handleTransfer = () => {
if (!hasOwnerRole) {
return;

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