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
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
286114ab81 Version Packages (#6435)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-07 14:08:27 -08:00
gregandGitHub cbc4e80330 fix: onramp improvements (#6437) 2025-03-07 13:49:15 -08:00
joaquim-verges efceadd3c1 [SDK] test: Update smart wallet tests to use Arbitrum Sepolia (#6436)
<!--

## 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 updates the test file `smart-wallet-integration-v07.test.ts` to switch the blockchain network from `sepolia` to `arbitrumSepolia`, modifies the contract address, and adds a new expectation for contract deployment verification.

### Detailed summary
- Added import for `arbitrumSepolia`.
- Changed the blockchain network from `sepolia` to `arbitrumSepolia`.
- Updated the contract address from `0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8` to `0x6A7a26c9a595E6893C255C9dF0b593e77518e0c3`.
- Added expectation to verify if the contract is deployed.

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

<!-- end pr-codex -->
2025-03-07 20:37:57 +00:00
Manan TankandGitHub c653d7033d [TOOL-3446] Dashboard: Update account onboarding UI, Add Team onboarding, Open stripe links in new tab (#6354) 2025-03-08 08:40:09 +13:00
YashandGitHub 77029a2e01 Interop module support (#6325) 2025-03-06 17:27:41 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
b0b3b7f8f6 Version Packages (#6433)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-06 16:58:32 -08:00
Joaquim VergesandGitHub 617fe68c32 [SDK] feat: Allow custom storage for inApp and ecosystem wallets (#6432) 2025-03-06 16:16:32 -08:00
saminacodes 01d0666208 [Portal] Renames (#6430)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the terminology and references in the codebase from `thirdweb Pay` to `thirdweb Universal Bridge`, reflecting a rebranding and restructuring of the payment system.

### Detailed summary
- Updated sidebar links, changing "Testing Pay" to "Developer Mode" and renamed "Guides" to "Tutorials".
- Replaced instances of `thirdweb Pay` with `thirdweb Universal Bridge` in multiple files.
- Updated metadata titles and descriptions across various `.mdx` files.
- Modified FAQs to reflect new terminology for the Universal Bridge service.
- Adjusted documentation on customization and integration guides to use the new branding.

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

<!-- end pr-codex -->
2025-03-06 20:53:34 +00:00
MananTank 621357a5c6 [TOOL-3635] Dashboard: Fix AA factories page crash (#6428) 2025-03-06 16:11:27 +00:00
MananTank 0527ee26b7 [TOOL-3630] Dashboard: Add cancel subscriptions option in delete account flow (#6429) 2025-03-06 15:59:30 +00:00
Waren GonzagaandGitHub 834ed8337b Update page.mdx (#6425)
Signed-off-by: Waren Gonzaga <[email protected]>
2025-03-06 02:44:50 -08:00
Phillip HoandGitHub 11bb4f5fee chore: update mainnet AA message (#6427) 2025-03-06 02:44:28 -08:00
319 changed files with 13771 additions and 6048 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;
+32 -3
View File
@@ -7,6 +7,7 @@ import { Inter as interFont } from "next/font/google";
// biome-ignore lint/style/useImportType: <explanation>
import React from "react";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { Button } from "../src/@/components/ui/button";
const queryClient = new QueryClient();
@@ -16,8 +17,30 @@ const fontSans = interFont({
variable: "--font-sans",
});
const customViewports = {
xs: {
// Regular sized phones (iphone 15 / 15 pro)
name: "iPhone",
styles: {
width: "390px",
height: "844px",
},
},
sm: {
// Larger phones (iphone 15 plus / 15 pro max)
name: "iPhone Plus",
styles: {
width: "430px",
height: "932px",
},
},
};
const preview: Preview = {
parameters: {
viewport: {
viewports: customViewports,
},
controls: {
matchers: {
color: /(background|color)$/i,
@@ -57,13 +80,13 @@ function StoryLayout(props: {
return (
<QueryClientProvider client={queryClient}>
<div className="flex min-h-screen min-w-0 flex-col bg-background text-foreground">
<div className="flex min-h-dvh min-w-0 flex-col bg-background text-foreground">
<div className="flex justify-end gap-2 border-b p-4">
<Button
onClick={() => setTheme("dark")}
size="sm"
variant={theme === "dark" ? "default" : "outline"}
className="h-auto w-auto rounded-full p-2"
className="h-auto w-auto shrink-0 rounded-full p-2"
>
<MoonIcon className="size-4" />
</Button>
@@ -72,14 +95,20 @@ function StoryLayout(props: {
onClick={() => setTheme("light")}
size="sm"
variant={theme === "light" ? "default" : "outline"}
className="h-auto w-auto rounded-full p-2"
className="h-auto w-auto shrink-0 rounded-full p-2"
>
<SunIcon className="size-4" />
</Button>
</div>
<div className="flex min-w-0 grow flex-col">{props.children}</div>
<ToasterSetup />
</div>
</QueryClientProvider>
);
}
function ToasterSetup() {
const { theme } = useTheme();
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
}
+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,
];
}
+20 -16
View File
@@ -1,21 +1,20 @@
"use server";
import "server-only";
import { API_SERVER_URL } from "@/constants/env";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
export type RedirectCheckoutOptions = {
export type GetBillingCheckoutUrlOptions = {
teamSlug: string;
sku: ProductSKU;
redirectUrl: string;
metadata?: Record<string, string>;
};
export async function redirectToCheckout(
options: RedirectCheckoutOptions,
): Promise<{ status: number }> {
export async function getBillingCheckoutUrl(
options: GetBillingCheckoutUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
@@ -49,6 +48,7 @@ export async function redirectToCheckout(
status: res.status,
};
}
const json = await res.json();
if (!json.result) {
return {
@@ -56,20 +56,22 @@ export async function redirectToCheckout(
};
}
// redirect to the stripe checkout session
redirect(json.result);
return {
status: 200,
url: json.result as string,
};
}
export type RedirectBillingCheckoutAction = typeof redirectToCheckout;
export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl;
export type BillingPortalOptions = {
export type GetBillingPortalUrlOptions = {
teamSlug: string | undefined;
redirectUrl: string;
};
export async function redirectToBillingPortal(
options: BillingPortalOptions,
): Promise<{ status: number }> {
export async function getBillingPortalUrl(
options: GetBillingPortalUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
@@ -110,8 +112,10 @@ export async function redirectToBillingPortal(
};
}
// redirect to the stripe billing portal
redirect(json.result);
return {
status: 200,
url: json.result as string,
};
}
export type BillingBillingPortalAction = typeof redirectToBillingPortal;
export type GetBillingPortalUrlAction = typeof getBillingPortalUrl;
+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();
@@ -1,19 +0,0 @@
import { cn } from "@/lib/utils";
export function TextDivider(props: {
text: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center text-muted-foreground text-sm",
props.className,
)}
>
<span className="h-[1px] flex-1 bg-border" />
<span className="mx-4">{props.text}</span>
<span className="h-[1px] flex-1 bg-border" />
</div>
);
}
+93 -35
View File
@@ -1,78 +1,136 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
BillingBillingPortalAction,
BillingPortalOptions,
RedirectBillingCheckoutAction,
RedirectCheckoutOptions,
GetBillingCheckoutUrlAction,
GetBillingCheckoutUrlOptions,
GetBillingPortalUrlAction,
GetBillingPortalUrlOptions,
} from "../actions/billing";
import { cn } from "../lib/utils";
import { Spinner } from "./ui/Spinner/Spinner";
import { Button, type ButtonProps } from "./ui/button";
type CheckoutButtonProps = Omit<RedirectCheckoutOptions, "redirectUrl"> &
ButtonProps & {
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
};
type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};
export function CheckoutButton({
onClick,
teamSlug,
sku,
metadata,
redirectPath,
getBillingCheckoutUrl,
children,
redirectToCheckout,
...restProps
buttonProps,
}: CheckoutButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingCheckoutUrl({
teamSlug,
sku,
metadata,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});
const errorMessage = "Failed to open checkout page";
return (
<Button
{...restProps}
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
onClick?.(e);
await redirectToCheckout({
teamSlug,
sku,
metadata,
redirectUrl: getRedirectUrl(redirectPath),
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess: (res) => {
if (!res.url) {
toast.error(errorMessage);
return;
}
const tab = window.open(res.url, "_blank");
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
);
}
type BillingPortalButtonProps = Omit<BillingPortalOptions, "redirectUrl"> &
ButtonProps & {
redirectPath: string;
redirectToBillingPortal: BillingBillingPortalAction;
};
type BillingPortalButtonProps = Omit<
GetBillingPortalUrlOptions,
"redirectUrl"
> & {
getBillingPortalUrl: GetBillingPortalUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};
export function BillingPortalButton({
onClick,
teamSlug,
redirectPath,
children,
redirectToBillingPortal,
...restProps
getBillingPortalUrl,
buttonProps,
}: BillingPortalButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingPortalUrl({
teamSlug,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});
const errorMessage = "Failed to open billing portal";
return (
<Button
{...restProps}
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
onClick?.(e);
await redirectToBillingPortal({
teamSlug,
redirectUrl: getRedirectUrl(redirectPath),
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess(res) {
if (!res.url) {
toast.error(errorMessage);
return;
}
const tab = window.open(res.url, "_blank");
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
);
}
function getRedirectUrl(path: string) {
function getAbsoluteUrl(path: string) {
const url = new URL(window.location.origin);
url.pathname = path;
return url.toString();
@@ -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"
@@ -1,3 +1,4 @@
"use client";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -8,7 +9,7 @@ import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react";
import type React from "react";
import { TEAM_PLANS } from "utils/pricing";
import { remainingDays } from "../../../utils/date-utils";
import type { RedirectBillingCheckoutAction } from "../../actions/billing";
import type { GetBillingCheckoutUrlAction } from "../../actions/billing";
import { CheckoutButton } from "../billing";
type ButtonProps = React.ComponentProps<typeof Button>;
@@ -27,14 +28,13 @@ type PricingCardProps = {
label?: string;
};
variant?: ButtonProps["variant"];
onClick?: () => void;
};
ctaHint?: string;
highlighted?: boolean;
current?: boolean;
canTrialGrowth?: boolean;
activeTrialEndsAt?: string;
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
};
export const PricingCard: React.FC<PricingCardProps> = ({
@@ -43,10 +43,8 @@ export const PricingCard: React.FC<PricingCardProps> = ({
cta,
highlighted = false,
current = false,
canTrialGrowth = false,
activeTrialEndsAt,
redirectPath,
redirectToCheckout,
getBillingCheckoutUrl,
}) => {
const plan = TEAM_PLANS[billingPlan];
const isCustomPrice = typeof plan.price === "string";
@@ -88,18 +86,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-3xl text-foreground tracking-tight">
{isCustomPrice ? (
plan.price
) : canTrialGrowth ? (
<>
<span className="text-muted-foreground line-through">
${plan.price}
</span>{" "}
$0
</>
) : (
`$${plan.price}`
)}
${plan.price}
</span>
{!isCustomPrice && (
@@ -135,7 +122,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
</div>
</div>
<div className="flex grow flex-col items-start gap-2.5 text-foreground">
<div className="flex grow flex-col items-start gap-2 text-foreground">
{plan.subTitle && (
<p className="font-medium text-foreground">{plan.subTitle}</p>
)}
@@ -149,11 +136,14 @@ export const PricingCard: React.FC<PricingCardProps> = ({
<div className="flex flex-col gap-3">
{billingPlan !== "pro" ? (
<CheckoutButton
variant={cta.variant || "outline"}
buttonProps={{
variant: cta.variant || "outline",
className: "gap-2",
onClick: cta.onClick,
}}
teamSlug={teamSlug}
sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"}
redirectPath={redirectPath}
redirectToCheckout={redirectToCheckout}
getBillingCheckoutUrl={getBillingCheckoutUrl}
>
{cta.title}
</CheckoutButton>
@@ -189,7 +179,7 @@ function FeatureItem({ text }: FeatureItemProps) {
const titleStr = Array.isArray(text) ? text[0] : text;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-sm">
<CheckIcon className="size-4 shrink-0 text-green-500" />
{Array.isArray(text) ? (
<div className="flex items-center gap-2">
@@ -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) {
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:selection:bg-foreground/20",
className,
)}
ref={ref}
+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)} />
@@ -44,14 +44,6 @@ export type Account = {
// TODO - add image URL
};
interface UpdateAccountInput {
name?: string;
email?: string;
linkWallet?: boolean;
subscribeToUpdates?: boolean;
onboardSkipped?: boolean;
}
interface UpdateAccountNotificationsInput {
billing: "email" | "none";
updates: "email" | "none";
@@ -140,45 +132,40 @@ export function useAccountCredits() {
});
}
export function useUpdateAccount() {
const queryClient = useQueryClient();
const address = useActiveAccount()?.address;
type UpdateAccountParams = {
name?: string;
email?: string;
linkWallet?: boolean;
subscribeToUpdates?: boolean;
onboardSkipped?: boolean;
};
return useMutation({
mutationFn: async (input: UpdateAccountInput) => {
type Result = {
data: object;
error?: { message: string };
};
export async function updateAccountClient(input: UpdateAccountParams) {
type Result = {
data: object;
error?: { message: string };
};
const res = await apiServerProxy<Result>({
pathname: "/v1/account",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: accountKeys.me(address || ""),
});
const res = await apiServerProxy<Result>({
pathname: "/v1/account",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
}
export function useUpdateNotifications() {
@@ -221,77 +208,61 @@ export function useUpdateNotifications() {
});
}
export function useConfirmEmail() {
return useMutation({
mutationFn: async (input: ConfirmEmailInput) => {
type Result = {
error?: { message: string };
data: { team: Team; account: Account };
};
export const verifyEmailClient = async (input: ConfirmEmailInput) => {
type Result = {
error?: { message: string };
data: { team: Team; account: Account };
};
const res = await apiServerProxy<Result>({
pathname: "/v1/account/confirmEmail",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
const res = await apiServerProxy<Result>({
pathname: "/v1/account/confirmEmail",
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
}
export function useResendEmailConfirmation() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
if (!res.ok) {
throw new Error(res.error);
}
return useMutation({
mutationFn: async () => {
type Result = {
error?: { message: string };
data: object;
};
const json = res.data;
const res = await apiServerProxy<Result>({
pathname: "/v1/account/resendEmailConfirmation",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (json.error) {
throw new Error(json.error.message);
}
if (!res.ok) {
throw new Error(res.error);
}
return json.data;
};
const json = res.data;
export const resendEmailClient = async () => {
type Result = {
error?: { message: string };
data: object;
};
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: accountKeys.me(address || ""),
});
const res = await apiServerProxy<Result>({
pathname: "/v1/account/resendEmailConfirmation",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
}
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
};
export async function createProjectClient(
teamId: string,
@@ -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;
};
};
@@ -52,7 +52,7 @@ import {
useWalletBalance,
} from "thirdweb/react";
import { z } from "zod";
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
import { isAccountOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
function formatTime(seconds: number) {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
@@ -210,7 +210,7 @@ export function FaucetButton({
);
}
if (!isOnboardingComplete(twAccount)) {
if (!isAccountOnboardingComplete(twAccount)) {
return (
<Button asChild className="w-full">
<Link
@@ -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"
@@ -1,7 +1,14 @@
"use client";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
@@ -11,6 +18,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
@@ -33,7 +41,9 @@ import { useForm } from "react-hook-form";
import {
defineChain,
eth_getCode,
getContract,
getRpcClient,
prepareContractCall,
prepareTransaction,
sendAndConfirmTransaction,
} from "thirdweb";
@@ -57,19 +67,21 @@ type CrossChain = {
status: "DEPLOYED" | "NOT_DEPLOYED";
};
const interopChains = ["420120000", "420120001"];
type ChainId = "420120000" | "420120001";
const formSchema = z.object({
amounts: z.object({
"84532": z.string(),
"11155420": z.string(),
"919": z.string(),
"111557560": z.string(),
"999999999": z.string(),
"11155111": z.string(),
"421614": z.string(),
"420120000": z.string(),
"420120001": z.string(),
}),
});
type FormSchema = z.output<typeof formSchema>;
const positiveIntegerRegex = /^[0-9]\d*$/;
const superchainBridgeAddress = "0x4200000000000000000000000000000000000028";
export function DataTable({
data,
coreMetadata,
@@ -97,13 +109,21 @@ export function DataTable({
"Failed to deploy contract",
);
const isCrosschain = !!modulesMetadata?.find(
(m) => m.name === "SuperChainInterop",
);
const addRowMutation = useMutation({
mutationFn: async (chain: { chainId: number; name: string }) => {
if (coreContract.chain.id === chain.chainId) {
return;
}
// eslint-disable-next-line no-restricted-syntax
const c = defineChain(chain.chainId);
const code = await eth_getCode(
getRpcClient({
client: getThirdwebClient(),
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(chain.chainId),
chain: c,
}),
{ address: coreContract.address },
);
@@ -155,19 +175,59 @@ export function DataTable({
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
values: {
defaultValues: {
amounts: {
"84532": "", // Base
"11155420": "", // OP testnet
"919": "", // Mode Network
"111557560": "", // Cyber
"999999999": "", // Zora
"11155111": "", // Sepolia
"421614": "",
"420120000": "",
"420120001": "",
},
},
});
const crossChainTransfer = async (chainId: ChainId) => {
if (!activeAccount) {
throw new Error("Account not connected");
}
const amount = form.getValues().amounts[chainId];
if (!positiveIntegerRegex.test(amount)) {
form.setError(`amounts.${chainId}`, { message: "Invalid Amount" });
return;
}
const superChainBridge = getContract({
address: superchainBridgeAddress,
chain: coreContract.chain,
client: coreContract.client,
});
const sendErc20Tx = prepareContractCall({
contract: superChainBridge,
method:
"function sendERC20(address _token, address _to, uint256 _amount, uint256 _chainId)",
params: [
coreContract.address,
activeAccount.address,
BigInt(amount),
BigInt(chainId),
],
});
await sendAndConfirmTransaction({
account: activeAccount,
transaction: sendErc20Tx,
});
};
const crossChainTransferNotifications = useTxNotifications(
"Successfully submitted cross chain transfer",
"Failed to submit cross chain transfer",
);
const crossChainTransferMutation = useMutation({
mutationFn: crossChainTransfer,
onSuccess: crossChainTransferNotifications.onSuccess,
onError: crossChainTransferNotifications.onError,
});
const columns: ColumnDef<CrossChain>[] = [
{
accessorKey: "network",
@@ -208,6 +268,54 @@ export function DataTable({
);
},
},
{
accessorKey: "transfer",
header: "",
cell: ({ row }) => {
const chain = row.getValue("chainId");
if (
row.getValue("status") === "DEPLOYED" &&
interopChains.includes(String(chain)) &&
isCrosschain
) {
return (
<FormField
disabled={false}
control={form.control}
name={`amounts.${row.getValue("chainId") as ChainId}`}
render={({ field }) => (
<FormItem>
<FormControl>
<ToolTipLabel label="Bridge tokens">
<div className="flex">
<Input
className="w-22 rounded-r-none border-r-0"
placeholder="amount"
{...field}
/>
<Button
type="button"
disabled={false}
onClick={() =>
crossChainTransferMutation.mutate(
row.getValue("chainId"),
)
}
className="rounded-lg rounded-l-none border border-l-0"
>
Bridge
</Button>
</div>
</ToolTipLabel>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
},
},
];
const table = useReactTable({
@@ -268,6 +376,18 @@ export function DataTable({
crosschainContractAddress = coreContract.address;
}
} else {
if (modulesMetadata) {
for (const m of modulesMetadata) {
await getOrDeployInfraForPublishedContract({
chain,
client,
account: activeAccount,
contractId: m.name,
publisher: m.publisher,
});
}
}
crosschainContractAddress = await deployContractfromDeployMetadata({
account: activeAccount,
chain,
@@ -283,17 +403,6 @@ export function DataTable({
chain,
client,
});
if (modulesMetadata) {
for (const m of modulesMetadata) {
await getOrDeployInfraForPublishedContract({
chain,
client,
account: activeAccount,
contractId: m.name,
publisher: m.publisher,
});
}
}
}
deployStatusModal.nextStep();
deployStatusModal.setViewContractLink(
@@ -2,6 +2,7 @@ import { fetchPublishedContractsFromDeploy } from "components/contract-component
import { notFound } from "next/navigation";
import {
type ContractOptions,
eth_blockNumber,
eth_getTransactionByHash,
eth_getTransactionReceipt,
getContractEvents,
@@ -110,10 +111,16 @@ export default async function Page(props: {
let creationBlockNumber: bigint | undefined;
if (twCloneFactoryContract) {
const latestBlockNumber = await eth_blockNumber(
getRpcClient({
client: contract.client,
chain: contract.chain,
}),
);
const events = await getContractEvents({
contract: twCloneFactoryContract,
events: [ProxyDeployedEvent],
blockRange: 123456n,
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
});
const event = events.find(
(e) =>
@@ -165,7 +172,7 @@ export default async function Page(props: {
}
}),
)
).filter((c) => c.status === "DEPLOYED");
).filter((c) => c.chainId !== contract.chain.id);
let coreMetadata: FetchDeployMetadataResult | undefined;
try {
@@ -260,10 +267,11 @@ export default async function Page(props: {
<>
<div>
<h2 className="mb-1 font-bold text-2xl tracking-tight">
Deploy Cross-chain
Cross-chain contracts
</h2>
<p className="text-muted-foreground">
Deterministically deploy your contracts on multiple networks.
Deterministically deploy and interact with your contracts on multiple
networks.
</p>
</div>
<div className="h-10" />
@@ -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"
@@ -1,6 +1,6 @@
"use client";
import { redirectToBillingPortal } from "@/actions/billing";
import { getBillingPortalUrl } from "@/actions/billing";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { apiServerProxy } from "@/actions/proxies";
import { updateAccount } from "@/actions/updateAccount";
@@ -36,6 +36,16 @@ export function AccountSettingsPage(props: {
client={props.client}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
cancelSubscriptions={async () => {
const res = await apiServerProxy({
method: "DELETE",
pathname: `/v1/teams/${props.defaultTeamSlug}/subscriptions`,
});
if (!res.ok) {
throw new Error(res.error);
}
}}
onAccountDeleted={async () => {
await doLogout();
if (activeWallet) {
@@ -58,7 +68,7 @@ export function AccountSettingsPage(props: {
return { status: 500 };
}
}}
redirectToBillingPortal={redirectToBillingPortal}
getBillingPortalUrl={getBillingPortalUrl}
updateAccountAvatar={async (file) => {
let uri: string | undefined = undefined;
@@ -109,7 +109,7 @@ function Variants() {
<AccountSettingsPageUI
defaultTeamSlug="foo"
defaultTeamName="Foo"
redirectToBillingPortal={async () => {
getBillingPortalUrl={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200 };
}}
@@ -143,6 +143,9 @@ function Variants() {
onAccountDeleted={() => {
console.log("Account deleted");
}}
cancelSubscriptions={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
<Toaster richColors />
</div>
@@ -1,6 +1,6 @@
"use client";
import type { BillingBillingPortalAction } from "@/actions/billing";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
@@ -38,7 +38,7 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
import { CircleXIcon, EllipsisIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -63,7 +63,8 @@ export function AccountSettingsPageUI(props: {
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
cancelSubscriptions: () => Promise<void>;
}) {
return (
<div className="flex flex-col gap-8">
@@ -88,7 +89,8 @@ export function AccountSettingsPageUI(props: {
onAccountDeleted={props.onAccountDeleted}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
redirectToBillingPortal={props.redirectToBillingPortal}
getBillingPortalUrl={props.getBillingPortalUrl}
cancelSubscriptions={props.cancelSubscriptions}
/>
</div>
);
@@ -206,7 +208,8 @@ function DeleteAccountCard(props: {
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
cancelSubscriptions: () => Promise<void>;
}) {
const title = "Delete Account";
const description = (
@@ -230,6 +233,10 @@ function DeleteAccountCard(props: {
},
});
const cancelSubscriptions = useMutation({
mutationFn: props.cancelSubscriptions,
});
function handleDelete() {
deleteAccount.mutate();
}
@@ -264,25 +271,47 @@ function DeleteAccountCard(props: {
<Alert variant="destructive">
<AlertTitle>Failed to delete account</AlertTitle>
<AlertDescription>
<span className="mb-1 block">
Your default team "{props.defaultTeamName}" has active
subscriptions. These subscriptions have to be cancelled
first before deleting account
</span>
<span className="mb-4 block">
Reach out to support to help you cancel them
</span>
<Button
variant="default"
asChild
size="sm"
className="gap-2"
>
<Link href="/support/create-ticket">
Contact Support
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
<div className="mb-4">
<span className="block">
Your default team "{props.defaultTeamName}" has active
subscriptions. These subscriptions have to be cancelled
before deleting account
</span>
</div>
<div className="flex flex-col gap-3 lg:flex-row">
<Button
variant="default"
size="sm"
className="gap-2"
onClick={() => {
const promise = cancelSubscriptions.mutateAsync();
toast.promise(promise, {
success: "Subscriptions cancelled successfully",
error: "Failed to cancel subscriptions",
});
}}
>
{cancelSubscriptions.isPending ? (
<Spinner className="size-4" />
) : (
<CircleXIcon className="size-4" />
)}
Cancel subscriptions
</Button>
<Button
variant="outline"
asChild
size="sm"
className="gap-2"
>
<Link href="/support/create-ticket" target="_blank">
Contact Support
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
</div>
</AlertDescription>
</Alert>
</div>
@@ -299,11 +328,12 @@ function DeleteAccountCard(props: {
account
</span>
<BillingPortalButton
size="sm"
buttonProps={{
size: "sm",
variant: "default",
}}
teamSlug={props.defaultTeamSlug}
variant="default"
redirectPath="/team"
redirectToBillingPortal={props.redirectToBillingPortal}
getBillingPortalUrl={props.getBillingPortalUrl}
>
Manage Billing
</BillingPortalButton>
@@ -2,7 +2,7 @@ import { API_SERVER_URL } from "@/constants/env";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { getAuthToken } from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
import { isOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
/**
* Just get the account object without enforcing onboarding.
@@ -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) {
@@ -40,7 +45,7 @@ export async function getValidAccount(pagePath?: string) {
const account = await getRawAccount();
// enforce login & onboarding
if (!account || !isOnboardingComplete(account)) {
if (!account || !isAccountOnboardingComplete(account)) {
loginRedirect(pagePath);
}
@@ -0,0 +1,21 @@
import { getTeamBySlug } from "@/api/team";
import { notFound } from "next/navigation";
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
if (!team) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={2}>
<InviteTeamMembers team={team} />
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,60 @@
import { getProjects } from "@/api/projects";
import { getTeamBySlug, getTeams } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { notFound } from "next/navigation";
import { getValidAccount } from "../../../account/settings/getAccount";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { TeamHeaderLoggedIn } from "../../../team/components/TeamHeader/team-header-logged-in.client";
export default async function Layout(props: {
params: Promise<{ team_slug: string }>;
children: React.ReactNode;
}) {
const params = await props.params;
const [team, account, accountAddress, authToken, teams] = await Promise.all([
getTeamBySlug(params.team_slug),
getValidAccount(`/team/${params.team_slug}`),
getAuthTokenWalletAddress(),
getAuthToken(),
getTeams(),
]);
if (!accountAddress || !account || !teams || !authToken) {
loginRedirect(`/get-started/team/${params.team_slug}`);
}
if (!team) {
notFound();
}
// Note:
// Do not check that team is already onboarded or not and redirect away from /get-started pages
// because the team is marked as onboarded in the first step- instead of after completing all the steps
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
team,
projects: await getProjects(team.slug),
})),
);
return (
<div className="flex min-h-dvh grow flex-col">
<div className="border-b bg-card">
<TeamHeaderLoggedIn
account={account}
accountAddress={accountAddress}
currentProject={undefined}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
/>
</div>
{props.children}
<AppFooter />
</div>
);
}
@@ -0,0 +1,35 @@
import { getTeamBySlug } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { notFound } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { TeamOnboardingLayout } from "../../../login/onboarding/onboarding-layout";
import { TeamInfoForm } from "../../../login/onboarding/team-onboarding/team-onboarding";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/get-started/team/${params.team_slug}`);
}
if (!team) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={1}>
<TeamInfoForm
client={getThirdwebClient(authToken)}
teamId={team.id}
teamSlug={params.team_slug}
/>
</TeamOnboardingLayout>
);
}
+119 -86
View File
@@ -1,7 +1,7 @@
"use client";
import { redirectToCheckout } from "@/actions/billing";
import { getRawAccountAction } from "@/actions/getAccount";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ToggleThemeButton } from "@/components/color-mode-toggle";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { TURNSTILE_SITE_KEY } from "@/constants/env";
@@ -12,16 +12,21 @@ import { Turnstile } from "@marsidev/react-turnstile";
import { useTheme } from "next-themes";
import Link from "next/link";
import { Suspense, lazy, useEffect, useState } from "react";
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
import {
ConnectEmbed,
useActiveAccount,
useActiveWalletConnectionStatus,
} 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";
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
import { isAccountOnboardingComplete } from "./onboarding/isOnboardingRequired";
const LazyOnboardingUI = lazy(
() => import("./onboarding/on-boarding-ui.client"),
const LazyAccountOnboarding = lazy(
() => import("./onboarding/account-onboarding"),
);
const loginOptions = [
@@ -115,28 +120,13 @@ export function LoginAndOnboardingPage(props: {
);
}
export function LoginAndOnboardingPageContent(props: {
account: Account | undefined;
redirectPath: string;
loginWithInAppWallet: boolean;
function LoginPageContainer(props: {
children: React.ReactNode;
}) {
return (
<div className="relative flex grow flex-col">
<main className="container z-10 flex grow flex-col justify-center gap-6 py-12">
<ClientOnly
ssr={
<div className="flex justify-center">
<LoadingCard />
</div>
}
className="flex justify-center"
>
<PageContent
redirectPath={props.redirectPath}
account={props.account}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</ClientOnly>
<>
<main className="container z-10 flex grow flex-col items-center justify-center gap-6 py-12">
{props.children}
</main>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -145,23 +135,16 @@ export function LoginAndOnboardingPageContent(props: {
src="/assets/login/background.svg"
className="-bottom-12 -right-12 pointer-events-none fixed lg:right-0 lg:bottom-0"
/>
</div>
</>
);
}
function LoadingCard() {
return (
<div className="flex min-h-[522px] w-full items-center justify-center rounded-xl border border-border bg-card shadow-lg max-sm:max-w-[358px] lg:min-h-[568px] lg:w-[728px]">
<Spinner className="size-10" />
</div>
);
}
function PageContent(props: {
export function LoginAndOnboardingPageContent(props: {
redirectPath: string;
account: Account | undefined;
loginWithInAppWallet: boolean;
}) {
const accountAddress = useActiveAccount()?.address;
const [screen, setScreen] = useState<
| { id: "login" }
| {
@@ -189,7 +172,7 @@ function PageContent(props: {
return;
}
if (!isOnboardingComplete(account)) {
if (!isAccountOnboardingComplete(account)) {
setScreen({
id: "onboarding",
account,
@@ -207,37 +190,55 @@ function PageContent(props: {
}
}, [connectionStatus, screen.id]);
if (connectionStatus === "connecting") {
return <LoadingCard />;
if (screen.id === "complete") {
return <GenericLoadingPage className="border-none" />;
}
if (connectionStatus !== "connected" || screen.id === "login") {
if (connectionStatus === "connecting") {
return (
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
<LoginPageContainer>
<ConnectEmbedSizedLoadingCard />
</LoginPageContainer>
);
}
if (
connectionStatus !== "connected" ||
screen.id === "login" ||
!accountAddress
) {
return (
<LoginPageContainer>
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</LoginPageContainer>
);
}
if (screen.id === "onboarding") {
return (
<Suspense fallback={<LoadingCard />}>
<LazyOnboardingUI
account={screen.account}
<Suspense fallback={<GenericLoadingPage className="border-none" />}>
<LazyAccountOnboarding
onComplete={onComplete}
redirectPath={props.redirectPath}
redirectToCheckout={redirectToCheckout}
onLogout={() => {
setScreen({ id: "login" });
}}
skipShowingPlans={props.redirectPath.startsWith("/join/team")}
accountAddress={accountAddress}
/>
</Suspense>
);
}
return <LoadingCard />;
return (
<LoginPageContainer>
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</LoginPageContainer>
);
}
function CustomConnectEmbed(props: {
@@ -246,55 +247,87 @@ 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",
}}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => setTurnstileToken(token)}
/>
<ConnectEmbed
auth={{
getLoginPayload,
doLogin: async (params) => {
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
console.error("Failed to login", result.error, result.context);
throw new Error(result.error);
<ClientOnly ssr={<ConnectEmbedSizedLoadingCard />}>
<ConnectEmbed
auth={{
getLoginPayload,
doLogin: async (params) => {
if (isVercel() && !turnstileToken) {
setAlwaysShowTurnstile(true);
throw new Error("Please complete the captcha.");
}
props.onLogin();
} catch (e) {
console.error("Failed to login", e);
throw e;
}
},
doLogout,
isLoggedIn: async (x) => {
const isLoggedInResult = await isLoggedIn(x);
if (isLoggedInResult) {
props.onLogin();
}
return isLoggedInResult;
},
}}
wallets={
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
}
client={client}
modalSize="wide"
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
className="shadow-lg"
privacyPolicyUrl="/privacy-policy"
termsOfServiceUrl="/terms"
/>
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
console.error(
"Failed to login",
result.error,
result.context,
);
throw new Error(result.error);
}
props.onLogin();
} catch (e) {
console.error("Failed to login", e);
throw e;
}
},
doLogout,
isLoggedIn: async (x) => {
const isLoggedInResult = await isLoggedIn(x);
if (isLoggedInResult) {
props.onLogin();
}
return isLoggedInResult;
},
}}
wallets={
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
}
client={client}
modalSize="wide"
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
className="shadow-lg"
privacyPolicyUrl="/privacy-policy"
termsOfServiceUrl="/terms"
/>
</ClientOnly>
</div>
);
}
function ConnectEmbedSizedCard(props: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-[522px] w-full items-center justify-center rounded-xl border border-border bg-card shadow-lg max-sm:max-w-[358px] lg:min-h-[568px] lg:w-[728px]">
{props.children}
</div>
);
}
function ConnectEmbedSizedLoadingCard() {
return (
<ConnectEmbedSizedCard>
<Spinner className="size-10" />
</ConnectEmbedSizedCard>
);
}
+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");
@@ -1,175 +0,0 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { type Account, useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type AccountValidationSchema,
accountValidationSchema,
} from "./validations";
interface AccountFormProps {
account: Account;
horizontal?: boolean;
showSubscription?: boolean;
hideName?: boolean;
buttonText?: string;
padded?: boolean;
trackingCategory?: string;
disableUnchanged?: boolean;
onSave?: (email: string) => void;
onDuplicateError?: (email: string) => void;
}
export const AccountForm: React.FC<AccountFormProps> = ({
account,
onSave,
onDuplicateError,
buttonText = "Save",
hideName = false,
showSubscription = false,
disableUnchanged = false,
}) => {
const [isSubscribing, setIsSubscribing] = useState(true);
const trackEvent = useTrack();
const form = useForm<AccountValidationSchema>({
resolver: zodResolver(accountValidationSchema),
defaultValues: {
name: account.name || "",
email: account.unconfirmedEmail || account.email || "",
},
values: {
name: account.name || "",
email: account.unconfirmedEmail || account.email || "",
},
});
const updateMutation = useUpdateAccount();
const handleSubmit = form.handleSubmit((values) => {
const formData = {
...values,
...(showSubscription
? {
subscribeToUpdates: isSubscribing,
}
: {}),
};
trackEvent({
category: "account",
action: "update",
label: "attempt",
data: formData,
});
updateMutation.mutate(formData, {
onSuccess: (data) => {
if (onSave) {
onSave(values.email);
}
trackEvent({
category: "account",
action: "update",
label: "success",
data,
});
},
onError: (error) => {
console.error(error);
if (
onDuplicateError &&
error?.message.match(/email address already exists/)
) {
onDuplicateError(values.email);
return;
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
toast.error("Invalid Email Address");
} else {
toast.error(error.message || "Failed to update account");
}
trackEvent({
category: "account",
action: "update",
label: "error",
error,
fromOnboarding: !!onDuplicateError,
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col items-start gap-8 rounded-xl">
<div className="flex w-full flex-col gap-4">
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
type="email"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
{!hideName && (
<FormFieldSetup
errorMessage={
form.getFieldState("name", form.formState).error?.message
}
label="Name"
htmlFor="name"
isRequired={false}
>
<Input
placeholder="Company Inc."
type="text"
{...form.register("name")}
id="name"
/>
</FormFieldSetup>
)}
{showSubscription && (
<CheckboxWithLabel>
<Checkbox
checked={isSubscribing}
onCheckedChange={(v) => setIsSubscribing(!!v)}
/>
Subscribe to new features and key product updates
</CheckboxWithLabel>
)}
</div>
<Button
type="button"
onClick={handleSubmit}
disabled={
updateMutation.isPending ||
(disableUnchanged && !form.formState.isDirty)
}
className="w-full gap-2"
>
{updateMutation.isPending && <Spinner className="size-4" />}
{buttonText}
</Button>
</div>
</form>
);
};
@@ -1,74 +0,0 @@
"use client";
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
import { TextDivider } from "@/components/TextDivider";
import { PricingCard } from "@/components/blocks/pricing-card";
import { Button } from "@/components/ui/button";
import { TitleAndDescription } from "./Title";
export function OnboardingChoosePlan(props: {
skipPlan: () => Promise<void>;
canTrialGrowth: boolean;
teamSlug: string;
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
}) {
return (
<div>
<TitleAndDescription
heading="Choose your Plan"
description="Get started for free with our Starter plan or subscribe to Growth plan to unlock higher rate limits and advanced features"
/>
<div className="h-4" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-5">
<PricingCard
billingPlan="starter"
teamSlug={props.teamSlug}
cta={{
title: "Get Started for free",
tracking: {
category: "account",
label: "starterPlan",
},
}}
redirectPath={props.redirectPath}
redirectToCheckout={props.redirectToCheckout}
/>
<PricingCard
billingPlan="growth"
teamSlug={props.teamSlug}
cta={{
title: "Get Started with Growth",
tracking: {
category: "account",
label: "growthPlan",
},
variant: "default",
}}
canTrialGrowth={false}
highlighted
redirectPath={props.redirectPath}
redirectToCheckout={props.redirectToCheckout}
/>
</div>
<TextDivider text="OR" className="my-4" />
<Button
variant="outline"
onClick={props.skipPlan}
className="relative h-auto w-full items-center gap-2 rounded-xl bg-card py-2.5"
>
<span className="flex flex-col gap-0.5">
<span className="text-base text-foreground">Skip for now</span>
<span className="text-muted-foreground text-sm">
You will have limited access to services
</span>
</span>
</Button>
</div>
);
}
@@ -1,261 +0,0 @@
"use client";
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { cn } from "@/lib/utils";
import {
type Account,
useConfirmEmail,
useResendEmailConfirmation,
} from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useActiveAccount } from "thirdweb/react";
import { shortenString } from "utils/usedapp-external";
import { TitleAndDescription } from "./Title";
import {
type EmailConfirmationValidationSchema,
emailConfirmationValidationSchema,
} from "./validations";
interface OnboardingConfirmEmailProps {
email: string;
linking?: boolean;
onEmailConfirm: (params: {
team: Team;
account: Account;
}) => void;
onComplete: () => void;
onBack: () => void;
}
// TODO - separate out "linking" and "confirmLinking" states into separate components
export const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
email,
linking,
onEmailConfirm,
onBack,
onComplete,
}) => {
const [token, setToken] = useState("");
const [completed, setCompleted] = useState(false);
const [saving, setSaving] = useState(false);
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
const { onSuccess: onResendSuccess, onError: onResendError } =
useTxNotifications(
!linking
? "We've sent you an email confirmation code."
: "We've sent you a wallet linking confirmation code.",
!linking
? "Couldn't send an email confirmation code. Try later!"
: "Couldn't send a wallet linking confirmation code. Try later!",
);
const form = useForm<EmailConfirmationValidationSchema>({
resolver: zodResolver(emailConfirmationValidationSchema),
defaultValues: {
confirmationToken: "",
},
});
const confirmEmail = useConfirmEmail();
const resendMutation = useResendEmailConfirmation();
const handleChange = (value: string) => {
setToken(value.toUpperCase());
form.setValue("confirmationToken", value);
};
const handleSubmit = form.handleSubmit((values) => {
const trackingAction = !linking ? "confirmEmail" : "confirmLinkWallet";
setSaving(true);
trackEvent({
category: "account",
action: trackingAction,
label: "attempt",
});
confirmEmail.mutate(values, {
onSuccess: (response) => {
if (!linking) {
onEmailConfirm(response);
} else {
setCompleted(true);
}
setSaving(false);
trackEvent({
category: "account",
action: trackingAction,
label: "success",
});
},
// biome-ignore lint/suspicious/noExplicitAny: FIXME
onError: (error: any) => {
const message =
"message" in error
? error.message
: "Couldn't verify your email address. Try later!";
toast.error(message);
form.reset();
setToken("");
setSaving(false);
trackEvent({
category: "account",
action: trackingAction,
label: "error",
error: message,
});
},
});
});
const handleResend = () => {
setSaving(true);
trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "attempt",
});
resendMutation.mutate(undefined, {
onSuccess: () => {
setSaving(false);
onResendSuccess();
trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "success",
});
},
onError: (error) => {
onResendError(error);
form.reset();
setToken("");
setSaving(false);
trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "error",
error,
});
},
});
};
return (
<>
<TitleAndDescription
heading={
!linking
? "Verify your email"
: completed
? "Wallet linked"
: "Linking Wallets"
}
description={
!completed ? (
<>
Enter the 6 letter confirmation code sent to{" "}
<span className="text-medium">{email}</span>
</>
) : (
<>
We've linked{" "}
{address && (
<span className="font-mono">{shortenString(address)}</span>
)}
wallet to <span className="font-medium">{email}</span> thirdweb
account.
</>
)
}
/>
<div className="h-5" />
{completed && (
<Button onClick={onComplete} className="w-full">
Continue to Dashboard
</Button>
)}
{!completed && (
<form onSubmit={handleSubmit}>
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
value={token}
onChange={handleChange}
disabled={saving}
>
<InputOTPGroup className="w-full">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={idx}
index={idx}
className={cn("h-12 grow text-lg", {
"border-red-500": form.getFieldState(
"confirmationToken",
form.formState,
).error,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
<div className="h-6" />
<div className="flex flex-col gap-4">
<Button
type="submit"
onClick={handleSubmit}
disabled={saving}
className="gap-2"
>
{saving && <Spinner className="size-4" />}
Verify
</Button>
<Button onClick={onBack} disabled={saving} variant="outline">
{!linking ? "Update email" : "Use another email"}
</Button>
<Button
variant="link"
onClick={handleResend}
disabled={saving}
className="text-link-foreground hover:text-foreground"
>
Resend Verification Code
</Button>
</div>
</form>
)}
</>
);
};
@@ -1,90 +0,0 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../auth-actions";
import { AccountForm } from "./AccountForm";
import { TitleAndDescription } from "./Title";
type OnboardingGeneralProps = {
account: Account;
onSave: (email: string) => void;
onDuplicate: (email: string) => void;
onLogout: () => void;
};
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
account,
onSave,
onDuplicate,
onLogout,
}) => {
const [existing, setExisting] = useState(false);
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
async function handleLogout() {
await doLogout();
onLogout();
if (activeWallet) {
disconnect(activeWallet);
}
}
const logoutMutation = useMutation({
mutationFn: handleLogout,
});
return (
<div>
<TitleAndDescription
heading={
!existing
? "Create your thirdweb account"
: "Log in to your thirdweb account"
}
description="Start building web3 apps and games, faster."
/>
<div className="h-6" />
<div className="flex flex-col gap-4">
<AccountForm
showSubscription={!existing}
hideName={existing}
account={account}
buttonText={!existing ? "Get Started for Free" : "Login"}
trackingCategory="onboarding"
onSave={onSave}
onDuplicateError={onDuplicate}
/>
{!existing ? (
<>
<Button variant="primary" onClick={() => setExisting(true)}>
I have a thirdweb account
</Button>
<Button
variant="outline"
onClick={() => {
logoutMutation.mutate();
}}
className="gap-2"
>
{logoutMutation.isPending && <Spinner className="size-4" />}
Log out
</Button>
</>
) : (
<Button variant="outline" onClick={() => setExisting(false)}>
I don&apos;t have an account
</Button>
)}
</div>
</div>
);
};
@@ -1,125 +0,0 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
import { useActiveAccount } from "thirdweb/react";
import { shortenString } from "utils/usedapp-external";
import { TitleAndDescription } from "./Title";
interface OnboardingLinkWalletProps {
email: string;
onSave: () => void;
onBack: () => void;
}
export const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
email,
onSave,
onBack,
}) => {
const address = useActiveAccount()?.address;
const trackEvent = useTrack();
const updateMutation = useUpdateAccount();
const handleSubmit = () => {
trackEvent({
category: "account",
action: "linkWallet",
label: "attempt",
data: {
email,
},
});
updateMutation.mutate(
{
email,
linkWallet: true,
},
{
onSuccess: (data) => {
if (onSave) {
onSave();
}
trackEvent({
category: "account",
action: "linkWallet",
label: "success",
data,
});
},
onError: (err) => {
const error = err as Error;
trackEvent({
category: "account",
action: "linkWallet",
label: "error",
error,
});
},
},
);
};
return (
<>
<TitleAndDescription
heading="Linking Wallets"
description={
<>
We&apos;ve noticed that there is another account associated with{" "}
<span className="text-foreground">{email}</span>. Would you like to
link your wallet{" "}
{address && (
<span className="font-mono text-foreground">
{shortenString(address)}
</span>
)}{" "}
to the existing account?
<div className="h-2" />
Once you agree, we will email you the details.{" "}
<TrackedLinkTW
href="https://portal.thirdweb.com/account/billing/account-info"
category="account"
label="learn-wallet-linking"
target="_blank"
className="underline hover:text-foreground"
>
Learn more about wallet linking
</TrackedLinkTW>
.
</>
}
/>
<div className="h-8" />
<form>
<div className="flex flex-col gap-3">
<Button
type="button"
onClick={handleSubmit}
disabled={updateMutation.isPending}
className="gap-2"
>
{updateMutation.isPending && <Spinner className="size-4" />}
{updateMutation.isPending ? "Linking" : "Yes, link them"}
</Button>
<Button
size="lg"
variant="outline"
onClick={onBack}
disabled={updateMutation.isPending}
>
Use another email
</Button>
</div>
</form>
</>
);
};
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookLog } from "../../../../stories/utils";
import { AccountOnboardingLayout } from "../onboarding-layout";
import { LinkWalletPrompt } from "./LinkWalletPrompt";
const meta = {
title: "Onboarding/AccountOnboarding/LinkWalletPrompt",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SendSuccess: Story = {
args: {
type: "success",
},
};
export const SendError: Story = {
args: {
type: "error",
},
};
function Story(props: {
type: "success" | "error";
}) {
return (
<AccountOnboardingLayout
currentStep={1}
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
>
<LinkWalletPrompt
onLinkWalletRequestSent={() => {
storybookLog("onLinkWalletRequestSent");
}}
email="[email protected]"
requestLinkWallet={async (email) => {
storybookLog("requestLinkWallet", email);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.type === "error") {
throw new Error("Example error");
}
}}
onBack={() => {
storybookLog("onBack");
}}
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
accountAddress="0x1234567890123456789012345678901234567890"
/>
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,123 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useMutation } from "@tanstack/react-query";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { toast } from "sonner";
import { shortenString } from "utils/usedapp-external";
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
export function LinkWalletPrompt(props: {
email: string;
accountAddress: string;
onBack: () => void;
requestLinkWallet: (email: string) => Promise<void>;
trackEvent: (params: TrackingParams) => void;
onLinkWalletRequestSent: () => void;
}) {
const requestLinkWallet = useMutation({
mutationFn: props.requestLinkWallet,
});
function handleLinkWalletRequest() {
props.trackEvent({
category: "account",
action: "linkWallet",
label: "attempt",
data: {
email: props.email,
},
});
requestLinkWallet.mutate(props.email, {
onSuccess: (data) => {
props.onLinkWalletRequestSent();
props.trackEvent({
category: "account",
action: "linkWallet",
label: "success",
data,
});
},
onError: (err) => {
const error = err as Error;
console.error(error);
toast.error("Failed to send link wallet request");
props.trackEvent({
category: "account",
action: "linkWallet",
label: "error",
error,
});
},
});
}
return (
<div className="rounded-lg border bg-card">
<h3 className="border-b px-4 py-6 font-semibold text-xl tracking-tight lg:p-6">
Link your wallet
</h3>
<div className="p-4 lg:p-6">
<div className="mb-6 space-y-2 text-muted-foreground">
<p>
An account with{" "}
<span className="text-foreground">{props.email}</span> already
exists, but your wallet is not linked to that account.
</p>
<p>
You can link your wallet with this account to access it. <br />{" "}
Multiple wallets can be linked to the same account.{" "}
<TrackedLinkTW
href="https://portal.thirdweb.com/account/billing/account-info"
category="account"
label="learn-wallet-linking"
target="_blank"
className="underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:text-foreground hover:decoration-foreground hover:decoration-solid"
>
Learn more about wallet linking
</TrackedLinkTW>
</p>
</div>
<p className="text-foreground">
Would you like to link your wallet{" "}
<span className="font-mono text-sm tracking-tight">
({shortenString(props.accountAddress)})
</span>{" "}
with this account?
</p>
</div>
<div className="flex flex-col-reverse gap-4 border-t px-4 py-6 md:flex-row lg:justify-between lg:p-6">
<Button
variant="outline"
className="gap-2 bg-card"
onClick={props.onBack}
disabled={requestLinkWallet.isPending}
>
<ArrowLeftIcon className="size-4" />
Change Email
</Button>
<Button
type="button"
onClick={handleLinkWalletRequest}
disabled={requestLinkWallet.isPending}
className="gap-2"
>
Link wallet and continue
{requestLinkWallet.isPending ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookLog } from "../../../../stories/utils";
import { AccountOnboardingLayout } from "../onboarding-layout";
import { LoginOrSignup } from "./LoginOrSignup";
const meta = {
title: "Onboarding/AccountOnboarding/LoginOrSignup",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Success: Story = {
args: {
type: "success",
},
};
export const EmailExists: Story = {
args: {
type: "email-exists",
},
};
export const OtherError: Story = {
args: {
type: "error",
},
};
function Story(props: {
type: "success" | "error" | "email-exists";
}) {
return (
<AccountOnboardingLayout
currentStep={1}
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
>
<LoginOrSignup
onRequestSent={(params) => {
storybookLog("onRequestSent", params);
}}
loginOrSignup={async (data) => {
storybookLog("loginOrSignup", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.type === "error") {
throw new Error("Error Example");
}
if (props.type === "email-exists") {
throw new Error("email address already exists");
}
}}
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
/>
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,274 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { TabButtons } from "@/components/ui/tabs";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
import {
type AccountValidationSchema,
accountValidationSchema,
emailSchema,
} from "../validations";
export function LoginOrSignup(props: {
onRequestSent: (options: {
email: string;
isExistingEmail: boolean;
}) => void;
loginOrSignup: (input: {
email: string;
subscribeToUpdates?: true;
name?: string;
}) => Promise<void>;
trackEvent: (params: TrackingParams) => void;
}) {
const [tab, setTab] = useState<"signup" | "login">("signup");
const loginOrSignup = useMutation({
mutationFn: props.loginOrSignup,
});
function handleSubmit(values: {
email: string;
subscribeToUpdates?: true;
name?: string;
}) {
loginOrSignup.mutate(values, {
onSuccess: (data) => {
props.onRequestSent({
email: values.email,
isExistingEmail: false,
});
props.trackEvent({
category: "onboarding",
action: "update",
label: "success",
data,
});
},
onError: (error) => {
if (error?.message.match(/email address already exists/)) {
props.onRequestSent({
email: values.email,
isExistingEmail: true,
});
return;
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
toast.error("Invalid Email Address");
} else {
toast.error("Failed to send confirmation email");
}
console.error(error);
props.trackEvent({
category: "account",
action: "update",
label: "error",
error: error.message,
fromOnboarding: true,
});
},
});
}
return (
<div className="flex flex-col rounded-lg border bg-card">
<TabButtons
tabContainerClassName="px-4 lg:px-6 pt-3 pb-0.5"
tabs={[
{
name: "Create account",
onClick: () => setTab("signup"),
isActive: tab === "signup",
},
{
name: "I already have an account",
onClick: () => setTab("login"),
isActive: tab === "login",
},
]}
/>
{tab === "signup" && (
<SignupForm
key="signup"
onSubmit={handleSubmit}
isSubmitting={loginOrSignup.isPending}
/>
)}
{tab === "login" && (
<LoginForm
key="login"
onSubmit={handleSubmit}
isSubmitting={loginOrSignup.isPending}
/>
)}
</div>
);
}
function SignupForm(props: {
onSubmit: (values: {
name: string;
email: string;
subscribeToUpdates?: true;
}) => void;
isSubmitting: boolean;
}) {
const [subscribeToUpdates, setSubscribeToUpdates] = useState(true);
const form = useForm<AccountValidationSchema>({
resolver: zodResolver(accountValidationSchema),
values: {
name: "",
email: "",
},
});
const handleSubmit = form.handleSubmit((values) => {
props.onSubmit({
...values,
...(subscribeToUpdates
? {
subscribeToUpdates: subscribeToUpdates,
}
: {}),
});
});
return (
<form onSubmit={handleSubmit} className="flex w-full grow flex-col">
<div className="flex w-full flex-col gap-4 px-4 py-6 lg:p-6">
<FormFieldSetup
errorMessage={
form.getFieldState("name", form.formState).error?.message
}
label="Name"
htmlFor="name"
isRequired={false}
>
<Input
placeholder="Company Inc."
className="bg-background"
type="text"
{...form.register("name")}
id="name"
/>
</FormFieldSetup>
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
className="bg-background"
type="email"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
<CheckboxWithLabel>
<Checkbox
checked={subscribeToUpdates}
onCheckedChange={(v) => setSubscribeToUpdates(!!v)}
/>
Subscribe to new features and key product updates
</CheckboxWithLabel>
</div>
<div className="mt-8 flex justify-end border-t px-4 py-6 lg:p-6">
<Button
type="button"
onClick={handleSubmit}
disabled={props.isSubmitting}
className="gap-2 px-6"
>
Get Started
{props.isSubmitting ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</form>
);
}
const loginFormSchema = z.object({
email: emailSchema,
});
type LoginFormSchema = z.infer<typeof loginFormSchema>;
function LoginForm(props: {
onSubmit: (values: {
email: string;
}) => void;
isSubmitting: boolean;
}) {
const form = useForm<LoginFormSchema>({
resolver: zodResolver(loginFormSchema),
values: {
email: "",
},
});
const handleSubmit = form.handleSubmit((values) => {
props.onSubmit(values);
});
return (
<form onSubmit={handleSubmit} className="flex w-full grow flex-col">
<div className="flex w-full flex-col gap-4 px-4 py-6 lg:p-6">
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
type="email"
className="bg-background"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
</div>
<div className="mt-6 flex justify-end border-t px-4 py-6 lg:p-6">
<Button
type="button"
onClick={handleSubmit}
disabled={props.isSubmitting}
className="gap-2 px-6"
>
Login
{props.isSubmitting ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</form>
);
}
@@ -1,23 +0,0 @@
import type { JSX } from "react";
type TitleAndDescriptionProps = {
heading: string | JSX.Element;
description: string | JSX.Element;
};
export const TitleAndDescription: React.FC<TitleAndDescriptionProps> = ({
heading,
description,
}) => {
return (
<div className="flex flex-col items-start gap-1">
<h3 className="font-semibold text-foreground text-xl tracking-tight">
{heading}
</h3>
{description && (
<div className="text-muted-foreground">{description}</div>
)}
</div>
);
};
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import { newAccountStub, teamStub } from "../../../../stories/stubs";
import { storybookLog } from "../../../../stories/utils";
import { AccountOnboardingLayout } from "../onboarding-layout";
import { VerifyEmail } from "./VerifyEmail";
const meta = {
title: "Onboarding/AccountOnboarding/VerifyEmail",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const VerifyEmailSuccess: Story = {
args: {
verifyEmailType: "success",
resendConfirmationEmailType: "success",
},
};
export const VerifyEmailError: Story = {
args: {
verifyEmailType: "error",
resendConfirmationEmailType: "success",
},
};
export const ResendCodeError: Story = {
args: {
verifyEmailType: "success",
resendConfirmationEmailType: "error",
},
};
function Story(props: {
verifyEmailType: "success" | "error";
resendConfirmationEmailType: "success" | "error";
}) {
return (
<AccountOnboardingLayout
currentStep={2}
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
>
<VerifyEmail
title="Custom Title"
trackingAction="customAction"
accountAddress="0x1234567890123456789012345678901234567890"
verifyEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.verifyEmailType === "error") {
throw new Error("Example error");
}
return {
team: teamStub("foo", "free"),
account: newAccountStub(),
};
}}
resendConfirmationEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.resendConfirmationEmailType === "error") {
throw new Error("Example error");
}
}}
email="[email protected]"
onEmailConfirmed={(params) => {
storybookLog("onEmailConfirmed", params);
}}
onBack={() => {
storybookLog("onBack");
}}
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
/>
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,225 @@
"use client";
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { TrackingParams } from "hooks/analytics/useTrack";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type EmailConfirmationValidationSchema,
emailConfirmationValidationSchema,
} from "../validations";
type VerifyEmailProps = {
email: string;
onEmailConfirmed: (params: {
team: Team;
account: Account;
}) => void;
onBack: () => void;
verifyEmail: (params: {
confirmationToken: string;
}) => Promise<{
team: Team;
account: Account;
}>;
resendConfirmationEmail: () => Promise<void>;
trackEvent: (params: TrackingParams) => void;
accountAddress: string;
title: string;
trackingAction: string;
};
export function VerifyEmail(props: VerifyEmailProps) {
const form = useForm<EmailConfirmationValidationSchema>({
resolver: zodResolver(emailConfirmationValidationSchema),
values: {
confirmationToken: "",
},
});
const verifyEmail = useMutation({
mutationFn: props.verifyEmail,
});
const resendConfirmationEmail = useMutation({
mutationFn: props.resendConfirmationEmail,
});
const handleSubmit = form.handleSubmit((values) => {
props.trackEvent({
category: "account",
action: props.trackingAction,
label: "attempt",
});
verifyEmail.mutate(values, {
onSuccess: (response) => {
props.onEmailConfirmed(response);
props.trackEvent({
category: "account",
action: props.trackingAction,
label: "success",
});
},
onError: (error) => {
console.error(error);
toast.error("Invalid confirmation code");
props.trackEvent({
category: "account",
action: props.trackingAction,
label: "error",
error: error.message,
});
},
});
});
function handleResend() {
form.setValue("confirmationToken", "");
verifyEmail.reset();
props.trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "attempt",
});
resendConfirmationEmail.mutate(undefined, {
onSuccess: () => {
toast.success("Verification code sent");
props.trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "success",
});
},
onError: (error) => {
toast.error("Failed to send verification code");
props.trackEvent({
category: "account",
action: "resendEmailConfirmation",
label: "error",
error,
});
},
});
}
return (
<div className="rounded-lg border bg-card">
<form onSubmit={handleSubmit}>
<div className="px-4 py-6 lg:px-6">
<h3 className="mb-1 font-semibold text-foreground text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-6 text-muted-foreground text-sm">
Enter the 6 letter confirmation code sent to{" "}
<span className="text-medium">{props.email}</span>
</p>
<div className="md:flex md:justify-start">
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
value={form.watch("confirmationToken")}
onChange={(otp) => {
form.setValue("confirmationToken", otp);
}}
disabled={verifyEmail.isPending}
>
<InputOTPGroup className="w-full grow bg-background lg:w-[300px]">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={idx}
index={idx}
className={cn("h-12 grow border-foreground/25 text-lg", {
"border-red-500":
form.getFieldState("confirmationToken", form.formState)
.error || verifyEmail.isError,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
</div>
<div className="mt-4 flex flex-col-reverse gap-4 border-t px-4 py-6 lg:flex-row lg:justify-between lg:px-6">
<Button
onClick={props.onBack}
variant="outline"
className="gap-2 bg-card"
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
Change Email
</Button>
<div className="flex justify-end gap-4">
<Button
variant="outline"
className="gap-2 bg-background"
onClick={handleResend}
disabled={resendConfirmationEmail.isPending}
>
{resendConfirmationEmail.isPending ? (
<Spinner className="size-4" />
) : (
<RotateCcwIcon className="size-4 text-muted-foreground" />
)}
Resend Code
</Button>
<Button
type="submit"
onClick={handleSubmit}
disabled={verifyEmail.isPending}
className="grow gap-2 px-6"
>
{verifyEmail.isPending && <Spinner className="size-4" />}
Verify
</Button>
</div>
</div>
</form>
</div>
);
}
export function LinkWalletVerifyEmail(
props: Omit<VerifyEmailProps, "title" | "trackingAction">,
) {
return (
<VerifyEmail
{...props}
title="Link Wallet"
trackingAction="confirmLinkWallet"
/>
);
}
export function SignupVerifyEmail(
props: Omit<VerifyEmailProps, "title" | "trackingAction">,
) {
return (
<VerifyEmail
{...props}
title="Verify Email"
trackingAction="confirmEmail"
/>
);
}
@@ -0,0 +1,133 @@
"use client";
import type { Team } from "@/api/team";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useState } from "react";
import type { TrackingParams } from "../../../hooks/analytics/useTrack";
import { LinkWalletPrompt } from "./LinkWalletPrompt/LinkWalletPrompt";
import { LoginOrSignup } from "./LoginOrSignup/LoginOrSignup";
import {
LinkWalletVerifyEmail,
SignupVerifyEmail,
} from "./VerifyEmail/VerifyEmail";
import { AccountOnboardingLayout } from "./onboarding-layout";
type AccountOnboardingScreen =
| { id: "login-or-signup" }
| { id: "link-wallet"; email: string; backScreen: AccountOnboardingScreen }
| {
id: "signup-verify-email";
email: string;
backScreen: AccountOnboardingScreen;
}
| {
id: "link-wallet-verify-email";
email: string;
backScreen: AccountOnboardingScreen;
};
type AccountOnboardingProps = {
onComplete: (param: {
team: Team;
account: Account;
}) => void;
accountAddress: string;
trackEvent: (params: TrackingParams) => void;
verifyEmail: (params: {
confirmationToken: string;
}) => Promise<{
team: Team;
account: Account;
}>;
resendEmailConfirmation: () => Promise<void>;
loginOrSignup: (input: {
email: string;
subscribeToUpdates?: true;
name?: string;
}) => Promise<void>;
requestLinkWallet: (email: string) => Promise<void>;
logout: () => Promise<void>;
};
export function AccountOnboardingUI(props: AccountOnboardingProps) {
const [screen, setScreen] = useState<AccountOnboardingScreen>({
id: "login-or-signup",
});
return (
<AccountOnboardingLayout
logout={props.logout}
currentStep={
screen.id === "login-or-signup" || screen.id === "link-wallet" ? 1 : 2
}
>
{screen.id === "login-or-signup" && (
<LoginOrSignup
loginOrSignup={props.loginOrSignup}
trackEvent={props.trackEvent}
onRequestSent={(params) => {
if (params.isExistingEmail) {
setScreen({
id: "link-wallet",
email: params.email,
backScreen: screen,
});
} else {
setScreen({
id: "signup-verify-email",
email: params.email,
backScreen: screen,
});
}
}}
/>
)}
{screen.id === "link-wallet" && (
<LinkWalletPrompt
accountAddress={props.accountAddress}
requestLinkWallet={props.requestLinkWallet}
trackEvent={props.trackEvent}
onLinkWalletRequestSent={() => {
setScreen({
id: "link-wallet-verify-email",
email: screen.email,
backScreen: screen,
});
}}
onBack={() => setScreen(screen.backScreen)}
email={screen.email}
/>
)}
{screen.id === "signup-verify-email" && (
<SignupVerifyEmail
accountAddress={props.accountAddress}
verifyEmail={props.verifyEmail}
resendConfirmationEmail={props.resendEmailConfirmation}
trackEvent={props.trackEvent}
onEmailConfirmed={(data) => {
props.onComplete({
team: data.team,
account: data.account,
});
}}
onBack={() => setScreen(screen.backScreen)}
email={screen.email}
/>
)}
{screen.id === "link-wallet-verify-email" && (
<LinkWalletVerifyEmail
accountAddress={props.accountAddress}
verifyEmail={props.verifyEmail}
resendConfirmationEmail={props.resendEmailConfirmation}
trackEvent={props.trackEvent}
onEmailConfirmed={props.onComplete}
onBack={() => setScreen(screen.backScreen)}
email={screen.email}
/>
)}
</AccountOnboardingLayout>
);
}
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import { newAccountStub, teamStub } from "../../../stories/stubs";
import { storybookLog } from "../../../stories/utils";
import { AccountOnboardingUI } from "./account-onboarding-ui";
const meta = {
title: "Onboarding/AccountOnboarding/Flow",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NewEmail: Story = {
args: {
loginOrSignupType: "success",
requestLinkWalletType: "success",
verifyEmailType: "success",
},
};
export const EmailExists: Story = {
args: {
loginOrSignupType: "error-email-exists",
requestLinkWalletType: "success",
verifyEmailType: "success",
},
};
function Story(props: {
loginOrSignupType: "success" | "error-email-exists" | "error-generic";
requestLinkWalletType: "success" | "error";
verifyEmailType: "success" | "error";
}) {
return (
<AccountOnboardingUI
logout={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("logout");
}}
onComplete={() => {
storybookLog("onComplete");
}}
accountAddress=""
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
loginOrSignup={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.loginOrSignupType === "error-email-exists") {
throw new Error("email address already exists");
}
if (props.loginOrSignupType === "error-generic") {
throw new Error("generic error");
}
}}
requestLinkWallet={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.requestLinkWalletType === "error") {
throw new Error("generic error");
}
}}
verifyEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (props.verifyEmailType === "error") {
throw new Error("generic error");
}
return {
team: teamStub("foo", "free"),
account: newAccountStub(),
};
}}
resendEmailConfirmation={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
);
}
@@ -0,0 +1,51 @@
"use client";
import {
resendEmailClient,
updateAccountClient,
verifyEmailClient,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useActiveWallet } from "thirdweb/react";
import { useDisconnect } from "thirdweb/react";
import { useTrack } from "../../../hooks/analytics/useTrack";
import { doLogout } from "../auth-actions";
import { AccountOnboardingUI } from "./account-onboarding-ui";
function AccountOnboarding(props: {
onComplete: () => void;
onLogout: () => void;
accountAddress: string;
}) {
const trackEvent = useTrack();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
return (
<AccountOnboardingUI
onComplete={props.onComplete}
logout={async () => {
if (activeWallet) {
disconnect(activeWallet);
}
await doLogout();
props.onLogout();
}}
accountAddress={props.accountAddress}
loginOrSignup={async (params) => {
await updateAccountClient(params);
}}
verifyEmail={verifyEmailClient}
resendEmailConfirmation={async () => {
await resendEmailClient();
}}
trackEvent={trackEvent}
requestLinkWallet={async (email) => {
await updateAccountClient({
email,
linkWallet: true,
});
}}
/>
);
}
export default AccountOnboarding;
@@ -1,6 +1,11 @@
import type { Team } from "@/api/team";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
export function isOnboardingComplete(account: Account) {
export function isAccountOnboardingComplete(account: Account) {
// if email is confirmed, onboarding is considered complete
return !!account.emailConfirmedAt;
}
export function isTeamOnboardingComplete(team: Team) {
return team.isOnboarded;
}
@@ -1,157 +0,0 @@
"use client";
import type { RedirectBillingCheckoutAction } from "@/actions/billing";
import type { Team } from "@/api/team";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { OnboardingChoosePlan } from "./ChoosePlan";
import { OnboardingConfirmEmail } from "./ConfirmEmail";
import { OnboardingGeneral } from "./General";
import { OnboardingLinkWallet } from "./LinkWallet";
import { useSkipOnboarding } from "./useSkipOnboarding";
type OnboardingScreen =
| { id: "onboarding" }
| { id: "linking" }
| { id: "confirming" }
| { id: "confirmLinking" }
| { id: "plan"; team: Team };
function OnboardingUI(props: {
account: Account;
onComplete: () => void;
onLogout: () => void;
// path to redirect from stripe
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
skipShowingPlans: boolean;
}) {
const { account } = props;
const [screen, setScreen] = useState<OnboardingScreen>({ id: "onboarding" });
const trackEvent = useTrack();
const [updatedEmail, setUpdatedEmail] = useState<string | undefined>();
const skipOnboarding = useSkipOnboarding();
function trackOnboardingStep(params: {
nextStep: OnboardingScreen["id"];
email?: string;
}) {
trackEvent({
category: "account",
action: "onboardingStep",
label: "next",
data: {
email: params.email || account.unconfirmedEmail || updatedEmail,
currentStep: screen,
nextStep: params.nextStep,
},
});
}
const handleDuplicateEmail = (email: string) => {
setScreen({
id: "linking",
});
trackOnboardingStep({
nextStep: "linking",
email,
});
};
return (
<div
className={cn(
"relative w-full rounded-xl border border-border bg-background p-6 shadow-lg",
screen.id === "plan" ? "lg:max-w-[750px]" : "lg:w-[500px]",
)}
>
{screen.id === "onboarding" && (
<OnboardingGeneral
account={account}
onLogout={props.onLogout}
onSave={(email) => {
setUpdatedEmail(email);
setScreen({
id: "confirming",
});
trackOnboardingStep({
nextStep: "confirming",
email,
});
}}
onDuplicate={(email) => {
setUpdatedEmail(email);
handleDuplicateEmail(email);
}}
/>
)}
{screen.id === "linking" && (
<OnboardingLinkWallet
onSave={() => {
setScreen({
id: "confirmLinking",
});
trackOnboardingStep({
nextStep: "confirmLinking",
});
}}
onBack={() => {
setUpdatedEmail(undefined);
setScreen({
id: "onboarding",
});
}}
email={updatedEmail as string}
/>
)}
{/* TODO - separate the confirming and confirmLinking into separate components */}
{(screen.id === "confirming" || screen.id === "confirmLinking") && (
<OnboardingConfirmEmail
linking={screen.id === "confirmLinking"}
onComplete={props.onComplete}
onEmailConfirm={(res) => {
if (screen.id === "confirmLinking") {
props.onComplete();
} else if (screen.id === "confirming") {
if (account.onboardSkipped) {
props.onComplete();
} else {
if (props.skipShowingPlans) {
props.onComplete();
skipOnboarding();
} else {
setScreen({ id: "plan", team: res.team });
}
}
}
}}
onBack={() =>
setScreen({
id: "onboarding",
})
}
email={(account.unconfirmedEmail || updatedEmail) as string}
/>
)}
{screen.id === "plan" && (
<OnboardingChoosePlan
redirectPath={props.redirectPath}
teamSlug={screen.team.slug}
skipPlan={async () => {
props.onComplete();
skipOnboarding();
}}
canTrialGrowth={true}
redirectToCheckout={props.redirectToCheckout}
/>
)}
</div>
);
}
export default OnboardingUI;
@@ -0,0 +1,163 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import {
BoxIcon,
LogOutIcon,
MailIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
type OnboardingStep = {
icon: React.FC<{ className?: string }>;
title: string;
description: string;
number: number;
};
const accountOnboardingSteps: OnboardingStep[] = [
{
icon: UserIcon,
title: "Account Details",
description: "Provide email address",
number: 1,
},
{
icon: MailIcon,
title: "Verify Email",
description: "Enter your verification code",
number: 2,
},
];
export function AccountOnboardingLayout(props: {
children: React.ReactNode;
currentStep: 1 | 2;
logout: () => Promise<void>;
}) {
const logout = useMutation({
mutationFn: props.logout,
});
return (
<OnboardingLayout
steps={accountOnboardingSteps}
currentStep={props.currentStep}
title="Get started with thirdweb"
cta={
<Button
variant="outline"
size="sm"
className="gap-2 bg-card px-4"
onClick={() => {
logout.mutate();
}}
>
{logout.isPending ? (
<Spinner className="size-3 text-muted-foreground" />
) : (
<LogOutIcon className="size-3 text-muted-foreground" />
)}
Logout
</Button>
}
>
{props.children}
</OnboardingLayout>
);
}
const teamOnboardingSteps: OnboardingStep[] = [
{
icon: BoxIcon,
title: "Team Details",
description: "Provide team details",
number: 1,
},
{
icon: UsersIcon,
title: "Team Members",
description: "Invite members to your team",
number: 2,
},
];
export function TeamOnboardingLayout(props: {
children: React.ReactNode;
currentStep: 1 | 2;
}) {
return (
<OnboardingLayout
steps={teamOnboardingSteps}
currentStep={props.currentStep}
title="Setup your team"
>
{props.children}
</OnboardingLayout>
);
}
function OnboardingLayout(props: {
steps: OnboardingStep[];
currentStep: number;
children: React.ReactNode;
title: string;
cta?: React.ReactNode;
}) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b py-10">
<div className="container flex items-center justify-between gap-6">
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
{props.title}
</h1>
{props.cta}
</div>
</div>
<div className="container flex grow flex-col gap-10 xl:flex-row">
{/* Left */}
<div className="flex w-full max-w-[850px] flex-col py-8">
{props.children}
</div>
{/* Right */}
<div className="hidden shrink-0 grow flex-col xl:flex">
{/* Steps */}
<div className="relative flex grow flex-col gap-7 pt-8">
{/* Timeline line */}
<div className="-translate-x-1/2 absolute top-0 bottom-0 left-5 border-border border-l border-dashed" />
{props.steps.map((step) => {
const isInactive = step.number !== props.currentStep;
return (
<div
key={step.number}
className={cn("flex items-center gap-4")}
>
<div className="relative z-10 flex size-10 items-center justify-center rounded-full border bg-card">
<step.icon
className={cn(
"size-5 text-foreground",
isInactive && "opacity-50",
)}
/>
</div>
<div
className={cn("flex flex-col", isInactive && "opacity-50")}
>
<h3 className="font-semibold">{step.title}</h3>
<p className="text-muted-foreground text-sm">
{step.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { teamStub } from "../../../../stories/stubs";
import { storybookLog } from "../../../../stories/utils";
import { TeamOnboardingLayout } from "../onboarding-layout";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
const meta = {
title: "Onboarding/TeamOnboarding/InviteTeamMembers",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FreePlan: Story = {
args: {
plan: "free",
},
};
// This is the case when user returns back to team onboarding flow from stripe
export const StarterPlan: Story = {
args: {
plan: "starter",
},
};
export const GrowthPlan: Story = {
args: {
plan: "growth",
},
};
function Story(props: {
plan: "free" | "growth" | "starter";
}) {
return (
<TeamOnboardingLayout currentStep={2}>
<InviteTeamMembersUI
trackEvent={(params) => {
storybookLog("trackEvent", params);
}}
getTeam={async () => {
return teamStub("foo", props.plan);
}}
team={teamStub("foo", props.plan)}
inviteTeamMembers={async (params) => {
return { results: params.map(() => "fulfilled") };
}}
getBillingCheckoutUrl={async () => {
return { status: 200 };
}}
onComplete={() => {
storybookLog("onComplete");
}}
/>
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,244 @@
"use client";
import type { GetBillingCheckoutUrlAction } from "@/actions/billing";
import type { Team } from "@/api/team";
import { PricingCard } from "@/components/blocks/pricing-card";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TabButtons } from "@/components/ui/tabs";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react";
import { useState, useTransition } from "react";
import type { TrackingParams } from "../../../../hooks/analytics/useTrack";
import { pollWithTimeout } from "../../../../utils/pollWithTimeout";
import { useStripeRedirectEvent } from "../../../stripe-redirect/stripeRedirectChannel";
import {
InviteSection,
type InviteTeamMembersFn,
} from "../../../team/[team_slug]/(team)/~/settings/members/InviteSection";
export function InviteTeamMembersUI(props: {
team: Team;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
inviteTeamMembers: InviteTeamMembersFn;
onComplete: () => void;
getTeam: () => Promise<Team>;
trackEvent: (params: TrackingParams) => void;
}) {
const [showPlanModal, setShowPlanModal] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useDashboardRouter();
const [isPollingTeam, setIsPollingTeam] = useState(false);
const [hasSentInvites, setHasSentInvites] = useState(false);
const showSpinner = isPollingTeam || isPending;
useStripeRedirectEvent(async () => {
setShowPlanModal(false);
setIsPollingTeam(true);
// poll until the team has a non-free billing plan with a timeout of 5 seconds
await pollWithTimeout({
shouldStop: async () => {
const team = await props.getTeam();
const isNonFreePlan = team.billingPlan !== "free";
if (isNonFreePlan) {
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "success",
plan: team.billingPlan,
});
}
return isNonFreePlan;
},
timeoutMs: 5000,
});
setIsPollingTeam(false);
// refresh the page to get the latest team data
startTransition(() => {
router.refresh();
});
});
return (
<div className="relative flex grow flex-col">
<Dialog open={showPlanModal} onOpenChange={setShowPlanModal}>
<DialogContent className="lg:max-w-[750px]">
<InviteModalContent
teamSlug={props.team.slug}
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
trackEvent={props.trackEvent}
/>
</DialogContent>
</Dialog>
<InviteSection
inviteTeamMembers={props.inviteTeamMembers}
team={props.team}
userHasEditPermission={true}
onInviteSuccess={() => setHasSentInvites(true)}
shouldHideInviteButton={hasSentInvites}
customCTASection={
<div className="flex gap-3">
{props.team.billingPlan === "free" && (
<Button
size="sm"
variant="default"
className="gap-2"
onClick={() => {
setShowPlanModal(true);
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "openModal",
});
}}
>
<CircleArrowUpIcon className="size-4" />
Upgrade
</Button>
)}
<Button
onClick={() => {
props.onComplete();
if (!hasSentInvites) {
props.trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "skip",
});
}
}}
className="gap-2"
size="sm"
variant={hasSentInvites ? "default" : "outline"}
>
{hasSentInvites ? "Continue to dashboard" : "Skip for now"}
{hasSentInvites && <ArrowRightIcon className="size-4" />}
</Button>
</div>
}
/>
{showSpinner && (
<div className="absolute top-6 right-6">
<Spinner className="size-5" />{" "}
</div>
)}
</div>
);
}
function InviteModalContent(props: {
teamSlug: string;
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
trackEvent: (params: TrackingParams) => void;
}) {
const [planToShow, setPlanToShow] = useState<"starter" | "growth">("starter");
const starterPlan = (
<PricingCard
billingPlan="starter"
teamSlug={props.teamSlug}
cta={{
title: "Get Started for free",
onClick() {
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "attempt",
plan: "starter",
});
},
tracking: {
category: "account",
label: "starterPlan",
},
}}
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
/>
);
const growthPlan = (
<PricingCard
billingPlan="growth"
teamSlug={props.teamSlug}
cta={{
title: "Get Started with Growth",
tracking: {
category: "account",
label: "growthPlan",
},
onClick() {
props.trackEvent({
category: "teamOnboarding",
action: "upgradePlan",
label: "attempt",
plan: "growth",
});
},
variant: "default",
}}
highlighted
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
/>
);
return (
<div>
<DialogHeader>
<DialogTitle className="text-xl">Choose a plan</DialogTitle>
<DialogDescription className="leading-relaxed">
Get started with the free Starter plan or upgrade to Growth plan for
increased limits and advanced features.{" "}
<UnderlineLink href="https://thirdweb.com/pricing" target="_blank">
Learn more about pricing
</UnderlineLink>
</DialogDescription>
</DialogHeader>
<div className="h-6" />
{/* Desktop */}
<div className="hidden grid-cols-1 gap-6 md:grid-cols-2 md:gap-4 lg:grid">
{starterPlan}
{growthPlan}
</div>
{/* Mobile */}
<div className="lg:hidden">
<TabButtons
tabs={[
{
name: "Starter",
onClick: () => setPlanToShow("starter"),
isActive: planToShow === "starter",
},
{
name: "Growth",
onClick: () => setPlanToShow("growth"),
isActive: planToShow === "growth",
},
]}
/>
<div className="h-4" />
{planToShow === "starter" && starterPlan}
{planToShow === "growth" && growthPlan}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import { Toaster } from "@/components/ui/sonner";
import type { Meta, StoryObj } from "@storybook/react";
import { teamStub } from "../../../../stories/stubs";
import { storybookLog } from "../../../../stories/utils";
import { TeamOnboardingLayout } from "../onboarding-layout";
import { TeamInfoFormUI } from "./TeamInfoForm";
const meta = {
title: "Onboarding/TeamOnboarding/TeamInfo",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SendSuccess: Story = {
args: {
sendType: "success",
},
};
export const SendError: Story = {
args: {
sendType: "error",
},
};
function Story(props: {
sendType: "success" | "error";
}) {
return (
<TeamOnboardingLayout currentStep={1}>
<TeamInfoFormUI
onComplete={() => {
storybookLog("onComplete");
}}
teamSlug="foo"
isTeamSlugAvailable={async (slug) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
// taken slugs stub
if (slug === "xyz" || slug === "abc" || slug === "xyz-1") {
return false;
}
return true;
}}
updateTeam={async (formData) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
storybookLog("updateTeam", formData);
if (props.sendType === "error") {
throw new Error("Test Error");
}
return teamStub("foo", "free");
}}
/>
<Toaster richColors />
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,274 @@
import type { Team } from "@/api/team";
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 { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FileInput } from "components/shared/FileInput";
import { ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { z } from "zod";
import { teamSlugRegex } from "../../../team/[team_slug]/(team)/~/settings/general/common";
type TeamData = {
name?: string;
slug?: string;
image?: File;
};
const teamSlugSchema = z
.string()
.min(3, {
message: "URL must be at least 3 characters",
})
.max(48, {
message: "URL must be at most 48 characters",
})
.refine((slug) => !teamSlugRegex.test(slug), {
message: "URL can only contain lowercase letters, numbers and hyphens",
});
const formSchema = z.object({
name: z
.string()
.min(3, {
message: "Name must be at least 3 characters",
})
.max(32, {
message: "Name must be at most 32 characters",
}),
slug: teamSlugSchema,
image: z.instanceof(File).optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function TeamInfoFormUI(props: {
updateTeam: (data: TeamData) => Promise<Team>;
onComplete: (updatedTeam: Team) => void;
isTeamSlugAvailable: (slug: string) => Promise<boolean>;
teamSlug: string;
}) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
slug: "",
image: undefined,
},
});
const [disableSlugSuggestion, setDisableSlugSuggestion] = useState(false);
const name = form.watch("name");
const slug = form.watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const [debouncedName] = useDebounce(name, 500);
const { data: suggestedSlug, isFetching: isCalculatingSlug } = useQuery({
queryKey: ["suggest-team-slug", debouncedName],
queryFn: async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const computedSlug = computeSlug(name, attempt);
if (teamSlugSchema.safeParse(computedSlug).error) {
return null;
}
const isAvailable = await props.isTeamSlugAvailable(computedSlug);
if (isAvailable) {
return computedSlug;
}
}
return null;
},
enabled: !!debouncedName && !disableSlugSuggestion,
retry: false,
});
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (suggestedSlug && !disableSlugSuggestion) {
form.setValue("slug", suggestedSlug, {
shouldValidate: true,
});
}
}, [suggestedSlug, form, disableSlugSuggestion]);
const shouldValidateSlug =
!!debouncedSlug &&
!form.getFieldState("slug").invalid &&
debouncedSlug !== props.teamSlug &&
suggestedSlug !== debouncedSlug;
const { data: isSlugAvailable, isFetching: isCheckingSlug } = useQuery({
queryKey: ["checkTeamSlug", debouncedSlug],
queryFn: () => {
return props.isTeamSlugAvailable(debouncedSlug);
},
enabled: shouldValidateSlug,
retry: false,
});
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (isSlugAvailable === undefined) {
return;
}
if (!isSlugAvailable) {
form.setError("slug", {
type: "manual",
message: "This URL is already taken",
});
} else {
const isValidSlugError = form.formState.errors.slug?.type === "manual";
if (isValidSlugError) {
form.clearErrors("slug");
}
}
}, [isSlugAvailable, form]);
const submit = useMutation({
mutationFn: props.updateTeam,
});
async function onSubmit(values: FormValues) {
submit.mutate(values, {
onError(error) {
console.error(error);
toast.error("Failed to submit team details");
},
onSuccess(updatedTeam) {
props.onComplete(updatedTeam);
},
});
}
return (
<div className="rounded-lg border bg-card ">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6 p-4 lg:p-6">
<FormField
control={form.control}
name="image"
render={({ field: { value, onChange } }) => (
<FormItem className="space-y-0">
<FormLabel>Team Avatar</FormLabel>
<FormDescription>
Enhance your team profile by adding a custom avatar
</FormDescription>
<FormMessage />
<div className="h-3" />
<FormControl>
<FileInput
accept={{ "image/*": [] }}
value={value}
setValue={(file) => onChange(file)}
className="w-28 rounded-full bg-background"
disableHelperText
/>
</FormControl>
<div className="h-3" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Team Name
<span className="ml-1 text-red-500">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Company Inc."
autoComplete="off"
{...field}
/>
</FormControl>
<FormDescription>
This is your team's name on thirdweb
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>
Team URL
<span className="ml-1 text-red-500">*</span>
</FormLabel>
<FormControl>
<div className="relative flex rounded-lg border border-border">
<div className="flex items-center self-stretch rounded-l-lg border-border border-r bg-card px-3 font-mono text-muted-foreground/80 text-sm">
thirdweb.com/team/
</div>
<Input
{...field}
onChange={(e) => {
setDisableSlugSuggestion(true);
field.onChange(e);
}}
className="truncate border-0 font-mono"
placeholder="my-team"
/>
{(isCheckingSlug || isCalculatingSlug) && (
<div className="-translate-y-1/2 fade-in-0 absolute top-1/2 right-3 duration-300">
<Spinner className="size-4" />
</div>
)}
</div>
</FormControl>
<FormDescription>
This is your team's URL namespace on thirdweb
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end border-t p-4 lg:px-6">
<Button type="submit" className="gap-2" size="sm">
Continue
{submit.isPending ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Button>
</div>
</form>
</Form>
</div>
);
}
const computeSlug = (name: string, attempt: number) => {
const baseSlug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return attempt === 0 ? baseSlug : `${baseSlug}-${attempt}`;
};
@@ -0,0 +1,158 @@
"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 { useTrack } from "../../../../hooks/analytics/useTrack";
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
import { TeamInfoFormUI } from "./TeamInfoForm";
export function TeamInfoForm(props: {
client: ThirdwebClient;
teamId: string;
teamSlug: string;
}) {
const router = useDashboardRouter();
const trackEvent = useTrack();
return (
<TeamInfoFormUI
isTeamSlugAvailable={async (slug) => {
const res = await apiServerProxy<{
result: boolean;
}>({
pathname: "/v1/teams/check-slug",
searchParams: {
slug,
},
method: "GET",
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data.result;
}}
teamSlug={props.teamSlug}
onComplete={(updatedTeam) => {
router.replace(`/get-started/team/${updatedTeam.slug}/add-members`);
}}
updateTeam={async (data) => {
const teamValue: Partial<Team> = {
name: data.name,
slug: data.slug,
};
trackEvent({
category: "teamOnboarding",
action: "updateTeam",
label: "attempt",
});
if (data.image) {
try {
teamValue.image = await upload({
client: props.client,
files: [data.image],
});
} catch {
// If image upload fails - ignore image, continue with the rest of the update
toast.error("Failed to upload image");
}
}
const res = await updateTeam({
teamId: props.teamId,
value: teamValue,
});
if (!res.ok) {
trackEvent({
category: "teamOnboarding",
action: "updateTeam",
label: "error",
});
throw new Error(res.error);
}
trackEvent({
category: "teamOnboarding",
action: "updateTeam",
label: "success",
});
return res.data;
}}
/>
);
}
export function InviteTeamMembers(props: {
team: Team;
}) {
const router = useDashboardRouter();
const trackEvent = useTrack();
return (
<InviteTeamMembersUI
trackEvent={trackEvent}
onComplete={() => {
router.replace(`/team/${props.team.slug}`);
}}
getTeam={async () => {
const res = await apiServerProxy<{
result: Team;
}>({
pathname: `/v1/teams/${props.team.slug}`,
method: "GET",
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data.result;
}}
team={props.team}
getBillingCheckoutUrl={getBillingCheckoutUrl}
inviteTeamMembers={async (params) => {
const res = await sendTeamInvites({
teamId: props.team.id,
invites: params,
});
trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "attempt",
});
if (!res.ok) {
trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "error",
});
throw new Error(res.errorMessage);
}
trackEvent({
category: "teamOnboarding",
action: "inviteTeamMembers",
label: "success",
});
return {
results: res.results,
};
}}
/>
);
}
@@ -1,42 +0,0 @@
"use client";
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
export function useSkipOnboarding() {
const mutation = useUpdateAccount();
const trackEvent = useTrack();
async function skipOnboarding() {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "attempt",
});
return mutation.mutateAsync(
{
onboardSkipped: true,
},
{
onSuccess: () => {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "success",
});
},
onError: (error) => {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "error",
error,
});
},
},
);
}
return skipOnboarding;
}
@@ -1,4 +1,3 @@
import { RE_EMAIL } from "utils/regex";
import { z } from "zod";
const nameValidation = z
@@ -6,12 +5,10 @@ const nameValidation = z
.min(3, { message: "Must be at least 3 chars" })
.max(64, { message: "Must be max 64 chars" });
const emailValidation = z.string().refine((str) => RE_EMAIL.test(str), {
message: "Email address is not valid",
});
export const emailSchema = z.string().email("Invalid email address");
export const accountValidationSchema = z.object({
email: emailValidation,
email: emailSchema,
name: nameValidation.or(z.literal("")),
});
@@ -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;
}
}
@@ -0,0 +1,22 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useEffect } from "react";
import { stripeRedirectPageChannel } from "./stripeRedirectChannel";
export default function ClosePage() {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
stripeRedirectPageChannel.postMessage("close");
// this will only work if this page is opened as a new tab
window.close();
}, []);
return (
<div className="flex min-h-dvh items-center justify-center">
<div className="flex flex-col items-center text-center text-sm">
<Spinner className="size-8" />
</div>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
export const stripeRedirectPageChannel = new BroadcastChannel(
"stripe-redirect",
);
export function useStripeRedirectEvent(cb?: () => void) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!cb) {
return;
}
function handleMessage(event: MessageEvent) {
if (!cb) {
return;
}
if (event.data === "close") {
cb();
}
}
stripeRedirectPageChannel.addEventListener("message", handleMessage);
return () => {
stripeRedirectPageChannel.removeEventListener("message", handleMessage);
};
}, [cb]);
return null;
}
@@ -8,6 +8,9 @@ const meta = {
title: "Banners/Billing Alerts",
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta;
@@ -20,12 +23,12 @@ export const PaymentAlerts: Story = {
<div className="space-y-10">
<PastDueBannerUI
teamSlug="foo"
redirectToBillingPortal={() => Promise.resolve({ status: 200 })}
getBillingPortalUrl={() => Promise.resolve({ status: 200 })}
/>
<ServiceCutOffBannerUI
teamSlug="foo"
redirectToBillingPortal={() => Promise.resolve({ status: 200 })}
getBillingPortalUrl={() => Promise.resolve({ status: 200 })}
/>
</div>
),
@@ -1,6 +1,6 @@
"use client";
import { redirectToBillingPortal } from "@/actions/billing";
import { getBillingPortalUrl } from "@/actions/billing";
import {
PastDueBannerUI,
ServiceCutOffBannerUI,
@@ -9,7 +9,7 @@ import {
export function PastDueBanner(props: { teamSlug: string }) {
return (
<PastDueBannerUI
redirectToBillingPortal={redirectToBillingPortal}
getBillingPortalUrl={getBillingPortalUrl}
teamSlug={props.teamSlug}
/>
);
@@ -18,7 +18,7 @@ export function PastDueBanner(props: { teamSlug: string }) {
export function ServiceCutOffBanner(props: { teamSlug: string }) {
return (
<ServiceCutOffBannerUI
redirectToBillingPortal={redirectToBillingPortal}
getBillingPortalUrl={getBillingPortalUrl}
teamSlug={props.teamSlug}
/>
);
@@ -1,10 +1,13 @@
"use client";
import type { BillingBillingPortalAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import type { GetBillingPortalUrlAction } from "@/actions/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 { useState } from "react";
import Link from "next/link";
import { useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
function BillingAlertBanner(props: {
title: string;
@@ -12,23 +15,38 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
redirectToBillingPortal: BillingBillingPortalAction;
}) {
const [isRouteLoading, setIsRouteLoading] = useState(false);
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
useStripeRedirectEvent(() => {
setTimeout(() => {
startTransition(() => {
router.refresh();
});
}, 1000);
});
return (
<div
className={cn(
"flex flex-col border-b bg-card px-4 py-6 lg:items-center lg:text-center",
"relative flex flex-col border-b bg-card px-4 py-6 lg:items-center lg:text-center",
props.variant === "warning" &&
"border-yellow-600 bg-yellow-50 text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950 dark:text-yellow-100",
props.variant === "error" &&
"border-red-600 bg-red-50 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{isPending && (
<div className="absolute top-4 right-4">
<Spinner className="size-5" />
</div>
)}
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
<p className="mt-1 mb-4 text-sm">{props.description}</p>
<BillingPortalButton
<Button
asChild
className={cn(
"gap-2",
props.variant === "warning" &&
@@ -36,31 +54,24 @@ function BillingAlertBanner(props: {
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",
)}
size="sm"
teamSlug={props.teamSlug}
redirectPath={`/team/${props.teamSlug}`}
redirectToBillingPortal={props.redirectToBillingPortal}
onClick={() => {
setIsRouteLoading(true);
}}
>
{props.ctaLabel}
{isRouteLoading ? <Spinner className="size-4" /> : null}
</BillingPortalButton>
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
{props.ctaLabel}
</Link>
</Button>
</div>
);
}
export function PastDueBannerUI(props: {
teamSlug: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
return (
<BillingAlertBanner
ctaLabel="View Invoices"
variant="warning"
title="Unpaid Invoices"
redirectToBillingPortal={props.redirectToBillingPortal}
description={
<>
You have unpaid invoices. Service may be suspended if not paid
@@ -74,14 +85,13 @@ export function PastDueBannerUI(props: {
export function ServiceCutOffBannerUI(props: {
teamSlug: string;
redirectToBillingPortal: BillingBillingPortalAction;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
return (
<BillingAlertBanner
ctaLabel="Pay Now"
variant="error"
title="Service Suspended"
redirectToBillingPortal={props.redirectToBillingPortal}
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;
}

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