Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ba92cd2cbe Version Packages (#6575)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-29 03:41:26 -07:00
Joaquim VergesandGitHub c3d0ea0059 [PayEmbed] Respect supportedTokens prop (#6580) 2025-03-29 03:35:22 -07:00
MananTank 35c5f57179 [TOOL-3839] Fix double scrollbars in project settings page (#6579) 2025-03-29 07:51:23 +00:00
gregandGitHub 2a1f4be48b Fix: Not Found Status should show Pending (#6578) 2025-03-28 20:11:43 -07:00
gregandGitHub 0121ccd702 fix: reduce cost of playground commerce example (#6577) 2025-03-28 19:19:03 -07:00
Toomas OosaluandGitHub 12bcfc596a insight webhooks docs (#6574) 2025-03-29 11:11:25 +13:00
MananTank a4ff738268 [TOOL-3833] Dashboard: Fix overflow scroll created by sr-only element (#6576) 2025-03-28 19:17:40 +00:00
YashandGitHub b49c191355 Support stylus contracts publish and deploy (#6495) 2025-03-29 07:34:37 +13:00
saminaandGitHub 06f0765665 Update page.mdx (#6572)
Signed-off-by: samina <[email protected]>
2025-03-27 19:54:50 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
12adbd8314 Version Packages (#6570)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-27 17:25:33 -07:00
saminacodes 48e014d426 added FAQ (#6567)
<!--

## 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 new details to the `page.mdx` file, providing guidance on how to enhance the performance of the `Nebula` character and improve its responses.

### Detailed summary
- Added a section on how to make `Nebula` play a certain character better, including:
  - Defining the role clearly.
  - Avoiding personality traits in responses.
  - Setting output expectations.
  - Preloading context for specific domains.
  - Utilizing session handling features.

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

<!-- end pr-codex -->
2025-03-27 23:41:37 +00:00
saminacodes 826748bb5d [Portal] fee-changes (#6573)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the fee structure and related information for the `Buy With Crypto` and swap transactions in the `fee-sharing` documentation.

### Detailed summary
- Removed references to a 1% fee and 70% sharing for `Buy With Crypto`.
- Updated the protocol fee to 0.3% for swap transactions.
- Allowed developers to set their own additional fees.
- Clarified fee structure for buying with crypto and swap transactions.

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

<!-- end pr-codex -->
2025-03-27 22:54:25 +00:00
gregfromstl d4f5a9db12 [Portal] Add cross-chain swapping guide and add bridge submodules (#6571)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the documentation and functionality surrounding the `thirdweb` Universal Bridge, particularly for cross-chain swapping and related features. It updates guides, introduces new functionalities, and improves the clarity of the code.

### Detailed summary
- Updated `yarn` command in `page.mdx` for installing `thirdweb`.
- Renamed the guide from "Build a Custom Experience" to "Build a Custom Onramp Experience" in `sidebar.tsx`.
- Added a new guide for "Cross-Chain Swapping" in `page.mdx`.
- Enhanced tag filtering in `slugs.ts` to include `@bridge`.
- Updated comments in `Buy.ts` and `Sell.ts` to specify `@bridge Buy` and `@bridge Sell`.
- Improved sidebar link generation in `getSidebarLinkGroups.ts` to include `@bridge` links.
- Added comprehensive steps for cross-chain swapping in the new guide, including installation, obtaining quotes, preparing transactions, and checking swap status.

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

<!-- end pr-codex -->
2025-03-27 19:07:18 +00:00
MananTank 3b827ea32c [NEB-148] Do not add active chain to nebula context by default (#6564) 2025-03-27 18:58:20 +00:00
MananTank 2231db7980 [NEB-125] Nebula: Show popular chains at the top of the list (#6565) 2025-03-27 18:49:02 +00:00
MananTank f9872f442c [TOOL-3761] Dashboard: Use new sidebar layout in project pages (#6551)
<!--

## 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 primarily focuses on enhancing the layout and styling of the application, particularly around sidebar and project-related components. It introduces new sidebar features, refines the UI, and improves mobile responsiveness.

### Detailed summary
- Removed unused files: `tabs.tsx`, `layout.tsx` in `connect`, and `settings/layout.tsx`.
- Simplified layout in `DeployedContractsPage.tsx` by removing unnecessary divs.
- Added `AnnouncementBanner` to multiple layouts for notifications.
- Introduced `useActiveSidebarLink` for better sidebar link management.
- Enhanced `Sidebar` components with new features and improved styling.
- Added `Header` component in `ProjectOverviewPage` for better title display.
- Updated Tailwind CSS configuration to include sidebar styles.
- Improved mobile responsiveness with `useIsMobile` hook.
- Cleaned up `AnnouncementBanner` logic and structure.

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

<!-- end pr-codex -->
2025-03-27 18:38:45 +00:00
Joe-Thirdweb 473a25237e [Dashboard] reactive(1597) chainlist page update (#6566)
Added custom chain page info for Reactive Network

* Banner image
* CTA image
* About section

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and functionality for a "Reactive Mainnet" section in the dashboard. It adds images and a call-to-action that promotes Web3 automation features.

### Detailed summary
- Added `reactiveBanner.jpg` and `reactiveCTA.jpg` assets.
- Introduced a new section for "Reactive Mainnet" in `chainMetaRecord`:
  - Set `headerImgUrl` to `reactiveBanner.src`.
  - Added an `about` description for Web3 automation.
  - Configured `cta` with `backgroundImageUrl`, `title`, `buttonLink`, and `buttonText`.

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

<!-- end pr-codex -->
2025-03-27 18:23:19 +00:00
Manan TankandGitHub 2d4c5ff07c Fix "Cannot read properties of undefined (reading 'forwardRef')" error on certain bundler setups (#6569) 2025-03-27 10:41:53 -07:00
saminacodes ab738eb7ba new narrative (#6563)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `AnnouncementBanner` and `Banner` components to promote the new `Universal Bridge` feature, enhancing the messaging around payment integration, and refining the content in the documentation to reflect these changes.

### Detailed summary
- Updated `AnnouncementBanner` with new `href`, `label`, and `trackingLabel`.
- Changed `Banner` ID, `text`, and `href` to reflect the `Universal Bridge` launch.
- Revised documentation for webhook events to include `Universal Bridge`.
- Enhanced content in the overview page for `Universal Bridge`.
- Updated descriptions and titles for multiple `FeatureCard` components.
- Removed fee-sharing section and added new integration options for `Universal Bridge`.

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

<!-- end pr-codex -->
2025-03-27 14:55:00 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8e6651e591 Version Packages (#6546)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-26 11:37:15 -07:00
gregandGitHub e5026a10ab [SDK] Fix: Account for bigint values in engine typed data (#6562) 2025-03-26 11:10:51 -07:00
MananTank 9c1c5f2fc8 [TOOL-3789] Dashboard: Replace simplehash with insight (#6554) 2025-03-26 14:49:27 +00:00
Phillip HoandGitHub 45cdb86911 chore: hide Engine projects from list (#6560) 2025-03-26 22:05:00 +08:00
Joaquim VergesandGitHub fe3351ce79 [TypeDoc] Remove Gist update from workflow (#6559) 2025-03-26 02:45:20 -07:00
Joaquim VergesandGitHub fc657e0b89 [Portal] Redesign documentation homepage with SDK cards and product icons (#6558) 2025-03-26 02:37:35 -07:00
Phillip HoandGitHub cff2481c06 chore: changset for make req optional when calling usage from cloudflare (#6557) 2025-03-26 12:14:05 +08:00
Phillip HoandGitHub e0e31826a8 chore: make req optional when calling usage from cloudflare (#6556) 2025-03-26 11:57:03 +08:00
Joaquim VergesandGitHub fe8b3ce5a5 [Playground] Rename "Fund Wallet" to "Buy Crypto" across pay components (#6555) 2025-03-25 17:48:21 -07:00
kumaryash90 3c120a7584 [SDK] Functions to manage extensions on a dynamic contract (#6550)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `thirdweb` library by adding functionality to manage extensions on dynamic contracts, including the ability to install and uninstall extensions.

### Detailed summary
- Introduced `uninstallExtension` function to remove extensions from dynamic contracts.
- Added `installPublishedExtension` function to install extensions with various options.
- Updated `generateExtensionFunctionsFromAbi` to use a specific string format for `functionSelector`.
- Enhanced tests for `uninstallExtension` and `installPublishedExtension` to ensure proper functionality.
- Updated generated files for `addExtension` and `removeExtension` methods with new parameter types and encoding functions.

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

<!-- end pr-codex -->
2025-03-26 00:24:19 +00:00
MananTank b51157c0ff [TOOL-3735] Fix minor typos (#6531)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on fixing typos and improving text clarity in various components of the application.

### Detailed summary
- In `PayConfig.tsx`, corrected "will are" to "are" in the `bottomText` prop.
- In `page.tsx` (commerce), corrected "paymets" to "payments".
- In `page.tsx` (headless), corrected "Seemlessly" to "Seamlessly" in the `description`.
- In `page.tsx` (embed), corrected "Seemlessly" to "Seamlessly" in the `description`.

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

<!-- end pr-codex -->
2025-03-25 22:48:05 +00:00
MananTank f1df19e612 [TOOL-3689] Dashboard: Keep switch in disabled state if field value is empty in Sponsorship rules form (#6535)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving the `transformedQueryData` computation by filtering out empty values from the `policy` properties. It enhances data handling for `allowedContractAddresses`, `allowedWallets`, `blockedWallets`, and `allowedChainIds`, ensuring cleaner and more accurate data is returned.

### Detailed summary
- Refactored `transformedQueryData` to filter out empty values from `allowedContractAddresses`, `allowedWallets`, `blockedWallets`, and `allowedChainIds`.
- Simplified return conditions for these properties.
- Changed handling of `allowedChainIds` to return an empty array instead of null to prevent API issues.

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

<!-- end pr-codex -->
2025-03-25 22:39:38 +00:00
MananTank 98781531bb [TOOL-3786] Portal: Improve llms.txt, llms-full.txt (#6532) 2025-03-26 03:59:07 +05:30
saminacodes d3a5ea7833 fixed broken links (#6553)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating documentation and modifying the sidebar structure in the `Nebula` application, including changes to links and adding a new FAQ section.

### Detailed summary
- Added a new FAQ section in `faqs/page.mdx` about using Nebula in a stateless manner.
- Changed the sidebar entry from `MCP Server` to `MCP Clients` in `sidebar.tsx`.
- Updated the link for `Examples` in `sidebar.tsx` to point to a GitHub repository.

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

<!-- end pr-codex -->
2025-03-25 20:43:24 +00:00
MananTank 1a9448e152 Portal: Fix lint error (#6552)
<!--

## 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 restructuring the `sidebar` object in the `apps/portal/src/app/nebula/sidebar.tsx` file, ensuring proper formatting of the sidebar items and improving the overall readability of the code.

### Detailed summary
- Adjusted the formatting of the `sidebar` entries for `MCP Server` and `Tools`.
- Converted closing brackets from separate lines to inline, enhancing code clarity.
- Ensured consistent object structure for items in the `sidebar`.

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

<!-- end pr-codex -->
2025-03-25 19:36:16 +00:00
saminaandGitHub 2fe16119f5 [Portal] AI updated docs (#6536) 2025-03-25 02:33:38 -07:00
Joaquim VergesandGitHub c549d5934e [Bridge] Skip tests without TW_SECRET_KEY environment variable (#6545) 2025-03-25 02:32:53 -07:00
Joaquim VergesandGitHub dd65506dfb [Playground] Refactor Universal Bridge sidebar links and update order (#6543) 2025-03-24 19:30:22 -07:00
MananTank 26cfd1b30e [TOOL-3682] Dashboard: Change deploy contract status for in-app wallet (#6533) 2025-03-25 01:57:05 +00:00
MananTank 495d8edfa2 [TOOL-3757] Dashboard: Prefill last used project in contract deployment page (#6534) 2025-03-25 01:49:04 +00:00
kumaryash90 88c213b6d5 Handle default extensions (#6497)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the deployment and transaction management of dynamic contracts within the `thirdweb` framework, adding support for extensions and improving the handling of contract metadata.

### Detailed summary
- Improved return formatting in `bootstrap.ts`.
- Updated `platformFeeBps` handling in `custom-contract.tsx`.
- Added tests for dynamic contract transactions in `get-required-transactions.test.ts`.
- Enhanced logic for deploying extensions in `deploy-published.ts`.
- Introduced new functions for managing dynamic contract extensions in `get-required-transactions.ts`.
- Updated `deploy-marketplace.ts` to include dynamic extensions in marketplace contracts.

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

<!-- end pr-codex -->
2025-03-24 17:21:13 +00:00
Joaquim VergesandGitHub d9091bd2e7 [Dashboard] Disable engineCloud service by default (#6530) 2025-03-24 02:31:52 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ab7bf1ad1d Version Packages (#6528)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-24 00:28:06 -07:00
Joaquim VergesandGitHub 21389bbef6 [Service-Utils] feat: Add engine-cloud service definition (#6526) 2025-03-24 00:16:18 -07:00
Joaquim VergesandGitHub 4f51201249 [SDK] Update @mobile-wallet-protocol/client to 1.0.0 (#6527) 2025-03-24 00:15:56 -07:00
pink hoodie.andGitHub a0cee488e9 quick get started docs fixes per Jem's revisions (#6525) 2025-03-23 14:46:23 -07:00
Phillip HoandGitHub 0b8907cb7c chore: Add jittered retry on auth call (#6524) 2025-03-23 22:51:59 +08:00
Reza RahemtolaandGitHub 85a78d4460 [Docs] Fix: typo (#6517)
Signed-off-by: Reza Rahemtola <[email protected]>
2025-03-23 17:42:29 +13:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jake Loo
2a6778eb42 chore(deps): bump the npm_and_yarn group across 5 directories with 1 update (#6522)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jake Loo <[email protected]>
2025-03-23 15:59:58 +13:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
882fd3dcbd Version Packages (#6519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-22 04:59:50 -07:00
Joaquim VergesandGitHub 744491f853 [SDK] fix: Handle wallet chain mismatch on post onramp flow (#6518) 2025-03-22 04:52:59 -07:00
d3f788471b [Portal] Neb Changes (#6451)
Signed-off-by: samina <[email protected]>
Co-authored-by: Manan Tank <[email protected]>
2025-03-22 00:48:04 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
6db81ef68d Version Packages (#6516)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-22 00:39:59 -07:00
Alberto GarcíaandGitHub f8b2bdc64b [Dashboard] Fix: wrong URL (#6510) 2025-03-22 00:18:06 -07:00
Joaquim VergesandGitHub c407bd10c7 [SDK] fix: Fetch fresh quotes before post onramp step in PayEmbed (#6515) 2025-03-22 00:14:45 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e540dc11f5 Version Packages (#6508)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-22 15:36:55 +13:00
Joaquim VergesandGitHub 7476b7927d [SDK] fix: Improve swap transaction details UI (#6509) 2025-03-21 02:45:34 -07:00
Joaquim VergesandGitHub ccb641698e [SDK] fix: Force redirect to phantom app on mobile (#6507) 2025-03-20 18:22:05 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
aa9d858f1c Version Packages (#6504)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-20 17:18:57 -07:00
Joaquim VergesandGitHub d8540216f1 [SDK] fix: Pass chainId to internal 1193 provider when connecting (#6506) 2025-03-20 14:52:26 -07:00
gregfromstl 654f8795b4 [SDK] Feature: Adds Universal Bridge (#6464)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `Bridge` module to the `thirdweb` SDK, enabling users to perform token buy and sell operations across chains, along with route discovery and transaction status checks.

### Detailed summary
- Added `Bridge` module for Universal Bridge operations.
- Implemented `Bridge.Buy` and `Bridge.Sell` for token transactions.
- Created `quote` and `prepare` functions for both buy and sell operations.
- Introduced `routes` function to discover available bridge routes.
- Added `status` function to check transaction statuses.
- Defined TypeScript types: `Route`, `Status`, `Quote`, `PreparedQuote`.

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

<!-- end pr-codex -->
2025-03-20 21:27:26 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
279fb918f5 chore(deps): bump lucide-react from 0.479.0 to 0.482.0 (#6487)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-20 02:00:22 -07:00
pink hoodie.andGitHub c5cff26faa Docs Revisions: In-app & ecosystem wallet FAQs page, Pay -> Univ Bridge, Wallets Get Started (#6494)
Signed-off-by: pink hoodie. <[email protected]>
2025-03-20 01:59:57 -07:00
Joaquim VergesandGitHub 78901457cf [SDK] fix: Enforce requireApproval option for wallet connection (#6505) 2025-03-20 01:59:33 -07:00
Joaquim VergesandGitHub 47f8cd6d8d [SDk] fix: Improve multistep swap UI in PayEmbed (#6503) 2025-03-20 00:08:12 -07:00
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
484 changed files with 24194 additions and 11235 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.'
+1 -21
View File
@@ -21,30 +21,10 @@ 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
- name: Run TypeDoc
run: pnpm typedoc
- name: Update Gist
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GIST_TOKEN }}
script: |
const fs = require('fs');
const content = fs.readFileSync('./packages/thirdweb/typedoc/parsed.json', 'utf8');
const gistId = '678fe1f331a01270bb002fee660f285d';
await github.rest.gists.update({
gist_id: gistId,
files: {
'data.json': {
content: content
}
}
});
console.log(`Permalink: https://gist.githubusercontent.com/raw/${gistId}/data.json`);
+3
View File
@@ -102,3 +102,6 @@ ANALYTICS_SERVICE_URL=""
# Required for Nebula Chat
NEXT_PUBLIC_NEBULA_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
+3
View File
@@ -27,5 +27,8 @@ const config: StorybookConfig = {
},
},
staticDirs: ["../public"],
features: {
experimentalRSC: true,
},
};
export default config;
+28 -27
View File
@@ -45,9 +45,9 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@sentry/nextjs": "9.3.0",
"@sentry/nextjs": "9.5.0",
"@shazow/whatsabi": "0.20.0",
"@tanstack/react-query": "5.67.1",
"@tanstack/react-query": "5.67.3",
"@tanstack/react-table": "^8.21.2",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "2.0.0",
@@ -61,18 +61,18 @@
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "12.4.10",
"framer-motion": "12.5.0",
"fuse.js": "7.1.0",
"idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.5.0",
"ioredis": "^5.6.0",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.477.0",
"next": "15.2.0",
"lucide-react": "0.483.0",
"next": "15.2.3",
"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.3",
"@next/eslint-plugin-next": "15.2.3",
"@playwright/test": "1.51.1",
"@storybook/addon-essentials": "8.6.4",
"@storybook/addon-interactions": "8.6.4",
"@storybook/addon-links": "8.6.4",
"@storybook/addon-onboarding": "8.6.4",
"@storybook/addon-viewport": "8.6.4",
"@storybook/blocks": "8.6.4",
"@storybook/nextjs": "8.6.4",
"@storybook/react": "8.6.4",
"@storybook/test": "8.6.4",
"@types/color": "4.2.0",
"@types/node": "22.13.9",
"@types/node": "22.13.10",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
@@ -129,16 +130,16 @@
"@types/swagger-ui-react": "^4.19.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.19",
"autoprefixer": "^10.4.21",
"checkly": "5.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"eslint-plugin-storybook": "0.11.3",
"knip": "5.45.0",
"eslint-plugin-storybook": "0.11.6",
"knip": "5.46.0",
"next-sitemap": "^4.2.3",
"postcss": "8.5.3",
"storybook": "8.6.3",
"storybook": "8.6.4",
"tailwindcss": "3.4.17",
"typescript": "5.8.2"
}
+7
View File
@@ -354,6 +354,13 @@ async function redirects() {
destination: "/",
permanent: false,
},
// pay > universal-bridge redirect
{
source: "/team/:team_slug/:project_slug/connect/pay/:path*",
destination:
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use server";
import "server-only";
import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
+120 -31
View File
@@ -10,12 +10,9 @@ import {
isMoralisSupported,
transformMoralisResponseToNFT,
} from "lib/wallet/nfts/moralis";
import {
generateSimpleHashUrl,
isSimpleHashSupported,
transformSimpleHashResponseToNFT,
} from "lib/wallet/nfts/simpleHash";
import type { WalletNFT } from "lib/wallet/nfts/types";
import { getVercelEnv } from "../../lib/vercel-utils";
import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../constants/env";
type WalletNFTApiReturn =
| { result: WalletNFT[]; error?: undefined }
@@ -24,40 +21,20 @@ type WalletNFTApiReturn =
export async function getWalletNFTs(params: {
chainId: number;
owner: string;
isInsightSupported: boolean;
}): Promise<WalletNFTApiReturn> {
const { chainId, owner } = params;
const supportedChainSlug = await isSimpleHashSupported(chainId);
if (supportedChainSlug && process.env.SIMPLEHASH_API_KEY) {
const url = generateSimpleHashUrl({ chainSlug: supportedChainSlug, owner });
if (params.isInsightSupported) {
const response = await getWalletNFTsFromInsight({ chainId, owner });
const response = await fetch(url, {
method: "GET",
headers: {
"X-API-KEY": process.env.SIMPLEHASH_API_KEY,
},
next: {
revalidate: 10, // cache for 10 seconds
},
});
if (response.status >= 400) {
if (!response.ok) {
return {
error: response.statusText,
error: response.error,
};
}
try {
const parsedResponse = await response.json();
const result = await transformSimpleHashResponseToNFT(
parsedResponse,
owner,
);
return { result };
} catch {
return { error: "error parsing response" };
}
return { result: response.data };
}
if (isAlchemySupported(chainId)) {
@@ -115,3 +92,115 @@ export async function getWalletNFTs(params: {
return { error: "unsupported chain" };
}
type OwnedNFTInsightResponse = {
name: string;
description: string;
image_url: string;
background_color: string;
external_url: string;
metadata_url: string;
extra_metadata: {
customImage?: string;
customAnimationUrl?: string;
animation_original_url?: string;
image_original_url?: string;
};
collection: {
name: string;
description: string;
extra_metadata: Record<string, string>;
};
contract: {
chain_id: number;
address: string;
type: "erc1155" | "erc721";
name: string;
};
owner_addresses: string[];
token_id: string;
balance: string;
token_type: "erc1155" | "erc721";
};
async function getWalletNFTsFromInsight(params: {
chainId: number;
owner: string;
}): Promise<
| {
data: WalletNFT[];
ok: true;
}
| {
ok: false;
error: string;
}
> {
const { chainId, owner } = params;
const thirdwebDomain =
getVercelEnv() === "production" ? "thirdweb" : "thirdweb-dev";
const url = new URL(`https://insight.${thirdwebDomain}.com/v1/nfts`);
url.searchParams.append("chain", chainId.toString());
url.searchParams.append("limit", "10");
url.searchParams.append("owner_address", owner);
const response = await fetch(url, {
headers: {
"x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID,
},
});
if (!response.ok) {
const errorMessage = await response.text();
return {
ok: false,
error: errorMessage,
};
}
const nftsResponse = (await response.json()) as {
data: OwnedNFTInsightResponse[];
};
const isDev = getVercelEnv() !== "production";
// NOTE: ipfscdn.io/ to thirdwebstorage-dev.com/ replacement is temporary
// This should be fixed in the insight dev endpoint
const walletNFTs = nftsResponse.data.map((nft) => {
const walletNFT: WalletNFT = {
id: nft.token_id,
contractAddress: nft.contract.address,
metadata: {
uri: isDev
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.metadata_url,
name: nft.name,
description: nft.description,
image: isDev
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.image_url,
animation_url: isDev
? nft.extra_metadata.animation_original_url?.replace(
"ipfscdn.io/",
"thirdwebstorage-dev.com/",
)
: nft.extra_metadata.animation_original_url,
external_url: nft.external_url,
background_color: nft.background_color,
},
owner: params.owner,
tokenURI: nft.metadata_url,
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
supply: nft.balance,
};
return walletNFT;
});
return {
data: walletNFTs,
ok: true,
};
}
+15 -11
View File
@@ -5,13 +5,14 @@ import { API_SERVER_URL } from "../constants/env";
type ProxyActionParams = {
pathname: string;
searchParams?: Record<string, string>;
searchParams?: Record<string, string | undefined>;
method: "GET" | "POST" | "PUT" | "DELETE";
body?: string;
headers?: Record<string, string>;
parseAsText?: boolean;
};
type ProxyActionResult<T extends object> =
type ProxyActionResult<T> =
| {
status: number;
ok: true;
@@ -23,7 +24,7 @@ type ProxyActionResult<T extends object> =
error: string;
};
async function proxy<T extends object>(
async function proxy<T>(
baseUrl: string,
params: ProxyActionParams,
): Promise<ProxyActionResult<T>> {
@@ -34,7 +35,10 @@ async function proxy<T extends object>(
url.pathname = params.pathname;
if (params.searchParams) {
for (const key in params.searchParams) {
url.searchParams.append(key, params.searchParams[key] as string);
const value = params.searchParams[key];
if (value) {
url.searchParams.append(key, value);
}
}
}
@@ -67,19 +71,15 @@ async function proxy<T extends object>(
return {
status: res.status,
ok: true,
data: await res.json(),
data: params.parseAsText ? await res.text() : await res.json(),
};
}
export async function apiServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function apiServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(API_SERVER_URL, params);
}
export async function payServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function payServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(
process.env.NEXT_PUBLIC_PAY_URL
? `https://${process.env.NEXT_PUBLIC_PAY_URL}`
@@ -87,3 +87,7 @@ export async function payServerProxy<T extends object = object>(
params,
);
}
export async function analyticsServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(process.env.ANALYTICS_SERVICE_URL || "", params);
}
@@ -0,0 +1,60 @@
import "server-only";
import Stripe from "stripe";
import type { Team } from "../api/team";
let existingStripe: Stripe | undefined;
function getStripe() {
if (!existingStripe) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (!STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
existingStripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2025-02-24.acacia",
});
}
return existingStripe;
}
export async function getTeamInvoices(
team: Team,
options?: { cursor?: string },
) {
try {
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Get the list of invoices for the customer
const invoices = await getStripe().invoices.list({
customer: customerId,
limit: 10,
starting_after: options?.cursor,
});
return invoices;
} catch (error) {
console.error("Error fetching billing history:", error);
// If the error is that the customer doesn't exist, return an empty array
// instead of throwing an error
if (
error instanceof Stripe.errors.StripeError &&
error.message.includes("No such customer")
) {
return {
data: [],
has_more: false,
};
}
throw new Error("Failed to fetch billing history");
}
}
+53
View File
@@ -0,0 +1,53 @@
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "../constants/env";
export type SMSCountryTiers = {
tier1: string[];
tier2: string[];
tier3: string[];
tier4: string[];
tier5: string[];
};
export async function getSMSCountryTiers() {
if (!THIRDWEB_API_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(`${API_SERVER_URL}/v1/sms/list-country-tiers`, {
headers: {
"Content-Type": "application/json",
"x-service-api-key": THIRDWEB_API_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
});
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
};
}
}
+2 -1
View File
@@ -3,7 +3,8 @@ import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
import type { TeamResponse } from "@thirdweb-dev/service-utils";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
export type Team = TeamResponse;
export type Team = TeamResponse & { stripeCustomerId: string | null };
export async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
@@ -2,11 +2,12 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { DownloadIcon } from "lucide-react";
import Papa from "papaparse";
import { toast } from "sonner";
import { cn } from "../../lib/utils";
export function ExportToCSVButton(props: {
getData: () => Promise<{ header: string[]; rows: string[][] }>;
getData: () => Promise<{ header: string[]; rows: string[][] } | string>;
fileName: string;
disabled?: boolean;
className?: string;
@@ -14,7 +15,12 @@ export function ExportToCSVButton(props: {
const exportMutation = useMutation({
mutationFn: async () => {
const data = await props.getData();
exportToCSV(props.fileName, data);
if (typeof data === "string") {
exportToCSV(props.fileName, data);
} else {
const fileContent = convertToCSVFormat(data);
exportToCSV(props.fileName, fileContent);
}
},
onError: () => {
toast.error("Failed to download CSV");
@@ -29,7 +35,7 @@ export function ExportToCSVButton(props: {
return (
<Button
variant="outline"
disabled={props.disabled}
disabled={props.disabled || exportMutation.isPending}
className={cn("flex items-center gap-2 border text-xs", props.className)}
onClick={async () => {
exportMutation.mutate();
@@ -50,15 +56,15 @@ export function ExportToCSVButton(props: {
);
}
function exportToCSV(
fileName: string,
data: { header: string[]; rows: string[][] },
) {
const { header, rows } = data;
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
.map((e) => e.join(","))
.join("\n")}`;
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
return Papa.unparse({
fields: data.header,
data: data.rows,
});
}
function exportToCSV(fileName: string, fileContent: string) {
const csvContent = `data:text/csv;charset=utf-8,${fileContent}`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
@@ -47,7 +47,7 @@ export function Img(props: imgElementProps) {
}, []);
return (
<div className="relative">
<div className="relative shrink-0">
<img
{...restProps}
// avoid setting empty src string to prevent request to the entire page
@@ -19,30 +19,7 @@ export function MobileSidebar(props: {
triggerClassName?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
const activeLink = useMemo(() => {
function isActive(link: SidebarBaseLink) {
if (link.exactMatch) {
return link.href === pathname;
}
return pathname?.startsWith(link.href);
}
for (const link of props.links) {
if ("group" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
}
}
} else {
if (isActive(link)) {
return link;
}
}
}
}, [props.links, pathname]);
const activeLink = useActiveSidebarLink(props.links);
const defaultTrigger = (
<Button
@@ -75,3 +52,32 @@ export function MobileSidebar(props: {
</Dialog>
);
}
export function useActiveSidebarLink(links: SidebarLink[]) {
const pathname = usePathname();
const activeLink = useMemo(() => {
function isActive(link: SidebarBaseLink) {
if (link.exactMatch) {
return link.href === pathname;
}
return pathname?.startsWith(link.href);
}
for (const link of links) {
if ("group" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
}
}
} else if ("href" in link) {
if (isActive(link)) {
return link;
}
}
}
}, [links, pathname]);
return activeLink;
}
@@ -18,17 +18,39 @@ export function MultiNetworkSelector(props: {
onChange: (chainIds: number[]) => void;
disableChainId?: boolean;
className?: string;
priorityChains?: number[];
}) {
const { allChains, idToChain } = useAllChainsData();
const options = useMemo(() => {
return allChains.map((chain) => {
let sortedChains = allChains;
if (props.priorityChains) {
const priorityChainsSet = new Set();
for (const chainId of props.priorityChains || []) {
priorityChainsSet.add(chainId);
}
const priorityChains = (props.priorityChains || [])
.map((chainId) => {
return idToChain.get(chainId);
})
.filter((v) => !!v);
const otherChains = allChains.filter(
(chain) => !priorityChainsSet.has(chain.chainId),
);
sortedChains = [...priorityChains, ...otherChains];
}
return sortedChains.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [allChains]);
}, [allChains, props.priorityChains, idToChain]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
@@ -104,6 +126,7 @@ export function SingleNetworkSelector(props: {
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData();
@@ -180,7 +203,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}
@@ -2,11 +2,13 @@ import { ExternalLinkIcon } from "lucide-react";
import type React from "react";
import { cn } from "../../lib/utils";
import { NavLink } from "../ui/NavLink";
import { Separator } from "../ui/separator";
export type SidebarBaseLink = {
href: string;
label: React.ReactNode;
exactMatch?: boolean;
icon?: React.FC<{ className?: string }>;
tracking?: {
category: string;
action: string;
@@ -19,6 +21,9 @@ export type SidebarLink =
| {
group: string;
links: SidebarBaseLink[];
}
| {
separator: true;
};
type SidebarContentProps = {
@@ -27,21 +32,18 @@ type SidebarContentProps = {
className?: string;
};
export function Sidebar(props: SidebarContentProps) {
export function CustomSidebar(props: SidebarContentProps) {
return (
<aside
className={cn(
"sticky top-0 hidden w-[230px] flex-shrink-0 self-start lg:block",
props.className,
)}
>
<div className="py-7">
{props.header}
<div className="flex flex-col gap-1">
<RenderSidebarLinks links={props.links} />
<div className={cn("hidden w-[230px] shrink-0 lg:block", props.className)}>
<aside className="sticky top-0 self-start">
<div className="py-7">
{props.header}
<div className="flex flex-col gap-1">
<RenderSidebarLinks links={props.links} />
</div>
</div>
</div>
</aside>
</aside>
</div>
);
}
@@ -61,6 +63,10 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
);
}
if ("separator" in link) {
return <Separator className="my-2" />;
}
const isExternal = link.href.startsWith("http");
return (
<NavLink
@@ -68,9 +74,10 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
key={i}
href={link.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-muted-foreground text-sm hover:bg-accent"
activeClassName="text-foreground"
activeClassName="text-foreground bg-accent"
exactMatch={link.exactMatch}
>
{link.icon && <link.icon className="size-4" />}
{link.label}
{isExternal && <ExternalLinkIcon className="size-3" />}
</NavLink>
@@ -1,6 +1,22 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "../../lib/utils";
import { MobileSidebar } from "./MobileSidebar";
import { Sidebar, type SidebarLink } from "./Sidebar";
import { NavLink } from "../ui/NavLink";
import { Separator } from "../ui/separator";
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
import { CustomSidebar, type SidebarLink } from "./Sidebar";
export function SidebarLayout(props: {
sidebarLinks: SidebarLink[];
@@ -17,7 +33,10 @@ export function SidebarLayout(props: {
props.className,
)}
>
<Sidebar links={sidebarLinks} className={props.desktopSidebarClassName} />
<CustomSidebar
links={sidebarLinks}
className={props.desktopSidebarClassName}
/>
<MobileSidebar
links={sidebarLinks}
triggerClassName={props.mobileSidebarClassName}
@@ -29,3 +48,118 @@ export function SidebarLayout(props: {
</div>
);
}
export function FullWidthSidebarLayout(props: {
contentSidebarLinks: SidebarLink[];
footerSidebarLinks?: SidebarLink[];
children: React.ReactNode;
className?: string;
footer?: React.ReactNode;
}) {
const { contentSidebarLinks, children, footerSidebarLinks } = props;
return (
<div
className={cn(
"relative flex w-full flex-1 overflow-y-hidden",
props.className,
)}
>
{/* left - sidebar */}
<Sidebar collapsible="icon" side="left">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<RenderSidebarGroup
sidebarLinks={contentSidebarLinks}
groupName={undefined}
/>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{footerSidebarLinks && (
<SidebarFooter className="pb-3">
<RenderSidebarGroup
sidebarLinks={footerSidebarLinks}
groupName={undefined}
/>
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
{/* right - content */}
<div className="flex h-full flex-grow flex-col overflow-y-auto">
<MobileSidebarTrigger
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
/>
<main className="container z-0 flex min-w-0 max-w-[1280px] grow flex-col pb-20 max-sm:w-full lg:pt-6">
{children}
</main>
{props.footer}
</div>
</div>
);
}
function RenderSidebarGroup(props: {
sidebarLinks: SidebarLink[];
groupName: string | undefined;
}) {
const { sidebarLinks } = props;
const sidebar = useSidebar();
return (
<SidebarMenu className="gap-1.5">
{sidebarLinks.map((link) => {
if ("href" in link) {
return (
<SidebarMenuItem key={link.href}>
<SidebarMenuButton asChild>
<NavLink
href={link.href}
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
activeClassName="text-foreground bg-accent"
exactMatch={link.exactMatch}
tracking={link.tracking}
onClick={() => {
sidebar.setOpenMobile(false);
}}
>
{link.icon && <link.icon className="size-4" />}
<span>{link.label}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
if ("separator" in link) {
return <SidebarSeparator className="my-1" />;
}
return (
<RenderSidebarGroup
sidebarLinks={link.links}
groupName={link.group}
key={link.group}
/>
);
})}
</SidebarMenu>
);
}
function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
const activeLink = useActiveSidebarLink(props.links);
return (
<div className="mb-4 flex items-center gap-3 border-b px-4 py-4 lg:hidden">
<SidebarTrigger className="size-4" />
<Separator orientation="vertical" className="h-4" />
{activeLink && <span className="text-sm">{activeLink.label}</span>}
</div>
);
}
@@ -96,7 +96,6 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
onClick: () => setEnvironment(env.environment),
isActive: activeEnvironment === env.environment,
name: env.title,
isEnabled: true,
}))}
tabClassName="text-sm gap-2 !text-sm"
tabIconClassName="size-4"
@@ -8,7 +8,7 @@ import {
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { Check, Copy } from "lucide-react";
import { Check, Copy, XIcon } from "lucide-react";
import { useMemo } from "react";
import { type ThirdwebClient, isAddress } from "thirdweb";
import { ZERO_ADDRESS } from "thirdweb";
@@ -16,6 +16,7 @@ import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { ToolTipLabel } from "../ui/tooltip";
import { Img } from "./Img";
export function WalletAddress(props: {
@@ -44,7 +45,16 @@ export function WalletAddress(props: {
const { onCopy, hasCopied } = useClipboard(address, 2000);
if (!isAddress(address)) {
return <span>Invalid Address ({address})</span>;
return (
<ToolTipLabel label={address} hoverable>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<div className="flex size-6 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
</div>
Invalid Address
</span>
</ToolTipLabel>
);
}
// special case for zero address
@@ -91,13 +91,11 @@ export function DatePickerWithRange(props: {
name: "From",
onClick: () => setScreen("from"),
isActive: screen === "from",
isEnabled: true,
},
{
name: "To",
onClick: () => setScreen("to"),
isActive: screen === "to",
isEnabled: true,
},
]}
/>
@@ -15,6 +15,7 @@ export type NavButtonProps = {
action: string;
label: string;
};
onClick?: () => void;
};
export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
@@ -30,7 +31,9 @@ export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
href={props.href}
className={cn(props.className, isActive && props.activeClassName)}
target={props.href.startsWith("http") ? "_blank" : undefined}
prefetch={false}
onClick={() => {
props.onClick?.();
if (props.tracking) {
track({
category: props.tracking.category,
@@ -96,7 +96,10 @@ const BreadcrumbEllipsis = ({
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
className={cn(
"relative flex h-9 w-9 items-center justify-center",
className,
)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
@@ -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) {
@@ -94,7 +94,10 @@ const PaginationEllipsis = ({
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
className={cn(
"relative flex h-9 w-9 items-center justify-center",
className,
)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
@@ -0,0 +1,773 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import * as React from "react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
// eslint-disable-next-line react-compiler/react-compiler
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer relative hidden flex-shrink-0 text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"absolute inset-y-0 z-10 hidden h-full w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:-inset-2 after:absolute after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:-inset-2 after:absolute after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+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)} />
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
@@ -5,6 +5,7 @@ import invariant from "tiny-invariant";
export function useWalletNFTs(params: {
chainId: number;
walletAddress?: string;
isInsightSupported: boolean;
}) {
return useQuery({
queryKey: ["walletNfts", params.chainId, params.walletAddress],
@@ -13,6 +14,7 @@ export function useWalletNFTs(params: {
return getWalletNFTs({
chainId: params.chainId,
owner: params.walletAddress,
isInsightSupported: params.isInsightSupported,
});
},
enabled: !!params.walletAddress,
@@ -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;
};
};
@@ -28,7 +28,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
<div className="h-6" />
<h2 className="px-4 text-center font-semibold text-lg tracking-tight">
Buy Funds on {sanitizedChainName} using thirdweb Pay
Buy Funds on {sanitizedChainName} using Universal Bridge
</h2>
<div className="h-2" />
@@ -50,8 +50,9 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
<Link
href="https://portal.thirdweb.com/connect/pay/overview"
className="inline-flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Learn more about thirdweb Pay
Learn more about Universal Bridge
<ExternalLinkIcon className="size-3" />
</Link>
</div>
@@ -13,7 +13,6 @@ import { ListerOnly } from "@3rdweb-sdk/react/components/roles/lister-only";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
import { useSimplehashSupport } from "lib/wallet/nfts/simpleHash";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebContract } from "thirdweb";
@@ -25,6 +24,7 @@ interface CreateListingButtonProps {
createText?: string;
type?: "direct-listings" | "english-auctions";
twAccount: Account | undefined;
isInsightSupported: boolean;
}
const LISTING_MODES = ["Select NFT", "Manual"] as const;
@@ -34,6 +34,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
type,
contract,
twAccount,
isInsightSupported,
...restButtonProps
}) => {
const address = useActiveAccount()?.address;
@@ -41,13 +42,12 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
const [listingMode, setListingMode] =
useState<(typeof LISTING_MODES)[number]>("Select NFT");
const simplehashQuery = useSimplehashSupport(contract.chain.id);
const isSupportedChain =
contract.chain.id &&
(simplehashQuery.data ||
(isInsightSupported ||
isAlchemySupported(contract.chain.id) ||
isMoralisSupported(contract.chain.id));
return (
<ListerOnly contract={contract}>
<Sheet open={open} onOpenChange={setOpen}>
@@ -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"
@@ -84,6 +83,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
actionText={createText}
setOpen={setOpen}
mode={listingMode === "Select NFT" ? "automatic" : "manual"}
isInsightSupported={isInsightSupported}
/>
</div>
</>
@@ -96,6 +96,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
actionText={createText}
setOpen={setOpen}
mode="manual"
isInsightSupported={isInsightSupported}
/>
</div>
)}
@@ -21,7 +21,6 @@ import { useAllChainsData } from "hooks/chains/allChains";
import { useTxNotifications } from "hooks/useTxNotifications";
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
import { useSimplehashSupport } from "lib/wallet/nfts/simpleHash";
import type { WalletNFT } from "lib/wallet/nfts/types";
import { CircleAlertIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
@@ -87,6 +86,7 @@ type CreateListingsFormProps = {
mode: "automatic" | "manual";
type?: "direct-listings" | "english-auctions";
twAccount: Account | undefined;
isInsightSupported: boolean;
};
const auctionTimes = [
@@ -106,16 +106,17 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
setOpen,
twAccount,
mode,
isInsightSupported,
}) => {
const trackEvent = useTrack();
const chainId = contract.chain.id;
const { idToChain } = useAllChainsData();
const network = idToChain.get(chainId);
const [isFormLoading, setIsFormLoading] = useState(false);
const simplehashQuery = useSimplehashSupport(contract.chain.id);
const isSupportedChain =
chainId &&
(!!simplehashQuery.data ||
(isInsightSupported ||
isAlchemySupported(chainId) ||
isMoralisSupported(chainId));
@@ -124,7 +125,9 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({
chainId,
walletAddress: account?.address,
isInsightSupported,
});
const sendAndConfirmTx = useSendAndConfirmTransaction();
const listingNotifications = useTxNotifications(
"NFT listed Successfully",
@@ -29,6 +29,7 @@ export function ContractDirectListingsPageClient(props: {
<ContractDirectListingsPage
contract={props.contract}
twAccount={props.twAccount}
isInsightSupported={metadataQuery.data.isInsightSupported}
/>
);
}
@@ -8,17 +8,19 @@ import { DirectListingsTable } from "./components/table";
interface ContractDirectListingsPageProps {
contract: ThirdwebContract;
twAccount: Account | undefined;
isInsightSupported: boolean;
}
export const ContractDirectListingsPage: React.FC<
ContractDirectListingsPageProps
> = ({ contract, twAccount }) => {
> = ({ contract, twAccount, isInsightSupported }) => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between">
<p className="text-lg">Contract Listings</p>
<div className="flex flex-row gap-4">
<CreateListingButton
isInsightSupported={isInsightSupported}
contract={contract}
type="direct-listings"
createText="Create Direct Listing"
@@ -29,15 +29,18 @@ export default async function Page(props: {
);
}
const { isDirectListingSupported } = await getContractPageMetadata(
info.contract,
);
const { isDirectListingSupported, isInsightSupported } =
await getContractPageMetadata(info.contract);
if (!isDirectListingSupported) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return (
<ContractDirectListingsPage contract={info.contract} twAccount={account} />
<ContractDirectListingsPage
contract={info.contract}
twAccount={account}
isInsightSupported={isInsightSupported}
/>
);
}
@@ -29,6 +29,7 @@ export function ContractEnglishAuctionsPageClient(props: {
<ContractEnglishAuctionsPage
contract={props.contract}
twAccount={props.twAccount}
isInsightSupported={metadataQuery.data.isInsightSupported}
/>
);
}
@@ -8,11 +8,12 @@ import { EnglishAuctionsTable } from "./components/table";
interface ContractEnglishAuctionsProps {
contract: ThirdwebContract;
twAccount: Account | undefined;
isInsightSupported: boolean;
}
export const ContractEnglishAuctionsPage: React.FC<
ContractEnglishAuctionsProps
> = ({ contract, twAccount }) => {
> = ({ contract, twAccount, isInsightSupported }) => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between">
@@ -23,6 +24,7 @@ export const ContractEnglishAuctionsPage: React.FC<
type="english-auctions"
createText="Create English Auction"
twAccount={twAccount}
isInsightSupported={isInsightSupported}
/>
</div>
</div>
@@ -30,9 +30,8 @@ export default async function Page(props: {
);
}
const { isEnglishAuctionSupported } = await getContractPageMetadata(
info.contract,
);
const { isEnglishAuctionSupported, isInsightSupported } =
await getContractPageMetadata(info.contract);
if (!isEnglishAuctionSupported) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
@@ -42,6 +41,7 @@ export default async function Page(props: {
<ContractEnglishAuctionsPage
contract={info.contract}
twAccount={twAccount}
isInsightSupported={isInsightSupported}
/>
);
}
@@ -21,7 +21,7 @@ import {
} from "./detectedFeatures/permissions";
import { supportedERCs } from "./detectedFeatures/supportedERCs";
import { type EmbedTypeToShow, getEmbedTypeToShow } from "./getEmbedTypeToShow";
import { isAnalyticsSupportedForChain } from "./isAnalyticsSupportedForChain";
import { isInsightSupportedForChain } from "./isAnalyticsSupportedForChain";
export type ContractPageMetadata = {
supportedERCs: {
@@ -36,7 +36,7 @@ export type ContractPageMetadata = {
isPermissionsEnumerableSupported: boolean;
isModularCore: boolean;
embedType: EmbedTypeToShow;
isAnalyticsSupported: boolean;
isInsightSupported: boolean;
isSplitSupported: boolean;
isERC721ClaimConditionsSupported: boolean;
isERC20ClaimConditionsSupported: boolean;
@@ -47,7 +47,7 @@ export type ContractPageMetadata = {
};
export async function getContractPageMetadata(contract: ThirdwebContract) {
return getContractPageMetadataSetup(contract, isAnalyticsSupportedForChain);
return getContractPageMetadataSetup(contract, isInsightSupportedForChain);
}
export async function getContractPageMetadataSetup(
@@ -56,7 +56,7 @@ export async function getContractPageMetadataSetup(
): Promise<ContractPageMetadata> {
const [
functionSelectorsResult,
isAnalyticsSupportedResult,
isInsightSupportedResult,
contractTypeResult,
] = await Promise.allSettled([
resolveFunctionSelectors(contract),
@@ -69,9 +69,9 @@ export async function getContractPageMetadataSetup(
? functionSelectorsResult.value
: [];
const isAnalyticsSupported =
isAnalyticsSupportedResult.status === "fulfilled"
? isAnalyticsSupportedResult.value
const isInsightSupported =
isInsightSupportedResult.status === "fulfilled"
? isInsightSupportedResult.value
: true;
const contractType =
@@ -86,7 +86,7 @@ export async function getContractPageMetadataSetup(
isPermissionsEnumerableSupported(functionSelectors),
isModularCore: isModularCoreContract(functionSelectors),
embedType: getEmbedTypeToShow(functionSelectors),
isAnalyticsSupported: isAnalyticsSupported,
isInsightSupported: isInsightSupported,
isSplitSupported: contractType === "Split",
isVoteContract: contractType === "VoteERC20",
isERC721ClaimConditionsSupported:
@@ -47,7 +47,7 @@ export function getContractPageSidebarLinks(data: {
{
label: "Analytics",
href: `${layoutPrefix}/analytics`,
hide: !data.metadata.isAnalyticsSupported,
hide: !data.metadata.isInsightSupported,
exactMatch: true,
},
{
@@ -4,7 +4,7 @@ import { getVercelEnv } from "lib/vercel-utils";
const thirdwebDomain =
getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb";
export async function isAnalyticsSupportedForChain(
export async function isInsightSupportedForChain(
chainId: number,
): Promise<boolean> {
try {
@@ -32,6 +32,7 @@ export function AccountPageClient(props: {
contract={props.contract}
chainMetadata={props.chainMetadata}
twAccount={props.twAccount}
isInsightSupported={metadataQuery.data.isInsightSupported}
/>
);
}
@@ -12,12 +12,14 @@ interface AccountPageProps {
contract: ThirdwebContract;
chainMetadata: ChainMetadata;
twAccount: Account | undefined;
isInsightSupported: boolean;
}
export const AccountPage: React.FC<AccountPageProps> = ({
contract,
chainMetadata,
twAccount,
isInsightSupported,
}) => {
const symbol = chainMetadata.nativeCurrency.symbol || "Native Token";
@@ -43,7 +45,7 @@ export const AccountPage: React.FC<AccountPageProps> = ({
<div className="flex flex-row items-center justify-between">
<Heading size="title.sm">NFTs owned</Heading>
</div>
<NftsOwned contract={contract} />
<NftsOwned contract={contract} isInsightSupported={isInsightSupported} />
</div>
);
};
@@ -6,12 +6,17 @@ import { NFTCards } from "../../_components/NFTCards";
interface NftsOwnedProps {
contract: ThirdwebContract;
isInsightSupported: boolean;
}
export const NftsOwned: React.FC<NftsOwnedProps> = ({ contract }) => {
export const NftsOwned: React.FC<NftsOwnedProps> = ({
contract,
isInsightSupported,
}) => {
const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({
chainId: contract.chain.id,
walletAddress: contract.address,
isInsightSupported: isInsightSupported,
});
const nfts = walletNFTs?.result || [];
@@ -33,7 +33,8 @@ export default async function Page(props: {
);
}
const { isAccount } = await getContractPageMetadata(contract);
const { isAccount, isInsightSupported } =
await getContractPageMetadata(contract);
if (!isAccount) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
@@ -44,6 +45,7 @@ export default async function Page(props: {
contract={contract}
chainMetadata={chainMetadata}
twAccount={account}
isInsightSupported={isInsightSupported}
/>
);
}
@@ -21,7 +21,7 @@ export function ContractAnalyticsPageClient(props: {
return <ErrorPage />;
}
if (!metadataQuery.data.isAnalyticsSupported) {
if (!metadataQuery.data.isInsightSupported) {
return <RedirectToContractOverview contract={props.contract} />;
}
@@ -34,9 +34,9 @@ export default async function Page(props: {
);
}
const { isAnalyticsSupported } = await getContractPageMetadata(info.contract);
const { isInsightSupported } = await getContractPageMetadata(info.contract);
if (!isAnalyticsSupported) {
if (!isInsightSupported) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
@@ -120,7 +120,7 @@ export default async function Page(props: {
const events = await getContractEvents({
contract: twCloneFactoryContract,
events: [ProxyDeployedEvent],
blockRange: latestBlockNumber < 2000000n ? latestBlockNumber : 2000000n,
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
});
const event = events.find(
(e) =>
@@ -34,7 +34,7 @@ export function ContractOverviewPageClient(props: {
contractPageMetadata.isPermissionsEnumerableSupported
}
chainSlug={chainMetadata.slug}
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
isAnalyticsSupported={contractPageMetadata.isInsightSupported}
functionSelectors={contractPageMetadata.functionSelectors}
// TODO - create a fully client rendered version of publishedBy and ContractCard and plug it here
publishedBy={undefined}
@@ -44,7 +44,7 @@ export default async function Page(props: {
contractPageMetadata.isPermissionsEnumerableSupported
}
chainSlug={chainMetadata.slug}
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
isAnalyticsSupported={contractPageMetadata.isInsightSupported}
functionSelectors={contractPageMetadata.functionSelectors}
publishedBy={
<ErrorBoundary fallback={null}>
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 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";
@@ -45,6 +47,8 @@ import metalBanner from "./temp-assets/metalBanner.png";
import plumeBanner from "./temp-assets/plumeBanner.png";
import plumeCTA from "./temp-assets/plumeCTA.png";
import reactiveBanner from "./temp-assets/reactiveBanner.jpg";
import reactiveCTA from "./temp-assets/reactiveCTA.jpg";
import rivalzBanner from "./temp-assets/rivalzBanner.png";
import rivalzCTA from "./temp-assets/rivalzCTA.png";
import rootStockBanner from "./temp-assets/rootstock-banner.png";
@@ -452,6 +456,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,
@@ -561,6 +577,18 @@ const chainMetaRecord = {
buttonText: "Learn More",
},
},
//Reactive Mainnet
1597: {
headerImgUrl: reactiveBanner.src,
about:
"Web3’s first IFTTT infrastructure. Automate multi-chain workflows with event-driven logic – execute actions without compromising decentralization.",
cta: {
backgroundImageUrl: reactiveCTA.src,
title: "Turn Smart Contracts Reactive",
buttonLink: "https://dev.reactive.network",
buttonText: "Learn More",
},
},
//Saakuru Mainnet
7225878: {
headerImgUrl: saakuruBanner.src,
@@ -13,7 +13,7 @@ export function DeployedContractsPageHeader(props: {
const [importModalOpen, setImportModalOpen] = useState(false);
return (
<div className="border-b">
<div>
<ImportModal
isOpen={importModalOpen}
onClose={() => {
@@ -23,27 +23,25 @@ export function DeployedContractsPageHeader(props: {
projectId={props.projectId}
/>
<div className="container">
<div className="flex flex-col gap-3 py-10 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
</div>
<div className="flex gap-3 [&>*]:grow">
<Button
className="gap-2 bg-card"
variant="outline"
onClick={() => setImportModalOpen(true)}
>
<DownloadIcon className="size-4" />
Import contract
</Button>
<Button asChild className="gap-2">
<Link href="/explore">
<PlusIcon className="size-4" />
Deploy contract
</Link>
</Button>
</div>
<div className="flex flex-col gap-3 pb-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
</div>
<div className="flex gap-3 [&>*]:grow">
<Button
className="gap-2 bg-card"
variant="outline"
onClick={() => setImportModalOpen(true)}
>
<DownloadIcon className="size-4" />
Import contract
</Button>
<Button asChild className="gap-2">
<Link href="/explore">
<PlusIcon className="size-4" />
Deploy contract
</Link>
</Button>
</div>
</div>
</div>
@@ -12,13 +12,12 @@ export function DeployedContractsPage(props: {
authToken: string;
}) {
return (
<div className="flex grow flex-col pb-20">
<div className="flex grow flex-col">
<DeployedContractsPageHeader
teamId={props.teamId}
projectId={props.projectId}
/>
<div className="h-10" />
<div className="container flex grow flex-col">
<div className="flex grow flex-col">
<Suspense fallback={<Loading />}>
<DeployedContractsPageAsync {...props} />
</Suspense>
@@ -100,7 +100,6 @@ const DeployOptions = (props: {
key,
name: value.title,
isActive: activeTab === key,
isEnabled: true,
onClick: () => setActiveTab(key as TabId),
}))}
tabClassName="font-medium"
@@ -4,6 +4,7 @@ import { AppFooter } from "@/components/blocks/app-footer";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type React from "react";
import { TabPathLinks } from "../../@/components/ui/tabs";
import { AnnouncementBanner } from "../../components/notices/AnnouncementBanner";
import { getAuthTokenWalletAddress } from "../api/lib/getAuthToken";
import { TWAutoConnect } from "../components/autoconnect";
import { loginRedirect } from "../login/loginRedirect";
@@ -53,6 +54,7 @@ async function HeaderAndNav(props: {
return (
<div className="bg-card">
<AnnouncementBanner />
<AccountHeader
teamsAndProjects={teamsAndProjects}
account={props.twAccount}
@@ -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) {
@@ -1,34 +0,0 @@
import { isSimpleHashSupported } from "lib/wallet/nfts/simpleHash";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const runtime = "edge";
export const GET = async (req: NextRequest) => {
const searchParams = req.nextUrl.searchParams;
const chainIdStr = searchParams.get("chainId");
if (!chainIdStr) {
return NextResponse.json(
{
error: "Missing chain ID parameter.",
},
{ status: 400 },
);
}
const chainId = Number.parseInt(chainIdStr);
if (!Number.isInteger(chainId)) {
return NextResponse.json(
{
error: "Invalid chain ID parameter.",
},
{ status: 400 },
);
}
const found = await isSimpleHashSupported(chainId);
return NextResponse.json({ result: found });
};
+1 -3
View File
@@ -1,4 +1,4 @@
import "@/styles/globals.css";
import "../global.css";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
@@ -6,7 +6,6 @@ import PlausibleProvider from "next-plausible";
import { Inter } from "next/font/google";
import NextTopLoader from "nextjs-toploader";
import { Suspense } from "react";
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
@@ -72,7 +71,6 @@ export default function RootLayout({
fontSans.variable,
)}
>
<OrganizeContractsToProjectsBanner />
<AppRouterProviders>
{children}
<Suspense fallback={null}>
+11 -2
View File
@@ -19,6 +19,7 @@ import {
} from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { isVercel } from "../../lib/vercel-utils";
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
import { getSDKTheme } from "../components/sdk-component-theme";
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
@@ -246,14 +247,17 @@ function CustomConnectEmbed(props: {
}) {
const { theme } = useTheme();
const client = useThirdwebClient();
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileToken, setTurnstileToken] = useState<string | undefined>(
undefined,
);
const [alwaysShowTurnstile, setAlwaysShowTurnstile] = useState(false);
return (
<div className="flex flex-col items-center gap-4">
<Turnstile
options={{
// only show if interaction is required
appearance: "interaction-only",
appearance: alwaysShowTurnstile ? "always" : "interaction-only",
// match the theme of the rest of the app
theme: theme === "light" ? "light" : "dark",
}}
@@ -265,6 +269,11 @@ function CustomConnectEmbed(props: {
auth={{
getLoginPayload,
doLogin: async (params) => {
if (isVercel() && !turnstileToken) {
setAlwaysShowTurnstile(true);
throw new Error("Please complete the captcha.");
}
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
+1 -1
View File
@@ -40,7 +40,7 @@ export async function getLoginPayload(
export async function doLogin(
payload: VerifyLoginPayloadParams,
turnstileToken: string,
turnstileToken: string | undefined,
) {
if (!THIRDWEB_API_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
@@ -89,13 +89,11 @@ export function LoginOrSignup(props: {
name: "Create account",
onClick: () => setTab("signup"),
isActive: tab === "signup",
isEnabled: true,
},
{
name: "I already have an account",
onClick: () => setTab("login"),
isActive: tab === "login",
isEnabled: true,
},
]}
/>
@@ -227,13 +227,11 @@ function InviteModalContent(props: {
name: "Starter",
onClick: () => setPlanToShow("starter"),
isActive: planToShow === "starter",
isEnabled: true,
},
{
name: "Growth",
onClick: () => setPlanToShow("growth"),
isActive: planToShow === "growth",
isEnabled: true,
},
]}
/>
@@ -1,13 +1,13 @@
"use client";
import { getBillingCheckoutUrl } from "@/actions/billing";
import { apiServerProxy } from "@/actions/proxies";
import { sendTeamInvites } from "@/actions/sendTeamInvite";
import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { apiServerProxy } from "../../../../@/actions/proxies";
import { useTrack } from "../../../../hooks/analytics/useTrack";
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
@@ -13,7 +13,7 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { useActiveAccount } from "thirdweb/react";
import { type NebulaContext, promptNebula } from "../api/chat";
import { createSession, updateSession } from "../api/session";
import type { SessionInfo } from "../api/types";
@@ -32,7 +32,6 @@ export function ChatPageContent(props: {
initialPrompt: string | undefined;
}) {
const address = useActiveAccount()?.address;
const activeChain = useActiveWalletChain();
const client = useThirdwebClient(props.authToken);
const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false);
const [messages, setMessages] = useState<Array<ChatMessage>>(() => {
@@ -96,6 +95,7 @@ export function ChatPageContent(props: {
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
_setContextFilters(v);
setHasUserUpdatedContextFilters(true);
saveLastUsedChainIds(v?.chainIds || undefined);
}, []);
const isNewSession = !props.session;
@@ -118,14 +118,23 @@ export function ChatPageContent(props: {
walletAddress: null,
};
// Only set wallet address from connected wallet
updatedContextFilters.walletAddress = address || null;
updatedContextFilters.chainIds = activeChain
? [activeChain.id.toString()]
: [];
// 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
}
return updatedContextFilters;
});
}, [address, isNewSession, hasUserUpdatedContextFilters, activeChain]);
}, [address, isNewSession, hasUserUpdatedContextFilters]);
const [sessionId, _setSessionId] = useState<string | undefined>(
props.session?.id,
@@ -493,3 +502,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;
}
}
@@ -199,6 +199,17 @@ export function ContextFiltersForm(props: {
onChange={(values) => {
form.setValue("chainIds", values.join(","));
}}
priorityChains={[
1, // ethereum
56, // bnb smart chain mainnet (bsc)
42161, // arbitrum one mainnet
8453, // base mainnet
43114, // avalanche mainnet
146, // sonic
137, // polygon
80094, // berachain mainnet
10, // optimism
]}
/>
</FormControl>
<FormMessage />
@@ -22,7 +22,7 @@ Send native and ERC-20 currencies
### Deploy
Deploy published contracts
- Deploy a Token ERC20 Contract
- Deploy a Token ERC20 Contract with name "Hello World" and description "My Hello Contract" on Ethereum.
- Deploy a Split contract with two recipients.
- Deploy an ERC1155 Contract named 'Hello World' with description 'Hello badges on Ethereum'
@@ -30,7 +30,7 @@ Deploy published contracts
Retrieve information about smart contracts.
- What ERC standards are implemented by contract address 0x59325733eb952a92e069C87F0A6168b29E80627f on Ethereum?
- What functions can I use to mint more of my contract's NFTs?
- What is the total supply of NFTs for my contract?
- What is the total supply of NFTs on 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e?
### Interact
Query wallet balances, addresses, and token holdings.
@@ -1,10 +1,11 @@
"use client";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
@@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
@@ -44,22 +44,21 @@ function BillingAlertBanner(props: {
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
<p className="mt-1 mb-4 text-sm">{props.description}</p>
<BillingPortalButton
buttonProps={{
size: "sm",
className: cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
),
}}
teamSlug={props.teamSlug}
getBillingPortalUrl={props.getBillingPortalUrl}
<Button
asChild
className={cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
)}
>
{props.ctaLabel}
</BillingPortalButton>
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
{props.ctaLabel}
</Link>
</Button>
</div>
);
}
@@ -73,7 +72,6 @@ export function PastDueBannerUI(props: {
ctaLabel="View Invoices"
variant="warning"
title="Unpaid Invoices"
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
You have unpaid invoices. Service may be suspended if not paid
@@ -94,7 +92,6 @@ export function ServiceCutOffBannerUI(props: {
ctaLabel="Pay Now"
variant="error"
title="Service Suspended"
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
Your service has been suspended due to unpaid invoices. Pay now to
@@ -1,7 +1,9 @@
import { getProjects } from "@/api/projects";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { TabPathLinks } from "@/components/ui/tabs";
import { redirect } from "next/navigation";
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
import { getValidAccount } from "../../../account/settings/getAccount";
import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
@@ -42,6 +44,7 @@ export default async function TeamLayout(props: {
return (
<div className="flex h-full grow flex-col">
<AnnouncementBanner />
<div className="bg-card">
<TeamHeaderLoggedIn
currentTeam={team}
@@ -92,6 +95,7 @@ export default async function TeamLayout(props: {
</div>
<main className="flex grow flex-col">{props.children}</main>
<AppFooter />
</div>
);
}
@@ -63,7 +63,12 @@ export default async function TeamOverviewPage(props: {
return (
<div className="flex grow flex-col">
<div className="border-b">
<AnalyticsHeader title="Analytics" interval={interval} range={range} />
<AnalyticsHeader
title="Analytics"
interval={interval}
range={range}
showRangeSelector={true}
/>
</div>
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16">
<Suspense fallback={<GenericLoadingPage />}>
@@ -0,0 +1,50 @@
import {} from "@/components/ui/breadcrumb";
import { getAuthToken } from "../../../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
import { AddPartnerForm } from "../components/client/add-partner-form.client";
import { fetchEcosystem } from "../hooks/fetchEcosystem";
export default async function AddPartnerPage({
params,
}: {
params: Promise<{ slug: string; team_slug: string }>;
}) {
const { slug, team_slug } = await params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}`);
}
const teamSlug = team_slug;
const ecosystemSlug = slug;
try {
const ecosystem = await fetchEcosystem({
teamIdOrSlug: teamSlug,
slug: ecosystemSlug,
authToken,
});
return (
<div className="flex flex-col">
<div className="container">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Add New Partner
</h1>
<AddPartnerForm ecosystem={ecosystem} authToken={authToken} />
</div>
</div>
);
} catch (error) {
console.error("Error fetching ecosystem:", error);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">Error</h1>
<p>Could not load ecosystem. Please try again.</p>
</div>
</div>
);
}
}
@@ -1,43 +1,23 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import type { Ecosystem } from "../../../../../types";
import { AddPartnerForm } from "./add-partner-form.client";
export function AddPartnerDialogButton(props: {
teamSlug: string;
ecosystem: Ecosystem;
authToken: string;
}) {
const [open, setOpen] = useState(false);
const addPartnerUrl = `/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/add-partner`;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle className="font-semibold text-2xl tracking-tight">
Add Partner
</DialogTitle>
</DialogHeader>
<AddPartnerForm
ecosystem={props.ecosystem}
onPartnerAdded={() => setOpen(false)}
authToken={props.authToken}
/>
</DialogContent>
</Dialog>
<Link href={addPartnerUrl} passHref>
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
</Link>
);
}
@@ -21,7 +21,11 @@ export function EcosystemPermissionsPage({
/>
<AuthOptionsSection ecosystem={ecosystem} authToken={authToken} />
{ecosystem?.permission === "PARTNER_WHITELIST" && (
<EcosystemPartnersSection ecosystem={ecosystem} authToken={authToken} />
<EcosystemPartnersSection
teamSlug={params.team_slug}
ecosystem={ecosystem}
authToken={authToken}
/>
)}
</div>
);
@@ -1,32 +1,22 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
"use client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { z } from "zod";
import type { Ecosystem } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import type { Ecosystem, Partner } from "../../../../../types";
import { useAddPartner } from "../../hooks/use-add-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
export function AddPartnerForm({
ecosystem,
onPartnerAdded,
authToken,
}: { authToken: string; ecosystem: Ecosystem; onPartnerAdded: () => void }) {
const form = useForm<z.input<typeof partnerFormSchema>>({
resolver: zodResolver(partnerFormSchema),
});
}: {
ecosystem: Ecosystem;
authToken: string;
}) {
const router = useDashboardRouter();
const params = useParams();
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const { mutateAsync: addPartner, isPending } = useAddPartner(
{
@@ -34,7 +24,13 @@ export function AddPartnerForm({
},
{
onSuccess: () => {
onPartnerAdded();
toast.success("Partner added successfully", {
description: "The partner has been added to your ecosystem.",
});
// Redirect to the redirect page that will take us back to the configuration page
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
router.push(redirectPath);
},
onError: (error) => {
const message =
@@ -46,95 +42,28 @@ export function AddPartnerForm({
},
);
const handleSubmit = (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
addPartner({
ecosystem,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
accessControl: finalAccessControl,
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
addPartner({
ecosystem,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
});
})}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> App Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "lg:-translate-y-4 hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription>
Space or comma-separated list of regex domains (e.g.
*.example.com)
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription>
Space or comma-separated list of bundle IDs
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button disabled={isPending} type="submit" className="w-full gap-2">
{isPending && <Spinner className="size-4" />}
Add
</Button>
</form>
</Form>
<PartnerForm
onSubmit={handleSubmit}
isSubmitting={isPending}
submitLabel="Add"
/>
);
}
@@ -0,0 +1,677 @@
"use client";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { type Control, useFieldArray } from "react-hook-form";
import { SingleNetworkSelector } from "../../../../../../../../../../../@/components/blocks/NetworkSelectors";
import type { PartnerFormValues } from "./partner-form.client";
type AllowedOperationsSectionProps = {
control: Control<PartnerFormValues>;
enabled: boolean;
onToggle: (checked: boolean) => void;
};
export function AllowedOperationsSection({
control,
enabled,
onToggle,
}: AllowedOperationsSectionProps) {
// Setup field array for allowed operations
const allowedOperationsFields = useFieldArray({
control,
name: "accessControl.allowedOperations",
});
return (
<div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-center justify-between gap-6 ">
<div>
<Label htmlFor="allowed-operations-switch" className="text-base">
Allowed Operations
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Configure which signing operations are allowed for this partner
</p>
</div>
<Switch
id="allowed-operations-switch"
checked={enabled}
onCheckedChange={onToggle}
/>
</div>
{enabled && (
<div className="rounded-lg">
<div className="flex flex-col gap-6">
{allowedOperationsFields.fields.map((field, index) => (
<div
key={field.id}
className="rounded-md border border-border p-4"
>
<div className="mb-4 flex items-center justify-between">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.signMethod`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Allowed Signature Method</FormLabel>
<FormDescription className="text-xs">
Select the types of signatures that are allowed for
this partner
</FormDescription>
<Select
onValueChange={(value) => {
// Update the signature method
field.onChange(value);
// Reset the operation data based on the selected method
if (value === "eth_signTransaction") {
// Remove any existing fields for other methods
allowedOperationsFields.update(index, {
signMethod: value,
allowedTransactions: [],
});
} else if (value === "eth_signTypedData_v4") {
allowedOperationsFields.update(index, {
signMethod: value,
allowedTypedData: [],
});
} else if (value === "personal_sign") {
allowedOperationsFields.update(index, {
signMethod: value,
allowedPersonalSigns: [],
});
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a signature method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="eth_signTransaction">
eth_signTransaction
</SelectItem>
<SelectItem value="eth_signTypedData_v4">
eth_signTypedData_v4
</SelectItem>
<SelectItem value="personal_sign">
personal_sign
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
aria-label="Remove operation"
onClick={() => {
allowedOperationsFields.remove(index);
}}
className="!w-auto ml-4 h-10 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
{/* Render different fields based on the signature method */}
{allowedOperationsFields.fields[index]?.signMethod ===
"eth_signTransaction" && (
<TransactionRestrictions control={control} index={index} />
)}
{allowedOperationsFields.fields[index]?.signMethod ===
"eth_signTypedData_v4" && (
<TypedDataRestrictions control={control} index={index} />
)}
{allowedOperationsFields.fields[index]?.signMethod ===
"personal_sign" && (
<PersonalSignRestrictions control={control} index={index} />
)}
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
allowedOperationsFields.append({
signMethod: "eth_signTransaction",
allowedTransactions: [],
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add operation
</Button>
</div>
</div>
)}
</div>
);
}
// Component for eth_signTransaction restrictions
function TransactionRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const transactionsArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedTransactions`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Transactions</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific transactions patterns that are allowed for this
partner. Optional, default is all transactions.
</FormDescription>
<FormDescription className="mb-4 text-warning-text text-xs">
Attention - 'eth_signTransaction' signatures are only for EOA
transactions. To specify allowed Smart Account transactions, you need to
select the 'eth_personalSign' signature method, with type 'userOp' (user
operations).
</FormDescription>
<div className="flex flex-col gap-4">
{transactionsArray.fields.map((field, txIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.contractAddress`}
render={({ field }) => (
<FormItem>
<FormLabel>Contract Address (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.selector`}
render={({ field }) => (
<FormItem>
<FormLabel>Function Selector (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.maxValue`}
render={({ field }) => (
<FormItem>
<FormLabel>Max Value in Wei (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove transaction"
onClick={() => {
transactionsArray.remove(txIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
transactionsArray.append({
chainId: 1,
contractAddress: "",
selector: "",
maxValue: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add transaction
</Button>
</div>
</div>
);
}
// Component for eth_signTypedData_v4 restrictions
function TypedDataRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const typedDataArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedTypedData`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Typed Data</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific typed data patterns that are allowed for this
partner. Optional, default is any typed data.
</FormDescription>
<div className="flex flex-col gap-4">
{typedDataArray.fields.map((field, dataIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.domain`}
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="Domain name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.verifyingContract`}
render={({ field }) => (
<FormItem>
<FormLabel>Verifying Contract (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain (optional)</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.primaryType`}
render={({ field }) => (
<FormItem>
<FormLabel>Primary Type (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="Type" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove typed data"
onClick={() => {
typedDataArray.remove(dataIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
typedDataArray.append({
domain: "",
verifyingContract: undefined,
chainId: undefined,
primaryType: undefined,
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add typed data
</Button>
</div>
</div>
);
}
// Component for personal_sign restrictions
function PersonalSignRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const personalSignArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedPersonalSigns`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Personal Sign Data</Label>
<FormDescription className="mb-4 text-xs">
Specify the types of personal signatures that are allowed for this
partner. Optional, default is all personal signatures.
</FormDescription>
<div className="flex flex-col gap-4">
{personalSignArray.fields.map((field, signIndex) => (
<div key={field.id} className="rounded-md border border-border p-3">
<div className="mb-4 flex items-center justify-between">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.messageType`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Message Type</FormLabel>
<Select
onValueChange={(value) => {
// Update the message type
field.onChange(value);
// Reset the message data based on the selected type
if (value === "userOp") {
personalSignArray.update(signIndex, {
messageType: value,
allowedTransactions: [],
});
} else if (value === "other") {
personalSignArray.update(signIndex, {
messageType: value,
message: "",
});
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select message type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="userOp">userOp</SelectItem>
<SelectItem value="other">other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
aria-label="Remove personal sign"
onClick={() => {
personalSignArray.remove(signIndex);
}}
className="!w-auto ml-4 h-10 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
{personalSignArray.fields[signIndex]?.messageType === "userOp" ? (
<UserOpTransactions
control={control}
opIndex={index}
signIndex={signIndex}
/>
) : (
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.message`}
render={({ field }) => (
<FormItem>
<FormLabel>Message (optional)</FormLabel>
<FormControl>
<Input placeholder="Message pattern" {...field} />
</FormControl>
<FormDescription className="text-xs">
Specify a message pattern that is allowed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
personalSignArray.append({
messageType: "other",
message: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add personal sign
</Button>
</div>
</div>
);
}
// Component for userOp transactions within personal_sign
function UserOpTransactions({
control,
opIndex,
signIndex,
}: {
control: Control<PartnerFormValues>;
opIndex: number;
signIndex: number;
}) {
const userOpTransactionsArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">
Allowed User Operations (smart account transactions)
</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific User Operations that are allowed for this partner.
Optional, default is all User Operations.
</FormDescription>
<div className="flex flex-col gap-4">
{userOpTransactionsArray.fields.map((field, txIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.contractAddress`}
render={({ field }) => (
<FormItem>
<FormLabel>Contract Address (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.selector`}
render={({ field }) => (
<FormItem>
<FormLabel>Function Selector (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.maxValue`}
render={({ field }) => (
<FormItem>
<FormLabel>Max Value in Wei (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove transaction"
onClick={() => {
userOpTransactionsArray.remove(txIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
userOpTransactionsArray.append({
chainId: 1,
contractAddress: "",
selector: "",
maxValue: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add transaction
</Button>
</div>
</div>
);
}
@@ -0,0 +1,374 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import type { z } from "zod";
import type { Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { AllowedOperationsSection } from "./allowed-operations-section";
export type PartnerFormValues = z.infer<typeof partnerFormSchema>;
type PartnerFormProps = {
partner?: Partner; // Optional for add form
onSubmit: (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => void;
isSubmitting: boolean;
submitLabel: string;
};
export function PartnerForm({
partner,
onSubmit,
isSubmitting,
submitLabel,
}: PartnerFormProps) {
// Check if partner has accessControl and serverVerifier
const hasAccessControl = partner ? !!partner.accessControl : false;
const hasServerVerifier =
hasAccessControl && !!partner?.accessControl?.serverVerifier;
const hasAllowedOperations =
hasAccessControl && !!partner?.accessControl?.allowedOperations?.length;
const form = useForm<PartnerFormValues>({
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: partner?.name || "",
domains: partner?.allowlistedDomains.join(",") || "",
bundleIds: partner?.allowlistedBundleIds.join(",") || "",
// Set the actual accessControl data if it exists
accessControl: partner?.accessControl,
// Set the UI control properties based on existing data
accessControlEnabled: hasAccessControl,
serverVerifierEnabled: hasServerVerifier,
allowedOperationsEnabled: hasAllowedOperations,
},
mode: "onChange", // Validate on change for better user experience
});
// Watch the boolean flags for UI state
const accessControlEnabled = form.watch("accessControlEnabled");
const serverVerifierEnabled = form.watch("serverVerifierEnabled");
const allowedOperationsEnabled = form.watch("allowedOperationsEnabled");
// Setup field array for headers
const customHeaderFields = useFieldArray({
control: form.control,
name: "accessControl.serverVerifier.headers",
});
const handleSubmit = form.handleSubmit(
(values) => {
// Construct the final accessControl object based on the enabled flags
let finalAccessControl: Partner["accessControl"] | null = null;
if (values.accessControlEnabled) {
finalAccessControl = {} as Partner["accessControl"];
if (finalAccessControl && values.serverVerifierEnabled) {
finalAccessControl.serverVerifier = {
url: values.accessControl?.serverVerifier?.url || "",
headers: values.accessControl?.serverVerifier?.headers || [],
};
}
if (finalAccessControl && values.allowedOperationsEnabled) {
finalAccessControl.allowedOperations =
values.accessControl?.allowedOperations || [];
}
// if no values have been set, remove the accessControl object
if (
finalAccessControl &&
Object.keys(finalAccessControl).length === 0
) {
finalAccessControl = null;
}
}
onSubmit(values, finalAccessControl);
},
(errors) => {
// Log validation errors for debugging
console.error("Form validation errors:", errors);
},
);
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex grow flex-col gap-5">
<FormField
control={form.control}
name="name"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4 lg:col-span-4">
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.domains?.message &&
"block translate-y-0 text-destructive opacity-100",
)}
>
{form.formState.errors.domains?.message ??
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.bundleIds?.message &&
"block text-destructive",
)}
>
{form.formState.errors.bundleIds?.message ??
"Space or comma-separated list of bundle IDs"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Access Control Section */}
<div className="mb-4 flex items-center justify-between gap-6">
<div>
<Label htmlFor="access-control-switch" className="text-base">
Access Control
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Enable access control for this partner
</p>
</div>
<Switch
id="access-control-switch"
checked={accessControlEnabled}
onCheckedChange={(checked) => {
form.setValue("accessControlEnabled", checked);
// If disabling access control, also disable server verifier and allowed operations
if (!checked) {
form.setValue("serverVerifierEnabled", false);
form.setValue("allowedOperationsEnabled", false);
}
}}
/>
</div>
{accessControlEnabled && (
<>
<div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-center justify-between gap-6">
<div>
<Label
htmlFor="server-verifier-switch"
className="text-base"
>
Server Verifier
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Configure a server verifier for access control
</p>
</div>
<Switch
id="server-verifier-switch"
checked={serverVerifierEnabled}
onCheckedChange={(checked) => {
form.setValue("serverVerifierEnabled", checked);
// Initialize serverVerifier fields if enabling
if (
checked &&
!form.getValues("accessControl.serverVerifier")
) {
form.setValue("accessControl.serverVerifier", {
url: "",
headers: [],
});
}
}}
/>
</div>
{serverVerifierEnabled && (
<div className="mt-4 grid grid-cols-1 gap-6">
<FormField
control={form.control}
name="accessControl.serverVerifier.url"
render={({ field }) => (
<FormItem>
<FormLabel>Server Verifier URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-verifier"
/>
</FormControl>
<FormDescription
className={cn(
"text-xs",
form.formState.errors.accessControl
?.serverVerifier?.url && "text-destructive",
)}
>
{form.formState.errors.accessControl?.serverVerifier
?.url?.message ||
"Enter the URL of your server where verification requests will be sent"}
</FormDescription>
</FormItem>
)}
/>
<div>
<Label className="mb-3 inline-block">
Custom Headers
</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, headerIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...form.register(
`accessControl.serverVerifier.headers.${headerIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...form.register(
`accessControl.serverVerifier.headers.${headerIdx}.value`,
)}
/>
<Button
variant="outline"
aria-label="Remove header"
onClick={() => {
customHeaderFields.remove(headerIdx);
}}
className="!w-auto px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
<p className="mt-3 text-muted-foreground text-xs">
Set custom headers to be sent along with verification
requests
</p>
</div>
</div>
)}
</div>
{/* Allowed Operations Section */}
<AllowedOperationsSection
control={form.control}
enabled={allowedOperationsEnabled}
onToggle={(checked) => {
form.setValue("allowedOperationsEnabled", checked);
// Initialize allowedOperations array if enabling
if (
checked &&
!form.getValues("accessControl.allowedOperations")
) {
form.setValue("accessControl.allowedOperations", []);
}
}}
/>
</>
)}
</div>
<Button
disabled={isSubmitting}
type="submit"
className="mt-4 w-full gap-2"
>
{isSubmitting && <Spinner className="size-4" />}
{submitLabel}
</Button>
</form>
</Form>
);
}
@@ -1,44 +1,24 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { z } from "zod";
import type { Ecosystem, Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { useUpdatePartner } from "../../hooks/use-update-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
export function UpdatePartnerForm({
ecosystem,
partner,
onSuccess,
authToken,
}: {
ecosystem: Ecosystem;
partner: Partner;
onSuccess: () => void;
authToken: string;
}) {
const form = useForm<z.input<typeof partnerFormSchema>>({
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: partner.name,
domains: partner.allowlistedDomains.join(","),
bundleIds: partner.allowlistedBundleIds.join(","),
},
});
const router = useDashboardRouter();
const params = useParams();
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
{
@@ -46,123 +26,48 @@ export function UpdatePartnerForm({
},
{
onSuccess: () => {
form.reset();
onSuccess();
toast.success("Partner updated successfully", {
description: "The partner details have been updated.",
});
// Redirect to the redirect page that will take us back to the configuration page
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
router.push(redirectPath);
},
onError: (error) => {
const message =
error instanceof Error
? error.message
: "Failed to add ecosystem partner";
: "Failed to update ecosystem partner";
toast.error(message);
},
},
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
updatePartner({
ecosystem,
partnerId: partner.id,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
});
})}
className="flex flex-col gap-4"
>
<div className="flex grow flex-col gap-5">
<FormField
control={form.control}
name="name"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4 lg:col-span-4">
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.domains?.message &&
"block translate-y-0 text-destructive opacity-100", // If there are errors show them rather than the tip
)}
>
{form.formState.errors.domains?.message ??
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.bundleIds?.message &&
"block text-destructive",
)}
>
{form.formState.errors.bundleIds?.message ??
"Space or comma-separated list of bundle IDs"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
const handleSubmit = (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
updatePartner({
ecosystem,
partnerId: partner.id,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
accessControl: finalAccessControl,
});
};
<Button
disabled={isPending}
type="submit"
className="mt-4 w-full gap-2"
>
{isPending && <Spinner className="size-4" />}
Update
</Button>
</form>
</Form>
return (
<PartnerForm
partner={partner}
onSubmit={handleSubmit}
isSubmitting={isPending}
submitLabel="Update"
/>
);
}
@@ -1,42 +0,0 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState } from "react";
import type { Ecosystem, Partner } from "../../../../../types";
import { UpdatePartnerForm } from "./update-partner-form.client";
export function UpdatePartnerModal({
children,
ecosystem,
partner,
authToken,
}: {
children: React.ReactNode;
ecosystem: Ecosystem;
partner: Partner;
authToken: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Update {partner.name}</DialogTitle>
</DialogHeader>
<UpdatePartnerForm
ecosystem={ecosystem}
partner={partner}
onSuccess={() => setOpen(false)}
authToken={authToken}
/>
</DialogContent>
</Dialog>
);
}
@@ -3,9 +3,10 @@ import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton";
import { PartnersTable } from "./partners-table";
export function EcosystemPartnersSection({
teamSlug,
ecosystem,
authToken,
}: { ecosystem: Ecosystem; authToken: string }) {
}: { teamSlug: string; ecosystem: Ecosystem; authToken: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-6 lg:px-6">
<div className="flex flex-col items-start justify-between max-sm:mb-5 lg:flex-row">
@@ -21,10 +22,18 @@ export function EcosystemPartnersSection({
own app.
</p>
</div>
<AddPartnerDialogButton ecosystem={ecosystem} authToken={authToken} />
<AddPartnerDialogButton
teamSlug={teamSlug}
ecosystem={ecosystem}
authToken={authToken}
/>
</div>
<PartnersTable ecosystem={ecosystem} authToken={authToken} />
<PartnersTable
ecosystem={ecosystem}
authToken={authToken}
teamSlug={teamSlug}
/>
</div>
);
}
@@ -12,20 +12,28 @@ import {
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Link } from "../../../../../../../../../../../tw-components/link";
import type { Ecosystem, Partner } from "../../../../../types";
import { usePartners } from "../../../hooks/use-partners";
import { useDeletePartner } from "../../hooks/use-delete-partner";
import { UpdatePartnerModal } from "../client/update-partner-modal.client";
export function PartnersTable({
ecosystem,
authToken,
}: { ecosystem: Ecosystem; authToken: string }) {
const { partners, isPending } = usePartners({ ecosystem, authToken });
teamSlug,
}: {
ecosystem: Ecosystem;
authToken: string;
teamSlug: string;
}) {
const { partners, isPending } = usePartners({
ecosystem,
authToken,
});
if (isPending) {
return (
@@ -59,6 +67,7 @@ export function PartnersTable({
partner={partner}
ecosystem={ecosystem}
authToken={authToken}
teamSlug={teamSlug}
/>
))}
</TableBody>
@@ -70,8 +79,10 @@ export function PartnersTable({
function PartnerRow(props: {
partner: Partner;
ecosystem: Ecosystem;
teamSlug: string;
authToken: string;
}) {
const router = useDashboardRouter();
const { mutateAsync: deletePartner, isPending: isDeleting } =
useDeletePartner(
{
@@ -117,22 +128,21 @@ function PartnerRow(props: {
<td className="table-cell py-3 align-middle">
<div className="flex justify-end gap-3 pr-3">
<UpdatePartnerModal
partner={props.partner}
ecosystem={props.ecosystem}
authToken={props.authToken}
<Button
type="button"
variant="outline"
size="icon"
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
disabled={isDeleting}
onClick={() => {
router.push(
`/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/partners/${props.partner.id}/edit`,
);
}}
>
<Button
type="button"
variant="outline"
size="icon"
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
disabled={isDeleting}
>
<Pencil className="size-4" />
<span className="sr-only">Edit</span>
</Button>
</UpdatePartnerModal>
<Pencil className="size-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
title={`Are you sure you want to delete the partner ${props.partner.name}?`}
description={
@@ -1,33 +1,248 @@
import { isAddress, isHex } from "thirdweb/utils";
import z from "zod";
const isDomainRegex =
/^(?:\*|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*(?:\*(?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*)?)|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|localhost(?::\d{1,5})?)$/;
export const partnerFormSchema = z.object({
name: z
// Define schemas for the different operation types
const allowedTransactionSchema = z.object({
chainId: z.number().refine(
(data) => {
if (data !== undefined) {
return data > 0;
}
return true;
},
{
message: "Invalid chain ID",
},
),
contractAddress: z
.string()
.trim()
.min(3, {
message: "Name must be at least 3 characters",
})
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
domains: z
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isAddress(data);
}
return true;
},
{
message: "Invalid contract address",
},
),
selector: z
.string()
.trim()
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
})
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
bundleIds: z
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isHex(data) && data.length === 10; // 0x + 4 bytes for the selector (8 chars)
}
return true;
},
{
message: "Invalid function selector",
},
),
maxValue: z
.string()
.trim()
.transform((s) =>
s
.split(/,| /)
.filter((d) => d.length > 0)
.join(","),
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return BigInt(data) >= 0;
}
return true;
},
{
message: "Invalid max value",
},
),
});
const allowedTypedDataSchema = z
.object({
domain: z.string().refine((data) => data.length > 0, {
message: "Domain is required",
}),
verifyingContract: z
.string()
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isAddress(data);
}
return true;
},
{
message: "Invalid verifying contract address",
},
),
chainId: z
.number()
.optional()
.refine(
(data) => {
if (data !== undefined) {
return data > 0;
}
return true;
},
{
message: "Invalid chain ID",
},
),
primaryType: z.string().optional(),
})
.transform((data) => {
return {
...data,
verifyingContract:
data.verifyingContract && data.verifyingContract.length > 0
? data.verifyingContract
: undefined,
primaryType:
data.primaryType && data.primaryType.length > 0
? data.primaryType
: undefined,
};
});
const personalSignRestrictionSchema = z.discriminatedUnion("messageType", [
z.object({
messageType: z.literal("userOp"),
allowedTransactions: z
.array(allowedTransactionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
messageType: z.literal("other"),
message: z.string().optional(),
}),
]);
const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
z.object({
signMethod: z.literal("eth_signTransaction"),
allowedTransactions: z
.array(allowedTransactionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
signMethod: z.literal("eth_signTypedData_v4"),
allowedTypedData: z
.array(allowedTypedDataSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
signMethod: z.literal("personal_sign"),
allowedPersonalSigns: z
.array(personalSignRestrictionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
]);
export const partnerFormSchema = z
.object({
name: z
.string()
.trim()
.min(3, {
message: "Name must be at least 3 characters",
})
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
domains: z
.string()
.trim()
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
})
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
bundleIds: z
.string()
.trim()
.transform((s) =>
s
.split(/,| /)
.filter((d) => d.length > 0)
.join(","),
),
accessControlEnabled: z.boolean().default(false),
serverVerifierEnabled: z.boolean().default(false),
allowedOperationsEnabled: z.boolean().default(false),
accessControl: z
.object({
serverVerifier: z
.object({
url: z.string().optional(),
headers: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
})
.optional(),
allowedOperations: z
.array(allowedOperationsSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
})
.optional(),
})
.refine(
(data) => {
// If serverVerifier is enabled, validate the URL
if (data.accessControlEnabled && data.serverVerifierEnabled) {
return (
data.accessControl?.serverVerifier?.url &&
data.accessControl.serverVerifier.url.trim() !== ""
);
}
// Otherwise, no validation needed
return true;
},
{
message:
"Server Verifier URL is required when Server Verifier is enabled",
path: ["accessControl", "serverVerifier", "url"],
},
)
.refine(
(data) => {
// If serverVerifier is enabled, validate the URL format
if (
data.accessControlEnabled &&
data.serverVerifierEnabled &&
data.accessControl?.serverVerifier?.url
) {
try {
new URL(data.accessControl.serverVerifier.url);
return true;
} catch {
return false;
}
}
// Otherwise, no validation needed
return true;
},
{
message: "Please enter a valid URL",
path: ["accessControl", "serverVerifier", "url"],
},
);
@@ -0,0 +1,34 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../../../../types";
/**
* Fetches ecosystem data from the server
*/
export async function fetchEcosystem(args: {
teamIdOrSlug: string;
slug: string;
authToken: string;
}): Promise<Ecosystem> {
const { teamIdOrSlug, slug, authToken } = args;
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
revalidate: 0,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystem",
);
}
return (await res.json()).result as Ecosystem;
}
@@ -0,0 +1,32 @@
import type { Ecosystem, Partner } from "../../../../types";
export async function fetchPartnerDetails(args: {
authToken: string;
ecosystem: Ecosystem;
partnerId: string;
}): Promise<Partner> {
const { authToken, ecosystem, partnerId } = args;
try {
const response = await fetch(
`${ecosystem.url}/${ecosystem.id}/partner/${partnerId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch partner details: ${response.status} - ${response.statusText}`,
);
}
return await response.json();
} catch (error) {
console.error("Error fetching partner:", error);
throw error;
}
}

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