Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
cfea3b39ee Version Packages (#7762)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-30 03:22:48 -07:00
Joaquim VergesandGitHub d35b6fc1fe Fix CheckoutWidget transaction invalidation and NFT filtering in connect UI (#7761) 2025-07-30 03:16:04 -07:00
Joaquim VergesandGitHub bcb3b92397 [Docs] Update import path in encodeAbiParameters example (#7760) 2025-07-30 02:18:28 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
be803185aa Version Packages (#7754)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-29 16:43:35 -07:00
Joaquim VergesandGitHub ea247dbd4c [Dashboard] Add FTUX completion tracking for Engine checklist (#7758) 2025-07-29 16:30:47 -07:00
MananTank e8470fcce1 Log SIWA escalation failure error message (#7740)
<!--

## 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 error handling in the `createSupportTicket` function within the `support.ts` file. It ensures that even if the feedback submission fails, the ticket creation process continues without interruption.

### Detailed summary
- Changed the variable `res` to store the result of the `fetch` call to the SIWA URL.
- Added an error handling block to log an error message if the `fetch` response is not OK, without failing the ticket creation process.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved error handling for support ticket escalation, ensuring errors are logged if escalation fails without interrupting ticket creation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-29 23:02:35 +00:00
MananTank a6483f4a80 Playground: Rename account-abstraction page slugs (#7756)
<!--

## 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 routing paths in the application to reflect new endpoints for account abstraction features. It changes the hrefs for specific wallet routes and adds new rewrite rules in the configuration.

### Detailed summary
- Updated `href` for the `sponsor` route to `/wallets/account-abstraction/eip-4337`.
- Updated `href` for the `7702` route to `/wallets/account-abstraction/eip-7702`.
- Updated `href` for the `5792` route to `/wallets/account-abstraction/eip-5792`.
- Added new rewrite rules in `next.config.mjs` for the updated routes.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Updated sidebar links for account abstraction wallets to use explicit EIP-prefixed paths for improved clarity.

* **Chores**
  * Added redirects to ensure legacy account abstraction wallet URLs automatically forward to the new EIP-prefixed paths.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-29 19:50:26 +00:00
MananTank 2e901123c4 Dashboard: Completely remove Chakra UI (#7755)
<!--

## 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 removing the `@chakra-ui/react` library and its related components from the project, transitioning to a custom UI implementation.

### Detailed summary
- Deleted multiple `chakra` components and utility files.
- Removed `ChakraProviderSetup` usage from various components.
- Updated styles and layout in the `ContractPageLayout` and `ContractSplitPage`.
- Modified ESLint rules to disable chakra-specific restrictions.
- Updated `package.json` and `pnpm-lock.yaml` to remove `@chakra-ui` dependencies.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-07-29 18:16:01 +00:00
MananTank 5c5b910570 Dashboard: migrate sponsorship policies from chakra to tailwind (#7753)
<!--

## 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 refactoring the `AccountAbstractionSettingsPage` component to improve its structure and replace Chakra UI components with custom UI components, enhancing maintainability and consistency.

### Detailed summary
- Removed `ChakraProviderSetup` wrapper.
- Replaced Chakra UI components with custom components (e.g., `Button`, `Form`, `Input`, `Textarea`, `Select`, `Switch`).
- Introduced `DynamicHeight` for responsive form sections.
- Updated form handling to use custom `FormField` component for better organization.
- Enhanced the layout and styling of sponsorship policies and admin accounts sections.

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

<!-- end pr-codex -->
2025-07-29 18:16:00 +00:00
MananTank 6b9f29d43c Dashboard: Migrate contract/settings page from chakra to tailwind (#7752)
<!--

## 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 refactoring the `ContractSettingsPage` and its components to improve code structure and readability. It introduces a new UI library for forms and enhances the user experience with better layout and error handling.

### Detailed summary
- Refactored `ContractSettingsPage` to use `div` and `cn` for layout instead of `Flex` and `GridItem`.
- Updated `ContractSettingsPageInner` to accept props directly instead of using an interface.
- Replaced `Card` and `Flex` components with `div` and new UI components for a cleaner look.
- Introduced new form components (`Form`, `FormControl`, etc.) for better organization and handling.
- Enhanced error handling for form submissions with toast notifications.
- Updated styling for a more modern UI approach across all settings components.
- Simplified the logic for rendering settings components based on their support status.

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

<!-- end pr-codex -->
2025-07-29 18:16:00 +00:00
MananTank fad7cb92b7 Dashboard: migrate contract/proposals from chakra to tailwind (#7751)
<!--

## 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 enhances the `DelegateButton` and `ProposalButton` components in a proposal management interface. It improves error handling, updates UI components, and modifies the structure of the `ContractProposalsPage` to enhance user experience and maintainability.

### Detailed summary
- Added `parseError` utility for improved error handling.
- Updated `DelegateButton` to show success/error messages using `toast`.
- Refactored `ContractProposalsPage` to use a cleaner layout with improved proposal handling.
- Enhanced `ProposalButton` with a new form structure and error messages.
- Updated `Proposal` component to use new UI elements like `Badge` and `CardContent`.
- Improved voting feedback by displaying vote counts and statuses more clearly.

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

<!-- end pr-codex -->
2025-07-29 18:16:00 +00:00
MananTank 03617047e2 Dashboard: Migrate contract/permissions page from chakra to tailwind, UI improvements (#7750)
<!--

## 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 `Permissions` component and related files by improving UI elements, restructuring code for clarity, and integrating better user feedback mechanisms such as alerts and toast notifications.

### Detailed summary
- Updated the `Permissions` component's return structure to include headings and descriptions.
- Refined the `alertVariants` in `alert.tsx` for better styling.
- Replaced `ButtonGroup` with a `div` for layout adjustments in the `Permissions` component.
- Enhanced error handling with toast notifications in `permissions-editor.tsx`.
- Improved form handling and cleaned up values before submission.
- Replaced `DelayedDisplay` with an `Alert` component in `permissions-editor.tsx`.
- Updated the `ContractPermission` component to utilize `Select` for role management instead of traditional dropdowns.
- Added conditional rendering for alerts based on role states in `ContractPermission`.
- Adjusted button styles and behavior for better user experience across components.

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

<!-- end pr-codex -->
2025-07-29 18:16:00 +00:00
MananTank 04c7243994 Dashboard: Migrate various components from chakra to tailwind #2 (#7749)
<!--

## 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 refactoring components for better structure and performance, particularly in the forms and tables related to NFTs. It also removes unused components and improves the user experience with updated UI elements and error handling.

### Detailed summary
- Removed `FormHelperText` and `FormErrorMessage` from `chakra/form`.
- Refactored `NFTSharedMetadataButton` to use a function component instead of a React.FC.
- Updated `ContractPermissionsPage` to use a function component and improved prop handling.
- Replaced `FormControl` and `FormErrorMessage` with a new form structure in `NFTRevealButton`.
- Refactored `SharedMetadataForm` to enhance form handling and error messaging.
- Updated `NFTGetAllTable` to streamline pagination and data fetching logic.
- Removed unused imports and components to clean up the codebase.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Updated multiple NFT dashboard components to use a new custom UI library, replacing Chakra UI and simplifying form and table logic.
  * Improved form validation and error handling with integrated schema validation and toast notifications.
  * Streamlined table rendering and pagination for NFT listings with a more explicit and user-friendly approach.
  * Simplified permission and metadata management pages for improved clarity and consistency.

* **Chores**
  * Removed deprecated Chakra UI-based components and helper utilities, including custom link and button components no longer in use.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-29 18:15:59 +00:00
MananTank 66cba857dc Dashboard: Migrate various components from chakra to tailwind (#7743)
<!--

## 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 refactoring and enhancing forms in various components by replacing `@chakra-ui/react` form elements with a custom UI form system. It also introduces new validation schemas using `zod` for improved form handling and user feedback.

### Detailed summary
- Replaced `FormControl`, `FormLabel`, and related components from `@chakra-ui/react` with custom UI components in multiple files.
- Introduced `FormFieldSetup` and `FormField` components for better form structure.
- Added `zod` validation schemas for form data validation in `ClaimTab` and `NFTClaimButton`.
- Updated form submission logic to use the new form handling approach.
- Enhanced user feedback through improved error handling and notifications.

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

<!-- end pr-codex -->
2025-07-29 17:36:26 +00:00
MananTank 0426fe5c6e Dashboard: Migrate contract/events from chakra to tailwind (#7742)
<!--

## 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 refactors the `EventsFeed` component in `events-feed.tsx`, updating its structure and styling while changing the imported UI components to a custom design system. It enhances the user interface and maintains the same functionality.

### Detailed summary
- Replaced Chakra UI components with custom components from the UI library.
- Modified the `EventsFeed` component to use `function` syntax instead of `React.FC`.
- Updated the layout from `Flex` and `SimpleGrid` to `div` with Tailwind CSS classes.
- Changed event selection handling to use `Select` from the custom library.
- Refined the `EventsFeedItem` component structure and styling.
- Updated the way transaction data is displayed with `CopyTextButton`.
- Adjusted the accordion implementation to use custom `Accordion` components.
- Removed unused imports and props for cleaner code.

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

<!-- end pr-codex -->
2025-07-29 17:36:26 +00:00
MananTank 542360cc69 Dashboard: Migrate contract/account from chakra to tailwind (#7741)
<!--

## 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 UI and refactoring components in the dashboard, specifically in the `account` section. It improves the display of error messages, balances, and NFT ownership, while also updating component structures for better readability and maintainability.

### Detailed summary
- Updated error messages in `nfts-owned.tsx` with improved styling.
- Refactored `AccountBalance` to use props directly and changed layout to a grid.
- Simplified the `AccountPage` component, enhancing readability and structure.
- Refactored `DepositNative` to streamline props and improve input handling.
- Enhanced `AccountSigner` layout for better visual presentation and clarity.

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

<!-- end pr-codex -->
2025-07-29 17:36:25 +00:00
MananTank 5e883ac550 Dashboard: Migrate contract/accounts page from chakra to tailwind (#7739)
<!--

## 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 refactoring components in the `accounts` section of the dashboard, enhancing code structure and UI elements while removing deprecated features.

### Detailed summary
- Deleted the `accounts-count.tsx` file.
- Removed `Legacy_CopyButton` and its usage.
- Updated `AccountsPage` to use `UnderlineLink` and improved layout.
- Refactored `CreateAccountButton` to streamline props handling.
- Replaced `TWTable` with custom table components in `AccountsTable`.
- Added pagination functionality and improved loading states.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Simplified the Accounts page layout and styling by switching from Chakra UI to Tailwind CSS.
  * Streamlined the AccountsTable with a new table design, fixed page size, and updated pagination controls.
  * Updated Create Account button with unified styling, improved labeling, and a new icon.
  * Removed the accounts count display from the page.
  * Updated external documentation link and link styling for clarity.
  * Removed legacy copy button component and related clipboard functionality.

* **Style**
  * Improved visual consistency with updated button and table styles.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-29 17:36:25 +00:00
gregandGitHub c717eaadae [SDK] Feature: Faster payment widget quote generation (#7745) 2025-07-29 10:07:01 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
691b7e1454 Version Packages (#7747)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-28 20:46:34 -07:00
Joaquim VergesandGitHub 243749b08c [Docs] Update README with comprehensive SDK package information (#7738) 2025-07-28 20:46:21 -07:00
Joaquim VergesandGitHub 4f38198ae1 [SDK] Improve WalletConnect connection and auto-connection flow (#7746) 2025-07-28 20:31:36 -07:00
MananTank b371bf9630 Dashboard: Published Contract page UI improvements, Fix audit link (#7735)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of IPFS URLs, enhancing the user interface, and refactoring components for better readability and maintainability. It includes updates to various components and functions to streamline the codebase and improve functionality.

### Detailed summary
- Changed `shortenString` default parameter from `true` to `false`.
- Updated `SelectTrigger` to have a `w-fit` class.
- Replaced `replaceIpfsUrl` with `publicIPFSGateway` and `resolveSchemeWithErrorHandler` in multiple components.
- Modified `ContractFunctionsOverview` to remove `onlyFunctions` prop.
- Updated UI elements for consistency and better spacing.
- Refactored `PublisherHeader` to `PublisherLink` for clarity.
- Enhanced `PublishedContract` component layout and structure.
- Added account address handling in multiple components.
- Improved error handling and logging in IPFS URL resolution.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added an "Edit" button to contract headers, visible only to the publisher when viewing their own contract.
  * Publisher information is now displayed with a more compact and streamlined UI.

* **UI Improvements**
  * Enhanced styling for contract headings, buttons, and function panels for better readability and consistency.
  * Updated publisher and contract layouts for improved clarity and usability.
  * Improved tab logic and feedback in contract function panels, including clearer "No inputs required" messaging.
  * Adjusted layout widths and border styles for published contract pages.
  * Refined link styling and layout in platform fee information sections.
  * Modified version selector width behavior for better fit.

* **Bug Fixes**
  * Updated IPFS URL handling for images and links, ensuring more reliable and consistent resolution.

* **Refactor**
  * Simplified and consolidated publisher and contract components for maintainability.
  * Replaced or removed deprecated utility functions for URL resolution.
  * Streamlined function panel logic and removed unnecessary props.
  * Refactored publisher header component to a more compact link-based design.

* **Chores**
  * Updated internal utility defaults (e.g., string shortening behavior) to improve display consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 18:49:17 +00:00
Joaquim VergesandGitHub cf401bee84 [Dashboard] Add thirdweb API option alongside Engine API (#7734) 2025-07-27 21:08:03 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c178d3ab2b Version Packages (#7733)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-27 18:53:00 -07:00
Joaquim VergesandGitHub e92d8f921a Update insight, add contract filtering (#7710) 2025-07-27 18:43:42 -07:00
Joaquim VergesandGitHub 1cbbbc3c12 [SDK] Add EIP-5792 support to Coinbase and refactor wallet capabilities (#7732) 2025-07-27 18:27:22 -07:00
Joaquim VergesandGitHub 58dbe90b93 [SDK] Fallback to onchain nonce in 7702 execution (#7729) 2025-07-27 17:47:38 -07:00
MananTank 0c6d75dfc6 Dashboard: Migrate Claim condition components from chakra to tailwind, UI improvements (#7731)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the user interface and functionality of the claim conditions forms in the dashboard application. It includes updates to component structures, styling improvements, and the introduction of new features for better user experience.

### Detailed summary
- Removed unused CSV files from the assets.
- Updated `variant` prop in `tx-button` to include "outline".
- Adjusted styles in `drop-zone` for improved layout.
- Enhanced text styles in `price-preview` and `claim-conditions` components.
- Replaced `CustomFormControl` with `FormFieldSetup` for better form handling.
- Added new components for displaying and managing claim conditions.
- Improved error handling and user feedback in various input components.
- Refactored `ClaimPriceInput` and other input components to streamline functionality.
- Introduced a new `SnapshotDataTable` for better display of CSV data.
- Enhanced snapshot upload functionality with clearer requirements and examples.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added support for an "outline" button style variant in transaction buttons.

* **Refactor**
  * Replaced Chakra UI components with a custom UI library and Tailwind CSS across claim conditions forms and related components.
  * Simplified and reorganized the layout of claim conditions phases, downloadable code blocks, and snapshot upload views for improved clarity and usability.
  * Updated CSV upload and data table experience with enhanced error display and a new code example download feature.

* **Style**
  * Improved spacing, typography, and overall visual consistency throughout claim conditions and related dashboard components.

* **Chores**
  * Removed unused or redundant components and files related to previous form controls and CSV tables.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 00:06:30 +00:00
MananTank 11d9f3e513 Dashboard: Migrate marketplace contract pages from chakra to tailwind (#7730)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the UI components and their functionality in the dashboard application, particularly around NFT listings, by enhancing type safety, optimizing rendering, and updating UI elements for better usability.

### Detailed summary
- Made `image_url` optional in `getWalletNFTs.ts`.
- Updated button styles and sizes in `list-button.tsx`.
- Enhanced `CurrencySelector` component with better class management.
- Refactored `ContractDirectListingsPage` and `ContractEnglishAuctionsPage` to use `props`.
- Improved `ListingDrawer` layout and added table components for better data presentation.
- Refined `MarketplaceTable` to handle pagination and data fetching more efficiently.
- Updated listing forms to use new UI components and improved validation.
- Enhanced error handling and loading states across various components.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Enhanced currency selector with an icon-based back button and improved styling flexibility.

* **Refactor**
  * Migrated marketplace listing forms and tables to a new custom UI component library for a more consistent and modern interface.
  * Simplified and streamlined marketplace table and drawer components, removing legacy dependencies and improving layout.
  * Updated listing and auction pages to delegate call-to-action buttons for better composability.
  * Improved listing details drawer with a structured table layout and clearer asset information.

* **Bug Fixes**
  * Improved handling of missing NFT images to prevent errors when image URLs are unavailable.

* **Style**
  * Updated button and icon styles for a more polished user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-27 22:56:58 +00:00
MananTank c7fab9f5dd Dashboard: Migrate Published Contract page from chakra to tailwind, UI improvements (#7728)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the UI components and structure of the published contract pages in the dashboard. It enhances layouts, updates component props, and modifies styles for better user experience and consistency.

### Detailed summary
- Updated `version-selector` styles.
- Modified `PublishedContractBreadcrumbs` to accept `className` prop.
- Simplified `PublishedContractLayout` by returning only `props.children`.
- Refactored `DeployFormForUri` to remove `ChakraProviderSetup`.
- Enhanced `ExplorePage` layout and styles.
- Changed icons in `PublishedActions` and updated button structure.
- Improved `DeployContractInfo` layout and styles.
- Updated `MarkdownRenderer` styles for better spacing and alignment.
- Refactored `PublishedContract` to use `className` prop for styling.
- Added `PublishedContractBreadcrumbs` to multiple pages for better navigation.
- Improved `PublisherHeader` layout and styling.
- Removed unused imports and cleaned up code.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added breadcrumb navigation to published contract and deploy pages for improved navigation clarity.

* **Refactor**
  * Replaced Chakra UI components with Tailwind CSS for more consistent styling and layout across published contract pages.
  * Simplified and reorganized contract detail and publisher header layouts for better readability and usability.
  * Updated contract info display to use an enhanced image component with fallback support.

* **Style**
  * Adjusted spacing, borders, and typography for headings, lists, and paragraphs in Markdown rendering.
  * Refined button and icon styles for contract actions and links.
  * Improved overall page structure and container consistency.

* **Bug Fixes**
  * Enhanced fallback behavior for contract images when unavailable.

* **Chores**
  * Removed unused sharing utility and related "Share" button from contract actions.
  * Cleaned up unused Chakra UI imports and wrappers.

* **Documentation**
  * No user-facing documentation changes included.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-27 19:27:11 +00:00
MananTank 882ed8348b Dashboard: Migrate engine/relayers page from chakra to tailwind, remove chakra provider from engine layout (#7724)
<!--

## 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 refactoring components related to relayers in a blockchain dashboard application, improving the user interface and functionality of forms, modals, and data handling for adding, editing, and removing relayers.

### Detailed summary
- Removed `preventOpenOnFocus` prop from several components.
- Updated `WalletAddress` component to streamline address handling.
- Refactored `AddRelayerButton` and `EditModal` to use new dialog components.
- Enhanced form validation using `zod` for relayer forms.
- Improved UI with consistent styling and layout adjustments.
- Updated `RelayersTable` to use new UI components and improved address display.
- Added error handling and success notifications for relayer actions.

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

<!-- end pr-codex -->
2025-07-27 12:22:30 +00:00
MananTank 0386638cbd Dashboard: Migrate engine/overview from chakra to tailwind (#7723)
<!--

## 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 refactoring UI components and improving the user experience in the wallet management section of a dashboard application. It includes updates to modals, button components, and form handling while ensuring better accessibility and responsiveness.

### Detailed summary
- Updated `create-backend-wallet-button.tsx` to modify button styles and add `tabIndex` for accessibility.
- Enhanced tooltip functionality in `tooltip.tsx`.
- Added `react-qrcode-logo` dependency for QR code generation.
- Refactored `transaction-timeline.tsx` to improve transaction display and interaction.
- Converted Chakra UI modals to custom dialog components for consistency.
- Updated `transactions-table.tsx` to streamline transaction details display and manage state more effectively.
- Refined backend wallet management in `backend-wallets-table.tsx`, replacing Chakra components with custom UI elements.
- Improved form handling using `react-hook-form` and validation with `zod` in wallet-related modals.
- Enhanced user feedback through toast notifications for actions like sending funds and deleting wallets.

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

<!-- end pr-codex -->
2025-07-27 12:22:29 +00:00
MananTank a00b7fa425 Dashboard: Migrate engine/metrics from chakra to tailwind (#7722)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the layout and user interface of the `Healthcheck` and `EngineSystemMetrics` components in the dashboard. It enhances responsiveness and readability, while also introducing new loading states and alert messages.

### Detailed summary
- Changed layout from row to column in `engine-contract-subscription.tsx`.
- Added `tracking-tight` class to the `h2` header for better text spacing.
- Refactored `Healthcheck` component to use a new loading indicator.
- Updated `EngineSystemMetrics` to use `GenericLoadingPage` instead of `Spinner`.
- Introduced `Alert` component for error messaging in `EngineSystemMetrics`.
- Replaced `Card` components with divs for layout consistency.
- Created a new `MetricRow` component for displaying metrics in a structured format.

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

<!-- end pr-codex -->
2025-07-27 17:40:35 +05:30
Joaquim VergesandGitHub b33073875f [Docs] Remove POST method from MCP server URL (#7727) 2025-07-27 00:13:05 -07:00
Joaquim VergesandGitHub 8ddf37861c [Docs] Update MCP authentication from headers to query parameters (#7726) 2025-07-27 16:31:22 +12:00
Joaquim VergesandGitHub 34067c05d0 [Docs] Update MCP server URLs and reorder usage sections (#7725) 2025-07-26 15:46:34 -07:00
MananTank 67abd0db5b Dashboard: Migrate engine/contract-submissions from chakra to tailwind (#7721)
<!--

## 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 refactoring the `EngineContractSubscriptions` and `ContractSubscriptionTable` components to improve their structure, style, and functionality. It also enhances the user experience by updating UI elements and integrating new components.

### Detailed summary
- Updated `className` in the `input` component to use `selection:bg-inverted`.
- Refactored `EngineContractSubscriptions` to use `div` instead of `Flex` for layout.
- Integrated `UnderlineLink` for better link styling.
- Changed `Switch` component's props from `isChecked` to `checked` and `onChange` to `onCheckedChange`.
- Replaced `Flex` with `div` for various elements to improve layout consistency.
- Enhanced `ContractSubscriptionTable` with new styling and improved event handling.
- Updated modal components to use `Dialog` instead of `Modal`.
- Improved error handling and user notifications using `toast` for actions like adding and removing subscriptions.
- Refactored form handling with `react-hook-form` and `zod` for validation.
- Updated `FilterSelector` to use `MultiSelect` for better user interaction.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Improved contract subscription management with a redesigned multi-step form, enhanced validation, and more user-friendly filtering controls.
  * Updated contract subscriptions table with improved modals and tooltips for better usability.

* **Refactor**
  * Replaced Chakra UI components with a custom UI library for dialogs, forms, tables, tooltips, and switches.
  * Enhanced visual styling using Tailwind CSS classes.
  * Streamlined error notifications and form validation for a more consistent user experience.

* **Style**
  * Updated selection styling for input fields to improve appearance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-26 19:15:41 +00:00
gregandGitHub 6e2f535bfa [Dashboard] Payment Links Page (#7706) 2025-07-25 15:42:04 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
430115cf2b Version Packages (#7719)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-25 13:33:10 -07:00
Jonas DanielsandGitHub 80c6b6a793 Add gateway team capabilities (#7718) 2025-07-25 13:26:17 -07:00
MananTank eb2d49c4e6 Dashboard: Migrate engine/webhooks page from chakra to tailwind (#7717)
<!--

## 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 refactoring the webhook components in the dashboard application. It improves the code structure, updates UI components, and enhances the form handling for creating and managing webhooks.

### Detailed summary
- Changed `CreateWebhookInput` type definition.
- Refactored `EngineWebhooks` component to simplify props handling.
- Updated UI components in `EngineWebhooks` for better styling.
- Replaced Chakra UI modal with custom dialog components in `AddWebhookButton`.
- Enhanced form validation using `zod` in `AddWebhookButton`.
- Refactored `WebhooksTable` component for improved UI and functionality.
- Replaced modals with dialogs for delete and test webhook actions.
- Improved error handling and user notifications with `toast`.
- Updated event type selection and input handling in forms.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Updated webhooks-related components to use a custom UI library instead of Chakra UI, resulting in a more consistent interface.
  * Improved form validation and inline error messages when adding webhooks.
  * Enhanced dialog and modal interactions for adding, deleting, and testing webhooks with better state management.
  * Updated table and tooltip styling for better readability and user experience.
  * Adjusted button states and feedback to clearly indicate loading and error conditions.
  * Improved external links with enhanced security attributes and streamlined layout for webhook management.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-26 00:54:59 +05:30
jnsdls 2996121187 Update credit purchase terminology in billing section (#7705)
# Update Credit Balance Section UI

This PR updates the UI text in the credit balance section to improve clarity:

- Changed "Top Up Credits" to "Credits" in the section header
- Updated tooltip text from "Only team owners can top up credits" to "Only team owners can purchase credits"
- Changed button text from "Top Up With Crypto" to "Buy Credits"

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Style**
  * Updated text labels in the credit balance section for improved clarity, including changes to headings, tooltips, and button text.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the terminology related to credit purchases in the `CreditBalanceSection` component, making it clearer for users. The changes enhance the user experience by refining the language used in the interface.

### Detailed summary
- Changed heading from `Top Up Credits` to `Credits`.
- Updated tooltip message from `Only team owners can top up credits.` to `Only team owners can purchase credits.`.
- Renamed button text from `Top Up With Crypto` to `Buy Credits`.
- Modified summary label from `Top-up amount:` to `Added credits:` in multiple places.

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

<!-- end pr-codex -->
2025-07-25 18:36:10 +00:00
MananTank cd6fd9a75d Dashboard: Migrate engine/configuration page from chakra to tailwind, UI improvements (#7716)
<!--

## 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 refactoring and improving the UI of various configuration components for the Engine dashboard, enhancing layout, styling, and user experience while updating some logic related to wallet configurations.

### Detailed summary
- Updated layout classes for `local-config.tsx`, `engine-configuration.tsx`, and `system.tsx`.
- Added `UnderlineLink` component for links in `engine-wallet-config.tsx`, `circle-config.tsx`, and others.
- Improved styling for alerts and forms in various components.
- Enhanced error handling and user feedback with toast notifications in `cors.tsx`, `ip-allowlist.tsx`, and wallet config components.
- Refactored props handling in `EngineWalletConfig`, `EngineCorsConfig`, and `EngineIpAllowlistConfig` to use inline type definitions.
- Added `SaveIcon` and `Spinner` components for better loading and saving indicators in forms.

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

<!-- end pr-codex -->
2025-07-25 18:22:38 +00:00
MananTank 12c42c0311 Dashboard: Migrate engine/admins page from chakra to tailwind (#7715)
<!--

## 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 refactoring the `EngineAdmins` and `AddAdminButton` components, enhancing their structure and UI by replacing Chakra UI components with custom UI components, and improving form handling using `react-hook-form` and `zod` for validation.

### Detailed summary
- Replaced Chakra UI components with custom UI components (`UnderlineLink`, `Dialog`, `Form`, etc.).
- Refactored `EngineAdmins` to use a simpler function signature.
- Updated the layout and styling of the `EngineAdmins` component.
- Enhanced `AddAdminButton` to use `react-hook-form` for form handling and validation.
- Introduced `zod` for schema validation in `AddAdminButton`.
- Refactored modal dialogs to use the new `Dialog` component.
- Updated `AdminsTable` to manage state for editing and removing admins more effectively.
- Improved error handling and notifications using `toast` for admin actions.

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

<!-- end pr-codex -->
2025-07-25 18:22:38 +00:00
MananTank 5e11d63e11 Dashboard: Migrate engine/access-tokens page from chakra to tailwind (#7714)
<!--

## 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 refactoring components in the `Engine` section of the dashboard, enhancing UI elements, and improving error handling for access tokens and keypairs management.

### Detailed summary
- Replaced `Modal` components with `Dialog` components for improved UI consistency.
- Enhanced error handling using `toast` notifications.
- Updated button components for better visual consistency.
- Refactored `AddAccessTokenButton`, `KeypairsTable`, and `AccessTokensTable` components for cleaner code.
- Simplified state management for modal visibility.
- Improved accessibility and user experience in forms and alerts.
- Updated various imports to use new UI components from the design system.

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

<!-- end pr-codex -->
2025-07-25 18:22:38 +00:00
MananTank 1d6e98dafd Dashboard: Migrate publish contract components from chakra to tailwind (#7713)
<!--

## 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 refactoring the code in the `dashboard` application to improve UI components by replacing `chakra-react-select` with a custom select component, updating various forms, and enhancing the layout and styling for better usability.

### Detailed summary
- Replaced `chakra-react-select` with a custom `Select` component.
- Updated imports for UI components to use a unified path.
- Refactored `RefInputImplFieldset` to destructure props.
- Enhanced `ExternalLinksFieldset` styling and functionality.
- Improved layout and styling in `FactoryFieldset` and `NetworksFieldset`.
- Updated `AbiSelector` to use the new `Select` component.
- Modified `CustomFactory` and `LandingFieldset` for better UI consistency.
- Changed form handling and submission logic in `ContractPublishForm`.

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

<!-- end pr-codex -->
2025-07-25 18:22:37 +00:00
MananTank c077f78e3a Dashboard: Migrate contract deployment UI from chakra to tailwind (#7712)
<!--

## 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 refactoring the button components and form controls in the dashboard application, improving the UI consistency and structure by replacing `FormControl` with a custom `FormFieldSetup` component and adjusting button properties.

### Detailed summary
- Changed the export of `ButtonProps` in `apps/dashboard/src/chakra/button.tsx` to remove the `export` keyword.
- Updated button imports in `apps/dashboard/src/@/components/solidity-inputs/button.tsx` to use the new path.
- Modified the `IpfsUploadButton` to use `variant="secondary"` and added a `disabled` state.
- Replaced `FormControl` with `FormFieldSetup` in multiple components for better structure and error handling.
- Adjusted the props passed to `FormFieldSetup` for improved clarity and functionality.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Unified form field presentation by replacing direct Chakra UI form controls with a custom, reusable form field component across contract deployment and module configuration forms.
  * Simplified error handling, labels, and helper text management for a more consistent user experience.

* **Style**
  * Updated button styling and icon placement for IPFS upload actions, improving visual consistency and feedback during upload operations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 18:11:53 +00:00
MananTank 6b7983f47c Dashboard: Update outdated Portal links, Portal redirect cleanup (#7699)
<!--

## 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 various links throughout the application to direct users to the new `wallets` and `payments` sections instead of the old `connect` endpoints, improving navigation consistency.

### Detailed summary
- Updated `href` links in multiple files to point to new `wallets` and `payments` URLs.
- Changed labels for links to reflect updated navigation terminology.
- Removed deprecated links related to the old `connect` structure.
- Ensured all changes maintain the same functionality while improving user experience.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Updated multiple documentation and help URLs throughout the dashboard to point to new or consolidated destinations for wallets, payments, and account abstraction features.
  * Adjusted footer and header links, as well as empty state and help links, to reflect the latest documentation structure.
  * Removed or replaced outdated links and labels for improved clarity and navigation.

* **Chores**
  * Refined and unified redirect mappings to consolidate legacy documentation paths, ensuring users are directed to the most relevant and up-to-date wallet and payment resources.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 12:12:19 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
859290f6c7 Version Packages (#7709)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-25 04:40:24 -07:00
Joaquim VergesandGitHub adec6be844 [SDK] Fix showQrModal option not respected on desktop web (#7711) 2025-07-25 04:34:04 -07:00
c80eed3552 [Docs] Add search and pagination to wallet list (#7703)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-25 03:50:50 -07:00
Joaquim VergesandGitHub 0c7051b999 [Docs] Update API endpoints and add frontend/backend examples (#7692) 2025-07-24 19:09:42 -07:00
Joaquim VergesandGitHub 3c8e444fe6 [SDK] Fix ecosystem wallet connection with default chains (#7708) 2025-07-24 19:02:00 -07:00
MananTank 367a25ca5a Dashboard: Migrate Explorer components from chakra to tailwind (#7707)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the UI components and functionality of the `ContractExplorerPage` and related components in the dashboard by refining layout, enhancing accessibility, and optimizing code structure.

### Detailed summary
- Updated layout for `ContractExplorerPage` to enhance UI consistency.
- Improved header elements for better accessibility.
- Refined tab structure in `ContractFunctionsOverview` for better navigation.
- Enhanced styling of buttons and inputs for a more modern look.
- Replaced `Card` components with simpler layouts where applicable.
- Introduced new utility functions for class name management.
- Optimized the handling of function inputs and outputs in `InteractiveAbiFunction`.
- Adjusted the rendering of function and event lists for improved clarity.

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

<!-- end pr-codex -->
2025-07-25 01:13:51 +00:00
MananTank c0ffa1c871 Dashboard: Migrate Published contract > code tab from chakra to tailwind (#7704)
<!--

## 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 improving the `CodeOverview` and `CodeSegment` components by enhancing their layouts, functionality, and structure. It introduces new sections, components, and props to better handle user interactions and display relevant information.

### Detailed summary
- Updated `className` in `CodeSegment` for better styling.
- Added `scrollableContainerClassName` prop in `CodeSegment`.
- Refactored `CodeOverview` to use props instead of interface.
- Introduced `AccentButtonListContainer` and `AccentButton` components for button lists.
- Created `AccountFactorySection` for account integration guidance.
- Enhanced tab handling and layout in `CodeOverview`.
- Improved user messaging and conditional rendering for SDK installation instructions.

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

<!-- end pr-codex -->
2025-07-25 01:13:51 +00:00
MananTank 36731096b9 Dashboard: Migrate /tokens contract page from chakra to tailwind (#7695)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the functionality and user experience of token management features in a dashboard application, including enhancements to file downloads, airdrop processes, and form validations.

### Detailed summary
- Removed unused `supportedERCs` from `ContractTokensPage`.
- Added redirects for unsupported ERC20 tokens.
- Improved the `LazyMintNftForm` with validation on image uploads.
- Introduced `DownloadableCode` component for code downloads.
- Added `AirdropCSVTable` for better CSV data handling.
- Enhanced `TokenAirdropForm` with improved transaction handling and notifications.
- Refactored various token management buttons to use consistent form structures and validation.
- Updated airdrop upload process to handle CSV files more effectively with user feedback.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced a new DownloadableCode component for displaying and downloading code examples.
  * Added a paginated CSV table for airdrop address and quantity previews.

* **Refactor**
  * Simplified and unified token action forms (airdrop, burn, claim, mint, transfer) with improved validation, streamlined UI, and consistent notifications.
  * Replaced custom code example components with the new DownloadableCode component across relevant views.
  * Updated CSV upload experience with clearer instructions and reusable components.
  * Improved conditional navigation for unsupported token standards.

* **Bug Fixes**
  * Enhanced form validation and error handling for token-related actions.

* **Chores**
  * Updated import paths for consistency and maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 01:13:51 +00:00
gregandGitHub 1607229b21 [Dashboard] Feature: New Payments Overview UI (#7702) 2025-07-24 15:22:44 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
75bf09126f Version Packages (#7701)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-24 13:40:52 -07:00
Jonas DanielsandGitHub 9d8c7f1f7d Add gateway to possible usage sources (#7700) 2025-07-24 13:39:12 -07:00
MananTank 9dc8f5a637 [TOOL-5111] Playground: Update slugs, add redirects (#7698)
<!--

## 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 restructuring routes from `/connect` to `/wallets`, alongside adjustments in documentation links and component imports to streamline the application.

### Detailed summary
- Updated redirect paths from `/connect/...` to `/wallets/...`
- Changed documentation links accordingly
- Refactored component imports to match new route structure
- Adjusted breadcrumb links and sidebar navigation items
- Renamed files and folders to reflect the new `/wallets` hierarchy

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Expanded and reorganized URL redirects for improved navigation, including new paths for wallets, payments, and transactions sections.

* **Refactor**
  * Updated navigation links and internal references to use new URL structures for wallets, payments, and transactions.
  * Simplified import paths and component structures for improved code clarity and maintainability.

* **Documentation**
  * Updated documentation links throughout the app to reflect new URLs and more relevant resources.

* **Style**
  * Minor formatting improvements for code consistency and readability.

* **Chores**
  * Enhanced code comments to clarify the use of array index as React list keys.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-24 20:20:12 +00:00
Jonas DanielsandGitHub 48361f92b7 Add custom amount input to credit balance section (#7697) 2025-07-24 12:46:25 -07:00
MananTank 1659ae3754 Dashboard: Migrate Various forms in contract pages from chakra to tailwind (#7690)
<!--

## 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 refactoring the form components in the dashboard's NFT management system to improve structure and usability. It replaces Chakra UI components with custom UI components, enhancing consistency and maintainability.

### Detailed summary
- Refactored `PropertiesFormControl` to utilize custom form components.
- Removed unused imports and consolidated imports from `@chakra-ui/react`.
- Updated form handling to use `FormField`, `FormItem`, and `FormDescription`.
- Enhanced error handling by integrating `FormMessage` for displaying validation messages.
- Replaced `Flex`, `FormControl`, and other Chakra UI components with custom equivalents.
- Updated `LazyMintNftForm`, `NFTMintForm`, and `UpdateNftMetadata` forms to use new structure and components.
- Improved user experience by refining button and input styles and behavior.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Refactor**
  * Migrated all NFT-related form components from Chakra UI to a custom UI library, updating form controls, buttons, accordions, and input elements.
  * Updated error handling and helper text display to use new form components.
  * Simplified and modernized form structures for minting, lazy minting, metadata updates, and property controls.
  * Removed file upload support and reset functionality from the properties form.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-24 18:49:38 +00:00
MananTank 88d44aff50 Dashboard: Migrate Batch Upload NFTs from chakra to tailwind, UI improvements (#7689)
<!--

## 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 refactoring and enhancing the `FilePreview` component, improving the upload process for NFTs, and updating various components to utilize the new structure. Several files related to example CSVs were deleted, and new features were added for better file handling.

### Detailed summary
- Deleted example CSV files.
- Added `FilePreview` import in multiple components.
- Enhanced `FilePreview` styling and functionality.
- Updated `DownloadFileButton` to accept additional props.
- Refactored `BatchTable` to improve pagination and display logic.
- Improved `UploadStep` for better user experience during file uploads.
- Introduced `DelayedRevealConfiguration` and `SelectReveal` components for managing NFT reveal types.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Refactor**
  * Replaced Chakra UI and third-party libraries with a custom UI system across batch upload components for a more unified look and feel.
  * Simplified and restyled batch minting forms, tables, upload steps, and reveal configuration.
  * Updated drag-and-drop functionality to use a dedicated DropZone component.
  * Streamlined table rendering and pagination with manual state management and local UI components.
  * Improved layout, styling, and accessibility for option selection, shuffle toggle, and modal dialogs.
  * Enhanced media preview styling and standardized file preview imports for improved consistency.
  * Added tabbed format selection and enriched instructional content with downloadable CSV/JSON examples in the upload step.
  * Improved submit button placement and added help links for user guidance during batch minting.
* **New Features**
  * Added customizable button variants and styling options to the file download button for better UI flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-24 18:17:47 +00:00
gregandGitHub c63b3497bd Feature: New Payments Overview Page (#7691) 2025-07-24 11:16:42 -07:00
74a21635e4 [Portal] Fix: Broken /connect Links (#7679)
Co-authored-by: Manan Tank <[email protected]>
2025-07-24 11:02:50 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
66b9bf3c6b Version Packages (#7694)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-24 02:00:52 -07:00
Joaquim VergesandGitHub cf22c23b45 [SDK] Optimize 4337 signature performance (#7693) 2025-07-24 00:47:45 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
170b020e17 Version Packages (#7682)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-23 19:25:46 -07:00
Joaquim VergesandGitHub 8260af3a92 [Dashboard] Replace modular contracts with Arbitrum Stylus contracts in explore page (#7685) 2025-07-22 19:15:42 -07:00
Joaquim VergesandGitHub b9154cc9e7 [API] Init API wrapper package (#7684) 2025-07-22 18:46:02 -07:00
Joaquim VergesandGitHub 08955f1588 [SDK] Expose getAuthToken from inApp and ecosystem wallets (#7683) 2025-07-22 18:26:46 -07:00
gregandGitHub 756295241a [SDK] Feature: Adds optional transactionId parameter to Bridge.status (#7681) 2025-07-22 15:11:04 -07:00
Joaquim VergesandGitHub 249679481e [Docs] Update redirects for wallets and payments paths (#7680) 2025-07-22 15:09:00 -07:00
Joaquim VergesandGitHub a491de4d7c [Docs] Refactor Contract and Tokens docs (#7676) 2025-07-22 17:20:00 +12:00
gregandGitHub 898efc56a9 [Portal] Fix: Test Jam Feedback (#7677) 2025-07-21 21:32:26 -07:00
c0e7ae95de Add external wallet filter and display (#7669)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-21 20:39:26 -07:00
Joaquim VergesandGitHub 1e8190a1f9 [Docs] Add frontend/backend tabs for transaction sending examples (#7670) 2025-07-21 19:36:18 -07:00
794 changed files with 35770 additions and 26509 deletions
+142 -28
View File
@@ -15,37 +15,150 @@
<p align="center"><strong>All-in-one web3 SDK for Browser, Node and Mobile apps</strong></p>
## Features
## Core Package
- Support for React & React-Native
- First party support for [Embedded Wallets](https://portal.thirdweb.com/connect/embedded-wallet/overview) (social/email login)
- First party support for [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview)
- Instant connection to any chain with RPC Edge integration
- Integrated IPFS upload/download
- UI Components for connection, transactions, nft rendering
- High level contract extensions for interacting with common standards
#### [`thirdweb`](./packages/thirdweb/README.md)
The main SDK package providing all-in-one web3 functionality for Browser, Node, and Mobile applications.
```bash
npm install thirdweb
```
**Features:**
- Type-safe contract and transaction APIs
- In-app wallets with social/email login
- Account abstraction (ERC4337/EIP7702) support
- 500+ external wallets supported
- Built in infra (RPC, bundler, paymaster, indexer)
- React hooks and UI components
- Automatic ABI resolution
- IPFS upload/download
- Cross-platform support (Web, React Native)
## Library Comparison
### Documentation
| | thirdweb | Wagmi + Viem | Ethers@6 |
| ----------------------------------------- | -------- | ------------------ | -------- |
| Type safe contract API | ✅ | ✅ | ✅ |
| Type safe wallet API | ✅ | ✅ | ✅ |
| EVM utils | ✅ | ✅ | ✅ |
| RPC for any EVM | ✅  | ⚠️ public RPC only | ❌ |
| Automatic ABI Resolution | ✅ | ❌ | ❌ |
| IPFS Upload/Download | ✅ | ❌ | ❌ |
| Embedded wallet (email/ social login) | ✅ | ⚠️ via 3rd party | ❌ |
| Account abstraction (ERC4337) support | ✅ | ⚠️ via 3rd party | ❌ |
| Web3 wallet connectors | ✅ | ✅ | ❌ |
| Local wallet creation | ✅ | ✅ | ✅ |
| Auth (SIWE) | ✅ | ✅ | ❌ |
| Extensions functions for common standards | ✅ | ✅ | ❌ |
| React Hooks | ✅ | ✅ | ❌ |
| React UI components | ✅ | ❌ | ❌ |
| React Native Hooks | ✅ | ✅ | ❌ |
| React Native UI Components | ✅ | ❌ | ❌ |
Visit the [developer portal](https://portal.thirdweb.com) for full documentation.
### 🚀 Quick Start
#### For React Applications
```bash
npm install thirdweb
```
```typescript
import { createThirdwebClient } from "thirdweb";
import { ConnectButton, useActiveAccount } from "thirdweb/react";
const client = createThirdwebClient({
clientId: "YOUR_CLIENT_ID",
});
function App() {
const account = useActiveAccount();
console.log("Connected as", account?.address);
return <ConnectButton client={client} />;
}
```
For React Native Applications, you'll also need to install the `@thirdweb-dev/react-native-adapter` package and import it at app startup for polyfills.
#### For Backend Applications
```bash
npm install thirdweb
```
```typescript
import { createThirdwebClient, Engine } from "thirdweb";
const client = createThirdwebClient({
secretKey: "YOUR_SECRET_KEY",
});
const wallet = Engine.serverWallet({
client,
address: "0x...",
});
const transaction = transfer({
contract: getContract({
client,
address: "0x...", // token contract
chain: defineChain(1),
}),
to: "0x...", // recipient
amount: "0.01", // amount in tokens
});
await wallet.enqueueTransaction({
transaction,
});
```
## Adapters
#### [`@thirdweb-dev/react-native-adapter`](./packages/react-native-adapter/README.md)
Required polyfills and configuration for running the thirdweb SDK in React Native applications.
```bash
npm install @thirdweb-dev/react-native-adapter
```
#### [`@thirdweb-dev/wagmi-adapter`](./packages/wagmi-adapter/README.md)
Integration layer for using thirdweb's in-app wallets with wagmi.
```bash
npm install @thirdweb-dev/wagmi-adapter
```
## Type safe API wrappers
#### [`@thirdweb-dev/api`](./packages/api/README.md)
TypeScript SDK for thirdweb's API, combining all of thirdweb products.
```bash
npm install @thirdweb-dev/api
```
#### [`@thirdweb-dev/engine`](./packages/engine/README.md)
TypeScript SDK for Engine, thirdweb's backend onchain executor service.
```bash
npm install @thirdweb-dev/engine
```
#### [`@thirdweb-dev/insight`](./packages/insight/README.md)
TypeScript SDK for Insight, thirdweb's multichain indexer service.
```bash
npm install @thirdweb-dev/insight
```
#### [`@thirdweb-dev/vault-sdk`](./packages/vault-sdk/README.md)
SDK for interacting with Vault, thirdweb's secure key management service.
```bash
npm install @thirdweb-dev/vault-sdk
```
#### [`@thirdweb-dev/nebula`](./packages/nebula/README.md)
TypeScript SDK for Nebula, thirdweb's AI agent service.
```bash
npm install @thirdweb-dev/nebula
```
## Contributing
@@ -55,7 +168,8 @@ See our [open source page](https://thirdweb.com/open-source) for more informatio
## Additional Resources
- [Documentation](https://portal.thirdweb.com/typescript/v5)
- [Dashboard](https://thirdweb.com/login)
- [Documentation](https://portal.thirdweb.com/)
- [Templates](https://thirdweb.com/templates)
- [YouTube](https://www.youtube.com/c/thirdweb)
-61
View File
@@ -10,13 +10,6 @@ module.exports = {
"plugin:storybook/recommended",
],
overrides: [
// disable restricted imports in chakra
{
files: "src/chakra/**/*",
rules: {
"no-restricted-imports": ["off"],
},
},
// allow direct PostHog imports inside analytics helpers
{
files: "src/@/analytics/**/*",
@@ -91,60 +84,6 @@ module.exports = {
"error",
{
paths: [
{
// these are provided by chakra folder, so we don't want to import them from chakra directly
importNames: [
"Card",
"Button",
"Checkbox",
"Badge",
"Drawer",
"Heading",
"Text",
"FormLabel",
"FormHelperText",
"FormErrorMessage",
"MenuGroup",
"VStack",
"HStack",
"AspectRatio",
"useToast",
"useClipboard",
"Badge",
"Stack",
// also the types
"ButtonProps",
"BadgeProps",
"DrawerProps",
"HeadingProps",
"TextProps",
"FormLabelProps",
"HelpTextProps",
"MenuGroupProps",
"MenuItemProps",
"AspectRatioProps",
"BadgeProps",
"StackProps",
],
message:
'import component from "chakra" folder instead if you have to use chakra component, But use shadcn component otherwise',
name: "@chakra-ui/react",
},
{
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/layout`.",
name: "@chakra-ui/layout",
},
{
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/button`.",
name: "@chakra-ui/button",
},
{
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
name: "@chakra-ui/menu",
},
{
importNames: ["useRouter"],
message:
-5
View File
@@ -20,11 +20,6 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/nextjs"),
options: {},
},
refs: {
"@chakra-ui/react": {
disable: true,
},
},
staticDirs: ["../public"],
features: {
experimentalRSC: true,
-3
View File
@@ -115,9 +115,6 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
const baseNextConfig: NextConfig = {
compiler: {
emotion: true,
},
eslint: {
ignoreDuringBuilds: true,
},
+2 -14
View File
@@ -1,10 +1,5 @@
{
"dependencies": {
"@chakra-ui/react": "^2.8.2",
"@chakra-ui/styled-system": "^2.9.2",
"@chakra-ui/theme-tools": "^2.1.2",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.11",
@@ -34,14 +29,11 @@
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"abitype": "1.0.8",
"chakra-react-select": "^4.7.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"color": "5.0.0",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"framer-motion": "12.23.0",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
@@ -57,7 +49,6 @@
"posthog-js": "1.256.1",
"posthog-node": "^5.4.0",
"prettier": "3.6.2",
"qrcode": "^1.5.3",
"react": "19.1.0",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
@@ -66,6 +57,7 @@
"react-error-boundary": "6.0.0",
"react-hook-form": "7.55.0",
"react-markdown": "10.1.0",
"react-qrcode-logo": "^3.0.0",
"react-table": "^7.8.0",
"recharts": "2.15.3",
"remark-gfm": "4.0.1",
@@ -86,7 +78,6 @@
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "4.0.1",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "15.3.5",
@@ -99,7 +90,6 @@
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react-table": "^7.7.20",
@@ -129,12 +119,10 @@
"dev": "next dev --turbopack",
"fix": "biome check ./src --fix && eslint ./src --fix",
"format": "biome format ./src --write",
"gen:theme-typings": "chakra-cli tokens src/chakra/theme/index.ts",
"knip": "knip",
"lint": "biome check ./src && knip && eslint ./src",
"playwright": "playwright test",
"postbuild": "next-sitemap",
"postinstall": "pnpm run gen:theme-typings",
"preinstall": "npx only-allow pnpm",
"start": "next start",
"storybook": "storybook dev -p 6006",
@@ -142,4 +130,4 @@
"update-checkly": "npx checkly deploy"
},
"version": "3.0.0"
}
}
@@ -1,2 +0,0 @@
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description image animation_url external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description ipfs://ipfsHash/0 ipfs://ipfsHash/0 https://thirdweb.com #0098EE every row is a property
@@ -1,4 +0,0 @@
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
Token 1 Name,Token 1 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
Token 2 Name,Token 2 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description image animation_url external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description 0.png 0.mp4 https://thirdweb.com #0098EE every row is a property
3 Token 1 Name Token 1 Description 0.png 0.mp4 https://thirdweb.com #0098EE every row is a property
4 Token 2 Name Token 2 Description 0.png 0.mp4 https://thirdweb.com #0098EE every row is a property
@@ -1,2 +0,0 @@
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description https://thirdweb.com #0098EE every row is a property
@@ -1,3 +0,0 @@
address,maxClaimable
0x0000000000000000000000000000000000000000,2
0x000000000000000000000000000000000000dEaD,5
1 address maxClaimable
2 0x0000000000000000000000000000000000000000 2
3 0x000000000000000000000000000000000000dEaD 5
@@ -1,3 +0,0 @@
address,maxClaimable,price,currencyAddress
0x0000000000000000000000000000000000000000,2,0.1,0x0000000000000000000000000000000000000000
0x000000000000000000000000000000000000dEaD,5,2.5,0x0000000000000000000000000000000000000000
1 address maxClaimable price currencyAddress
2 0x0000000000000000000000000000000000000000 2 0.1 0x0000000000000000000000000000000000000000
3 0x000000000000000000000000000000000000dEaD 5 2.5 0x0000000000000000000000000000000000000000
@@ -1,3 +0,0 @@
address
0x0000000000000000000000000000000000000000
0x000000000000000000000000000000000000dEaD
1 address
2 0x0000000000000000000000000000000000000000
3 0x000000000000000000000000000000000000dEaD
+20 -5
View File
@@ -66,15 +66,20 @@ const projectRoute = "/team/:team_slug/:project_slug";
const projectPageRedirects = [
{
destination: `${projectRoute}/universal-bridge/:path*`,
destination: `${projectRoute}/payments/:path*`,
permanent: false,
source: `${projectRoute}/connect/pay/:path*`,
},
{
destination: `${projectRoute}/universal-bridge/:path*`,
destination: `${projectRoute}/payments/:path*`,
permanent: false,
source: `${projectRoute}/connect/universal-bridge/:path*`,
},
{
destination: `${projectRoute}/payments/:path*`,
permanent: false,
source: `${projectRoute}/universal-bridge/:path*`,
},
{
destination: `${projectRoute}/account-abstraction/:path*`,
permanent: false,
@@ -375,12 +380,22 @@ async function redirects() {
permanent: false,
source: "/template/:slug",
},
// redirect /connect/pay to /universal-bridge
// redirect /connect/pay to /payments
{
destination: "/universal-bridge",
permanent: false,
destination: "/payments",
permanent: true,
source: "/connect/pay",
},
{
destination: "/payments",
permanent: true,
source: "/universal-bridge",
},
{
destination: "/payments/:slug",
permanent: true,
source: "/universal-bridge/:slug",
},
// PREVIOUS CAMPAIGNS
{
destination: "/",
@@ -98,7 +98,7 @@ export async function getWalletNFTs(params: {
type OwnedNFTInsightResponse = {
name: string;
description: string;
image_url: string;
image_url?: string;
background_color: string;
external_url: string;
metadata_url: string;
@@ -186,7 +186,7 @@ async function getWalletNFTsFromInsight(params: {
description: nft.description,
external_url: nft.external_url,
image: isDev
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
? nft.image_url?.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.image_url,
name: nft.name,
uri: isDev
+15
View File
@@ -413,3 +413,18 @@ export function reportUpsellShown(properties: UpsellParams) {
export function reportUpsellClicked(properties: UpsellParams) {
posthog.capture("upsell clicked", properties);
}
// ----------------------------
// PAYMENTS
// ----------------------------
/**
* ### Why do we need to report this event?
* - To track conversions on payment overview page
*
* ### Who is responsible for this event?
* @samina
*/
export function reportPaymentCardClick(properties: { id: string }) {
posthog.capture("payment card clicked", properties);
}
+7 -1
View File
@@ -61,7 +61,7 @@ export async function createSupportTicket(params: {
try {
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
if (siwaUrl) {
await fetch(`${siwaUrl}/v1/chat/feedback`, {
const res = await fetch(`${siwaUrl}/v1/chat/feedback`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -73,6 +73,12 @@ export async function createSupportTicket(params: {
feedbackRating: ESCALATION_FEEDBACK_RATING,
}),
});
if (!res.ok) {
// Log error but don't fail the ticket creation
const errorMessage = await res.text();
console.error("Failed to escalate to SIWA feedback:", errorMessage);
}
}
} catch (error) {
// Log error but don't fail the ticket creation
@@ -1,4 +1,5 @@
"use server";
import type { Address } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
@@ -96,6 +97,139 @@ export async function deleteWebhook(props: {
return;
}
type PaymentLink = {
id: string;
link: string;
title: string;
imageUrl: string;
createdAt: string;
updatedAt: string;
destinationToken: {
chainId: number;
address: Address;
symbol: string;
name: string;
decimals: number;
iconUri: string;
};
receiver: Address;
amount: bigint;
};
export async function getPaymentLinks(props: {
clientId: string;
teamId: string;
}): Promise<Array<PaymentLink>> {
const authToken = await getAuthToken();
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "GET",
});
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
const json = (await res.json()) as {
data: Array<PaymentLink & { amount: string }>;
};
return json.data.map((link) => ({
id: link.id,
link: link.link,
title: link.title,
imageUrl: link.imageUrl,
createdAt: link.createdAt,
updatedAt: link.updatedAt,
destinationToken: {
chainId: link.destinationToken.chainId,
address: link.destinationToken.address,
symbol: link.destinationToken.symbol,
name: link.destinationToken.name,
decimals: link.destinationToken.decimals,
iconUri: link.destinationToken.iconUri,
},
receiver: link.receiver,
amount: BigInt(link.amount),
}));
}
export async function createPaymentLink(props: {
clientId: string;
teamId: string;
title: string;
imageUrl?: string;
intent: {
destinationChainId: number;
destinationTokenAddress: Address;
receiver: Address;
amount: bigint;
purchaseData?: unknown;
};
}) {
const authToken = await getAuthToken();
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
body: JSON.stringify({
title: props.title,
imageUrl: props.imageUrl,
intent: {
destinationChainId: props.intent.destinationChainId,
destinationTokenAddress: props.intent.destinationTokenAddress,
receiver: props.intent.receiver,
amount: props.intent.amount.toString(),
purchaseData: props.intent.purchaseData,
},
}),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "POST",
});
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
return;
}
export async function deletePaymentLink(props: {
clientId: string;
teamId: string;
paymentLinkId: string;
}) {
const authToken = await getAuthToken();
const res = await fetch(
`${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.clientId,
"x-team-id": props.teamId,
},
method: "DELETE",
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
return;
}
export type Fee = {
feeRecipient: string;
feeBps: number;
@@ -195,30 +329,28 @@ export type Payment = {
export async function getPayments(props: {
clientId: string;
teamId: string;
paymentLinkId?: string;
limit?: number;
offset?: number;
}) {
const authToken = await getAuthToken();
// Build URL with query parameters if provided
let url = `${UB_BASE_URL}/v1/developer/payments`;
const queryParams = new URLSearchParams();
const url = new URL(`${UB_BASE_URL}/v1/developer/payments`);
if (props.limit) {
queryParams.append("limit", props.limit.toString());
url.searchParams.append("limit", props.limit.toString());
}
if (props.offset) {
queryParams.append("offset", props.offset.toString());
url.searchParams.append("offset", props.offset.toString());
}
// Append query params to URL if any exist
const queryString = queryParams.toString();
if (queryString) {
url = `${url}?${queryString}`;
if (props.paymentLinkId) {
url.searchParams.append("paymentLinkId", props.paymentLinkId);
}
const res = await fetch(url, {
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
@@ -13,12 +13,18 @@ export type TokenMetadata = {
iconUri?: string;
};
export async function getUniversalBridgeTokens(props: { chainId?: number }) {
export async function getUniversalBridgeTokens(props: {
chainId?: number;
address?: string;
}) {
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
if (props.chainId) {
url.searchParams.append("chainId", String(props.chainId));
}
if (props.address) {
url.searchParams.append("tokenAddress", props.address);
}
url.searchParams.append("limit", "1000");
const res = await fetch(url.toString(), {
@@ -1,23 +1,18 @@
"use client";
import {
Alert,
AlertIcon,
Flex,
FormControl,
Input,
InputGroup,
InputRightElement,
Textarea,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "chakra/button";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { ChevronLeftIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
ArrowLeftIcon,
ArrowRightIcon,
EyeIcon,
EyeOffIcon,
HelpCircleIcon,
LockKeyholeIcon,
RefreshCcwIcon,
ShuffleIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721";
@@ -25,12 +20,14 @@ import type { NFTInput } from "thirdweb/utils";
import { z } from "zod";
import { FileInput } from "@/components/blocks/FileInput";
import { TransactionButton } from "@/components/tx-button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import type { ComponentWithChildren } from "@/types/component-with-children";
import { Button } from "@/components/ui/button";
import { Form, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { processInputData, shuffleData } from "@/utils/batch";
import { TabButtons } from "../ui/tabs";
import { BatchTable } from "./batch-table";
import { SelectOption } from "./lazy-mint-form/select-option";
import { UploadStep } from "./upload-step";
type DelayedSubmit = {
@@ -44,16 +41,6 @@ type InstantSubmit = {
type SubmitType = DelayedSubmit | InstantSubmit;
interface BatchLazyMintEVMProps {
nextTokenIdToMint: bigint;
canCreateDelayedRevealBatch: boolean;
onSubmit: (formData: SubmitType) => Promise<unknown>;
chainId: number;
client: ThirdwebClient;
}
type BatchLazyMintProps = BatchLazyMintEVMProps;
const BatchLazyMintFormSchema = z
.object({
confirmPassword: z
@@ -73,7 +60,7 @@ const BatchLazyMintFormSchema = z
name: z.string().min(1, "A name is required"),
})
.optional(),
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
revealType: z.literal("instant").or(z.literal("delayed")),
// shared logic
shuffle: z.boolean().default(false),
@@ -91,18 +78,21 @@ function useBatchLazyMintForm() {
return useForm<BatchLazyMintFormType>({
defaultValues: {
metadatas: [],
revealType: undefined,
revealType: "instant",
shuffle: false,
},
resolver: zodResolver(BatchLazyMintFormSchema),
});
}
export const BatchLazyMint: ComponentWithChildren<
BatchLazyMintProps & {
isLoggedIn: boolean;
}
> = (props) => {
export function BatchLazyMint(props: {
nextTokenIdToMint: bigint;
canCreateDelayedRevealBatch: boolean;
onSubmit: (formData: SubmitType) => Promise<unknown>;
chainId: number;
client: ThirdwebClient;
isLoggedIn: boolean;
}) {
const [step, setStep] = useState(0);
const form = useBatchLazyMintForm();
@@ -110,369 +100,415 @@ export const BatchLazyMint: ComponentWithChildren<
const nftMetadatas = form.watch("metadatas");
const hasError = !!form.getFieldState("metadatas", form.formState).error;
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles) => {
try {
await processInputData(acceptedFiles, (data) =>
form.setValue("metadatas", data),
);
} catch {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
if (nftMetadatas.length === 0) {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
},
});
const paginationPortalRef = useRef<HTMLDivElement>(null);
return (
<form
className="mt-4 flex w-full flex-col"
onSubmit={form.handleSubmit((data) => {
// first shuffle
const shuffledMetadatas = data.shuffle
? shuffleData(data.metadatas)
: data.metadatas;
<Form {...form}>
<form
className="mt-4 flex w-full flex-col"
onSubmit={form.handleSubmit(async (data) => {
// first shuffle
const shuffledMetadatas = data.shuffle
? shuffleData(data.metadatas)
: data.metadatas;
// check submit is instant
if (data.revealType === "instant") {
return props.onSubmit({
data: { metadatas: shuffledMetadatas },
revealType: "instant",
});
}
// validate password
if (!data.password) {
form.setError("password", {
message: "A password is required for delayed reveal.",
type: "validate",
});
return;
}
// validate placeholder
if (!data.placeHolder?.name) {
form.setError("placeHolder.name", {
message: "A name is required for delayed reveal.",
type: "validate",
});
}
// submit
return props.onSubmit({
data: {
metadata: shuffledMetadatas,
password: data.password,
placeholderMetadata: {
description: data.placeHolder?.description,
image: data.placeHolder?.image,
name: data.placeHolder?.name,
// check submit is instant
if (data.revealType === "instant") {
return props.onSubmit({
data: { metadatas: shuffledMetadatas },
revealType: "instant",
});
}
// validate password
if (!data.password) {
form.setError("password", {
message: "A password is required for delayed reveal.",
type: "validate",
});
return;
}
// validate placeholder
if (!data.placeHolder?.name) {
form.setError("placeHolder.name", {
message: "A name is required for delayed reveal.",
type: "validate",
});
}
// submit
await props.onSubmit({
data: {
metadata: shuffledMetadatas,
password: data.password,
placeholderMetadata: {
description: data.placeHolder?.description,
image: data.placeHolder?.image,
name: data.placeHolder?.name,
},
},
},
revealType: "delayed",
});
})}
>
{step === 0 ? (
<div className="flex flex-col gap-6">
{nftMetadatas.length > 0 ? (
<>
<BatchTable
client={props.client}
data={nftMetadatas}
nextTokenIdToMint={props.nextTokenIdToMint}
portalRef={paginationPortalRef}
/>
<div className="border-border border-t">
<div className="flex flex-col items-center justify-between p-0 md:flex-row md:p-4">
<div ref={paginationPortalRef} />
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
<Button
borderRadius="md"
isDisabled={!hasError}
onClick={() => {
form.reset();
}}
w={{ base: "100%", md: "auto" }}
>
Reset
</Button>
<Button
borderRadius="md"
colorScheme="primary"
onClick={() => setStep(1)}
w={{ base: "100%", md: "auto" }}
>
Next
</Button>
</div>
revealType: "delayed",
});
})}
>
{step === 0 ? (
<div>
{nftMetadatas.length > 0 ? (
<div className="pb-10">
<h2 className="text-2xl font-semibold tracking-tight mb-4">
Upload NFTs
</h2>
<BatchTable
client={props.client}
data={nftMetadatas}
nextTokenIdToMint={props.nextTokenIdToMint}
/>
<div className="mt-5 flex justify-end gap-4">
<Button
className="w-full md:w-auto gap-2"
disabled={!hasError}
onClick={() => {
form.reset();
}}
variant="outline"
>
<RefreshCcwIcon className="size-4" />
Reset
</Button>
<Button
className="w-full md:w-auto gap-2"
onClick={() => setStep(1)}
>
Next <ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
</>
) : (
<UploadStep
getInputProps={getInputProps}
getRootProps={getRootProps}
hasFailed={hasError}
isDragActive={isDragActive}
/>
)}
</div>
) : (
<>
<Flex align="center" justify="space-between" mb={2} py={4} w="100%">
<div className="flex flex-row items-center gap-2">
) : (
<UploadStep
hasFailed={hasError}
reset={() => {
form.reset();
}}
onDrop={async (acceptedFiles) => {
try {
await processInputData(acceptedFiles, (data) =>
form.setValue("metadatas", data),
);
} catch {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
if (nftMetadatas.length === 0) {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
}}
/>
)}
</div>
) : (
<div className="container px-0 max-w-4xl pb-14">
<div className="flex mb-3">
<Button
className="text-muted-foreground"
className="gap-2 -translate-x-3 text-muted-foreground"
size="sm"
onClick={() => setStep(0)}
variant="ghost"
>
<ChevronLeftIcon className="size-5 cursor-pointer" />
<ArrowLeftIcon className="size-4" />
Back
</Button>
<Heading className="my-auto" size="title.md">
When will you reveal your NFTs?
</Heading>
</div>
</Flex>
<SelectReveal
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
client={props.client}
form={form}
/>
{form.watch("revealType") && (
<>
<CheckboxWithLabel>
<Checkbox
checked={form.watch("shuffle")}
className="mt-3"
onCheckedChange={(val) => form.setValue("shuffle", !!val)}
/>
<div className="flex flex-col items-center gap-1 md:flex-row">
<p>Shuffle the order of the NFTs before uploading.</p>
<em>This is an off-chain operation and is not provable.</em>
</div>
</CheckboxWithLabel>
<div className="w-full md:w-[61%]">
<TransactionButton
className="mt-4 w-full"
client={props.client}
disabled={!nftMetadatas.length}
isLoggedIn={props.isLoggedIn}
isPending={form.formState.isSubmitting}
transactionCount={1}
txChainID={props.chainId}
type="submit"
>
{form.formState.isSubmitting
? `Uploading ${nftMetadatas.length} NFTs`
: `Upload ${nftMetadatas.length} NFTs`}
</TransactionButton>
{props.children}
</div>
<Text mt={2} size="body.sm">
<UnderlineLink
className="text-primary-500"
href="https://support.thirdweb.com/dashboard/n5evQ4EfEjEifczEQaZ1hL/batch-upload-troubleshooting/5WMQFqfaUTU1C8NM8FtJ2X"
rel="noopener noreferrer"
target="_blank"
>
Experiencing issues uploading your files?
</UnderlineLink>
</Text>
</>
)}
</>
)}
</form>
);
};
interface SelectRevealProps {
<h2 className="text-2xl font-semibold tracking-tight mb-4">
Upload NFTs
</h2>
<SelectReveal
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
client={props.client}
form={form}
/>
<div className="h-4" />
<div className="space-y-6">
{form.watch("revealType") === "delayed" && (
<DelayedRevealConfiguration client={props.client} form={form} />
)}
{/* shuffle */}
<ShuffleNFTsCard
isShuffleEnabled={form.watch("shuffle")}
onShuffleChange={(val) => form.setValue("shuffle", val)}
/>
<div className="space-y-3">
<div className="flex justify-end">
<TransactionButton
client={props.client}
disabled={!nftMetadatas.length}
isLoggedIn={props.isLoggedIn}
isPending={form.formState.isSubmitting}
transactionCount={1}
txChainID={props.chainId}
type="submit"
>
{form.formState.isSubmitting
? `Uploading ${nftMetadatas.length} NFTs`
: `Upload ${nftMetadatas.length} NFTs`}
</TransactionButton>
</div>
<div className="flex justify-end">
<Link
className="text-sm flex items-center gap-2 text-muted-foreground hover:text-foreground"
href="https://portal.thirdweb.com/knowledge-base/troubleshoot/contracts/batch-upload"
rel="noopener noreferrer"
target="_blank"
>
<HelpCircleIcon className="size-4" />
Experiencing issues uploading your files?
</Link>
</div>
</div>
</div>
</div>
)}
</form>
</Form>
);
}
function ShuffleNFTsCard(props: {
isShuffleEnabled: boolean;
onShuffleChange: (val: boolean) => void;
}) {
return (
<div className="px-4 lg:px-6 py-8 border rounded-lg bg-card relative">
<div className="flex mb-3">
<div className="p-2 rounded-full border bg-card">
<ShuffleIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
Shuffle NFTs
</h3>
<p className="text-sm text-muted-foreground">
Shuffle the order of the NFTs before uploading. This is an off-chain
operation and is not provable
</p>
<Switch
checked={props.isShuffleEnabled}
onCheckedChange={(val) => props.onShuffleChange(!!val)}
className="absolute right-4 top-4 lg:right-6 lg:top-8"
/>
</div>
);
}
function SelectReveal(props: {
form: ReturnType<typeof useBatchLazyMintForm>;
canCreateDelayedRevealBatch: boolean;
client: ThirdwebClient;
}) {
const { form, canCreateDelayedRevealBatch } = props;
return (
<div>
{canCreateDelayedRevealBatch && (
<TabButtons
containerClassName="mb-8"
tabClassName="!text-sm"
tabs={[
{
name: "Reveal upon mint",
onClick: () => {
form.setValue("revealType", "instant");
// reset all fields related to delayed reveal
form.resetField("password");
form.resetField("confirmPassword");
form.resetField("placeHolder");
},
isActive: form.watch("revealType") === "instant",
},
{
name: "Delayed Reveal",
onClick: () => form.setValue("revealType", "delayed"),
isActive: form.watch("revealType") === "delayed",
},
]}
/>
)}
{form.watch("revealType") === "instant" && (
<div>
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
Reveal upon mint
</h2>
<p className="text-muted-foreground text-sm">
Collectors will immediately see the final NFT when they complete the
minting
</p>
</div>
)}
{form.watch("revealType") === "delayed" && (
<div>
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
Delayed Reveal
</h2>
<p className="text-muted-foreground text-sm">
Collectors will mint your placeholder image, then you reveal at a
later time
</p>
</div>
)}
</div>
);
}
const SelectReveal: React.FC<SelectRevealProps> = ({
form,
client,
canCreateDelayedRevealBatch,
}) => {
function DelayedRevealConfiguration(props: {
form: ReturnType<typeof useBatchLazyMintForm>;
client: ThirdwebClient;
}) {
const { form, client } = props;
const [show, setShow] = useState(false);
const imageUrl = form.watch("placeHolder.image");
return (
<Flex flexDir="column">
<Flex
flexDir={{ base: "column", md: "row" }}
gap={{ base: 3, md: 6 }}
mb={6}
>
<SelectOption
description="Collectors will immediately see the final NFT when they complete the minting"
isActive={form.watch("revealType") === "instant"}
name="Reveal upon mint"
onClick={() => form.setValue("revealType", "instant")}
/>
<SelectOption
description="Collectors will mint your placeholder image, then you reveal at a later time"
disabled={!canCreateDelayedRevealBatch}
disabledText="This contract doesn't implement Delayed Reveal"
isActive={form.watch("revealType") === "delayed"}
name="Delayed Reveal"
onClick={() => form.setValue("revealType", "delayed")}
/>
</Flex>
<div className="flex flex-col gap-3">
{form.watch("revealType") === "delayed" && (
<>
<Heading size="title.sm">Let&apos;s set a password</Heading>
<Alert borderRadius="lg" status="warning">
<AlertIcon />
You&apos;ll need this password to reveal your NFTs. Please save it
somewhere safe.
</Alert>
<div className="space-y-6">
{/* password */}
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
<div className="flex mb-3">
<div className="p-2 rounded-full border bg-card">
<LockKeyholeIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
Set password
</h3>
<p className="text-sm text-muted-foreground mb-4">
You'll need this password to reveal your NFTs. Please save it
somewhere safe
</p>
<Flex
flexDir={{ base: "column", md: "row" }}
gap={{ base: 4, md: 0 }}
>
<FormControl
isInvalid={
!!form.getFieldState("password", form.formState).error
}
isRequired
mr={4}
<div className="space-y-5">
{/* select password */}
<FormItem className="space-y-1">
<FormLabel>Password</FormLabel>
<div className="relative max-w-md">
<Input
{...form.register("password")}
className="pr-10"
type={show ? "text" : "password"}
/>
<Button
variant="ghost"
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 h-auto text-muted-foreground"
onClick={() => setShow(!show)}
>
<FormLabel>Password</FormLabel>
<InputGroup>
<Input
{...form.register("password")}
placeholder="Choose password"
type={show ? "text" : "password"}
/>
<InputRightElement cursor="pointer">
{show ? (
<EyeIcon
className="size-3"
onClick={() => setShow(!show)}
/>
) : (
<EyeOffIcon
className="size-3"
onClick={() => setShow(!show)}
/>
)}
</InputRightElement>
</InputGroup>
<FormErrorMessage>
{
form.getFieldState("password", form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
<FormControl
isInvalid={
!!form.getFieldState("confirmPassword", form.formState).error
}
isRequired
>
<FormLabel>Confirm password</FormLabel>
<Input
{...form.register("confirmPassword")}
placeholder="Confirm password"
type="password"
/>
<FormErrorMessage>
{
form.getFieldState("confirmPassword", form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
</Flex>
<div className="flex flex-col gap-5">
<Heading size="title.sm">Placeholder</Heading>
<FormControl
isInvalid={
!!form.getFieldState("placeHolder.image", form.formState)
.error
}
>
<FormLabel>Image</FormLabel>
<div className="w-auto md:w-[350px]">
<FileInput
accept={{ "image/*": [] }}
className="rounded border border-border transition-all duration-200"
client={client}
setValue={(file) =>
form.setValue("placeHolder.image", file)
}
showUploadButton
value={imageUrl}
/>
</div>
<FormHelperText>
You can optionally upload an image as the placeholder.
</FormHelperText>
<FormErrorMessage>
{
form.getFieldState("placeHolder.image", form.formState)
.error?.message
}
</FormErrorMessage>
</FormControl>
<FormControl
isInvalid={
!!form.getFieldState("placeHolder.name", form.formState).error
}
isRequired
>
<FormLabel>Name</FormLabel>
<Input
{...form.register("placeHolder.name")}
placeholder="eg. My NFT (Coming soon)"
/>
<FormErrorMessage>
{
form.getFieldState("placeHolder.name", form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
<FormControl
isInvalid={
!!form.getFieldState("placeHolder.name", form.formState).error
}
>
<FormLabel>Description</FormLabel>
<Textarea
{...form.register("placeHolder.description")}
placeholder="eg. Reveal on July 15th!"
/>
<FormErrorMessage>
{
form.getFieldState(
"placeHolder.description",
form.formState,
).error?.message
}
</FormErrorMessage>
</FormControl>
{show ? (
<EyeIcon className="size-4" />
) : (
<EyeOffIcon className="size-4" />
)}
</Button>
</div>
</>
)}
<FormMessage>
{form.getFieldState("password", form.formState).error?.message}
</FormMessage>
</FormItem>
{/* confirm password */}
<FormItem className="space-y-1">
<FormLabel>Confirm password</FormLabel>
<div className="relative max-w-md">
<Input
{...form.register("confirmPassword")}
className="pr-10"
type="password"
/>
</div>
<FormMessage>
{
form.getFieldState("confirmPassword", form.formState).error
?.message
}
</FormMessage>
</FormItem>
</div>
</div>
</Flex>
{/* placeholder */}
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
<div className="flex mb-3">
<div className="p-2 rounded-full border bg-card">
<EyeIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-semibold text-lg tracking-tight mb-0.5">
Set placeholder metadata
</h3>
<p className="text-sm text-muted-foreground mb-4">
The placeholder metadata will be displayed for all NFTs until you
reveal your NFTs
</p>
<div className="flex flex-col lg:flex-row gap-5">
<FormItem className="w-[200px]">
<FormLabel>Image</FormLabel>
<FileInput
accept={{ "image/*": [] }}
className="rounded border bg-background transition-all duration-200 "
client={client}
setValue={(file) => form.setValue("placeHolder.image", file)}
value={imageUrl}
/>
<FormMessage>
{
form.getFieldState("placeHolder.image", form.formState).error
?.message
}
</FormMessage>
</FormItem>
<div className="grow space-y-5 flex flex-col">
<FormItem>
<FormLabel>Name</FormLabel>
<Input
className="max-w-sm"
{...form.register("placeHolder.name")}
placeholder="My NFT (Coming soon)"
/>
<FormMessage>
{
form.getFieldState("placeHolder.name", form.formState).error
?.message
}
</FormMessage>
</FormItem>
<FormItem className="grow flex flex-col">
<FormLabel>Description</FormLabel>
<Textarea
{...form.register("placeHolder.description")}
className="grow"
placeholder="Reveal on July 15th!"
/>
<FormMessage>
{
form.getFieldState("placeHolder.description", form.formState)
.error?.message
}
</FormMessage>
</FormItem>
</div>
</div>
</div>
</div>
);
};
}
@@ -1,237 +1,183 @@
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
import {
Flex,
IconButton,
Portal,
Select,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { useMemo } from "react";
import { type Column, usePagination, useTable } from "react-table";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { NFTInput } from "thirdweb/utils";
import { FilePreview } from "@/components/blocks/file-preview";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { CodeClient } from "@/components/ui/code/code.client";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
interface BatchTableProps {
type BatchTableProps = {
data: NFTInput[];
portalRef: React.RefObject<HTMLDivElement | null>;
nextTokenIdToMint?: bigint;
client: ThirdwebClient;
}
};
export const BatchTable: React.FC<BatchTableProps> = ({
export function BatchTable({
data,
portalRef,
nextTokenIdToMint,
client,
}) => {
const columns = useMemo(() => {
let cols: Column<NFTInput>[] = [];
if (nextTokenIdToMint !== undefined) {
cols = cols.concat({
accessor: (_row, index) => String(nextTokenIdToMint + BigInt(index)),
Header: "Token ID",
});
}
}: BatchTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
cols = cols.concat([
{
accessor: (row) => row.image,
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
<FilePreview
className="size-24 shrink-0 rounded-lg object-contain"
client={client}
srcOrFile={value}
/>
),
Header: "Image",
},
{
accessor: (row) => row.animation_url,
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
<FilePreview
className="size-24 shrink-0 rounded-lg"
client={client}
srcOrFile={value}
/>
),
Header: "Animation Url",
},
{ accessor: (row) => row.name, Header: "Name" },
{
accessor: (row) => (
<ToolTipLabel label={row.description}>
<p className="line-clamp-6 whitespace-pre-wrap">
{row.description}
</p>
</ToolTipLabel>
),
Header: "Description",
},
{
accessor: (row) => row.attributes || row.properties,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
Cell: ({ cell }: { cell: any }) =>
cell.value ? (
<CodeClient
code={JSON.stringify(cell.value || {}, null, 2)}
lang="json"
scrollableClassName="max-w-[300px]"
/>
) : null,
Header: "Attributes",
},
{ accessor: (row) => row.external_url, Header: "External URL" },
{ accessor: (row) => row.background_color, Header: "Background Color" },
]);
return cols;
}, [nextTokenIdToMint, client]);
const totalPages = Math.ceil(data.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentData = data.slice(startIndex, endIndex);
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
// Instead of using 'rows', we'll use page,
page,
// which has only the rows for the active page
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// The rest of these things are super handy, too ;)
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable(
{
columns,
data,
initialState: {
pageIndex: 0,
pageSize: 50,
},
},
// will be fixed with @tanstack/react-table v8
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
);
const showTokenId = nextTokenIdToMint !== undefined;
const showExternalUrl = data.some((row) => row.external_url);
const showBackgroundColor = data.some((row) => row.background_color);
const showAnimationUrl = data.some((row) => row.animation_url);
const showDescription = data.some((row) => row.description);
const showAttributes = data.some((row) => row.attributes || row.properties);
const showPagination = totalPages > 1;
// Render the UI for your table
return (
<Flex flexGrow={1} overflow="auto">
<TableContainer className="w-full" maxW="100%">
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...headerGroup.getHeaderGroupProps()} key={index}>
{headerGroup.headers.map((column, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Th {...column.getHeaderProps()} border="none" key={i}>
<p className="text-muted-foreground">
{column.render("Header")}
</p>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody {...getTableBodyProps()}>
{page.map((row, rowIndex) => {
prepareRow(row);
<div className="rounded-lg border bg-card">
<TableContainer className="border-0">
<Table>
<TableHeader>
<TableRow>
{showTokenId && <TableHead>Token ID</TableHead>}
<TableHead>Image</TableHead>
{showAnimationUrl && <TableHead>Animation Url</TableHead>}
<TableHead>Name</TableHead>
{showDescription && (
<TableHead className="min-w-[300px]">Description</TableHead>
)}
{showAttributes && <TableHead>Attributes</TableHead>}
{showExternalUrl && <TableHead>External URL</TableHead>}
{showBackgroundColor && <TableHead>Background Color</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{currentData.map((row, rowIndex) => {
const actualIndex = startIndex + rowIndex;
return (
<Tr
{...row.getRowProps()}
_last={{ borderBottomWidth: 0 }}
borderBottomWidth={1}
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={rowIndex}
<TableRow
className="border-b last:border-b-0"
key={actualIndex}
>
{row.cells.map((cell, cellIndex) => {
return (
<Td
{...cell.getCellProps()}
borderBottomWidth="inherit"
borderColor="borderColor"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
);
})}
</Tr>
{/* Token ID */}
{showTokenId && (
<TableCell>
{String(nextTokenIdToMint + BigInt(actualIndex))}
</TableCell>
)}
{/* Image */}
<TableCell className="min-w-36">
<FilePreview
className="size-36 shrink-0 rounded-lg object-contain"
client={client}
srcOrFile={
typeof row.image === "string" ||
row.image instanceof File
? row.image
: undefined
}
/>
</TableCell>
{/* Animation Url */}
{showAnimationUrl && (
<TableCell className="min-w-36">
<FilePreview
className="size-36 shrink-0 rounded-lg"
client={client}
srcOrFile={
typeof row.animation_url === "string" ||
row.animation_url instanceof File
? row.animation_url
: undefined
}
/>
</TableCell>
)}
{/* Name */}
<TableCell>{row.name}</TableCell>
{/* Description */}
{showDescription && (
<TableCell className="max-w-xs">
<p className="whitespace-pre-wrap">{row.description}</p>
</TableCell>
)}
{/* Attributes */}
{showAttributes && (
<TableCell>
{row.attributes || row.properties ? (
<CodeClient
code={JSON.stringify(
row.attributes || row.properties || {},
null,
2,
)}
lang="json"
scrollableClassName="max-w-[300px] max-h-[400px]"
className="bg-background [&>*]:text-xs"
/>
) : null}
</TableCell>
)}
{/* External URL */}
{showExternalUrl && (
<TableCell>
{typeof row.external_url === "string" ? (
<ToolTipLabel label={row.external_url}>
<span>
{row.external_url.slice(0, 20) +
(row.external_url.length > 20 ? "..." : "")}
</span>
</ToolTipLabel>
) : row.external_url instanceof File ? (
<FilePreview
client={client}
srcOrFile={row.external_url}
/>
) : null}
</TableCell>
)}
{/* Background Color */}
{showBackgroundColor && (
<TableCell>{row.background_color}</TableCell>
)}
</TableRow>
);
})}
</Tbody>
</TableBody>
</Table>
</TableContainer>
<Portal containerRef={portalRef}>
<div className="flex w-full items-center justify-center">
<div className="flex flex-row items-center gap-2">
<IconButton
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
isDisabled={!canPreviousPage}
onClick={() => gotoPage(0)}
/>
<IconButton
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
isDisabled={!canPreviousPage}
onClick={() => previousPage()}
/>
<p className="whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
isDisabled={!canNextPage}
onClick={() => nextPage()}
/>
<IconButton
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
isDisabled={!canNextPage}
onClick={() => gotoPage(pageCount - 1)}
/>
<Select
onChange={(e) => {
setPageSize(Number.parseInt(e.target.value as string, 10));
}}
value={pageSize}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</Select>
</div>
{showPagination && (
<div className="border-t py-5">
<PaginationButtons
activePage={currentPage}
totalPages={totalPages}
onPageClick={handlePageChange}
/>
</div>
</Portal>
</Flex>
)}
</div>
);
};
}
@@ -42,18 +42,18 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
disabled
? "pointer-events-none cursor-not-allowed bg-muted"
: "cursor-pointer",
isActive && "border-primary",
isActive && "bg-muted/50 border-active-border",
className,
)}
onClick={onClick}
{...divProps}
>
<div className="flex flex-row justify-between">
<div className="flex flex-row items-start">
<div className="mt-0.5 mr-2.5 flex h-4 w-4 items-center justify-center rounded-full border border-inverted text-inverted">
<div className="flex flex-row items-start gap-4">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-inverted text-inverted">
{isActive && <div className="h-2 w-2 rounded-full bg-inverted" />}
</div>
<div className="ml-4 flex flex-col gap-2 self-start">
<div>
<h4
className={cn(
"font-semibold text-sm",
@@ -62,7 +62,11 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
>
{name}
</h4>
{description && <p className="mt-1 text-sm">{description}</p>}
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
{infoText && (
@@ -1,137 +1,299 @@
import { UploadIcon } from "lucide-react";
import { useState } from "react";
import { InlineCode } from "@/components/ui/inline-code";
import { UnorderedList } from "@/components/ui/List/List";
import { cn } from "@/lib/utils";
import { TabButtons } from "@/components/ui/tabs";
import { DownloadableCode } from "../blocks/code/downloadable-code";
import { DropZone } from "../blocks/drop-zone/drop-zone";
interface UploadStepProps {
// biome-ignore lint/suspicious/noExplicitAny: FIXME
getRootProps: any;
// biome-ignore lint/suspicious/noExplicitAny: FIXME
getInputProps: any;
hasFailed: boolean;
isDragActive: boolean;
onDrop: (acceptedFiles: File[]) => void;
reset: () => void;
}
export const UploadStep: React.FC<UploadStepProps> = ({
getRootProps,
hasFailed,
isDragActive,
getInputProps,
}) => {
export function UploadStep(props: UploadStepProps) {
const [tab, setTab] = useState<"csv" | "json">("csv");
return (
<div className="flex flex-col items-center gap-8 md:flex-row">
<div className="relative aspect-square w-full md:w-1/2">
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-blue-500",
hasFailed ? "bg-red-200" : "bg-background",
)}
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="m-auto flex flex-col p-6">
<UploadIcon
className={cn(
"mx-auto mb-2 size-8",
hasFailed ? "text-red-500" : "text-gray-600",
)}
<div className="container px-0 max-w-3xl">
<h2 className="text-2xl font-semibold tracking-tight mb-4">
Upload NFTs
</h2>
<DropZone
className="w-full py-24"
onDrop={props.onDrop}
isError={props.hasFailed}
title={props.hasFailed ? "Invalid files" : "Upload files"}
description={
props.hasFailed
? "Make sure the uploaded files follow the requirements mentioned below"
: "Drag and drop the files or folder"
}
resetButton={{
label: "Reset",
onClick: props.reset,
}}
accept={undefined}
/>
<div className="h-8" />
<div className="w-full">
<TabButtons
tabs={[
{
isActive: tab === "csv",
name: "CSV",
onClick: () => setTab("csv"),
},
{
isActive: tab === "json",
name: "JSON",
onClick: () => setTab("json"),
},
]}
/>
<div className="h-6" />
<div className="space-y-8">
<Section title="Uploading Files">
<p>
Make sure to drag and drop the {tab === "csv" ? "CSV" : "JSON"}{" "}
and the assets at the same time or upload a folder with all the
files in it.
</p>
<p>
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension].
It doesn't matter at what number you begin. Example: 131.png,
132.png etc.
</p>
<p>
Images and other file types can be used in combination. They both
have to follow the asset naming convention above. Example: 0.png
and 0.mp4, 1.png and 1.glb etc.
</p>
</Section>
<Section title={`${tab === "csv" ? "CSV" : "JSON"} File Format`}>
<p>
The {tab === "csv" ? "CSV" : "JSON"} file must have a{" "}
<InlineCode code="name" /> {tab === "csv" ? "column" : "property"}
, which defines the name of the NFT
</p>
<p>
Other optional {tab === "csv" ? "columns" : "properties"} are{" "}
<InlineCode code="description" />,{" "}
<InlineCode code="background_color" />,{" "}
<InlineCode code="external_url" />,{" "}
<InlineCode code="animation_url" />,{" "}
{tab === "json" && (
<>
,
<InlineCode code="attributes" />
</>
)}{" "}
and <InlineCode code="supply" />
{". "}
{tab === "csv" &&
"All other columns will be treated as Attributes. For example: See 'eyes', 'nose' below."}
</p>
<DownloadableCode
code={tab === "csv" ? csv_example_basic : json_example_basic}
fileNameWithExtension={
tab === "csv" ? "example.csv" : "example.json"
}
lang={tab === "csv" ? "csv" : "json"}
/>
{isDragActive ? (
<p className="text-center text-muted-foreground">
Drop the files here
</p>
) : (
<p
className={cn(
"text-center leading-[1.2]",
hasFailed ? "text-red-500" : "text-muted-foreground",
)}
>
{hasFailed
? `No valid CSV or JSON file found. Please make sure your NFT metadata includes at least a "name" field and try again.`
: "Drag & Drop files or folders here, or click to select files"}
</p>
)}
</div>
</Section>
<Section title="Map your assets to NFTs">
<p>
If you want to map your media files to your NFTs, you can add the
name of your files to the <InlineCode code="image" /> and{" "}
<InlineCode code="animation_url" />{" "}
{tab === "csv" ? "columns" : "properties"}.{" "}
<DownloadableCode
code={
tab === "csv"
? csv_with_image_number_example
: json_with_image_number_example
}
fileNameWithExtension={
tab === "csv"
? "example-with-maps.csv"
: "example-with-maps.json"
}
lang={tab === "csv" ? "csv" : "json"}
/>
</p>
</Section>
<Section title="Using asset links instead of uploading assets">
<p>
When uploading files, assets will be uploaded and pinned to IPFS
automatically. If you already have the files uploaded elsewhere,
you can specify the IPFS or HTTP(s) links in the{" "}
<InlineCode code="image" /> and/or{" "}
<InlineCode code="animation_url" />{" "}
{tab === "csv" ? "columns" : "properties"}. instead of uploading
the assets and just upload a single{" "}
{tab === "csv" ? "CSV" : "JSON"} file.
<DownloadableCode
code={
tab === "csv"
? csv_with_image_link_example
: json_with_image_link_example
}
fileNameWithExtension={
tab === "csv"
? "example-with-ipfs.csv"
: "example-with-ipfs.json"
}
lang={tab === "csv" ? "csv" : "json"}
/>
</p>
</Section>
</div>
</div>
<div className="flex w-full flex-col gap-2 md:w-1/2">
<p className="text-lg">Requirements</p>
<UnorderedList>
<li>
Files <em>must</em> contain one .csv or .json file with metadata. -{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example.csv"
>
Download example.csv
</a>
.{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example.json"
>
Download example.json
</a>
.
</li>
<li>
The csv <em>must</em> have a <InlineCode code="name" /> column,
which defines the name of the NFT.
</li>
<li>
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension]. It
doesn&apos;t matter at what number you begin. (Example:{" "}
<InlineCode code="131.png" />, <InlineCode code="132.png" />
).
</li>
<li>
Make sure to drag and drop the CSV/JSON and the images{" "}
<strong>at the same time</strong>.
</li>
</UnorderedList>
<p className="mt-4 text-lg">Options</p>
<UnorderedList>
<li>
Images and other file types can be used in combination.
<br />
<small>
They both have to follow the asset naming convention above.
(Example: <InlineCode code="0.png" /> and{" "}
<InlineCode code="0.mp4" />,<InlineCode code="1.png" /> and{" "}
<InlineCode code="1.glb" />, etc.)
</small>
</li>
<li>
When uploading files, we will upload them and pin them to IPFS
automatically for you. If you already have the files uploaded, you
can add an <InlineCode code="image" /> and/or
<InlineCode code="animation_url" /> column and add the IPFS hashes
there.{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example-with-ipfs.csv"
>
Download example.csv
</a>
</li>
<li>
If you want to make your media files map to your NFTs, you can add
the name of your files to the <InlineCode code="image" /> and
<InlineCode code="animation_url" /> column.{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example-with-maps.csv"
>
Download example.csv
</a>
</li>
</UnorderedList>
</div>
</div>
);
};
}
function Section(props: { title: string; children: React.ReactNode }) {
return (
<section>
<h2 className="mb-1 font-medium text-base text-foreground">
{props.title}
</h2>
<div className="space-y-1.5 text-muted-foreground leading-relaxed">
{props.children}
</div>
</section>
);
}
const csv_example_basic = `\
name,description,background_color,eyes,nose
Token 0 Name,Token 0 Description,#0098EE,red,green
Token 1 Name,Token 1 Description,#0098EE,blue,yellow`;
const json_example_basic = `\
[
{
"name": "Token 0 Name",
"description": "Token 0 Description",
"background_color": "#0098EE",
"attributes": [
{
"trait_type": "eyes",
"value": "red"
},
{
"trait_type": "nose",
"value": "green"
}
]
},
{
"name": "Token 1 Name",
"description": "Token 1 Description",
"background_color": "#0098EE",
"attributes": [
{
"trait_type": "eyes",
"value": "blue"
},
{
"trait_type": "nose",
"value": "yellow"
}
]
}
]`;
const csv_with_image_link_example = `\
name,description,image,animation_url,background_color,eyes,nose
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,#0098EE,red,green
Token 1 Name,Token 1 Description,ipfs://ipfsHash/1,ipfs://ipfsHash/1,#0098EE,blue,yellow`;
const json_with_image_link_example = `\
[
{
"name": "Token 0 Name",
"description": "Token 0 Description",
"image": "ipfs://ipfsHash/0",
"animation_url": "ipfs://ipfsHash/0",
"attributes": [
{
"trait_type": "eyes",
"value": "red"
},
{
"trait_type": "nose",
"value": "green"
}
]
},
{
"name": "Token 1 Name",
"description": "Token 1 Description",
"image": "ipfs://ipfsHash/1",
"animation_url": "ipfs://ipfsHash/1",
"attributes": [
{
"trait_type": "eyes",
"value": "blue"
},
{
"trait_type": "nose",
"value": "yellow"
}
]
}
]`;
const csv_with_image_number_example = `\
name,description,image,animation_url,background_color,eyes,nose
Token 0 Name,Token 0 Description,0.png,0.mp4,#0098EE,red,green
Token 1 Name,Token 1 Description,1.png,1.mp4,#0098EE,blue,yellow`;
const json_with_image_number_example = `\
[
{
"name": "Token 0 Name",
"description": "Token 0 Description",
"image": "0.png",
"animation_url": "0.mp4",
"attributes": [
{
"trait_type": "eyes",
"value": "red"
},
{
"trait_type": "nose",
"value": "green"
}
]
},
{
"name": "Token 1 Name",
"description": "Token 1 Description",
"image": "1.png",
"animation_url": "1.mp4",
"attributes": [
{
"trait_type": "eyes",
"value": "blue"
},
{
"trait_type": "nose",
"value": "yellow"
}
]
}
]`;
@@ -1,3 +1,4 @@
import { ArrowLeftIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { isAddress, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb";
import { Button } from "@/components/ui/button";
@@ -109,10 +110,10 @@ export function CurrencySelector({
className="rounded-r-none rounded-l-md"
onClick={() => setIsAddingCurrency(false)}
>
&lt;-
<ArrowLeftIcon className="size-4" />
</Button>
<Input
className="w-full rounded-none"
className={cn("w-full rounded-none", className)}
onChange={(e) => setEditCustomCurrency(e.target.value)}
placeholder="ERC20 Address"
required
@@ -156,7 +157,7 @@ export function CurrencySelector({
: value?.toLowerCase()
}
>
<SelectTrigger>
<SelectTrigger className={className}>
<SelectValue placeholder="Select Currency" />
</SelectTrigger>
<SelectContent>
@@ -1,4 +1,3 @@
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
import { FilePlusIcon, UploadIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import {
@@ -8,6 +7,7 @@ import {
useDropzone,
} from "react-dropzone";
import type { ThirdwebClient } from "thirdweb";
import { FilePreview } from "@/components/blocks/file-preview";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -2,6 +2,7 @@ import { AsteriskIcon, InfoIcon } from "lucide-react";
import type React from "react";
import { Label } from "@/components/ui/label";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "../../lib/utils";
export function FormFieldSetup(props: {
htmlFor?: string;
@@ -10,13 +11,22 @@ export function FormFieldSetup(props: {
children: React.ReactNode;
tooltip?: React.ReactNode;
isRequired: boolean;
labelClassName?: string;
labelContainerClassName?: string;
helperText?: React.ReactNode;
className?: string;
}) {
return (
<div className={props.className}>
<div className="mb-2 inline-flex items-center gap-1">
<Label htmlFor={props.htmlFor}>{props.label}</Label>
<div
className={cn(
"mb-2 inline-flex items-center gap-1",
props.labelContainerClassName,
)}
>
<Label htmlFor={props.htmlFor} className={props.labelClassName}>
{props.label}
</Label>
{props.isRequired && (
<AsteriskIcon className="size-3.5 text-destructive-text" />
@@ -11,9 +11,10 @@ import { SelectWithSearch } from "@/components/blocks/select-with-search";
import { Badge } from "@/components/ui/badge";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useTokensData } from "@/hooks/tokens";
import { replaceIpfsUrl } from "@/lib/sdk";
import { cn } from "@/lib/utils";
import { fallbackChainIcon } from "@/utils/chain-icons";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { Spinner } from "../ui/Spinner/Spinner";
type Option = { label: string; value: string };
@@ -121,7 +122,10 @@ export function TokenSelector(props: {
return option.label;
}
const resolvedSrc = token.iconUri
? replaceIpfsUrl(token.iconUri, props.client)
? resolveSchemeWithErrorHandler({
client: props.client,
uri: token.iconUri,
})
: fallbackChainIcon;
return (
@@ -186,9 +190,14 @@ export function TokenSelector(props: {
options={options}
overrideSearchFn={searchFn}
placeholder={
tokensQuery.isPending
? "Loading Tokens"
: props.placeholder || "Select Token"
tokensQuery.isPending ? (
<div className="flex items-center gap-2">
<Spinner className="size-4" />
<span>Loading tokens</span>
</div>
) : (
props.placeholder || "Select Token"
)
}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
@@ -3,6 +3,7 @@ import type React from "react";
import { type Dispatch, type SetStateAction, useMemo } from "react";
import { CodeClient } from "@/components/ui/code/code.client";
import { TabButtons } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
export type CodeEnvironment =
| "javascript"
@@ -50,6 +51,7 @@ interface CodeSegmentProps {
isInstallCommand?: boolean;
hideTabs?: boolean;
onlyTabs?: boolean;
codeContainerClassName?: string;
}
export const CodeSegment: React.FC<CodeSegmentProps> = ({
@@ -59,6 +61,7 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
isInstallCommand,
hideTabs,
onlyTabs,
codeContainerClassName,
}) => {
const activeEnvironment: CodeEnvironment = useMemo(() => {
return (
@@ -86,7 +89,7 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
return (
<div
className={
"flex flex-col overflow-hidden rounded-lg border border-border"
"flex flex-col overflow-hidden rounded-lg border border-border grow"
}
>
{!hideTabs && (
@@ -106,7 +109,11 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
{onlyTabs ? null : (
<CodeClient
className="rounded-none border-none"
className={cn(
"rounded-none border-none grow",
codeContainerClassName,
)}
scrollableContainerClassName="h-full"
code={code}
lang={
isInstallCommand
@@ -0,0 +1,45 @@
"use client";
import { ArrowDownToLineIcon, FileTextIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CodeClient } from "@/components/ui/code/code.client";
import { handleDownload } from "../download-file-button";
export function DownloadableCode(props: {
code: string;
lang: "csv" | "json";
fileNameWithExtension: string;
}) {
return (
<div className="!my-5 bg-card">
<p className="text-sm text-muted-foreground border border-b-0 px-4 py-3 pr-3.5 rounded-lg rounded-b-none flex items-center justify-between">
<span className="flex items-center gap-1.5">
<FileTextIcon className="size-3.5" />
{props.fileNameWithExtension}
</span>
<Button
className="h-auto bg-background p-2"
onClick={() => {
handleDownload({
fileContent: props.code,
fileFormat:
props.lang === "csv" ? "text/csv" : "application/json",
fileNameWithExtension: props.fileNameWithExtension,
});
}}
size="sm"
variant="outline"
>
<ArrowDownToLineIcon className="size-3" />
</Button>
</p>
<div className="relative">
<CodeClient
code={props.code}
lang={props.lang}
scrollableClassName="max-h-[300px] bg-background bg-card"
className="rounded-t-none"
/>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { ArrowDownToLineIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function handleDownload(params: {
fileContent: string;
@@ -22,10 +23,14 @@ export function DownloadFileButton(props: {
fileNameWithExtension: string;
fileFormat: "text/csv" | "application/json";
label: string;
variant?: "default" | "outline";
className?: string;
iconClassName?: string;
}) {
return (
<Button
className="gap-2"
className={cn("gap-2", props.className)}
variant={props.variant}
onClick={() => {
handleDownload({
fileContent: props.fileContent,
@@ -35,7 +40,7 @@ export function DownloadFileButton(props: {
}}
size="sm"
>
<ArrowDownToLineIcon className="size-4" />
<ArrowDownToLineIcon className={cn("size-4", props.iconClassName)} />
{props.label}
</Button>
);
@@ -26,7 +26,7 @@ export function DropZone(props: {
return (
<div
className={cn(
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border",
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border px-4",
props.isError &&
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
props.className,
@@ -37,7 +37,12 @@ export function FilePreview(props: {
if (!objectUrl) {
return (
<div className={cn("flex items-center justify-center", props.className)}>
<div
className={cn(
"flex items-center justify-center bg-accent",
props.className,
)}
>
<ImageOffIcon className="size-6 text-muted-foreground" />
</div>
);
@@ -56,7 +61,10 @@ export function FilePreview(props: {
return (
<MediaRenderer
className={cn(props.className, "[&>div]:!bg-muted")}
className={cn(
props.className,
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative overflow-hidden",
)}
client={props.client}
src={objectUrl}
/>
@@ -94,7 +94,7 @@ export const MarkdownRenderer: React.FC<{
<h2
className={cn(
commonHeadingClassName,
"mb-3 border-border border-b pb-2 text-xl md:text-2xl",
"mb-3 border-dashed border-b pb-2 text-xl md:text-2xl",
)}
{...cleanedProps(props)}
/>
@@ -105,7 +105,7 @@ export const MarkdownRenderer: React.FC<{
{...cleanedProps(props)}
className={cn(
commonHeadingClassName,
"mt-8 mb-3 border-border border-b pb-2 text-lg md:text-xl",
"mt-8 mb-3 border-dashed border-b pb-2 text-lg md:text-xl",
)}
/>
),
@@ -147,7 +147,7 @@ export const MarkdownRenderer: React.FC<{
li: ({ children: c, ...props }) => (
<li
className={cn(
"mb-1.5 text-muted-foreground leading-loose [&>p]:m-0",
"text-muted-foreground leading-relaxed [&>p]:m-0",
markdownProps.li?.className,
)}
{...cleanedProps(props)}
@@ -157,7 +157,7 @@ export const MarkdownRenderer: React.FC<{
),
ol: (props) => (
<ol
className="mb-4 list-outside list-decimal pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
className="mb-4 list-outside list-decimal pl-4 space-y-2 [&>li]:first:mt-2"
{...cleanedProps(props)}
/>
),
@@ -165,7 +165,7 @@ export const MarkdownRenderer: React.FC<{
p: (props) => (
<p
className={cn(
"mb-4 text-muted-foreground leading-loose",
"mb-3 text-muted-foreground leading-7",
markdownProps.p?.className,
)}
{...cleanedProps(props)}
@@ -201,7 +201,7 @@ export const MarkdownRenderer: React.FC<{
ul: (props) => {
return (
<ul
className="mb-4 list-outside list-disc pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
className="mb-4 list-outside list-disc pl-4 space-y-2 [&>li]:first:mt-2"
{...cleanedProps(props)}
/>
);
@@ -22,7 +22,7 @@ interface SelectWithSearchProps
}[];
value: string | undefined;
onValueChange: (value: string) => void;
placeholder: string;
placeholder: string | React.ReactNode;
searchPlaceholder?: string;
className?: string;
overrideSearchFn?: (
@@ -23,7 +23,6 @@ export function WalletAddress(props: {
className?: string;
iconClassName?: string;
client: ThirdwebClient;
preventOpenOnFocus?: boolean;
}) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
@@ -68,10 +67,7 @@ export function WalletAddress(props: {
return (
<HoverCard>
<HoverCardTrigger
asChild
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
>
<HoverCardTrigger asChild tabIndex={-1}>
<Button
className={cn(
"flex flex-row items-center gap-2 px-0",
@@ -1,9 +1,6 @@
"use client";
import { Flex, FormControl } from "@chakra-ui/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { verifyContract } from "app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
import { FormHelperText, FormLabel } from "chakra/form";
import { Text } from "chakra/text";
import {
ArrowUpFromLineIcon,
CircleAlertIcon,
@@ -27,6 +24,7 @@ import {
reportContractDeployed,
reportContractDeployFailed,
} from "@/analytics/report";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { NetworkSelectorButton } from "@/components/misc/NetworkSelectorButton";
import { SolidityInput } from "@/components/solidity-inputs";
import {
@@ -932,14 +930,17 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
<Fieldset legend="Deploy Options">
<div className="flex flex-col gap-6">
{/* Chain */}
<FormControl isRequired>
<FormLabel>Chain</FormLabel>
<p className="mb-3 text-muted-foreground text-sm">
Select a network to deploy this contract on. We recommend
starting with a testnet
</p>
<FormFieldSetup
isRequired
label="Chain"
errorMessage={undefined}
helperText={
<span>
Select a network to deploy this contract on. We recommend
starting with a testnet
</span>
}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4 md:flex-row">
<NetworkSelectorButton
@@ -953,7 +954,6 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
: metadata?.networksForDeployment?.networksEnabled
}
/>
<Button asChild variant="outline">
<Link
className="gap-3"
@@ -967,12 +967,11 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
</Button>
</div>
</div>
</FormControl>
</FormFieldSetup>
{metadata?.deployType === "standard" && (
<>
{/* Deterministic deploy */}
<div className="flex flex-col gap-3">
<CheckboxWithLabel>
<Checkbox
@@ -1006,16 +1005,21 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
{/* Optional Salt Input */}
{isCreate2Deployment && (
<FormControl>
<Flex alignItems="center" my={1}>
<FormLabel display="flex" flex="1" mb={0}>
<Flex alignItems="baseline" gap={1}>
Optional Salt Input
<Text size="label.sm">(saltForCreate2)</Text>
</Flex>
</FormLabel>
<FormHelperText mt={0}>string</FormHelperText>
</Flex>
<FormFieldSetup
isRequired={false}
label={
<span className="flex items-center gap-1">
Optional Salt Input
<span className="font-normal text-muted-foreground text-xs">
(saltForCreate2)
</span>
</span>
}
errorMessage={
form.getFieldState("saltForCreate2", form.formState)
.error?.message
}
>
<SolidityInput
solidityType="string"
{...form.register("saltForCreate2")}
@@ -1034,7 +1038,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
Include deployer wallet address in salt (recommended)
</span>
</CheckboxWithLabel>
</FormControl>
</FormFieldSetup>
)}
</>
)}
@@ -1,8 +1,7 @@
import { FormControl } from "@chakra-ui/react";
import { FormErrorMessage, FormLabel } from "chakra/form";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { FetchDeployMetadataResult } from "thirdweb/contract";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SolidityInput } from "@/components/solidity-inputs";
import type { CustomContractDeploymentForm } from "./custom-contract";
import { PrimarySaleFieldset } from "./primary-sale-fieldset";
@@ -104,14 +103,15 @@ function RenderModule(props: {
`moduleData.${module.name}.${param.name}` as const;
return (
<FormControl
isInvalid={
!!form.getFieldState(formFieldKey, form.formState).error
}
isRequired
<FormFieldSetup
key={formFieldKey}
htmlFor={formFieldKey}
label={param.name}
isRequired={true}
errorMessage={
form.getFieldState(formFieldKey, form.formState).error?.message
}
>
<FormLabel> {param.name}</FormLabel>
<SolidityInput
client={props.client}
// @ts-expect-error - old types, need to update
@@ -119,13 +119,7 @@ function RenderModule(props: {
solidityType={param.type}
{...form.register(formFieldKey)}
/>
<FormErrorMessage>
{
form.getFieldState(formFieldKey, form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
</FormFieldSetup>
);
})}
</div>
@@ -1,8 +1,8 @@
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import { BasisPointsInput } from "@/components/blocks/BasisPointsInput";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SolidityInput } from "@/components/solidity-inputs";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Fieldset } from "./common";
import type { CustomContractDeploymentForm } from "./custom-contract";
@@ -78,30 +78,28 @@ export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = ({
</FormFieldSetup>
</>
) : isMarketplace ? (
<p className="mb-3 pt-4 text-muted-foreground text-sm italic">
<p className="text-muted-foreground text-sm leading-relaxed">
A 2.5% platform fee is deducted from each sale to support ongoing
platform operations and improvements.{" "}
<Link
className="text-blue-500 underline"
platform operations and improvements. <br />
<UnderlineLink
href={"https://thirdweb.com/pricing"}
rel="noopener noreferrer"
target="_blank"
>
See fee breakdown on pricing page.
</Link>
</UnderlineLink>
</p>
) : (
<p className="mb-3 pt-4 text-muted-foreground text-sm italic">
<p className="text-muted-foreground text-sm leading-relaxed">
A 2.5% platform fee is deducted from each primary sale price to
support ongoing platform operations and improvements.{" "}
<Link
className="text-blue-500 underline"
support ongoing platform operations and improvements. <br />
<UnderlineLink
href={"https://thirdweb.com/pricing"}
rel="noopener noreferrer"
target="_blank"
>
See fee breakdown on pricing page.
</Link>
</UnderlineLink>
</p>
)}
</div>
@@ -1,20 +1,24 @@
import type { Abi } from "abitype";
import { Select } from "chakra-react-select";
import { useMemo } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface AbiSelectorProps {
abi: Abi;
defaultValue: string;
value: string;
onChange: (fn: string) => void;
}
export const AbiSelector: React.FC<AbiSelectorProps> = ({
export function AbiSelector({
abi,
value,
defaultValue,
onChange,
}) => {
}: {
abi: Abi;
defaultValue: string;
value: string;
onChange: (fn: string) => void;
}) {
const options = useMemo(() => {
return abi
.filter((f) => f.type === "function")
@@ -26,24 +30,17 @@ export const AbiSelector: React.FC<AbiSelectorProps> = ({
}, [abi]);
return (
<div className="flex w-full flex-row items-center gap-2">
<Select
chakraStyles={{
container: (provided) => ({
...provided,
width: "full",
}),
}}
defaultValue={options.find((o) => o.value === defaultValue)}
onChange={(selectedFn) => {
if (selectedFn) {
onChange((selectedFn as { label: string; value: string }).value);
}
}}
options={options}
placeholder="Select function"
value={options.find((o) => o.value === value)}
/>
</div>
<Select value={value} onValueChange={onChange} defaultValue={defaultValue}>
<SelectTrigger className="w-full bg-card">
<SelectValue placeholder="Select function" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
}
@@ -1,4 +1,3 @@
import { FormControl, useBreakpointValue } from "@chakra-ui/react";
import type { AbiParameter } from "abitype";
import { useId, useState } from "react";
import { useFormContext } from "react-hook-form";
@@ -12,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import { getTemplateValuesForType } from "@/lib/deployment/template-values";
import { DecodedInputArrayFieldset } from "./decoded-bytes-input/decoded-input-array-fieldset";
import { RefInputFieldset } from "./ref-contract-input/ref-input-fieldset";
@@ -25,7 +25,7 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
client,
}) => {
const form = useFormContext();
const isMobile = useBreakpointValue({ base: true, md: false });
const isMobile = useIsMobile();
const displayNameId = useId();
const descriptionId = useId();
const [isCustomInputEnabledArray, setIsCustomInputEnabledArray] = useState(
@@ -70,22 +70,20 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
return (
<fieldset>
<h2 className="font-semibold text-3xl tracking-tight">
<h2 className="text-2xl font-semibold tracking-tight mb-1">
Contract Parameters
</h2>
<p className="text-muted-foreground">
<p className="text-muted-foreground text-sm mb-6">
These are the parameters users will need to fill in when deploying this
contract.
</p>
<div className="h-6" />
<div className="flex flex-col gap-8">
{deployParams.map((param, idx) => {
const paramTemplateValues = getTemplateValuesForType(param.type);
return (
<div
className="rounded-lg border border-border bg-card p-6"
className="rounded-lg border border-border bg-card p-4"
key={`implementation_${param.name}`}
>
{/* Title + Type */}
@@ -100,12 +98,12 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
)}
</h3>
<InlineCode
className="px-2 text-base text-muted-foreground"
className="px-2 text-muted-foreground text-sm"
code={param.type}
/>
</div>
<div className="h-5" />
<div className="h-4" />
{/* Display Name */}
<FormFieldSetup
@@ -140,7 +138,7 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
/>
</FormFieldSetup>
<div className="h-5" />
<div className="h-4" />
{/* Description */}
<FormFieldSetup
@@ -217,25 +215,23 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
{/* Inputs */}
{!isCustomInputEnabledArray[idx] ? (
<FormControl>
<SolidityInput
className="!bg-background !text-sm placeholder:!text-sm"
client={client}
placeholder={
isMobile ||
paramTemplateValues?.[0]?.value ===
"{{trusted_forwarders}}"
? "Pre-filled value."
: "This value will be pre-filled in the deploy form."
}
solidityType={param.type}
{...form.register(
`constructorParams.${
param.name ? param.name : "*"
}.defaultValue`,
)}
/>
</FormControl>
<SolidityInput
className="!bg-background !text-sm placeholder:!text-sm"
client={client}
placeholder={
isMobile ||
paramTemplateValues?.[0]?.value ===
"{{trusted_forwarders}}"
? "Pre-filled value."
: "This value will be pre-filled in the deploy form."
}
solidityType={param.type}
{...form.register(
`constructorParams.${
param.name ? param.name : "*"
}.defaultValue`,
)}
/>
) : param.type === "address" || param.type === "address[]" ? (
<RefInputFieldset client={client} param={param} />
) : (
@@ -1,33 +1,26 @@
import {
Box,
Flex,
FormControl,
IconButton,
Input,
ListItem,
UnorderedList,
} from "@chakra-ui/react";
import type { Abi } from "abitype";
import { Button } from "chakra/button";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { PlusIcon, TrashIcon } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect } from "react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { useFieldArray, useFormContext } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useCustomFactoryAbi } from "@/hooks/contract-hooks";
import { AbiSelector } from "./abi-selector";
interface CustomFactoryProps {
export function CustomFactory(props: {
setCustomFactoryAbi: Dispatch<SetStateAction<Abi>>;
client: ThirdwebClient;
}
export const CustomFactory: React.FC<CustomFactoryProps> = ({
setCustomFactoryAbi,
client,
}) => {
}) {
const { setCustomFactoryAbi, client } = props;
const form = useFormContext();
const customFactoryAbi = useCustomFactoryAbi(
@@ -35,7 +28,6 @@ export const CustomFactory: React.FC<CustomFactoryProps> = ({
form.watch("customFactoryAddresses[0].value"),
form.watch("customFactoryAddresses[0].key"),
);
// FIXME: all of this logic needs to be reworked
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -58,102 +50,130 @@ export const CustomFactory: React.FC<CustomFactoryProps> = ({
}, [fields, append]);
return (
<Flex flexDir="column" gap={12} pb={0} px={0}>
<UnorderedList>
<Text as={ListItem}>
<div className="space-y-12">
{/* Description */}
<div className="space-y-1.5 text-sm text-muted-foreground">
<p>
Use this if you want to invoke your own function with custom logic
when users deploy your contract.
</Text>
<Text as={ListItem}>
</p>
<p>
You need to have factory contracts pre-deployed to all networks that
you want to support.
</Text>
</UnorderedList>
<Flex flexDir="column" gap={4}>
<Flex flexDir="column" gap={2}>
<Heading size="title.md">Factory contract addresses</Heading>
<Text>
Paste the contract address of your factory contracts. Your contract
can be deployed only to networks with valid factory address.
</Text>
</Flex>
{fields.map((field, index) => (
<div key={field.id}>
<FormControl as={Flex} gap={4} isRequired>
<Box w={{ base: "full", md: "40%" }}>
<Controller
control={form.control}
name={`customFactoryAddresses[${index}].key`}
render={({ field: _field }) => {
return (
<SingleNetworkSelector
chainId={_field.value}
client={client}
onChange={(value) => {
_field.onChange(value);
}}
/>
);
}}
/>
</Box>
<Box w="full">
<Input
isRequired
{...form.register(`customFactoryAddresses[${index}].value`)}
placeholder="Factory contract address"
/>
</Box>
<IconButton
aria-label="Remove row"
icon={<TrashIcon className="size-5" />}
isDisabled={fields.length === 1 || form.formState.isSubmitting}
onClick={() => remove(index)}
</p>
</div>
{/* Factory contract addresses */}
<div>
<h2 className="text-xl font-semibold tracking-tight mb-1">
Factory contract addresses
</h2>
<p className="text-sm text-muted-foreground mb-4">
Paste the contract address of your factory contracts. Your contract
can be deployed only to networks with valid factory address.
</p>
<div className="space-y-3 mb-5">
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col lg:flex-row gap-4 lg:items-end"
>
<FormField
control={form.control}
name={`customFactoryAddresses[${index}].key`}
render={({ field }) => (
<FormItem className="w-full lg:w-80 space-y-1.5">
<FormLabel>Network</FormLabel>
<SingleNetworkSelector
chainId={field.value}
disableChainId
className="bg-card"
client={client}
onChange={field.onChange}
/>
</FormItem>
)}
/>
</FormControl>
</div>
))}
<FormField
control={form.control}
name={`customFactoryAddresses[${index}].value`}
render={({ field }) => (
<FormItem className="grow space-y-1.5">
<FormLabel>Factory contract address</FormLabel>
<FormControl>
<Input
required
className="bg-card"
{...field}
placeholder="0x..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
className="rounded-full p-0 size-10 bg-card self-end"
aria-label="Remove row"
disabled={fields.length === 1 || form.formState.isSubmitting}
onClick={() => remove(index)}
>
<TrashIcon className="size-4" />
</Button>
</div>
))}
</div>
<div>
<Button
borderRadius="md"
colorScheme="primary"
leftIcon={<PlusIcon className="size-5" />}
onClick={() => append({ key: "", value: "" })}
variant="outline"
size="sm"
type="button"
className="rounded-full bg-card"
onClick={() => append({ key: "", value: "" })}
>
Add Network
<PlusIcon className="size-5 mr-2" /> Add Network
</Button>
</div>
</Flex>
<Flex flexDir="column" gap={4}>
<Flex flexDir="column" gap={2}>
<Heading size="title.md">Factory function</Heading>
<Text>Choose the factory function to deploy your contracts.</Text>
</Flex>
<FormControl isRequired>
{customFactoryAbi?.data ? (
<AbiSelector
abi={customFactoryAbi.data}
defaultValue="deployProxyByImplementation"
onChange={(selectedFn) =>
form.setValue(
"factoryDeploymentData.customFactoryInput.factoryFunction",
selectedFn,
)
}
value={form.watch(
</div>
{/* Factory function */}
<div>
<h2 className="text-xl font-semibold tracking-tight mb-1">
Factory function
</h2>
<p className="text-sm text-muted-foreground mb-4">
Choose the factory function to deploy your contracts.
</p>
{customFactoryAbi?.data ? (
<AbiSelector
abi={customFactoryAbi.data}
defaultValue="deployProxyByImplementation"
onChange={(selectedFn) =>
form.setValue(
"factoryDeploymentData.customFactoryInput.factoryFunction",
)}
/>
) : (
<Text fontStyle="italic">
Custom factory ABI not found. Please provide a valid imported
contract on the previous step.
</Text>
)}
</FormControl>
</Flex>
</Flex>
selectedFn,
)
}
value={form.watch(
"factoryDeploymentData.customFactoryInput.factoryFunction",
)}
/>
) : (
<p className="text-sm text-red-500 font-medium">
Custom factory ABI not found. Please provide a valid imported
contract on the previous step.
</p>
)}
</div>
</div>
);
};
}
@@ -1,10 +1,7 @@
import { Flex, FormControl, ListItem, UnorderedList } from "@chakra-ui/react";
import type { Abi } from "abitype";
import { Heading } from "chakra/heading";
import { Link } from "chakra/link";
import { Text } from "chakra/text";
import { useFormContext } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { UnderlineLink } from "../../ui/UnderlineLink";
import { AbiSelector } from "./abi-selector";
import { NetworksFieldset } from "./networks-fieldset";
@@ -13,63 +10,62 @@ interface DefaultFactoryProps {
client: ThirdwebClient;
}
export const DefaultFactory: React.FC<DefaultFactoryProps> = ({
abi,
client,
}) => {
export function DefaultFactory({ abi, client }: DefaultFactoryProps) {
const form = useFormContext();
return (
<Flex flexDir="column" gap={12} pb={0} px={0}>
<UnorderedList>
<Text as={ListItem}>
<div className="space-y-12">
{/* Description */}
<div className="space-y-1.5 text-sm text-muted-foreground">
<p>
Default factory lets users deploy your contract to{" "}
<strong>any EVM network</strong>.
</Text>
<Text as={ListItem}>
<em className="font-medium">any EVM network</em>.
</p>
<p>
The factory deploys EIP-1167 minimal proxies of your contract. This
makes it much cheaper to deploy.
</Text>
<Text as={ListItem}>
</p>
<p>
The factory is{" "}
<Link
color="primary.500"
<UnderlineLink
href="https://github.com/thirdweb-dev/contracts/blob/main/contracts/TWStatelessFactory.sol"
isExternal
target="_blank"
rel="noopener noreferrer"
>
open-source
</Link>
</UnderlineLink>
, permissionless, and does not alter contract ownership.
</Text>
<Text as={ListItem}>
</p>
<p>
Your contract needs to be written in the upgradeable/initializable
pattern. It needs to contain an initializer function.
</Text>
</UnorderedList>
<Flex flexDir="column" gap={4}>
<Flex flexDir="column" gap={2}>
<Heading size="title.md">Initializer function</Heading>
<Text>
Choose the initializer function to invoke on your proxy contracts.
</Text>
</Flex>
<FormControl isRequired>
<AbiSelector
abi={abi}
defaultValue="initialize"
onChange={(selectedFn) =>
form.setValue(
"factoryDeploymentData.implementationInitializerFunction",
selectedFn,
)
}
value={form.watch(
</p>
</div>
{/* Initializer function */}
<div>
<h3 className="text-lg font-semibold mb-1">Initializer function</h3>
<p className="text-muted-foreground text-sm mb-4">
Choose the initializer function to invoke on your proxy contracts.
</p>
<AbiSelector
abi={abi}
defaultValue="initialize"
onChange={(selectedFn) =>
form.setValue(
"factoryDeploymentData.implementationInitializerFunction",
)}
/>
</FormControl>
</Flex>
selectedFn,
)
}
value={form.watch(
"factoryDeploymentData.implementationInitializerFunction",
)}
/>
</div>
{/* Networks */}
<NetworksFieldset client={client} />
</Flex>
</div>
);
};
}
@@ -1,5 +1,4 @@
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { ExternalLinksInput } from "./external-links-input";
@@ -12,35 +11,26 @@ export const ExternalLinksFieldset = () => {
name: "externalLinks",
});
// FIXME: all of this logic needs to be reworked
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (fields.length === 0) {
append(
{
name: "",
url: "",
},
{ shouldFocus: false },
);
}
}, [fields, append]);
return (
<fieldset className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-xl tracking-tight">Resources</h3>
<p className="text-muted-foreground">
<fieldset>
<div>
<h3 className="font-semibold text-xl tracking-tight mb-1">Resources</h3>
<p className="text-muted-foreground text-sm mb-5">
Provide links to docs, usage guides etc. for the contract.
</p>
</div>
<div className="flex flex-col gap-4">
{fields.map((item, index) => (
<ExternalLinksInput index={index} key={item.id} remove={remove} />
<ExternalLinksInput
index={index}
key={item.id}
remove={remove}
disableRemove={fields.length === 1}
/>
))}
<div>
<Button
className="gap-2"
className="gap-2 bg-card rounded-full"
onClick={() =>
append({
name: "",
@@ -51,7 +41,7 @@ export const ExternalLinksFieldset = () => {
type="button"
variant="outline"
>
<PlusIcon className="size-5" />
<PlusIcon className="size-4" />
Add Resource
</Button>
</div>
@@ -3,82 +3,82 @@ import { useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
interface ExternalLinksInputProps {
index: number;
remove: (index: number) => void;
disableRemove: boolean;
}
export const ExternalLinksInput: React.FC<ExternalLinksInputProps> = ({
index,
remove,
disableRemove,
}) => {
const form = useFormContext();
return (
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-4 md:flex-row md:gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label className="capitalize" htmlFor={`externalLinks.${index}.name`}>
Resource Name
</Label>
<Input
id={`externalLinks.${index}.name`}
placeholder="Resource name"
{...form.register(`externalLinks.${index}.name`)}
/>
{form.getFieldState(`externalLinks.${index}.name`, form.formState)
.error?.message && (
<p className="text-destructive-text text-sm">
{
form.getFieldState(
`externalLinks.${index}.name`,
form.formState,
).error?.message
}
</p>
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<Label className="capitalize" htmlFor={`externalLinks.${index}.url`}>
Link
</Label>
<Input
id={`externalLinks.${index}.url`}
placeholder="Provide URL to the resource page"
{...form.register(`externalLinks.${index}.url`, {
required: true,
validate: (value: string) => {
if (value.match(new RegExp(/^https:\/\/[^\s/$.?#].[^\s]*$/))) {
return true;
}
return "Provide a valid URL";
},
})}
/>
{form.getFieldState(`externalLinks.${index}.url`, form.formState)
.error?.message && (
<p className="text-destructive-text text-sm">
{
form.getFieldState(`externalLinks.${index}.url`, form.formState)
.error?.message
}
</p>
)}
</div>
<Button
aria-label="Remove row"
className="self-end"
onClick={() => remove(index)}
size="icon"
variant="ghost"
>
<TrashIcon className="size-5" />
<span className="sr-only">Remove row</span>
</Button>
<div className="flex w-full flex-col gap-3 md:flex-row">
{/* Resource name */}
<div className="flex flex-1 flex-col gap-2">
<Label className="capitalize" htmlFor={`externalLinks.${index}.name`}>
Name
</Label>
<Input
id={`externalLinks.${index}.name`}
{...form.register(`externalLinks.${index}.name`)}
className="bg-card"
/>
{form.getFieldState(`externalLinks.${index}.name`, form.formState).error
?.message && (
<p className="text-destructive-text text-sm">
{
form.getFieldState(`externalLinks.${index}.name`, form.formState)
.error?.message
}
</p>
)}
</div>
<Separator />
{/* Resource URL */}
<div className="flex flex-1 flex-col gap-2">
<Label className="capitalize" htmlFor={`externalLinks.${index}.url`}>
Link
</Label>
<Input
id={`externalLinks.${index}.url`}
className="bg-card"
{...form.register(`externalLinks.${index}.url`, {
required: true,
validate: (value: string) => {
if (value.match(new RegExp(/^https:\/\/[^\s/$.?#].[^\s]*$/))) {
return true;
}
return "Provide a valid URL";
},
})}
/>
{form.getFieldState(`externalLinks.${index}.url`, form.formState).error
?.message && (
<p className="text-destructive-text text-sm">
{
form.getFieldState(`externalLinks.${index}.url`, form.formState)
.error?.message
}
</p>
)}
</div>
<Button
className="self-end rounded-full p-0 bg-card"
disabled={disableRemove}
onClick={() => remove(index)}
size="icon"
variant="outline"
aria-label="Remove resource"
>
<TrashIcon className="size-4" />
</Button>
</div>
);
};
@@ -1,10 +1,8 @@
import { ButtonGroup, Flex } from "@chakra-ui/react";
import type { Abi } from "abitype";
import { Button } from "chakra/button";
import { Heading } from "chakra/heading";
import type { Dispatch, SetStateAction } from "react";
import { useFormContext } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { TabButtons } from "../../ui/tabs";
import { CustomFactory } from "./custom-factory";
import { DefaultFactory } from "./default-factory";
@@ -20,47 +18,45 @@ export const FactoryFieldset: React.FC<FactoryFieldsetProps> = ({
client,
}) => {
const form = useFormContext();
const tab = form.watch("deployType");
return (
<Flex as="fieldset" direction="column" gap={12}>
<Flex direction="column" gap={6}>
<Heading size="title.lg">Factory deploy settings</Heading>
<ButtonGroup size="sm" spacing={{ base: 0.5, md: 2 }} variant="ghost">
<Button
_active={{
bg: "bgBlack",
color: "bgWhite",
}}
isActive={form.watch("deployType") === "autoFactory"}
onClick={() => form.setValue("deployType", "autoFactory")}
rounded="lg"
type="button"
>
Default Factory
</Button>
<Button
_active={{
bg: "bgBlack",
color: "bgWhite",
}}
isActive={form.watch("deployType") === "customFactory"}
onClick={() => form.setValue("deployType", "customFactory")}
rounded="lg"
type="button"
>
Custom Factory (Advanced)
</Button>
</ButtonGroup>
{form.watch("deployType") === "autoFactory" && (
<DefaultFactory abi={abi} client={client} />
)}
{form.watch("deployType") === "customFactory" && (
<CustomFactory
client={client}
setCustomFactoryAbi={setCustomFactoryAbi}
/>
)}
</Flex>
</Flex>
<div>
<h2 className="text-2xl font-semibold tracking-tight mb-1">
Factory deploy settings
</h2>
<p className="text-muted-foreground mb-6">
Choose how you want to deploy your contract
</p>
<TabButtons
tabClassName="!text-sm font-semibold"
tabs={[
{
name: "Default Factory",
onClick: () => form.setValue("deployType", "autoFactory"),
isActive: tab === "autoFactory",
},
{
name: "Custom Factory (Advanced)",
onClick: () => form.setValue("deployType", "customFactory"),
isActive: tab === "customFactory",
},
]}
/>
<div className="h-4" />
{form.watch("deployType") === "autoFactory" && (
<DefaultFactory abi={abi} client={client} />
)}
{form.watch("deployType") === "customFactory" && (
<CustomFactory
client={client}
setCustomFactoryAbi={setCustomFactoryAbi}
/>
)}
</div>
);
};
@@ -1,4 +1,3 @@
import { FormControl, useBreakpointValue } from "@chakra-ui/react";
import type { AbiParameter } from "abitype";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
@@ -7,6 +6,7 @@ import { SolidityInput } from "@/components/solidity-inputs";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { InlineCode } from "@/components/ui/inline-code";
import { Switch } from "@/components/ui/switch";
import { useIsMobile } from "@/hooks/use-mobile";
import { getTemplateValuesForType } from "@/lib/deployment/template-values";
import { RefInputImplFieldset } from "./ref-contract-impl-input/ref-input-impl-fieldset";
@@ -19,7 +19,7 @@ export const ImplementationParamsFieldset: React.FC<
> = ({ implParams, client }) => {
const form = useFormContext();
const isMobile = useBreakpointValue({ base: true, md: false });
const isMobile = useIsMobile();
const [isCustomInputEnabled, setIsCustomInputEnabled] = useState(
Array(implParams.length).fill(false),
@@ -119,25 +119,23 @@ export const ImplementationParamsFieldset: React.FC<
</div>
{!isCustomInputEnabled[idx] ? (
<FormControl>
<SolidityInput
className="!bg-background !text-sm placeholder:!text-sm"
client={client}
placeholder={
isMobile ||
paramTemplateValues?.[0]?.value ===
"{{trusted_forwarders}}"
? "Pre-filled value."
: "This value will be pre-filled in the deploy form."
}
solidityType={param.type}
{...form.register(
`implConstructorParams.${
param.name ? param.name : "*"
}.defaultValue`,
)}
/>
</FormControl>
<SolidityInput
className="!bg-background !text-sm placeholder:!text-sm"
client={client}
placeholder={
isMobile ||
paramTemplateValues?.[0]?.value ===
"{{trusted_forwarders}}"
? "Pre-filled value."
: "This value will be pre-filled in the deploy form."
}
solidityType={param.type}
{...form.register(
`implConstructorParams.${
param.name ? param.name : "*"
}.defaultValue`,
)}
/>
) : (
<RefInputImplFieldset client={client} param={param} />
)}
@@ -1,9 +1,6 @@
"use client";
import { Box, Divider, Flex, IconButton } from "@chakra-ui/react";
import type { Abi } from "abitype";
import { Button } from "chakra/button";
import { Text } from "chakra/text";
import { ChevronFirstIcon } from "lucide-react";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
@@ -15,6 +12,8 @@ import {
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { reportContractPublished } from "@/analytics/report";
import { CustomConnectWallet } from "@/components/connect-wallet";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
DASHBOARD_ENGINE_RELAYER_URL,
DASHBOARD_FORWARDER_ADDRESS,
@@ -22,7 +21,6 @@ import {
import { useEns, useFunctionParamsFromABI } from "@/hooks/contract-hooks";
import { useTxNotifications } from "@/hooks/useTxNotifications";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
import { ContractParamsFieldset } from "./contract-params-fieldset";
import { FactoryFieldset } from "./factory-fieldset";
import { ImplementationParamsFieldset } from "./impl-params-fieldset";
@@ -183,284 +181,238 @@ export function ContractPublishForm(props: {
// during loading and after success we should stay in loading state
const isPending = sendTx.isPending || sendTx.isSuccess;
useIsomorphicLayoutEffect(() => {
window?.scrollTo({
behavior: "smooth",
top: 0,
});
}, []);
const formId = useId();
return (
<FormProvider {...form}>
<div className="w-full">
<Flex
as="form"
direction="column"
gap={6}
id={formId}
onSubmit={form.handleSubmit((data) => {
if (!account) {
// no account, do nothing
return;
}
const addressArray =
Object.keys(data?.customFactoryAddresses || {}).length > 0
? (data?.customFactoryAddresses as unknown as {
key: number;
value: string;
}[])
: [];
<form
className="flex flex-col gap-6"
id={formId}
onSubmit={form.handleSubmit((data) => {
if (!account) {
// no account, do nothing
return;
}
const addressArray =
Object.keys(data?.customFactoryAddresses || {}).length > 0
? (data?.customFactoryAddresses as unknown as {
key: number;
value: string;
}[])
: [];
const addressObj = addressArray.reduce<Record<number, string>>(
(obj, item) => {
obj[item.key] = item.value;
return obj;
},
{},
);
const addressObj = addressArray.reduce<Record<number, string>>(
(obj, item) => {
obj[item.key] = item.value;
return obj;
},
{},
);
const metadata = {
...data,
factoryDeploymentData: {
...data.factoryDeploymentData,
customFactoryInput: {
customFactoryAddresses:
addressObj ||
data.factoryDeploymentData?.customFactoryInput
?.customFactoryAddresses,
factoryFunction:
data.factoryDeploymentData?.customFactoryInput
?.factoryFunction || "deployProxyByImplementation",
params:
data.factoryDeploymentData?.customFactoryInput?.params ||
[],
},
implementationAddresses:
data.factoryDeploymentData?.implementationAddresses || {},
implementationInitializerFunction:
data.factoryDeploymentData
?.implementationInitializerFunction || "initialize",
const metadata = {
...data,
factoryDeploymentData: {
...data.factoryDeploymentData,
customFactoryInput: {
customFactoryAddresses:
addressObj ||
data.factoryDeploymentData?.customFactoryInput
?.customFactoryAddresses,
factoryFunction:
data.factoryDeploymentData?.customFactoryInput
?.factoryFunction || "deployProxyByImplementation",
params:
data.factoryDeploymentData?.customFactoryInput?.params || [],
},
networksForDeployment: {
allNetworks:
data.deployType === "customFactory"
? false
: data.networksForDeployment?.allNetworks,
networksEnabled:
addressArray.length > 0
? Object.keys(addressObj).map((val) => Number(val))
: data.networksForDeployment?.networksEnabled,
},
} satisfies FetchDeployMetadataResult & {
name: string;
version: string;
};
implementationAddresses:
data.factoryDeploymentData?.implementationAddresses || {},
implementationInitializerFunction:
data.factoryDeploymentData?.implementationInitializerFunction ||
"initialize",
},
networksForDeployment: {
allNetworks:
data.deployType === "customFactory"
? false
: data.networksForDeployment?.allNetworks,
networksEnabled:
addressArray.length > 0
? Object.keys(addressObj).map((val) => Number(val))
: data.networksForDeployment?.networksEnabled,
},
} satisfies FetchDeployMetadataResult & {
name: string;
version: string;
};
const tx = publishContract({
account,
contract: getContractPublisher(props.client),
metadata,
previousMetadata: props.publishMetadata,
});
const tx = publishContract({
account,
contract: getContractPublisher(props.client),
metadata,
previousMetadata: props.publishMetadata,
});
sendTx.mutate(tx, {
onError: (err) => {
console.error("Failed to publish contract", err);
onError(err);
},
onSuccess: async () => {
onSuccess();
sendTx.mutate(tx, {
onError: (err) => {
console.error("Failed to publish contract", err);
onError(err);
},
onSuccess: async () => {
onSuccess();
reportContractPublished({
contractName: props.publishMetadata.name,
deployType: data.deployType,
publisher: ensNameOrAddress ?? "",
version: data.version,
});
await props.onPublishSuccess().catch((err) => {
console.error("Failed to run onPublishSuccess", err);
});
if (successRedirectUrl) {
router.push(successRedirectUrl, { scroll: true });
}
},
});
})}
>
{fieldsetToShow !== "landing" && (
<div>
<IconButton
aria-label="Back"
icon={<ChevronFirstIcon className="size-6" />}
onClick={() =>
fieldsetToShow === "contractParams" &&
(form.watch("deployType") === "autoFactory" ||
form.watch("deployType") === "customFactory")
? setFieldsetToShow("factory")
: fieldsetToShow === "contractParams" &&
form.watch("deployType") === "standard"
? setFieldsetToShow("networks")
: fieldsetToShow === "implParams"
? setFieldsetToShow("contractParams")
: setFieldsetToShow("landing")
}
variant="ghost"
w="inherit"
reportContractPublished({
contractName: props.publishMetadata.name,
deployType: data.deployType,
publisher: ensNameOrAddress ?? "",
version: data.version,
});
await props.onPublishSuccess().catch((err) => {
console.error("Failed to run onPublishSuccess", err);
});
if (successRedirectUrl) {
router.push(successRedirectUrl, { scroll: true });
}
},
});
})}
>
{fieldsetToShow !== "landing" && (
<div>
<Button
size="sm"
className="-translate-x-3"
variant="ghost"
onClick={() =>
fieldsetToShow === "contractParams" &&
(form.watch("deployType") === "autoFactory" ||
form.watch("deployType") === "customFactory")
? setFieldsetToShow("factory")
: fieldsetToShow === "contractParams" &&
form.watch("deployType") === "standard"
? setFieldsetToShow("networks")
: fieldsetToShow === "implParams"
? setFieldsetToShow("contractParams")
: setFieldsetToShow("landing")
}
>
<ArrowLeftIcon className="size-4 mr-2" />
Back
</Button>
</div>
)}
{fieldsetToShow === "landing" && (
<LandingFieldset
client={props.client}
latestVersion={latestVersion}
placeholderVersion={placeholderVersion}
/>
)}
{fieldsetToShow === "contractParams" && (
<ContractParamsFieldset
client={props.client}
deployParams={deployParams}
/>
)}
{fieldsetToShow === "implParams" && implDeployParams?.length > 0 && (
<ImplementationParamsFieldset
client={props.client}
implParams={implDeployParams}
/>
)}
{fieldsetToShow === "factory" && (
<div className="flex flex-col gap-24">
<FactoryFieldset
abi={props.publishMetadata.abi || []}
client={props.client}
setCustomFactoryAbi={setCustomFactoryAbi}
/>
</div>
)}
{fieldsetToShow === "networks" && (
<NetworksFieldset client={props.client} />
)}
<div className="flex items-center gap-4 justify-end border-t py-6 mt-6">
{!account ? (
<CustomConnectWallet
client={props.client}
isLoggedIn={props.isLoggedIn}
/>
) : fieldsetToShow === "landing" &&
form.watch("deployType") === "standard" ? (
<NextButton
disabled={disableNext}
onClick={() => setFieldsetToShow("networks")}
/>
) : fieldsetToShow === "landing" &&
(form.watch("deployType") === "autoFactory" ||
form.watch("deployType") === "customFactory") ? (
<NextButton
disabled={disableNext}
onClick={() => setFieldsetToShow("factory")}
/>
) : fieldsetToShow !== "contractParams" &&
fieldsetToShow !== "implParams" &&
deployParams?.length > 0 ? (
<NextButton
disabled={disableNext}
onClick={() => setFieldsetToShow("contractParams")}
/>
) : fieldsetToShow !== "contractParams" &&
fieldsetToShow !== "implParams" &&
deployParams?.length === 0 &&
implDeployParams?.length > 0 ? (
<NextButton
disabled={disableNext}
onClick={() => setFieldsetToShow("implParams")}
/>
) : fieldsetToShow === "contractParams" &&
implDeployParams?.length > 0 ? (
<NextButton
disabled={disableNext}
onClick={() => setFieldsetToShow("implParams")}
/>
) : (
<div className="flex items-start justify-between gap-4 w-full">
<span className="text-sm text-muted-foreground">
Publishing your contract is free, we cover all gas costs
</span>
<Button
className="gap-2"
form={formId}
disabled={isDisabled}
type="submit"
>
Back
</IconButton>
{isPending && <Spinner className="size-4" />}
{sendTx.isSuccess
? "Preparing page"
: sendTx.isPending
? "Publishing contract"
: "Publish Contract"}
</Button>
</div>
)}
{fieldsetToShow === "landing" && (
<LandingFieldset
client={props.client}
latestVersion={latestVersion}
placeholderVersion={placeholderVersion}
/>
)}
{fieldsetToShow === "contractParams" && (
<ContractParamsFieldset
client={props.client}
deployParams={deployParams}
/>
)}
{fieldsetToShow === "implParams" && implDeployParams?.length > 0 && (
<ImplementationParamsFieldset
client={props.client}
implParams={implDeployParams}
/>
)}
{fieldsetToShow === "factory" && (
<Flex flexDir="column" gap={24}>
<FactoryFieldset
abi={props.publishMetadata.abi || []}
client={props.client}
setCustomFactoryAbi={setCustomFactoryAbi}
/>
</Flex>
)}
{fieldsetToShow === "networks" && (
<Flex flexDir="column" gap={24}>
<NetworksFieldset client={props.client} fromStandard />
</Flex>
)}
<Flex flexDir="column" gap={6}>
<Divider />
<Flex
alignItems="center"
flexDir={{ base: "column", md: "row" }}
gap={4}
justifyContent="space-between"
>
{!account ? (
<>
<Box />
<CustomConnectWallet
client={props.client}
isLoggedIn={props.isLoggedIn}
/>
</>
) : fieldsetToShow === "landing" &&
form.watch("deployType") === "standard" ? (
<>
<Box />
<Button
colorScheme="primary"
isDisabled={disableNext}
onClick={() => setFieldsetToShow("networks")}
>
Next
</Button>
</>
) : fieldsetToShow === "landing" &&
(form.watch("deployType") === "autoFactory" ||
form.watch("deployType") === "customFactory") ? (
<>
<Box />
<Button
colorScheme="primary"
isDisabled={disableNext}
onClick={() => setFieldsetToShow("factory")}
>
Next
</Button>
</>
) : fieldsetToShow !== "contractParams" &&
fieldsetToShow !== "implParams" &&
deployParams?.length > 0 ? (
<>
<Box />
<Button
colorScheme="primary"
isDisabled={disableNext}
onClick={() => setFieldsetToShow("contractParams")}
>
Next
</Button>
</>
) : fieldsetToShow !== "contractParams" &&
fieldsetToShow !== "implParams" &&
deployParams?.length === 0 &&
implDeployParams?.length > 0 ? (
<>
<Box />
<Button
colorScheme="primary"
isDisabled={disableNext}
onClick={() => setFieldsetToShow("implParams")}
>
Next
</Button>
</>
) : fieldsetToShow === "contractParams" &&
implDeployParams?.length > 0 ? (
<>
<Box />
<Button
colorScheme="primary"
isDisabled={disableNext}
onClick={() => setFieldsetToShow("implParams")}
>
Next
</Button>
</>
) : (
<>
<Text fontStyle="italic">
Publishing your contract is free, we cover all gas costs.
</Text>
<Button
// differentiate this from the edit button
borderRadius="md"
colorScheme="blue"
form={formId}
isDisabled={isDisabled}
isLoading={isPending}
key="submit-button"
loadingText={
sendTx.isSuccess
? "Preparing page"
: "Publishing contract"
}
position="relative"
type="submit"
>
Publish Contract
</Button>
</>
)}
</Flex>
</Flex>
</Flex>
</div>
</div>
</form>
</FormProvider>
);
}
function NextButton(props: { disabled: boolean; onClick: () => void }) {
return (
<Button
disabled={props.disabled}
onClick={props.onClick}
className="gap-2 w-32"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
);
}
@@ -1,41 +1,30 @@
import {
Box,
Flex,
FormControl,
Input,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Textarea,
} from "@chakra-ui/react";
import { Card } from "chakra/card";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import { Link } from "chakra/link";
import { Text } from "chakra/text";
import { compare, validate } from "compare-versions";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import type { ExtendedMetadata } from "thirdweb/utils";
import { SelectOption } from "@/components/batch-upload/lazy-mint-form/select-option";
import { FileInput } from "@/components/blocks/FileInput";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SolidityInput } from "@/components/solidity-inputs";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownRenderer } from "../../blocks/markdown-renderer";
import { TabButtons } from "../../ui/tabs";
import { UnderlineLink } from "../../ui/UnderlineLink";
import { ExternalLinksFieldset } from "./external-links-fieldset";
interface LandingFieldsetProps {
type LandingFieldsetProps = {
latestVersion: string | undefined;
placeholderVersion: string;
client: ThirdwebClient;
}
};
export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
export function LandingFieldset({
latestVersion,
placeholderVersion,
client,
}) => {
}: LandingFieldsetProps) {
const form = useFormContext<ExtendedMetadata>();
const logoUrl = form.watch("logo");
@@ -66,205 +55,235 @@ export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
}
};
const [readmeTab, setReadmeTab] = useState<"write" | "preview">("write");
const [changelogTab, setChangelogTab] = useState<"write" | "preview">(
"write",
);
return (
<Flex as="fieldset" direction="column" gap={16} mt={{ base: 4, md: 12 }}>
<Flex direction="column" gap={2}>
<Heading size="title.lg">
<fieldset className="space-y-12">
{/* header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight mb-1">
{!latestVersion ? "Publish" : "Edit"} your contract
</Heading>
<Text fontStyle="normal">
</h1>
<p className="text-muted-foreground text-sm">
Publishing your contract makes it shareable, discoverable, and
deployable in a single click.{" "}
<Link
color="blue.500"
<UnderlineLink
href="https://portal.thirdweb.com/contracts/publish/overview"
isExternal
target="_blank"
rel="noopener noreferrer"
>
Learn more
</Link>
</Text>
</Flex>
<Flex gap={6} w="full">
<FormControl isInvalid={!!form.formState.errors.logo} w="auto">
<FormLabel>Image</FormLabel>
<Box width="141px">
</UnderlineLink>
</p>
</div>
{/* logo, name, description */}
<div className="flex flex-col md:flex-row gap-6 w-full">
<FormFieldSetup
label="Image"
errorMessage={form.formState.errors?.logo?.message as string}
isRequired={false}
className="w-auto"
>
<div className="w-[200px]">
<FileInput
accept={{ "image/*": [] }}
className="rounded border border-border transition-all duration-200"
className="rounded-lg border bg-card transition-all duration-200"
client={client}
helperText="Image"
// @ts-expect-error - we upload the file later this is fine
setValue={(file) => form.setValue("logo", file)}
value={logoUrl}
/>
</Box>
<FormErrorMessage>
{form.formState.errors?.logo?.message as unknown as string}
</FormErrorMessage>
</FormControl>
<Flex flexDir="column" gap={4} w="full">
<FormControl
isInvalid={!!form.formState.errors.displayName}
isRequired
</div>
</FormFieldSetup>
<div className="flex flex-col gap-4 w-full grow">
<FormFieldSetup
htmlFor="displayName"
label="Contract Name"
errorMessage={form.formState.errors?.displayName?.message}
isRequired={true}
>
<FormLabel>Contract Name</FormLabel>
<Input
id="displayName"
onChange={(e) => form.setValue("displayName", e.target.value)}
placeholder="Ex. MyContract"
className="bg-card"
value={form.watch("displayName")}
/>
<FormErrorMessage>
{form.formState.errors?.displayName?.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!form.formState.errors.description}>
<FormLabel>Description</FormLabel>
</FormFieldSetup>
<FormFieldSetup
htmlFor="description"
label="Description"
errorMessage={form.formState.errors?.description?.message}
isRequired={false}
className="grow flex flex-col"
>
<Textarea
id="description"
onChange={(e) => form.setValue("description", e.target.value)}
placeholder="Briefly describe what your contract does."
rows={2}
className="bg-card grow"
value={form.watch("description")}
/>
<FormErrorMessage>
{form.formState.errors?.description?.message}
</FormErrorMessage>
</FormControl>
</Flex>
</Flex>
</FormFieldSetup>
</div>
</div>
<div>
<Heading mb={2} size="title.md">
README
</Heading>
<Text mb={4} size="body.md">
<h2 className="text-xl font-semibold mb-1 tracking-tight">README</h2>
<p className="text-muted-foreground text-sm mb-5">
Describe what your contract does and how it should be used. Markdown
formatting is supported.
</Text>
<FormControl isInvalid={!!form.formState.errors.readme}>
<Tabs colorScheme="gray" isLazy lazyBehavior="keepMounted">
<TabList
borderBottomColor="borderColor"
borderBottomWidth="1px"
px={0}
>
<Tab gap={2}>
<Heading size="label.lg">Write</Heading>
</Tab>
<Tab gap={2}>
<Heading size="label.lg">Preview</Heading>
</Tab>
</TabList>
<TabPanels pt={2}>
<TabPanel pb={0} px={0}>
<Textarea
{...form.register("readme")}
placeholder="Describe how users can use this contract. Add links if relevant."
rows={12}
/>
<FormErrorMessage>
{form.formState.errors?.readme?.message}
</FormErrorMessage>
</TabPanel>
<TabPanel pb={0} px={0}>
<Card>
<MarkdownRenderer markdownText={form.watch("readme") || ""} />
</Card>
</TabPanel>
</TabPanels>
</Tabs>
</FormControl>
</p>
<TabButtons
tabClassName="!text-sm"
tabs={[
{
name: "Write",
onClick: () => setReadmeTab("write"),
isActive: readmeTab === "write",
},
{
name: "Preview",
onClick: () => setReadmeTab("preview"),
isActive: readmeTab === "preview",
},
]}
/>
<div className="mt-2">
{readmeTab === "write" && (
<Textarea
{...form.register("readme")}
placeholder="Describe how users can use this contract. Add links if relevant."
rows={12}
className="bg-card"
/>
)}
{readmeTab === "preview" && (
<div className="p-4 rounded-lg border border-border bg-card">
<MarkdownRenderer markdownText={form.watch("readme") || ""} />
</div>
)}
</div>
{form.formState.errors.readme && (
<p className="text-red-500 text-sm mt-2">
{form.formState.errors.readme.message}
</p>
)}
</div>
<ExternalLinksFieldset />
<div>
<Heading mb={2} size="title.md">
Version information
</Heading>
<Text mb={4} size="body.md">
<h2 className="text-xl font-semibold mb-1">Version information</h2>
<p className="text-muted-foreground text-sm mb-5">
Set your contract version number, add release notes, and link to your
contract&apos;s audit report.
</Text>
<Flex flexDir="column" gap={6}>
<FormControl isInvalid={!!form.formState.errors.version} isRequired>
<Flex alignItems="center" mb={1}>
<FormLabel flex="1" mb={0}>
Version
</FormLabel>
{latestVersion && (
<Text size="body.md">latest version: {latestVersion}</Text>
)}
</Flex>
</p>
<div className="space-y-6">
<FormFieldSetup
htmlFor="version"
label={
<div className="flex items-center justify-between w-full">
<span>Version</span>
{latestVersion && (
<span className="text-sm text-muted-foreground">
latest version: {latestVersion}
</span>
)}
</div>
}
errorMessage={form.formState.errors?.version?.message}
isRequired={true}
>
<Input
id="version"
onChange={handleVersionChange}
className="bg-card"
placeholder={placeholderVersion}
value={form.watch("version")}
/>
<FormErrorMessage>
{form.formState.errors?.version?.message}
</FormErrorMessage>
</FormControl>
</FormFieldSetup>
{latestVersion && (
<FormControl isInvalid={!!form.formState.errors.changelog}>
<FormLabel>Release notes</FormLabel>
<Tabs colorScheme="gray" isLazy lazyBehavior="keepMounted">
<TabList
borderBottomColor="borderColor"
borderBottomWidth="1px"
px={0}
>
<Tab gap={2}>
<Heading size="label.lg">Write</Heading>
</Tab>
<Tab gap={2}>
<Heading size="label.lg">Preview</Heading>
</Tab>
</TabList>
<TabPanels pt={2}>
<TabPanel pb={0} px={0}>
<Textarea
{...form.register("changelog")}
placeholder="Mention what is new in this version of your contract."
<div>
<h3 className="text-sm font-medium mb-2">Release notes</h3>
{form.formState.errors?.changelog?.message && (
<p className="text-red-500 text-sm mt-2">
{form.formState.errors.changelog.message}
</p>
)}
<TabButtons
tabClassName="!text-sm"
tabs={[
{
name: "Write",
onClick: () => setChangelogTab("write"),
isActive: changelogTab === "write",
},
{
name: "Preview",
onClick: () => setChangelogTab("preview"),
isActive: changelogTab === "preview",
},
]}
/>
<div className="mt-2">
{changelogTab === "write" && (
<Textarea
{...form.register("changelog")}
placeholder="Mention what is new in this version of your contract."
className="bg-card"
/>
)}
{changelogTab === "preview" && (
<div className="p-4 rounded-lg border bg-card">
<MarkdownRenderer
markdownText={form.watch("changelog") || ""}
/>
<FormErrorMessage>
{form.formState.errors?.changelog?.message}
</FormErrorMessage>
</TabPanel>
<TabPanel pb={0} px={0}>
<Card>
<MarkdownRenderer
markdownText={form.watch("changelog") || ""}
/>
</Card>
</TabPanel>
</TabPanels>
</Tabs>
</FormControl>
</div>
)}
</div>
</div>
)}
<FormControl isInvalid={!!form.formState.errors.audit}>
<FormLabel>Audit report</FormLabel>
<FormFieldSetup
label="Audit report"
errorMessage={form.formState.errors?.audit?.message}
isRequired={false}
helperText="You can add a IPFS hash or URL pointing to an audit report, or add a file and we'll upload it to IPFS."
>
<SolidityInput
client={client}
solidityType="string"
{...form.register("audit")}
placeholder="ipfs://... or https://..."
className="bg-card"
/>
<FormHelperText>
<Text size="body.sm">
You can add a IPFS hash or URL pointing to an audit report, or
add a file and we&apos;ll upload it to IPFS.
</Text>
</FormHelperText>
</FormControl>
</Flex>
</FormFieldSetup>
</div>
</div>
<div>
<Heading mb={2} size="title.md">
Deployment options
</Heading>
<Text mb={4} size="body.md">
<h2 className="text-xl font-semibold mb-1">Deployment options</h2>
<p className="text-muted-foreground text-sm mb-3">
Choose how users will deploy your published contract.
</Text>
<Flex flexDir="column" gap={2} width="full">
</p>
<div className="flex flex-col md:flex-row gap-4 w-full">
<SelectOption
className="w-full"
description="Users will directly deploy the full contract."
@@ -282,8 +301,8 @@ export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
name="Deploy via factory"
onClick={() => form.setValue("deployType", "autoFactory")}
/>
</Flex>
</div>
</div>
</Flex>
</fieldset>
);
};
}
@@ -1,5 +1,3 @@
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { useFormContext } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
@@ -10,57 +8,55 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface NetworksFieldsetProps {
fromStandard?: boolean;
client: ThirdwebClient;
}
export const NetworksFieldset: React.FC<NetworksFieldsetProps> = ({
fromStandard,
client,
}) => {
const form = useFormContext();
return (
<div className={cn("flex flex-col", fromStandard ? "gap-6" : "gap-4")}>
<div className="flex flex-col gap-2">
<Heading size={fromStandard ? "title.lg" : "title.md"}>
Networks that your contract can be deployed to
</Heading>
</div>
<div className="flex flex-col gap-2" data-required>
<Select
onValueChange={(value) =>
form.setValue("networksForDeployment.allNetworks", value === "all")
}
value={
form.watch("networksForDeployment.allNetworks") ? "all" : "specific"
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All networks</SelectItem>
<SelectItem value="specific">Specific networks</SelectItem>
</SelectContent>
</Select>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight mb-1">
Select networks
</h2>
<p className="text-muted-foreground text-sm mb-4">
Select the networks that your contract can be deployed to
</p>
</div>
<Select
onValueChange={(value) =>
form.setValue("networksForDeployment.allNetworks", value === "all")
}
value={
form.watch("networksForDeployment.allNetworks") ? "all" : "specific"
}
>
<SelectTrigger className="w-full bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All networks</SelectItem>
<SelectItem value="specific">Specific networks</SelectItem>
</SelectContent>
</Select>
{!form.watch("networksForDeployment.allNetworks") && (
<div className="flex flex-col gap-2">
<Text>Please select the networks you want to enable:</Text>
<MultiNetworkSelector
client={client}
onChange={(chainIds) =>
form.setValue("networksForDeployment.networksEnabled", chainIds)
}
selectedChainIds={
form.watch("networksForDeployment.networksEnabled") || []
}
/>
</div>
<MultiNetworkSelector
className="bg-card"
client={client}
onChange={(chainIds) =>
form.setValue("networksForDeployment.networksEnabled", chainIds)
}
selectedChainIds={
form.watch("networksForDeployment.networksEnabled") || []
}
/>
)}
</div>
);
@@ -1,20 +1,16 @@
import type { AbiParameter } from "abitype";
import { Button } from "chakra/button";
import { PlusIcon } from "lucide-react";
import { useFieldArray, useFormContext } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { RefContractImplInput } from "./ref-input-impl";
interface RefInputImplFieldsetProps {
export function RefInputImplFieldset(props: {
param: AbiParameter;
client: ThirdwebClient;
}
export const RefInputImplFieldset: React.FC<RefInputImplFieldsetProps> = ({
param,
client,
}) => {
}) {
const { param, client } = props;
const form = useFormContext();
const { fields, append, remove } = useFieldArray({
@@ -63,4 +59,4 @@ export const RefInputImplFieldset: React.FC<RefInputImplFieldsetProps> = ({
)}
</div>
);
};
}
@@ -1,85 +1,82 @@
"use client";
import { Edit3Icon } from "lucide-react";
import Link from "next/link";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import {
AccountAddress,
AccountAvatar,
AccountBlobbie,
AccountName,
AccountProvider,
} from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useEns } from "@/hooks/contract-hooks";
import { replaceDeployerAddress } from "@/lib/publisher-utils";
import { shortenIfAddress } from "@/utils/usedapp-external";
interface PublisherHeaderProps {
wallet: string;
client: ThirdwebClient;
}
export const PublisherHeader: React.FC<PublisherHeaderProps> = ({
export function PublisherLink({
wallet,
client,
}) => {
}: {
wallet: string;
client: ThirdwebClient;
}) {
const ensQuery = useEns({
addressOrEnsName: wallet,
client,
});
return (
<div className="flex w-full flex-col gap-4">
<h4 className="font-semibold text-lg tracking-tight">Published by</h4>
<AccountProvider
// passing zero address during loading time to prevent the component from crashing
address={ensQuery.data?.address || ZERO_ADDRESS}
client={client}
>
<div className="relative flex items-center gap-3">
<AccountProvider
// passing zero address during loading time to prevent the component from crashing
address={ensQuery.data?.address || ZERO_ADDRESS}
client={client}
>
<div className="flex items-center gap-2.5 relative">
<div>
<AccountAvatar
className="size-10 rounded-full border border-border border-solid object-cover"
className="size-[34px] rounded-full border border-border border-solid object-cover"
fallbackComponent={
<AccountBlobbie className="size-10 rounded-full" />
<div className="size-[34px] rounded-full flex items-center justify-center border bg-card">
<Edit3Icon className="size-3.5 text-muted-foreground" />
</div>
}
loadingComponent={<Skeleton className="size-10 rounded-full" />}
loadingComponent={<Skeleton className="size-[34px] rounded-full" />}
/>
<Link
className="before:absolute before:inset-0 hover:underline"
href={replaceDeployerAddress(
`/${ensQuery.data?.ensName || wallet}`,
)}
rel="noopener noreferrer"
target="_blank"
>
<AccountName
className="font-medium"
fallbackComponent={
// When social profile API support other TLDs as well - we can remove this condition
ensQuery.data?.ensName ? (
<span> {ensQuery.data?.ensName} </span>
) : (
<AccountAddress
formatFn={(addr) =>
shortenIfAddress(replaceDeployerAddress(addr))
}
/>
)
}
formatFn={(name) => replaceDeployerAddress(name)}
loadingComponent={<Skeleton className="h-8 w-40" />}
/>
</Link>
</div>
</AccountProvider>
<Button asChild className="bg-card" variant="outline">
<Link href={replaceDeployerAddress(`/${wallet}`)}>
View all contracts
<Link
className="hover:underline before:absolute before:inset-0 space-y-1.5"
href={replaceDeployerAddress(`/${ensQuery.data?.ensName || wallet}`)}
rel="noopener noreferrer"
target="_blank"
>
<div className="text-sm font-medium text-foreground leading-none">
Published by
</div>
<AccountName
className="text-sm text-foreground leading-none"
fallbackComponent={
// When social profile API support other TLDs as well - we can remove this condition
ensQuery.data?.ensName ? (
<span className="text-sm text-muted-foreground leading-none">
{ensQuery.data?.ensName}
</span>
) : (
<AccountAddress
className="text-sm text-muted-foreground leading-none"
formatFn={(addr) =>
shortenIfAddress(replaceDeployerAddress(addr))
}
/>
)
}
formatFn={(name) => replaceDeployerAddress(name)}
loadingComponent={<Skeleton className="h-[17px] w-40" />}
/>
</Link>
</Button>
</div>
</div>
</AccountProvider>
);
};
}
@@ -5,7 +5,7 @@ import Image from "next/image";
import type { FetchDeployMetadataResult } from "thirdweb/contract";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { replaceIpfsUrl } from "@/lib/sdk";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import generalContractIcon from "../../../../../public/assets/tw-icons/general.png";
type ContractIdImageProps = {
@@ -29,13 +29,13 @@ export const ContractIdImage: React.FC<ContractIdImageProps> = ({
className="size-8 rounded-full"
src={
DASHBOARD_THIRDWEB_SECRET_KEY
? replaceIpfsUrl(
logo,
getConfiguredThirdwebClient({
? resolveSchemeWithErrorHandler({
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
)
uri: logo,
})
: logo
}
/>
@@ -1,32 +1,11 @@
"use client";
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Flex,
GridItem,
List,
ListItem,
SimpleGrid,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
useBreakpointValue,
} from "@chakra-ui/react";
import {
type Abi,
type AbiEvent,
type AbiFunction,
formatAbiItem,
} from "abitype";
import { Button } from "chakra/button";
import { Card } from "chakra/card";
import { Heading } from "chakra/heading";
import { Link } from "chakra/link";
import { Text } from "chakra/text";
import { CircleAlertIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import * as ERC20Ext from "thirdweb/extensions/erc20";
@@ -35,23 +14,19 @@ import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import * as ERC4337Ext from "thirdweb/extensions/erc4337";
import { useActiveAccount } from "thirdweb/react";
import { toFunctionSelector } from "thirdweb/utils";
import {
type CodeEnvironment,
CodeSegment,
} from "@/components/blocks/code/code-segment.client";
import { getContractFunctionsFromAbi } from "@/components/contract-components/getContractFunctionsFromAbi";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabButtons } from "@/components/ui/tabs";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useContractEvents } from "@/hooks/contract-hooks";
interface CodeOverviewProps {
abi?: Abi;
contractAddress?: string;
onlyInstall?: boolean;
chainId: number;
noSidebar?: boolean;
}
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
export const COMMANDS = {
events: {
@@ -434,7 +409,7 @@ const EXTENSION_NAMESPACE_FUNCTION_MAPPING = {
>
>;
interface SnippetOptions {
type SnippetOptions = {
contractAddress?: string;
fn?: AbiFunction | AbiEvent;
args?: string[];
@@ -442,7 +417,7 @@ interface SnippetOptions {
clientId?: string;
chainId?: number;
extensionNamespace?: string;
}
};
export function formatSnippet(
// biome-ignore lint/suspicious/noExplicitAny: FIXME
@@ -537,13 +512,21 @@ export function formatSnippet(
return code;
}
export const CodeOverview: React.FC<CodeOverviewProps> = ({
abi,
contractAddress = "0x...",
onlyInstall = false,
noSidebar = false,
chainId,
}) => {
export function CodeOverview(props: {
abi?: Abi;
contractAddress?: string;
onlyInstall?: boolean;
chainId: number;
noSidebar?: boolean;
}) {
const {
abi,
contractAddress = "0x...",
onlyInstall = false,
noSidebar = false,
chainId,
} = props;
const searchParams = useSearchParams();
const defaultEnvironment = searchParams?.get("environment") as
| CodeEnvironment
@@ -556,7 +539,7 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
const [tab, setTab] = useState("write");
const address = useActiveAccount()?.address;
const isMobile = useBreakpointValue({ base: true, md: false });
const isMobile = useIsMobile();
const functionSelectors = useMemo(() => {
return (abi || [])
@@ -626,340 +609,306 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
events && events.length > 0 ? events[0] : undefined,
);
const tabList = useMemo(() => {
const _tablist: Array<{
name: string;
onClick: () => void;
isActive: boolean;
}> = [];
if (writeFunctions && writeFunctions.length > 0) {
_tablist.push({
name: "Write",
onClick: () => setTab("write"),
isActive: tab === "write",
});
}
if (readFunctions && readFunctions.length > 0) {
_tablist.push({
name: "Read",
onClick: () => setTab("read"),
isActive: tab === "read",
});
}
if (events && events.length > 0) {
_tablist.push({
name: "Events",
onClick: () => setTab("events"),
isActive: tab === "events",
});
}
return _tablist;
}, [writeFunctions, readFunctions, events, tab]);
return (
<SimpleGrid
columns={12}
display={{ base: "block", md: "grid" }}
gap={16}
justifyContent="space-between"
maxW="full"
overflowX={{ base: "scroll", md: "hidden" }}
>
<GridItem as={Flex} colSpan={12} flexDir="column" gap={12}>
{isAccountFactory && (
<Flex flexDirection="column" gap={4}>
<Flex flexDir="column" gap={6}>
<Heading size="title.md">Integrate your account factory</Heading>
<Alert
alignItems="start"
as={Flex}
borderRadius="md"
flexDir="column"
gap={2}
status="info"
>
<Flex justifyContent="start">
<AlertIcon />
<AlertTitle>Account Factory</AlertTitle>
</Flex>
<AlertDescription>
The recommended way to use account factories is to integrate
the{" "}
<UnderlineLink
className="text-primary-500"
href="https://portal.thirdweb.com/connect/account-abstraction/overview"
rel="noopener noreferrer"
target="_blank"
>
thirdweb SDK
</UnderlineLink>{" "}
in your applications. This will ensure account contracts are
deployed for your users only when they need it.
</AlertDescription>
</Alert>
<Flex flexDir="column" gap={2}>
<CodeSegment
environment={environment}
hideTabs
setEnvironment={setEnvironment}
snippet={formatSnippet(
(WALLETS_SNIPPETS.find((w) => w.id === "smart-wallet")
?.supportedLanguages || {}) as Record<
CodeEnvironment,
string
>,
{
address,
chainId,
contractAddress,
},
)}
/>
</Flex>
</Flex>
</Flex>
<div className="space-y-12">
{isAccountFactory && (
<AccountFactorySection
address={address}
chainId={chainId}
contractAddress={contractAddress}
environment={environment}
setEnvironment={setEnvironment}
/>
)}
<div className="flex flex-col gap-4">
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
{isAccountFactory
? "Direct contract interaction (advanced)"
: chainInfo
? "Interact with this contract from your app"
: "Getting Started"}
</h2>
</div>
{(noSidebar || isMobile) && (
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Choose a language:</p>
<CodeSegment
environment={environment}
onlyTabs
setEnvironment={setEnvironment}
snippet={COMMANDS.install}
/>
</div>
)}
<Flex flexDirection="column" gap={4}>
<Flex flexDir="column" gap={2}>
<Heading size="title.md">
{isAccountFactory
? "Direct contract interaction (advanced)"
: chainInfo
? "Interact with this contract from your app"
: "Getting Started"}
</Heading>
</Flex>
{(noSidebar || isMobile) && (
<Flex flexDir="column" gap={2}>
<Text>Choose a language:</Text>
<div className="flex flex-col gap-2">
{environment === "react-native" || environment === "unity" ? (
<p className="text-sm text-muted-foreground">
Install the latest version of the SDK.{" "}
<UnderlineLink
className="text-primary-500"
href={`https://portal.thirdweb.com/${environment}`}
rel="noopener noreferrer"
target="_blank"
>
Learn how in the{" "}
{environment === "react-native" ? "React Native" : "Unity"}{" "}
documentation
</UnderlineLink>
.
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
Install the latest version of the SDK:
</p>
<CodeSegment
environment={environment}
onlyTabs
hideTabs
isInstallCommand
setEnvironment={setEnvironment}
snippet={COMMANDS.install}
/>
</Flex>
</>
)}
<Flex flexDir="column" gap={2}>
{environment === "react-native" || environment === "unity" ? (
<Text>
Install the latest version of the SDK. <br />
<UnderlineLink
className="text-primary-500"
href={`https://portal.thirdweb.com/${environment}`}
rel="noopener noreferrer"
target="_blank"
>
Learn how in the{" "}
{environment === "react-native" ? "React Native" : "Unity"}{" "}
documentation
</UnderlineLink>
.
</Text>
) : (
<>
<Text>Install the latest version of the SDK:</Text>
<CodeSegment
environment={environment}
hideTabs
isInstallCommand
setEnvironment={setEnvironment}
snippet={COMMANDS.install}
/>
</>
)}
</Flex>
<Flex flexDir="column" gap={2}>
<Text>Initialize the SDK and contract on your project:</Text>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Initialize the SDK and contract on your project:
</p>
<CodeSegment
environment={environment}
hideTabs
setEnvironment={setEnvironment}
snippet={formatSnippet(COMMANDS.setup, {
chainId,
contractAddress,
})}
/>
<p className="text-sm text-muted-foreground">
You will need to pass a client ID/secret key to use thirdweb&apos;s
infrastructure services. If you don&apos;t have any API keys yet you
can create one by creating a project for free from the{" "}
<UnderlineLink href="/team">dashboard</UnderlineLink>.
</p>
</div>
</div>
{!onlyInstall && (
<div>
<h2 className="text-lg font-semibold tracking-tight mb-3">
All Functions & Events
</h2>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-4">
<div className="border bg-card rounded-lg overflow-hidden ">
{(writeFunctions || []).length > 0 ||
(readFunctions || []).length > 0 ? (
<div>
{/* tabs */}
<div className="bg-background">
<TabButtons
tabs={tabList}
tabClassName="!text-sm"
tabContainerClassName="px-3 pt-2"
/>
</div>
{tab === "write" && (
<AccentButtonListContainer>
{writeFunctions?.map((fn) => (
<AccentButton
isActive={write?.signature === fn.signature}
key={fn.signature}
label={fn.name}
onClick={() => {
setTab("write");
setWrite(fn);
}}
/>
))}
</AccentButtonListContainer>
)}
{tab === "read" && (
<AccentButtonListContainer>
{readFunctions?.map((fn) => (
<AccentButton
isActive={read?.signature === fn.signature}
key={fn.signature}
onClick={() => {
setTab("read");
setRead(fn);
}}
label={fn.name}
/>
))}
</AccentButtonListContainer>
)}
{tab === "events" && (
<AccentButtonListContainer>
{events?.map((ev) => (
<AccentButton
isActive={event?.name === ev.name}
key={ev.name}
onClick={() => {
setTab("events");
setEvent(ev);
}}
label={ev.name}
/>
))}
</AccentButtonListContainer>
)}
</div>
) : null}
</div>
<CodeSegment
environment={environment}
hideTabs
setEnvironment={setEnvironment}
snippet={formatSnippet(COMMANDS.setup, {
chainId,
contractAddress,
})}
/>
<Text>
You will need to pass a client ID/secret key to use
thirdweb&apos;s infrastructure services. If you don&apos;t have
any API keys yet you can create one by creating a project for free
from the{" "}
<Link color="primary.500" href="/team">
dashboard
</Link>
.
</Text>
</Flex>
</Flex>
{!onlyInstall && (
<Flex flexDirection="column" gap={6}>
<Heading size="title.md">All Functions & Events</Heading>
<SimpleGrid columns={12} gap={3} height="100%">
<GridItem
as={Card}
colSpan={{ base: 12, md: 4 }}
height="100%"
overflow="auto"
overflowY="auto"
pt={0}
px={0}
>
<List height="100%" overflowX="hidden">
{((writeFunctions || []).length > 0 ||
(readFunctions || []).length > 0) && (
<Tabs
colorScheme="gray"
display="flex"
flexDir="column"
h="100%"
position="relative"
>
<TabList as={Flex}>
{(writeFunctions || []).length > 0 && (
<Tab flex="1 1 0" gap={2}>
<Heading color="inherit" my={1} size="label.md">
Write
</Heading>
</Tab>
)}
{(readFunctions || []).length > 0 && (
<Tab flex="1 1 0" gap={2}>
<Heading color="inherit" my={1} size="label.md">
Read
</Heading>
</Tab>
)}
{(events || []).length > 0 && (
<Tab flex="1 1 0" gap={2}>
<Heading color="inherit" my={1} size="label.md">
Events
</Heading>
</Tab>
)}
</TabList>
<TabPanels h="auto" overflow="auto">
<TabPanel>
{writeFunctions?.map((fn) => (
<ListItem key={fn.signature} my={0.5}>
<Button
_hover={{
opacity: 1,
textDecor: "underline",
}}
color="heading"
fontFamily="mono"
fontWeight={
tab === "write" &&
write?.signature === fn.signature
? 600
: 400
}
onClick={() => {
setTab("write");
setWrite(fn);
}}
opacity={
tab === "write" &&
write?.signature === fn.signature
? 1
: 0.65
}
size="sm"
variant="link"
>
{fn.name}
</Button>
</ListItem>
))}
</TabPanel>
<TabPanel>
{readFunctions?.map((fn) => (
<ListItem key={fn.signature} my={0.5}>
<Button
_hover={{
opacity: 1,
textDecor: "underline",
}}
color="heading"
fontFamily="mono"
fontWeight={
tab === "read" &&
read?.signature === fn.signature
? 600
: 400
}
onClick={() => {
setTab("read");
setRead(fn);
}}
opacity={
tab === "read" &&
read?.signature === fn.signature
? 1
: 0.65
}
size="sm"
variant="link"
>
{fn.name}
</Button>
</ListItem>
))}
</TabPanel>
<TabPanel>
{events?.map((ev) => (
<ListItem key={ev.name} my={0.5}>
<Button
_hover={{
opacity: 1,
textDecor: "underline",
}}
color="heading"
fontFamily="mono"
fontWeight={
tab === "events" &&
(event as AbiEvent).name ===
(ev as AbiEvent).name
? 600
: 400
}
onClick={() => {
setTab("events");
setEvent(ev);
}}
opacity={
tab === "events" &&
(event as AbiEvent).name ===
(ev as AbiEvent).name
? 1
: 0.65
}
size="sm"
variant="link"
>
{ev.name}
</Button>
</ListItem>
))}
</TabPanel>
</TabPanels>
</Tabs>
)}
</List>
</GridItem>
<GridItem
as={Card}
colSpan={{ base: 12, md: 8 }}
height="100%"
overflow="auto"
>
<CodeSegment
environment={environment}
setEnvironment={setEnvironment}
snippet={formatSnippet(
// biome-ignore lint/suspicious/noExplicitAny: FIXME
COMMANDS[tab as keyof typeof COMMANDS] as any,
{
args:
abi
?.filter(
(f) => f.type === "function" || f.type === "event",
)
?.find(
(f) =>
f.name ===
(tab === "read"
? read?.name
: tab === "write"
? write?.name
: event?.name),
)
?.inputs.map((i) => i.name || "") || [],
snippet={formatSnippet(
// biome-ignore lint/suspicious/noExplicitAny: FIXME
COMMANDS[tab as keyof typeof COMMANDS] as any,
{
args:
abi
?.filter(
(f) => f.type === "function" || f.type === "event",
)
?.find(
(f) =>
f.name ===
(tab === "read"
? read?.name
: tab === "write"
? write?.name
: event?.name),
)
?.inputs.map((i) => i.name || "") || [],
chainId,
contractAddress,
extensionNamespace,
fn:
tab === "read" ? read : tab === "write" ? write : event,
},
)}
/>
</GridItem>
</SimpleGrid>
</Flex>
)}
</GridItem>
</SimpleGrid>
chainId,
contractAddress,
extensionNamespace,
fn: tab === "read" ? read : tab === "write" ? write : event,
},
)}
/>
</div>
</div>
)}
</div>
);
};
}
function AccentButtonListContainer(props: { children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5 px-2 py-3 max-h-[300px] lg:max-h-[700px] overflow-y-auto">
{props.children}
</div>
);
}
function AccentButton(props: {
isActive: boolean;
onClick: () => void;
label: string;
}) {
return (
<Button
className={cn(
"text-sm justify-start h-auto py-1.5 px-2 text-muted-foreground font-normal font-mono",
props.isActive && "text-foreground bg-accent",
)}
onClick={props.onClick}
size="sm"
variant="ghost"
>
{props.label}
</Button>
);
}
function AccountFactorySection(props: {
environment: CodeEnvironment;
setEnvironment: (environment: CodeEnvironment) => void;
address?: string;
chainId: number;
contractAddress: string;
}) {
return (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-semibold tracking-tight">
Integrate your account factory
</h2>
<Alert variant="info">
<CircleAlertIcon className="w-5 h-5 text-blue-500" />
<AlertTitle>Account Factory</AlertTitle>
<AlertDescription>
The recommended way to use account factories is to integrate the{" "}
<UnderlineLink
href="https://portal.thirdweb.com/transactions/sponsor"
rel="noopener noreferrer"
target="_blank"
>
thirdweb SDK
</UnderlineLink>{" "}
in your applications. This will ensure account contracts are
deployed for your users only when they need it.
</AlertDescription>
</Alert>
</div>
<div className="flex flex-col gap-2">
<CodeSegment
environment={props.environment}
hideTabs
setEnvironment={props.setEnvironment}
snippet={formatSnippet(
(WALLETS_SNIPPETS.find((w) => w.id === "smart-wallet")
?.supportedLanguages || {}) as Record<CodeEnvironment, string>,
{
address: props.address,
chainId: props.chainId,
contractAddress: props.contractAddress,
},
)}
/>
</div>
</div>
);
}
@@ -22,15 +22,16 @@ export default function ContractFunctionComment({
return null;
}
return (
<>
<p className="mt-6">
<div>
<h3 className="text-base font-semibold mt-6 mb-3">
About this function <Badge>Beta</Badge>
</p>
</h3>
<CodeClient
code={query.data}
copyButtonClassName="hidden"
lang="wikitext"
lang="solidity"
className="bg-background"
/>
</>
</div>
);
}
@@ -1,32 +1,7 @@
"use client";
import {
Box,
Divider,
Flex,
GridItem,
Image,
List,
SimpleGrid,
Tab,
TabList,
Table,
TabPanel,
TabPanels,
Tabs,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import type { AbiEvent, AbiFunction } from "abitype";
import { Button } from "chakra/button";
import { Card } from "chakra/card";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { SearchIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import {
type Dispatch,
lazy,
@@ -45,12 +20,21 @@ import {
type CodeEnvironment,
CodeSegment,
} from "@/components/blocks/code/code-segment.client";
import { camelToTitle } from "@/components/solidity-inputs/helpers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TabButtons } from "@/components/ui/tabs";
import { useContractFunctionSelectors } from "@/hooks/contract-ui/useContractFunctionSelectors";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { COMMANDS, formatSnippet } from "../code-overview";
import { InteractiveAbiFunction } from "./interactive-abi-function";
@@ -137,25 +121,21 @@ function ContractFunctionInner(props: ContractFunctionProps) {
});
return (
<Flex direction="column" gap={1.5}>
<Flex alignItems="center" direction="row" flexWrap="wrap" gap={2}>
<Flex alignItems="baseline" flexWrap="wrap" gap={1}>
<Heading size="subtitle.md">{camelToTitle(fn.name)}</Heading>
<Heading className="text-muted-foreground" size="subtitle.sm">
({fn.name})
</Heading>
</Flex>
<div className="flex flex-col gap-1.5">
<div className="flex items-center flex-wrap gap-2 border-b pb-3 mb-3">
<h3 className="text-lg font-semibold">{fn.name}</h3>
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
{functionSelector && (
<CopyTextButton
className="ml-auto text-xs"
className="ml-auto text-xs font-mono py-1"
copyIconPosition="right"
textToCopy={functionSelector}
textToShow={functionSelector}
tooltip="The selector of this function"
/>
)}
</Flex>
</div>
{isFunction && (
<InteractiveAbiFunction
abiFunction={fn}
@@ -166,20 +146,20 @@ function ContractFunctionInner(props: ContractFunctionProps) {
)}
{codeSnippet && (
<>
<Heading mt={6} size="subtitle.md">
<div>
<h3 className="text-base font-semibold mt-6 mb-3">
Use this function in your app
</Heading>
<Divider mb={2} />
</h3>
<CodeSegment
environment={environment}
setEnvironment={setEnvironment}
snippet={codeSnippet}
codeContainerClassName="bg-background"
/>
</>
</div>
)}
<ContractFunctionComment contract={contract} functionName={fn.name} />
</Flex>
</div>
);
}
@@ -188,84 +168,48 @@ function ContractFunctionInputs(props: { fn: AbiFunction | AbiEvent }) {
const isFunction = "stateMutability" in fn;
return (
<Flex direction="column" gap={1.5}>
<Flex
alignItems={{ base: "start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={2}
>
<Flex alignItems="baseline" flexWrap="wrap" gap={1}>
<Heading size="subtitle.md">{camelToTitle(fn.name)}</Heading>
<Heading className="text-muted-foreground" size="subtitle.sm">
({fn.name})
</Heading>
</Flex>
<div className="flex flex-col gap-1.5">
<div className="flex items-center flex-wrap gap-2 border-b pb-2.5 mb-3">
<h3 className="text-base font-semibold">{fn.name}</h3>
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
</Flex>
</div>
{fn.inputs?.length ? (
<>
<Divider my={2} />
<Flex flexDir="column" gap={3}>
<Heading size="label.lg">Inputs</Heading>
<Box
borderTopRadius="lg"
overflowX="auto"
p={0}
position="relative"
>
<Table size="sm">
<Thead>
<Tr>
<Th border="none">
<Heading as="label" color="faded" size="label.sm">
Name
</Heading>
</Th>
<Th border="none">
<Heading as="label" color="faded" size="label.sm">
Type
</Heading>
</Th>
</Tr>
</Thead>
<Tbody>
{fn.inputs.map((input, idx) => (
<Tr
_last={{ borderBottomWidth: 0 }}
borderBottomWidth={1}
key={`${input.name}+${idx}}`}
>
<Td
borderBottomColor="borderColor"
borderBottomWidth="inherit"
>
{input?.name ? (
<Text fontFamily="mono">{input.name}</Text>
) : (
<Text
className="text-muted-foreground"
fontStyle="italic"
>
No name defined
</Text>
)}
</Td>
<Td
borderBottomColor="borderColor"
borderBottomWidth="inherit"
>
<Text fontFamily="mono">{input.type}</Text>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Flex>
</>
) : null}
</Flex>
<div>
<h4 className="text-base font-semibold mb-3">Inputs</h4>
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fn.inputs.map((input, idx) => (
<TableRow key={`${input.name}+${idx}}`}>
<TableCell className="font-medium py-3">
{input?.name ? (
<span className="font-mono">{input.name}</span>
) : (
<span className="text-muted-foreground italic">
No name defined
</span>
)}
</TableCell>
<TableCell className="py-3">
<span className="font-mono">{input.type}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
) : (
<p className="text-muted-foreground text-base">No inputs required</p>
)}
</div>
);
}
@@ -280,6 +224,8 @@ type ExtensionFunctions = {
functions: AbiFunction[];
};
type ReadOrWriteTab = "read" | "write";
export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
fnsOrEvents,
contract,
@@ -296,6 +242,7 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
});
return results;
}, [fnsOrEvents]);
const writeFunctions: ExtensionFunctions[] = useMemo(() => {
return functionsWithExtension
.map((e) => {
@@ -313,6 +260,7 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
})
.filter((e) => e !== undefined) as ExtensionFunctions[];
}, [functionsWithExtension]);
const viewFunctions: ExtensionFunctions[] = useMemo(() => {
return functionsWithExtension
.map((e) => {
@@ -336,166 +284,117 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
return fnsOrEvents.filter((fn) => !("stateMutability" in fn)) as AbiEvent[];
}, [fnsOrEvents]);
// Load state from the URL
const searchParams = useSearchParams();
const _selector = searchParams?.get("selector");
const _item = _selector
? fnsOrEvents.find((o) => {
if (o.type === "function") {
const selector = toFunctionSelector(o as AbiFunction);
return selector === _selector;
}
return null;
})
: undefined;
const defaultSelected = fnsOrEvents[0];
const [selectedFunction, setSelectedFunction] = useState<
AbiFunction | AbiEvent | undefined
>(_item ?? fnsOrEvents[0]);
// Set the active tab to Write or Read depends on the `_item`
const _defaultTabIndex =
_item &&
"stateMutability" in _item &&
(_item.stateMutability === "view" || _item.stateMutability === "pure")
? 1
: 0;
>(defaultSelected);
const [_keywordSearch, setKeywordSearch] = useState<string>("");
const [keywordSearch] = useDebounce(_keywordSearch, 150);
const [activeTab, setActiveTab] = useState<ReadOrWriteTab>(() => {
const isDefaultSelectedReadFunction = viewFunctions.find((e) =>
e.functions.find((fn) => fn.name === defaultSelected?.name),
);
return isDefaultSelectedReadFunction ? "read" : "write";
});
const functionSection = (e: ExtensionFunctions) => {
const filteredFunctions = keywordSearch
? e.functions.filter((o) =>
o.name.toLowerCase().includes(keywordSearch.toLowerCase()),
)
: e.functions;
return (
<Flex flexDir="column" key={e.extension} mb={6}>
{e.extension ? (
<>
<Flex alignContent="center" alignItems="center" gap={2}>
<Image
alt="Extension detected"
mb="2px"
objectFit="contain"
src="/assets/dashboard/extension-check.svg"
<div key={e.extension} className="pb-6">
<div className="space-y-0.5">
{selectedFunction &&
filteredFunctions.map((fn) => (
<FunctionsOrEventsListItem
fn={fn}
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
selectedFunction={selectedFunction}
setSelectedFunction={setSelectedFunction}
/>
<Heading as="label" size="label.md">
{e.extension}
</Heading>
</Flex>
<Divider my={2} />
</>
) : (
<>
<Flex alignContent="center" alignItems="center" gap={2}>
<Heading as="label" size="label.md">
Other Functions
</Heading>
</Flex>
<Divider my={2} />
</>
)}
{selectedFunction &&
filteredFunctions.map((fn) => (
<FunctionsOrEventsListItem
fn={fn}
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
selectedFunction={selectedFunction}
setSelectedFunction={setSelectedFunction}
/>
))}
</Flex>
))}
</div>
</div>
);
};
return (
<SimpleGrid columns={12} gap={5} height="100%">
<GridItem
as={Card}
className="bg-card"
colSpan={{ base: 12, md: 4 }}
height="100%"
overflow="auto"
overflowY="auto"
pt={0}
px={0}
>
<List height="100%" overflowX="hidden">
{(writeFunctions.length > 0 || viewFunctions.length > 0) && (
<Tabs
colorScheme="gray"
defaultIndex={_defaultTabIndex}
display="flex"
flexDir="column"
h="100%"
position="relative"
>
<TabList as={Flex}>
{writeFunctions.length > 0 && (
<Tab flex="1 1 0" gap={2}>
<Heading color="inherit" my={1} size="label.md">
Write
</Heading>
</Tab>
)}
{viewFunctions.length > 0 && (
<Tab flex="1 1 0" gap={2}>
<Heading color="inherit" my={1} size="label.md">
Read
</Heading>
</Tab>
)}
</TabList>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] h-full bg-card rounded-lg border">
{/* left */}
<div className="overflow-auto border-b lg:border-b-0 lg:border-r">
{(writeFunctions.length > 0 || viewFunctions.length > 0) && (
<div className="flex flex-col h-full relative">
<TabButtons
tabContainerClassName="px-3 pt-2"
tabClassName="!text-sm"
tabs={[
...(viewFunctions.length > 0
? [
{
name: "Read",
onClick: () => setActiveTab("read"),
isActive: activeTab === "read",
},
]
: []),
...(writeFunctions.length > 0
? [
{
name: "Write",
onClick: () => setActiveTab("write"),
isActive: activeTab === "write",
},
]
: []),
]}
/>
<div className="sticky top-0 z-[1]">
<div className="relative w-full">
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
<Input
className="h-auto rounded-none border-x-0 bg-card py-3 pl-9 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => setKeywordSearch(e.target.value)}
placeholder="Search"
value={_keywordSearch}
/>
</div>
</div>
<TabPanels h="auto" overflow="auto">
{writeFunctions.length > 0 && (
<TabPanel>
{writeFunctions.map((e) => functionSection(e))}
</TabPanel>
)}
{viewFunctions.length > 0 && (
<TabPanel>
{viewFunctions.map((e) => functionSection(e))}
</TabPanel>
)}
</TabPanels>
</Tabs>
)}
{events.length > 0 && selectedFunction && (
<Box overflowX="hidden" pt={2} px={4}>
{events.map((fn) => (
<FunctionsOrEventsListItem
fn={fn}
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
selectedFunction={selectedFunction}
setSelectedFunction={setSelectedFunction}
<div className="sticky top-0 z-[1]">
<div className="relative w-full">
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
<Input
className="h-auto rounded-none border-x-0 border-t-0 bg-card py-3 pl-9 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => setKeywordSearch(e.target.value)}
placeholder="Search"
value={_keywordSearch}
/>
))}
</Box>
)}
</List>
</GridItem>
<GridItem
as={Card}
className="bg-card"
colSpan={{ base: 12, md: 8 }}
height="100%"
overflow="auto"
>
</div>
</div>
<div className="h-auto overflow-auto pt-2 px-2">
{activeTab === "write" &&
writeFunctions.length > 0 &&
writeFunctions.map((e) => functionSection(e))}
{activeTab === "read" &&
viewFunctions.length > 0 &&
viewFunctions.map((e) => functionSection(e))}
</div>
</div>
)}
{events.length > 0 && selectedFunction && (
<div className="p-4 space-y-0.5">
{events.map((fn) => (
<FunctionsOrEventsListItem
fn={fn}
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
selectedFunction={selectedFunction}
setSelectedFunction={setSelectedFunction}
/>
))}
</div>
)}
</div>
{/* right */}
<div className="overflow-auto p-4">
{selectedFunction && (
<ContractFunction
contract={contract}
@@ -503,8 +402,8 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
isLoggedIn={isLoggedIn}
/>
)}
</GridItem>
</SimpleGrid>
</div>
</div>
);
};
@@ -524,31 +423,19 @@ const FunctionsOrEventsListItem: React.FC<FunctionsOrEventsListItemProps> = ({
const isActive =
selectedFunction?.name === fn.name &&
selectedFunction.inputs?.length === fn.inputs?.length;
const pathname = usePathname();
const router = useDashboardRouter();
return (
<li className="my-1">
<Button
className={cn(
"!font-medium !text-muted-foreground hover:!text-foreground font-mono",
{
"!text-foreground": isActive,
},
)}
onClick={() => {
setSelectedFunction(fn);
// Only apply to the Explorer page
if (pathname?.endsWith("/explorer") && fn.type === "function") {
const selector = toFunctionSelector(fn);
router.push(`${pathname}?selector=${selector}`);
}
}}
size="sm"
variant="link"
>
{fn.name}
</Button>
</li>
<Button
className={cn(
"text-muted-foreground hover:text-foreground font-mono w-full justify-start h-auto px-2 py-1.5",
isActive && "text-foreground bg-accent",
)}
onClick={() => {
setSelectedFunction(fn);
}}
size="sm"
variant="ghost"
>
{fn.name}
</Button>
);
};
@@ -1,19 +1,12 @@
"use client";
import {
Flex,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from "@chakra-ui/react";
import type { Abi, AbiEvent, AbiFunction } from "abitype";
import { Heading } from "chakra/heading";
import { useState } from "react";
import type { ThirdwebContract } from "thirdweb";
import { SourcesPanel } from "@/components/contract-components/shared/sources-panel";
import type { SourceFile } from "@/components/contract-components/types";
import { CodeOverview } from "@/components/contracts/code-overview";
import { TabButtons } from "@/components/ui/tabs";
import { ContractFunctionsPanel } from "./contract-function";
interface ContractFunctionsOverview {
@@ -22,103 +15,96 @@ interface ContractFunctionsOverview {
contract?: ThirdwebContract;
sources?: SourceFile[];
abi?: Abi;
onlyFunctions?: boolean;
isLoggedIn: boolean;
}
type Tab = "functions" | "events" | "code" | "sources";
export const ContractFunctionsOverview: React.FC<ContractFunctionsOverview> = ({
functions,
events,
contract,
sources,
abi,
onlyFunctions,
isLoggedIn,
}) => {
if (onlyFunctions) {
return (
<Flex flexDir="column" gap={2} height="100%">
{functions && functions.length > 0 && (
<ContractFunctionsPanel
contract={contract}
fnsOrEvents={functions}
isLoggedIn={isLoggedIn}
/>
)}
</Flex>
);
}
const [activeTab, setActiveTab] = useState<Tab | undefined>(() => {
if (functions && functions.length > 0) return "functions";
if (events && events.length > 0) return "events";
if (abi) return "code";
if (sources && sources.length > 0) return "sources";
return undefined;
});
// Tab index: 0 = Functions, 1 = Events, 2 = Code, 3 = Sources
const tabOptions = [
...(functions && functions.length > 0
? [
{
name: "Functions",
onClick: () => setActiveTab("functions"),
isActive: activeTab === "functions",
},
]
: []),
...(events && events.length > 0
? [
{
name: "Events",
onClick: () => setActiveTab("events"),
isActive: activeTab === "events",
},
]
: []),
...(abi
? [
{
name: "Code",
onClick: () => setActiveTab("code"),
isActive: activeTab === "code",
},
]
: []),
...(sources && sources.length > 0
? [
{
name: "Sources",
onClick: () => setActiveTab("sources"),
isActive: activeTab === "sources",
},
]
: []),
];
return (
<Flex flexDir="column" gap={2} w="100%">
<Tabs colorScheme="gray" isLazy lazyBehavior="keepMounted">
<TabList borderBottomColor="borderColor" borderBottomWidth="1px" px={0}>
{functions && functions.length > 0 ? (
<Tab>
<Heading color="inherit" my={1} size="label.lg">
Functions
</Heading>
</Tab>
) : null}
{events?.length ? (
<Tab>
<Heading color="inherit" my={1} size="label.lg">
Events
</Heading>
</Tab>
) : null}
{abi && (
<Tab>
<Heading color="inherit" my={1} size="label.lg">
Code
</Heading>
</Tab>
)}
{sources && sources?.length > 0 && (
<Tab>
<Heading color="inherit" my={1} size="label.lg">
Sources
</Heading>
</Tab>
)}
</TabList>
<TabPanels py={2}>
{functions && functions.length > 0 ? (
<TabPanel h="40rem">
<ContractFunctionsPanel
contract={contract}
fnsOrEvents={functions}
isLoggedIn={isLoggedIn}
/>
</TabPanel>
) : null}
{events && events?.length > 0 ? (
<TabPanel>
<ContractFunctionsPanel
contract={contract}
fnsOrEvents={events}
isLoggedIn={isLoggedIn}
/>
</TabPanel>
) : null}
{abi && (
<TabPanel>
<div className="flex flex-col gap-6">
<CodeOverview
abi={abi}
chainId={contract?.chain.id || 1}
noSidebar
/>
</div>
</TabPanel>
)}
{(sources || abi) && (
<TabPanel>
<SourcesPanel abi={abi} sources={sources} />
</TabPanel>
)}
</TabPanels>
</Tabs>
</Flex>
<div>
<TabButtons tabs={tabOptions} />
<div className="pt-4">
{functions && functions.length > 0 && activeTab === "functions" && (
<div className="h-[70vh]">
<ContractFunctionsPanel
contract={contract}
fnsOrEvents={functions}
isLoggedIn={isLoggedIn}
/>
</div>
)}
{events && events.length > 0 && activeTab === "events" && (
<div className="h-[70vh]">
<ContractFunctionsPanel
contract={contract}
fnsOrEvents={events}
isLoggedIn={isLoggedIn}
/>
</div>
)}
{abi && activeTab === "code" && (
<CodeOverview abi={abi} chainId={contract?.chain.id || 1} noSidebar />
)}
{sources && sources.length > 0 && activeTab === "sources" && (
<SourcesPanel abi={abi} sources={sources} />
)}
</div>
</div>
);
};
@@ -1,20 +1,13 @@
"use client";
import {
ButtonGroup,
Divider,
Flex,
FormControl,
Input,
} from "@chakra-ui/react";
import { useMutation } from "@tanstack/react-query";
import { type AbiFunction, type AbiParameter, formatAbiItem } from "abitype";
import { Button } from "chakra/button";
import { Card } from "chakra/card";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { ExternalLinkIcon, InfoIcon, PlayIcon } from "lucide-react";
import {
ExternalLinkIcon,
InfoIcon,
PlayIcon,
RefreshCcwIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useId, useMemo, useState } from "react";
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
@@ -29,16 +22,19 @@ import {
} from "thirdweb";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { parseAbiParams, stringify, toFunctionSelector } from "thirdweb/utils";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SolidityInput } from "@/components/solidity-inputs";
import { camelToTitle } from "@/components/solidity-inputs/helpers";
import { TransactionButton } from "@/components/tx-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CodeClient } from "@/components/ui/code/code.client";
import { PlainTextCodeBlock } from "@/components/ui/code/plaintext-code";
import { InlineCode } from "@/components/ui/inline-code";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { replaceIpfsUrl } from "@/lib/sdk";
function formatResponseData(data: unknown): {
type: "json" | "text";
@@ -122,11 +118,11 @@ function formatContractCall(
return parsedParams;
}
interface InteractiveAbiFunctionProps {
type InteractiveAbiFunctionProps = {
abiFunction: AbiFunction;
contract: ThirdwebContract;
isLoggedIn: boolean;
}
};
function useAsyncRead(contract: ThirdwebContract, abiFunction: AbiFunction) {
const formattedAbi = formatAbiItem({
@@ -366,97 +362,72 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
return (
<FormProvider {...form}>
<Card
as={Flex}
bg="transparent"
border="none"
borderRadius="none"
boxShadow="none"
flexDirection="column"
flexGrow={1}
gap={4}
gridColumn={{ base: "span 12", md: "span 9" }}
p={0}
w="100%"
>
<Flex
as="form"
direction="column"
gap={2}
id={formId}
position="relative"
w="100%"
>
{fields.length > 0 && (
<>
<Divider mb="8px" />
{fields.map((item, index) => {
return (
<FormControl
isInvalid={
!!form.getFieldState(
`params.${index}.value`,
form.formState,
).error
}
key={item.id}
mb="8px"
>
<Flex justify="space-between">
<FormLabel>{camelToTitle(item.key)}</FormLabel>
<Text fontSize="12px">{item.key}</Text>
</Flex>
<SolidityInput
// @ts-expect-error - old types, need to update
solidityComponents={item.components}
solidityName={item.key}
solidityType={item.type}
{...form.register(`params.${index}.value`)}
functionName={abiFunction.name}
/>
<FormErrorMessage>
{
form.getFieldState(
`params.${index}.value`,
form.formState,
).error?.message
}
</FormErrorMessage>
</FormControl>
);
})}
</>
)}
<div className="space-y-6">
<form className="space-y-5 relative w-full" id={formId}>
{fields.length > 0 &&
fields.map((item, index) => {
const fieldError = form.getFieldState(
`params.${index}.value`,
form.formState,
).error;
return (
<FormFieldSetup
key={item.id}
htmlFor={`params.${index}.value`}
labelClassName="w-full"
labelContainerClassName="flex w-full"
label={
<div className="flex justify-between items-center w-full gap-3">
<span>{camelToTitle(item.key)}</span>
<span className="text-xs text-muted-foreground">
{item.key}
</span>
</div>
}
errorMessage={fieldError?.message}
isRequired={false}
className="mb-2"
>
<SolidityInput
// @ts-expect-error - old types, need to update
solidityComponents={item.components}
solidityName={item.key}
solidityType={item.type}
{...form.register(`params.${index}.value`)}
functionName={abiFunction.name}
/>
</FormFieldSetup>
);
})}
{abiFunction.stateMutability === "payable" && (
<>
<Divider mb="8px" />
<FormControl gap={0.5}>
<FormLabel>Native Token Value</FormLabel>
<Input {...form.register("value")} />
<FormHelperText>
The native currency value (in Ether) to send with this
transaction (ex: 0.01 to send 0.01 native currency).
</FormHelperText>
</FormControl>
</>
<FormFieldSetup
htmlFor="value"
label="Native Token Value"
errorMessage={undefined}
isRequired={false}
helperText="The native currency value (in Ether) to send with this transaction (ex: 0.01 to send 0.01 native currency)."
>
<Input {...form.register("value")} className="bg-background" />
</FormFieldSetup>
)}
{error ? (
<>
<Divider />
<Heading size="label.sm">Error</Heading>
<h3 className="text-sm font-medium">Error</h3>
<InlineCode
className="relative w-full whitespace-pre-wrap rounded-md border border-border p-4 text-red-500"
// biome-ignore lint/suspicious/noExplicitAny: FIXME
code={formatError(error as any)}
/>
</>
) : readLoading ? (
<Skeleton className="h-20 w-full rounded-lg" />
) : formattedResponseData ? (
<>
<Divider />
<div className="flex flex-row items-center gap-2">
<Heading size="label.sm">Output</Heading>
<h3 className="text-sm font-medium">Output</h3>
{/* Show the Solidity type of the function's output */}
{abiFunction.outputs.length > 0 && (
<Badge variant="default">
@@ -466,30 +437,32 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
</div>
{formattedResponseData.type === "text" ? (
<PlainTextCodeBlock code={formattedResponseData.data} />
<PlainTextCodeBlock
code={formattedResponseData.data}
className="bg-background"
/>
) : (
<CodeClient
code={formattedResponseData.data}
lang={formattedResponseData.type}
className="bg-background"
/>
)}
{/* If the result is an IPFS URI, show a handy link so that users can open it in a new tab */}
{formattedResponseData.type === "text" &&
formattedResponseData.data.startsWith("ipfs://") && (
<Text size="label.sm">
<UnderlineLink
href={replaceIpfsUrl(
formattedResponseData.data,
contract.client,
)}
rel="noopener noreferrer"
target="_blank"
>
Open in gateway
</UnderlineLink>
</Text>
<Link
href={`https://ipfs.io/ipfs/${formattedResponseData.data.split("ipfs://")[1]}`}
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-2 mt-1"
target="_blank"
>
Open In IPFS Gateway
<ExternalLinkIcon className="size-3.5" />
</Link>
)}
{/* Same with the logic above but this time it's applied to traditional urls */}
{((formattedResponseData.type === "text" &&
formattedResponseData.data.startsWith("https://")) ||
@@ -506,37 +479,46 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
)}
</>
) : null}
</Flex>
</form>
<Divider mt="auto" />
<ButtonGroup ml="auto">
<div className="flex gap-3 justify-end">
{isView ? (
<Button
colorScheme="primary"
form={formId}
isDisabled={!abiFunction}
isLoading={readLoading}
disabled={!abiFunction}
onClick={handleContractRead}
rightIcon={<PlayIcon className="size-4" />}
className="gap-2 bg-background rounded-full"
variant="outline"
>
Run
{/* run / rerun */}
{readLoading ? (
<Spinner className="size-4" />
) : readData ? (
<RefreshCcwIcon className="size-4 text-muted-foreground" />
) : (
<PlayIcon className="size-4 text-muted-foreground" />
)}
{readData ? "Run again" : readLoading ? "Running" : "Run"}
</Button>
) : (
<>
<Button
isDisabled={
!abiFunction || txSimulation.isPending || mutationLoading
}
isLoading={txSimulation.isPending}
onClick={handleContractSimulation}
>
<ToolTipLabel label="Simulate the transaction to see its potential outcome without actually sending it to the network. This action doesn't cost gas.">
<span className="mr-3">
<InfoIcon className="size-5" />
</span>
</ToolTipLabel>
Simulate
</Button>
<ToolTipLabel label="Simulate the transaction to see its potential outcome without actually sending it to the network. This action doesn't cost gas.">
<Button
className="gap-2 bg-background"
variant="outline"
disabled={
!abiFunction || txSimulation.isPending || mutationLoading
}
onClick={handleContractSimulation}
>
{txSimulation.isPending ? (
<Spinner className="size-4" />
) : (
<InfoIcon className="size-4 text-muted-foreground" />
)}
Simulate
</Button>
</ToolTipLabel>
<TransactionButton
client={contract.client}
disabled={
@@ -553,8 +535,8 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
</TransactionButton>
</>
)}
</ButtonGroup>
</Card>
</div>
</div>
</FormProvider>
);
};
@@ -1,15 +1,4 @@
import {
Flex,
FormControl,
IconButton,
Input,
InputGroup,
InputRightElement,
Tooltip,
} from "@chakra-ui/react";
import { Button } from "chakra/button";
import { FormErrorMessage, FormLabel } from "chakra/form";
import { BanIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from "lucide-react";
import { PlusIcon, TrashIcon, XIcon } from "lucide-react";
import { useEffect } from "react";
import {
type ArrayPath,
@@ -25,7 +14,16 @@ import {
type WatchObserver,
} from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { FileInput } from "@/components/blocks/FileInput";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type OptionalPropertiesInput = {
[key: string]: string | number;
@@ -46,17 +44,15 @@ interface IPropertiesFormControlProps<
client: ThirdwebClient;
}
export const PropertiesFormControl = <
export function PropertiesFormControl<
TFieldValues extends IPropertyFieldValues,
>({
control,
register,
watch,
errors,
setValue,
client,
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) => {
const { fields, append, remove, replace } = useFieldArray({
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) {
const { fields, append, remove } = useFieldArray({
control,
name: "attributes" as ArrayPath<TFieldValues>,
});
@@ -72,19 +68,7 @@ export const PropertiesFormControl = <
return (
<div className="flex flex-col gap-4">
<Flex align="center" direction="row" justify="space-between">
<FormLabel m={0}>Attributes</FormLabel>
<Button
colorScheme="red"
// biome-ignore lint/suspicious/noExplicitAny: FIXME
onClick={() => replace([{ trait_type: "", value: "" } as any])}
rightIcon={<BanIcon className="size-4" />}
size="xs"
variant="outline"
>
Reset
</Button>
</Flex>
<FormLabel>Attributes</FormLabel>
{fields.map((field, index) => {
// biome-ignore lint/suspicious/noExplicitAny: FIXME
const keyError = (errors as any)?.attributes?.[index]?.trait_type
@@ -92,102 +76,108 @@ export const PropertiesFormControl = <
// biome-ignore lint/suspicious/noExplicitAny: FIXME
const valueError = (errors as any)?.attributes?.[index]?.value
?.message as string | undefined;
const isInvalid = !!(keyError || valueError);
const _isInvalid = !!(keyError || valueError);
return (
<div className="flex flex-row items-center gap-2" key={field.id}>
<FormControl
className="flex flex-row items-start gap-3"
isInvalid={isInvalid}
>
<FormControl isInvalid={!!keyError}>
<Input
{...register(
`attributes.${index}.trait_type` as Path<TFieldValues>,
)}
placeholder="trait_type"
/>
<FormErrorMessage>{keyError}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!valueError}>
{watch(
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
) instanceof File ? (
<InputGroup>
<Input
isDisabled
value={
watch(`attributes.${index}.value` as Path<TFieldValues>)
.name
}
/>
<InputRightElement>
<TrashIcon
className="size-4 cursor-pointer text-red-300 hover:text-red-200"
onClick={() =>
setValue(
`attributes.${index}.value` as Path<TFieldValues>,
"" as PathValue<TFieldValues, Path<TFieldValues>>,
)
}
<div className="flex flex-row items-start gap-3 flex-1">
<FormField
control={control}
name={`attributes.${index}.trait_type` as Path<TFieldValues>}
render={({ field: traitField }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...traitField}
placeholder="trait_type"
className={cn(
"bg-card",
keyError && "border-destructive",
)}
/>
</InputRightElement>
</InputGroup>
) : (
<InputGroup>
<Input
{...register(
`attributes.${index}.value` as Path<TFieldValues>,
)}
placeholder="value"
/>
<InputRightElement>
<Tooltip label="Upload file" shouldWrapChildren>
<FileInput
client={client}
setValue={(file) => {
setValue(
`attributes.${index}.value` as Path<TFieldValues>,
file as PathValue<
TFieldValues,
Path<TFieldValues>
>,
);
}}
>
<UploadIcon className="size-4 text-muted-foreground" />
</FileInput>
</Tooltip>
</InputRightElement>
</InputGroup>
</FormControl>
{keyError && <FormMessage>{keyError}</FormMessage>}
</FormItem>
)}
<FormErrorMessage>{valueError}</FormErrorMessage>
</FormControl>
</FormControl>
<IconButton
aria-label="remove key value pair"
colorScheme="red"
icon={<XIcon />}
/>
<FormField
control={control}
name={`attributes.${index}.value` as Path<TFieldValues>}
render={({ field: valueField }) => (
<FormItem className="flex-1">
<FormControl>
{watch(
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
) instanceof File ? (
<div className="relative">
<Input
disabled
value={
watch(
`attributes.${index}.value` as Path<TFieldValues>,
).name
}
className="pr-10 bg-card"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() =>
setValue(
`attributes.${index}.value` as Path<TFieldValues>,
"" as PathValue<
TFieldValues,
Path<TFieldValues>
>,
)
}
>
<TrashIcon className="size-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
) : (
<div className="relative">
<Input
{...valueField}
placeholder="value"
className={`pr-10 bg-card ${valueError ? "border-destructive" : ""}`}
/>
</div>
)}
</FormControl>
{valueError && <FormMessage>{valueError}</FormMessage>}
</FormItem>
)}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full p-0 size-10 bg-card"
onClick={() => remove(index)}
size="xs"
variant="ghost"
/>
>
<XIcon className="size-4" />
</Button>
</div>
);
})}
<div className="flex flex-row gap-2">
<Button
colorScheme="purple"
leftIcon={<PlusIcon className="size-5" />}
className="rounded-full"
onClick={() =>
// biome-ignore lint/suspicious/noExplicitAny: FIXME
append({ trait_type: undefined, value: undefined } as any)
}
size="sm"
>
<PlusIcon className="size-4 mr-2" />
Add Row
</Button>
</div>
</div>
);
};
}
@@ -1,40 +1,32 @@
"use client";
import { Divider, Flex, GridItem, List, ListItem } from "@chakra-ui/react";
import { useQuery } from "@tanstack/react-query";
import { LinkButton } from "chakra/button";
import { Card } from "chakra/card";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { format } from "date-fns";
import {
BookOpenTextIcon,
CalendarDaysIcon,
ExternalLinkIcon,
PencilIcon,
ServerIcon,
ShieldCheckIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { download } from "thirdweb/storage";
import invariant from "tiny-invariant";
import { PublisherHeader } from "@/components//contract-components/publisher/publisher-header";
import { PublisherLink } from "@/components//contract-components/publisher/publisher-header";
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
import type { PublishedContractWithVersion } from "@/components/contract-components/fetch-contracts-with-versions";
import { ContractFunctionsOverview } from "@/components/contracts/functions/contract-functions";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
usePublishedContractEvents,
usePublishedContractFunctions,
} from "@/hooks/contract-hooks";
import { correctAndUniqueLicenses } from "@/lib/licenses";
import { replaceIpfsUrl } from "@/lib/sdk";
import { publicIPFSGateway } from "@/lib/sdk";
import { cn } from "@/lib/utils";
interface ExtendedPublishedContract extends PublishedContractWithVersion {
type ExtendedPublishedContract = PublishedContractWithVersion & {
name: string;
displayName?: string;
description?: string;
@@ -43,21 +35,19 @@ interface ExtendedPublishedContract extends PublishedContractWithVersion {
tags?: string[];
logo?: string;
audit?: string;
}
};
interface PublishedContractProps {
publishedContract: ExtendedPublishedContract;
isLoggedIn: boolean;
client: ThirdwebClient;
}
export const PublishedContract: React.FC<PublishedContractProps> = ({
export function PublishedContract({
publishedContract,
isLoggedIn,
client,
}) => {
const address = useActiveAccount()?.address;
maxWidthClassName,
}: {
publishedContract: ExtendedPublishedContract;
isLoggedIn: boolean;
client: ThirdwebClient;
maxWidthClassName?: string;
}) {
const contractFunctions = usePublishedContractFunctions(publishedContract);
const contractEvents = usePublishedContractEvents(publishedContract);
@@ -108,164 +98,143 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
);
return (
<>
<GridItem colSpan={{ base: 12, md: 9 }}>
<Flex flexDir="column" gap={6}>
{address === publishedContract.publisher && (
<LinkButton
href={`/contracts/publish/${encodeURIComponent(
publishedContract.publishMetadataUri.replace("ipfs://", ""),
)}`}
leftIcon={<PencilIcon className="size-3" />}
ml="auto"
size="sm"
variant="outline"
>
Edit
</LinkButton>
)}
{publishedContract?.readme && (
<Card as={Flex} flexDir="column" gap={2} p={6} position="relative">
<MarkdownRenderer markdownText={publishedContract?.readme} />
</Card>
)}
{publishedContract?.changelog && (
<Card as={Flex} flexDir="column" gap={2} p={0}>
<Heading pb={2} pt={5} px={6} size="title.sm">
{publishedContract?.version} Release Notes
</Heading>
<Divider />
<MarkdownRenderer
className="px-6 pt-2 pb-5"
markdownText={publishedContract?.changelog}
/>
</Card>
)}
{contractFunctions && (
<Card p={0}>
<ContractFunctionsOverview
abi={publishedContract?.abi}
events={contractEvents}
functions={contractFunctions}
isLoggedIn={isLoggedIn}
sources={sources.data}
/>
</Card>
)}
</Flex>
</GridItem>
<GridItem colSpan={{ base: 12, md: 3 }}>
<Flex flexDir="column" gap={6}>
{publishedContract.publisher && (
<PublisherHeader
<div>
{/* right side content moved below changelog */}
{publishedContract.publisher && (
<div className="border-b py-6">
<div
className={cn(
"flex flex-col md:flex-row lg:items-center gap-6 justify-between flex-wrap",
maxWidthClassName,
)}
>
<PublisherLink
client={client}
wallet={publishedContract.publisher}
/>
)}
<Separator />
<div className="flex flex-col gap-4">
<h4 className="font-semibold text-xl tracking-tight">Details</h4>
<List as={Flex} flexDir="column" gap={5}>
<ul className="flex flex-col md:flex-row gap-8 lg:items-center lg:gap-6">
{/* timestamp */}
{publishedContract.publishTimestamp && (
<ListItem>
<Flex alignItems="flex-start" gap={3}>
<CalendarDaysIcon className="size-5 text-muted-foreground" />
<Flex direction="column" gap={1}>
<Heading as="h5" size="label.sm">
Publish Date
</Heading>
<Text lineHeight={1.2} size="body.md">
{publishDate}
</Text>
</Flex>
</Flex>
</ListItem>
<li className="flex items-center gap-2.5">
<div className="p-2 rounded-full border bg-card">
<CalendarDaysIcon className="size-4 text-muted-foreground" />
</div>
<div className="space-y-1.5 [&>*]:leading-none">
<h5 className="text-sm font-medium">Publish Date</h5>
<p className="text-sm text-muted-foreground">
{publishDate}
</p>
</div>
</li>
)}
{/* audit */}
{publishedContract?.audit && (
<ListItem>
<Flex alignItems="flex-start" gap={3}>
<ShieldCheckIcon className="size-5 text-green-500" />
<Flex direction="column" gap={1}>
<Heading as="h5" size="label.sm">
Audit Report
</Heading>
<Text lineHeight={1.2} size="body.md">
<Link
className="text-link-foreground hover:text-foreground"
href={replaceIpfsUrl(publishedContract.audit, client)}
rel="noopener noreferrer"
target="_blank"
>
View Audit Report
</Link>
</Text>
</Flex>
</Flex>
</ListItem>
<li>
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-full border bg-card">
<ShieldCheckIcon className="size-4 text-muted-foreground" />
</div>
<div className="space-y-1.5 [&>*]:leading-none">
<h5 className="text-sm font-medium">Audit Report</h5>
<Link
href={publicIPFSGateway(publishedContract.audit)}
rel="noopener noreferrer"
target="_blank"
className="flex items-center gap-2 text-sm hover:underline leading-none text-muted-foreground hover:text-foreground"
>
View Audit Report
<ExternalLinkIcon className="size-3.5" />
</Link>
</div>
</div>
</li>
)}
<ListItem>
<Flex alignItems="flex-start" gap={3}>
<BookOpenTextIcon className="size-5 text-muted-foreground" />
<Flex direction="column" gap={1}>
<Heading as="h5" size="label.sm">
License
{licenses.length > 1 ? "s" : ""}
</Heading>
<Text lineHeight={1.2} size="body.md">
{licenses.join(", ") || "None"}
</Text>
</Flex>
</Flex>
</ListItem>
{/* license */}
<li className="flex items-center gap-2.5">
<div className="p-2 rounded-full border bg-card">
<BookOpenTextIcon className="size-4 text-muted-foreground" />
</div>
<div className="space-y-1.5 [&>*]:leading-none">
<h5 className="text-sm font-medium">
License{licenses.length > 1 ? "s" : ""}
</h5>
<p className="text-sm text-muted-foreground">
{licenses.join(", ") || "None"}
</p>
</div>
</li>
{(publishedContract?.isDeployableViaProxy &&
hasImplementationAddresses) ||
(publishedContract?.isDeployableViaFactory &&
hasFactoryAddresses) ? (
<ListItem>
<Flex alignItems="flex-start" gap={2}>
<ServerIcon className="size-5 text-muted-foreground" />
<Flex direction="column" gap={1}>
<Heading as="h5" size="label.sm">
{publishedContract?.isDeployableViaFactory
? "Factory"
: "Proxy"}{" "}
Enabled
</Heading>
</Flex>
</Flex>
</ListItem>
<li className="flex items-center gap-2.5">
<div className="p-2 rounded-full border bg-card">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
<h5 className="text-sm font-medium">
{publishedContract?.isDeployableViaFactory
? "Factory"
: "Proxy"}{" "}
Enabled
</h5>
</li>
) : null}
</List>
</ul>
</div>
</div>
)}
<Separator />
<div className={cn("py-8 space-y-8", maxWidthClassName)}>
{/* main content */}
<div className="space-y-6">
{/* readme */}
{publishedContract?.readme && (
<div className="overflow-hidden">
<MarkdownRenderer markdownText={publishedContract?.readme} />
</div>
)}
<Button asChild className="w-full gap-2 bg-card" variant="outline">
<Link
href="https://portal.thirdweb.com/contracts/publish/overview"
rel="noopener noreferrer"
target="_blank"
>
Learn more about Publish{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
</Flex>
</GridItem>
</>
{/* changelog */}
{publishedContract?.changelog && (
<div className="overflow-hidden">
<div className="border-b border-dashed pb-3 mb-3">
<h3 className="text-lg font-semibold">
{publishedContract?.version} Release Notes
</h3>
</div>
<MarkdownRenderer
markdownText={publishedContract?.changelog}
p={{
className: "text-sm leading-6",
}}
/>
</div>
)}
{contractFunctions && (
<ContractFunctionsOverview
abi={publishedContract?.abi}
events={contractEvents}
functions={contractFunctions}
isLoggedIn={isLoggedIn}
sources={sources.data}
/>
)}
</div>
</div>
</div>
);
};
}
// TODO: find a place to put this
type ContractSource = {
filename: string;
source: string;
};
async function fetchSourceFilesFromMetadata(
publishedMetadata: ExtendedPublishedContract,
client: ThirdwebClient,
@@ -1,37 +0,0 @@
import { useEffect, useState } from "react";
import type { ComponentWithChildren } from "@/types/component-with-children";
interface DelayedDisplayProps {
delay: number;
}
function useDelayedDisplay(delay: number) {
const [displayContent, setDisplayContent] = useState(false);
// FIXME: this is a weird thing, we should not need it - in the meantime this is a legit use case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const timer = setTimeout(() => {
setDisplayContent(true);
}, delay);
return () => {
clearTimeout(timer);
};
}, [delay]);
return displayContent;
}
export const DelayedDisplay: ComponentWithChildren<DelayedDisplayProps> = ({
delay,
children,
}) => {
const displayContent = useDelayedDisplay(delay);
if (!displayContent) {
return null;
}
return <>{children}</>;
};
@@ -10,9 +10,9 @@ export const SolidityBoolInput: React.FC<SolidityInputProps> = ({
const watchInput = form.watch(inputName);
return (
<div className="flex flex-row">
<div className="inline-flex overflow-hidden rounded-md border border-border">
<div className="inline-flex">
<Button
className="rounded-none border-r-0"
className="border-r-0 rounded-r-none"
onClick={() => form.setValue(inputName, "true")}
size="sm"
variant={watchInput === "true" ? "default" : "outline"}
@@ -20,7 +20,7 @@ export const SolidityBoolInput: React.FC<SolidityInputProps> = ({
True
</Button>
<Button
className="rounded-none rounded-l-none"
className="rounded-l-none"
onClick={() => form.setValue(inputName, "false")}
size="sm"
variant={watchInput === "false" ? "default" : "outline"}
@@ -1,9 +1,9 @@
import type { UseMutationResult } from "@tanstack/react-query";
import { Button, type ButtonProps } from "chakra/button";
import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { FileInput } from "@/components/blocks/FileInput";
import { Button, type ButtonProps } from "@/components/ui/button";
import { useErrorHandler } from "@/contexts/error-handler";
import type { ComponentWithChildren } from "@/types/component-with-children";
@@ -39,12 +39,13 @@ export const IpfsUploadButton: ComponentWithChildren<IpfsUploadButtonProps> = ({
<FileInput client={client} setValue={handleUpload}>
<Button
aria-label="Upload to IPFS"
isLoading={storageUpload.isPending}
rightIcon={<UploadIcon className="size-4" />}
variant="secondary"
disabled={storageUpload.isPending}
size="sm"
variant="solid"
className="h-auto py-1.5 text-xs -translate-x-0.5 rounded-sm"
{...buttonProps}
>
<UploadIcon className="size-3 mr-2 text-muted-foreground" />
{children}
</Button>
</FileInput>
@@ -28,7 +28,7 @@ type TransactionButtonProps = Omit<ButtonProps, "variant"> & {
transactionCount: number | undefined; // support for unknown number of tx count
isPending: boolean;
txChainID: number;
variant?: "destructive" | "primary" | "default";
variant?: "destructive" | "primary" | "default" | "outline";
isLoggedIn: boolean;
checkBalance?: boolean;
client: ThirdwebClient;
@@ -8,29 +8,42 @@ import { ToolTipLabel } from "./tooltip";
export function CopyButton(props: {
text: string;
label?: string;
className?: string;
iconClassName?: string;
variant?: "ghost" | "primary" | "secondary";
tooltip?: boolean;
variant?: "ghost" | "primary" | "secondary" | "default" | "outline";
}) {
const { hasCopied, onCopy } = useClipboard(props.text, 1000);
return (
<ToolTipLabel label="Copy">
<Button
aria-label="Copy"
className={cn("h-auto w-auto p-1", props.className)}
onClick={onCopy}
variant={props.variant || "ghost"}
>
{hasCopied ? (
<CheckIcon
className={cn("size-4 text-green-500", props.iconClassName)}
/>
) : (
<CopyIcon
className={cn("size-4 text-muted-foreground", props.iconClassName)}
/>
)}
</Button>
</ToolTipLabel>
const showTooltip = props.tooltip ?? true;
const button = (
<Button
aria-label="Copy"
className={cn(
"h-auto w-auto flex items-center gap-2 text-muted-foreground",
props.label ? "p-2" : "p-1",
props.className,
)}
onClick={onCopy}
variant={props.variant || "ghost"}
>
{hasCopied ? (
<CheckIcon
className={cn("size-4 text-green-500", props.iconClassName)}
/>
) : (
<CopyIcon
className={cn("size-4 text-muted-foreground", props.iconClassName)}
/>
)}
{props.label}
</Button>
);
if (!showTooltip) {
return button;
}
return <ToolTipLabel label="Copy">{button}</ToolTipLabel>;
}
+3 -3
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"bg-card relative w-full rounded-lg border border-border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"bg-card relative w-full rounded-xl border border-border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
defaultVariants: {
variant: "default",
@@ -38,7 +38,7 @@ const AlertTitle = React.forwardRef<
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
className={cn("font-medium leading-tight tracking-tight", className)}
className={cn("font-medium leading-tight", className)}
ref={ref}
{...props}
/>
@@ -51,7 +51,7 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
className={cn(
"mt-1.5 text-muted-foreground text-sm [&_p]:leading-relaxed",
"mt-1 text-muted-foreground text-sm [&_p]:leading-relaxed",
className,
)}
ref={ref}
+1 -1
View File
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<input
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:selection:bg-foreground/20",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-inverted selection:text-inverted-foreground file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+1 -1
View File
@@ -56,7 +56,7 @@ export function TabLinks(props: {
<Link
aria-disabled={tab.isDisabled}
className={cn(
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm hover:bg-accent lg:text-sm",
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm lg:text-sm",
!tab.isActive && !tab.isDisabled && "hover:text-foreground",
tab.isDisabled && "pointer-events-none",
tab.isActive && "!text-foreground",
@@ -46,7 +46,7 @@ export function ToolTipLabel(props: {
return (
<TooltipProvider>
<Tooltip delayDuration={100} disableHoverableContent={!props.hoverable}>
<TooltipTrigger asChild className="!pointer-events-auto">
<TooltipTrigger asChild className="!pointer-events-auto" tabIndex={-1}>
{props.children}
</TooltipTrigger>
<TooltipContent
+1 -1
View File
@@ -1223,7 +1223,7 @@ export function useEngineUpdateAccessToken(params: {
});
}
export type CreateWebhookInput = {
type CreateWebhookInput = {
url: string;
name: string;
eventType: string;
+7 -3
View File
@@ -2,10 +2,9 @@
import type { ThirdwebClient } from "thirdweb";
import { Img } from "@/components/blocks/Img";
/* eslint-disable @next/next/no-img-element */
import { replaceIpfsUrl } from "@/lib/sdk";
import { cn } from "@/lib/utils";
import { fallbackChainIcon } from "@/utils/chain-icons";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
type ImageProps = React.ComponentProps<"img">;
@@ -19,7 +18,12 @@ export const ChainIconClient = ({
src,
...restProps
}: ChainIconProps) => {
const resolvedSrc = src ? replaceIpfsUrl(src, client) : fallbackChainIcon;
const resolvedSrc = src
? resolveSchemeWithErrorHandler({
client,
uri: src,
})
: fallbackChainIcon;
return (
<Img
+5 -13
View File
@@ -1,15 +1,7 @@
import type { ThirdwebClient } from "thirdweb";
import { resolveScheme } from "thirdweb/storage";
export function replaceIpfsUrl(uri: string, client: ThirdwebClient) {
try {
// eslint-disable-next-line no-restricted-syntax
return resolveScheme({
client,
uri,
});
} catch (err) {
console.error("error resolving ipfs url", uri, err);
return uri;
export function publicIPFSGateway(uri: string) {
if (uri.startsWith("ipfs://")) {
return uri.replace("ipfs://", "https://ipfs.io/ipfs/");
}
return uri;
}
+4
View File
@@ -89,6 +89,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
enabled: true,
rateLimit: 10,
},
gateway: {
enabled: true,
rateLimit: 1000,
},
},
createdAt: new Date().toISOString(),
dedicatedSupportChannel: null,
-17
View File
@@ -1,17 +0,0 @@
import { toast } from "sonner";
// if browser supports sharing use native sharing - use that, else just copy to clipboard + show toast
export async function shareLink(_data: ShareData) {
const data = { ..._data, url: _data.url || window.location.href };
if (
"canShare" in navigator &&
navigator.canShare &&
navigator.canShare(data)
) {
await navigator.share(data);
} else {
await navigator.clipboard.writeText(data.url);
toast.info("URL copied to clipboard");
}
}
@@ -1,6 +1,6 @@
import { isPossibleEVMAddress } from "@/utils/address-utils";
export function shortenString(str: string, extraShort = true) {
export function shortenString(str: string, extraShort = false) {
return `${str.substring(0, extraShort ? 4 : 6)}...${str.substring(
str.length - (extraShort ? 3 : 4),
)}`;
@@ -74,7 +74,7 @@ export default async function RoutesPage(props: {
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/pay"
href="https://portal.thirdweb.com/payments"
rel="noopener noreferrer"
target="_blank"
>
@@ -23,29 +23,27 @@ export const CancelTab: React.FC<CancelTabProps> = ({
: cancelListing({ contract, listingId: BigInt(id) });
const cancelQuery = useSendAndConfirmTransaction();
return (
<div className="flex flex-col gap-3 pt-3">
<TransactionButton
className="self-end"
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={cancelQuery.isPending}
onClick={() => {
const promise = cancelQuery.mutateAsync(transaction, {
onError: (error) => {
console.error(error);
},
});
toast.promise(promise, {
error: "Failed to cancel",
loading: `Cancelling ${isAuction ? "auction" : "listing"}`,
success: "Item cancelled successfully",
});
}}
transactionCount={1}
txChainID={contract.chain.id}
>
Cancel {isAuction ? "Auction" : "Listing"}
</TransactionButton>
</div>
<TransactionButton
className="self-end"
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={cancelQuery.isPending}
onClick={() => {
const promise = cancelQuery.mutateAsync(transaction, {
onError: (error) => {
console.error(error);
},
});
toast.promise(promise, {
error: "Failed to cancel",
loading: `Cancelling ${isAuction ? "auction" : "listing"}`,
success: "Item cancelled successfully",
});
}}
transactionCount={1}
txChainID={contract.chain.id}
>
Cancel {isAuction ? "Auction" : "Listing"}
</TransactionButton>
);
};
@@ -51,8 +51,14 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
<ListerOnly contract={contract}>
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button variant="primary" {...restButtonProps} disabled={!address}>
{createText} <PlusIcon className="ml-2 size-5" />
<Button
{...restButtonProps}
disabled={!address}
className="gap-2"
size="sm"
>
<PlusIcon className="size-4" />
{createText}
</Button>
</SheetTrigger>
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
@@ -1,14 +1,4 @@
import {
Box,
Flex,
FormControl,
Input,
Select,
Spinner,
Tooltip,
} from "@chakra-ui/react";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { CircleAlertIcon, InfoIcon } from "lucide-react";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -44,19 +34,34 @@ import { CurrencySelector } from "@/components/blocks/CurrencySelector";
import { NFTMediaWithEmptyState } from "@/components/blocks/nft-media";
import { SolidityInput } from "@/components/solidity-inputs";
import { TransactionButton } from "@/components/tx-button";
import { Alert, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardOwnedNFTs } from "@/hooks/useDashboardOwnedNFTs";
import { useTxNotifications } from "@/hooks/useTxNotifications";
import { useWalletNFTs } from "@/hooks/useWalletNFTs";
import { cn } from "@/lib/utils";
import { isAlchemySupported } from "@/lib/wallet/nfts/isAlchemySupported";
import { isMoralisSupported } from "@/lib/wallet/nfts/isMoralisSupported";
import type { WalletNFT } from "@/lib/wallet/nfts/types";
import { shortenIfAddress } from "@/utils/usedapp-external";
const LIST_FORM_ID = "marketplace-list-form";
type ListForm =
| (Omit<CreateListingParams, "quantity" | "currencyContractAddress"> & {
currencyContractAddress: string;
@@ -221,284 +226,309 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
const nfts = ownedWalletNFTs || walletNFTs?.result;
const noNfts = !nfts?.length;
return (
<form
className="flex flex-col gap-6 pb-16"
id={LIST_FORM_ID}
onSubmit={form.handleSubmit(async (formData) => {
if (!account) {
return toast.error("No account detected");
}
setIsFormLoading(true);
let nftType: "ERC1155" | "ERC721";
let _selectedContract: ThirdwebContract;
let selectedTokenId: bigint;
const selectedQuantity = BigInt(formData.quantity);
try {
if (mode === "manual") {
if (!formData.assetContractAddress) {
setIsFormLoading(false);
return toast.error("Enter a valid NFT contract address");
}
_selectedContract = getContract({
address: formData.assetContractAddress,
chain: contract.chain,
client: contract.client,
});
/**
* In manual mode we need to detect the NFT type ourselves
* instead of relying on the third-party providers
*/
const [is721, is1155] = await Promise.all([
isERC721({ contract: _selectedContract }),
isERC1155({ contract: _selectedContract }),
]);
if (!is721 && !is1155) {
setIsFormLoading(false);
return toast.error(
`Error: ${formData.assetContractAddress} is neither an ERC721 or ERC1155 contract`,
);
}
selectedTokenId = BigInt(formData.tokenId);
nftType = is721 ? "ERC721" : "ERC1155";
/**
* Also in manual mode we need to make sure the user owns the tokenId they entered
* For ERC1155, the owned balance must be >= the entered quantity
* For ERC721, the owner address must match theirs
*/
if (nftType === "ERC1155") {
const balance = await balanceOf({
contract: _selectedContract,
owner: account.address,
tokenId: selectedTokenId,
});
if (balance === 0n) {
setIsFormLoading(false);
return toast.error(
`You do not own any tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
);
}
if (balance < selectedQuantity) {
setIsFormLoading(false);
return toast.error(
`The balance you own for tokenId #${selectedTokenId.toString()} is less than the quantity (you own ${balance.toString()})`,
);
}
} else {
if (selectedQuantity !== 1n) {
setIsFormLoading(false);
return toast.error(
"The quantity can only be 1 for ERC721 token",
);
}
const owner = await ownerOf({
contract: _selectedContract,
tokenId: selectedTokenId,
}).catch(() => undefined);
if (owner?.toLowerCase() !== account.address.toLowerCase()) {
setIsFormLoading(false);
return toast.error(
`You do not own the tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
);
}
}
} else {
if (!formData.selected || !selectedContract) {
setIsFormLoading(false);
return toast.error("Please select an NFT to list");
}
nftType = formData.selected.type;
_selectedContract = selectedContract;
selectedTokenId = BigInt(formData.selected.id);
<Form {...form}>
<form
className="space-y-5"
onSubmit={form.handleSubmit(async (formData) => {
if (!account) {
return toast.error("No account detected");
}
/**
* Make sure the selected item is approved to be listed on the marketplace contract
* todo: We are checking "isApprovedForAll" for both erc1155 and 721.
* However for ERC721 there's also a function called "getApproved" which is used to check for approval status of a single token
* - might worth adding that logic here.
*/
const isNftApproved =
nftType === "ERC1155" ? isApprovedForAll1155 : isApprovedForAll721;
const isApproved = await isNftApproved({
contract: _selectedContract,
operator: contract.address,
owner: account.address,
});
if (!isApproved) {
const setNftApproval =
nftType === "ERC1155"
? setApprovalForAll1155
: setApprovalForAll721;
const approveTx = setNftApproval({
approved: true,
contract: _selectedContract,
operator: contract.address,
});
const promise = sendAndConfirmTx.mutateAsync(approveTx);
toast.promise(promise, {
error: "Failed to approve NFT",
loading: "Approving NFT for listing",
success: "NFT approved successfully",
});
await promise;
}
if (formData.listingType === "direct") {
// Hard code to 100 years for now
const endTimestamp = new Date(
new Date().setFullYear(new Date().getFullYear() + 100),
);
const transaction = createListing({
assetContractAddress: _selectedContract.address,
contract,
currencyContractAddress: formData.currencyContractAddress,
endTimestamp,
pricePerToken: String(formData.pricePerToken),
quantity: selectedQuantity,
startTimestamp: formData.startTimestamp,
tokenId: selectedTokenId,
});
await sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => setOpen(false),
});
listingNotifications.onSuccess();
} else if (formData.listingType === "auction") {
let minimumBidAmountWei: bigint;
let buyoutBidAmountWei: bigint;
if (
formData.currencyContractAddress.toLowerCase() ===
NATIVE_TOKEN_ADDRESS.toLocaleLowerCase()
) {
minimumBidAmountWei = toWei(
formData.reservePricePerToken.toString(),
);
buyoutBidAmountWei = toWei(
formData.buyoutPricePerToken.toString(),
);
} else {
const tokenContract = getContract({
address: formData.currencyContractAddress,
setIsFormLoading(true);
let nftType: "ERC1155" | "ERC721";
let _selectedContract: ThirdwebContract;
let selectedTokenId: bigint;
const selectedQuantity = BigInt(formData.quantity);
try {
if (mode === "manual") {
if (!formData.assetContractAddress) {
setIsFormLoading(false);
return toast.error("Enter a valid NFT contract address");
}
_selectedContract = getContract({
address: formData.assetContractAddress,
chain: contract.chain,
client: contract.client,
});
const _decimals = await decimals({ contract: tokenContract });
minimumBidAmountWei = toUnits(
formData.reservePricePerToken.toString(),
_decimals,
);
buyoutBidAmountWei = toUnits(
formData.buyoutPricePerToken.toString(),
_decimals,
);
/**
* In manual mode we need to detect the NFT type ourselves
* instead of relying on the third-party providers
*/
const [is721, is1155] = await Promise.all([
isERC721({ contract: _selectedContract }),
isERC1155({ contract: _selectedContract }),
]);
if (!is721 && !is1155) {
setIsFormLoading(false);
return toast.error(
`Error: ${formData.assetContractAddress} is neither an ERC721 or ERC1155 contract`,
);
}
selectedTokenId = BigInt(formData.tokenId);
nftType = is721 ? "ERC721" : "ERC1155";
/**
* Also in manual mode we need to make sure the user owns the tokenId they entered
* For ERC1155, the owned balance must be >= the entered quantity
* For ERC721, the owner address must match theirs
*/
if (nftType === "ERC1155") {
const balance = await balanceOf({
contract: _selectedContract,
owner: account.address,
tokenId: selectedTokenId,
});
if (balance === 0n) {
setIsFormLoading(false);
return toast.error(
`You do not own any tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
);
}
if (balance < selectedQuantity) {
setIsFormLoading(false);
return toast.error(
`The balance you own for tokenId #${selectedTokenId.toString()} is less than the quantity (you own ${balance.toString()})`,
);
}
} else {
if (selectedQuantity !== 1n) {
setIsFormLoading(false);
return toast.error(
"The quantity can only be 1 for ERC721 token",
);
}
const owner = await ownerOf({
contract: _selectedContract,
tokenId: selectedTokenId,
}).catch(() => undefined);
if (owner?.toLowerCase() !== account.address.toLowerCase()) {
setIsFormLoading(false);
return toast.error(
`You do not own the tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
);
}
}
} else {
if (!formData.selected || !selectedContract) {
setIsFormLoading(false);
return toast.error("Please select an NFT to list");
}
nftType = formData.selected.type;
_selectedContract = selectedContract;
selectedTokenId = BigInt(formData.selected.id);
}
/**
* Make sure the selected item is approved to be listed on the marketplace contract
* todo: We are checking "isApprovedForAll" for both erc1155 and 721.
* However for ERC721 there's also a function called "getApproved" which is used to check for approval status of a single token
* - might worth adding that logic here.
*/
const isNftApproved =
nftType === "ERC1155"
? isApprovedForAll1155
: isApprovedForAll721;
const isApproved = await isNftApproved({
contract: _selectedContract,
operator: contract.address,
owner: account.address,
});
if (!isApproved) {
const setNftApproval =
nftType === "ERC1155"
? setApprovalForAll1155
: setApprovalForAll721;
const approveTx = setNftApproval({
approved: true,
contract: _selectedContract,
operator: contract.address,
});
const promise = sendAndConfirmTx.mutateAsync(approveTx);
toast.promise(promise, {
error: "Failed to approve NFT",
loading: "Approving NFT for listing",
success: "NFT approved successfully",
});
await promise;
}
const transaction = createAuction({
assetContractAddress: _selectedContract.address,
buyoutBidAmountWei: buyoutBidAmountWei * selectedQuantity,
contract,
currencyContractAddress: formData.currencyContractAddress,
endTimestamp: new Date(
Date.now() +
Number.parseInt(formData.listingDurationInSeconds) * 1000,
),
minimumBidAmountWei: minimumBidAmountWei * selectedQuantity,
startTimestamp: formData.startTimestamp,
tokenId: selectedTokenId,
});
if (formData.listingType === "direct") {
// Hard code to 100 years for now
const endTimestamp = new Date(
new Date().setFullYear(new Date().getFullYear() + 100),
);
const transaction = createListing({
assetContractAddress: _selectedContract.address,
contract,
currencyContractAddress: formData.currencyContractAddress,
endTimestamp,
pricePerToken: String(formData.pricePerToken),
quantity: selectedQuantity,
startTimestamp: formData.startTimestamp,
tokenId: selectedTokenId,
});
await sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => {
setOpen(false);
},
});
auctionNotifications.onSuccess();
}
} catch (err) {
console.error(err);
if (formData.listingType === "auction") {
auctionNotifications.onError(err);
} else {
listingNotifications.onError(err);
}
}
await sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => setOpen(false),
});
setIsFormLoading(false);
})}
>
{mode === "manual" ? (
<>
<FormControl isRequired>
<FormHelperText className="!mt-0 mb-5">
Manually enter the contract address and token ID of the NFT you
want to list for sale
</FormHelperText>
<FormLabel>NFT Contract Address</FormLabel>
<Input {...form.register("assetContractAddress")} />
</FormControl>
<FormControl isRequired>
<FormLabel>Token ID</FormLabel>
<Input {...form.register("tokenId")} />
</FormControl>
</>
) : (
<FormControl>
<FormHelperText className="!mt-0 mb-5">
Select the NFT you want to list for sale
</FormHelperText>
{!isSupportedChain ? (
<Flex flexDir="column" gap={4} mb={4}>
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
<p className="text-orange-800 dark:text-orange-900">
This chain is not supported by our NFT API yet, please enter
the contract address of the NFT you want to list.
</p>
</div>
<FormControl
isInvalid={!!form.formState.errors.selected?.contractAddress}
>
<FormLabel>Contract address</FormLabel>
<SolidityInput
client={contract.client}
formContext={form}
solidityType="address"
{...form.register("selected.contractAddress", {
required: "Contract address is required",
})}
placeholder="0x..."
listingNotifications.onSuccess();
} else if (formData.listingType === "auction") {
let minimumBidAmountWei: bigint;
let buyoutBidAmountWei: bigint;
if (
formData.currencyContractAddress.toLowerCase() ===
NATIVE_TOKEN_ADDRESS.toLocaleLowerCase()
) {
minimumBidAmountWei = toWei(
formData.reservePricePerToken.toString(),
);
buyoutBidAmountWei = toWei(
formData.buyoutPricePerToken.toString(),
);
} else {
const tokenContract = getContract({
address: formData.currencyContractAddress,
chain: contract.chain,
client: contract.client,
});
const _decimals = await decimals({ contract: tokenContract });
minimumBidAmountWei = toUnits(
formData.reservePricePerToken.toString(),
_decimals,
);
buyoutBidAmountWei = toUnits(
formData.buyoutPricePerToken.toString(),
_decimals,
);
}
const transaction = createAuction({
assetContractAddress: _selectedContract.address,
buyoutBidAmountWei: buyoutBidAmountWei * selectedQuantity,
contract,
currencyContractAddress: formData.currencyContractAddress,
endTimestamp: new Date(
Date.now() +
Number.parseInt(formData.listingDurationInSeconds) * 1000,
),
minimumBidAmountWei: minimumBidAmountWei * selectedQuantity,
startTimestamp: formData.startTimestamp,
tokenId: selectedTokenId,
});
await sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => {
setOpen(false);
},
});
auctionNotifications.onSuccess();
}
} catch (err) {
console.error(err);
if (formData.listingType === "auction") {
auctionNotifications.onError(err);
} else {
listingNotifications.onError(err);
}
}
setIsFormLoading(false);
})}
>
{mode === "manual" && (
<>
{/* contract address */}
<FormItem>
<FormDescription className="!mt-0 mb-5">
Manually enter the contract address and token ID of the NFT you
want to list for sale
</FormDescription>
<FormLabel>NFT Contract Address</FormLabel>
<FormControl>
<Input
{...form.register("assetContractAddress")}
className="bg-card"
/>
<FormErrorMessage>
{form.formState.errors.selected?.contractAddress?.message}
</FormErrorMessage>
<FormHelperText>
This will display all the NFTs you own from this contract.
</FormHelperText>
</FormControl>
</Flex>
) : null}
{isWalletNFTsLoading ||
(isOwnedNFTsLoading &&
!isSupportedChain &&
form.watch("selected.contractAddress")) ? (
<div className="flex h-[60px] items-center justify-center">
<Spinner />
</div>
) : nfts && nfts.length !== 0 ? (
<Flex flexWrap="wrap" gap={2}>
{nfts?.map((nft) => {
return (
<Tooltip
bg="transparent"
boxShadow="none"
key={nft.contractAddress + nft.id}
label={
<Card className="p-4">
<FormMessage />
</FormItem>
{/* token id */}
<FormItem>
<FormLabel>Token ID</FormLabel>
<FormControl>
<Input {...form.register("tokenId")} className="bg-card" />
</FormControl>
<FormMessage />
</FormItem>
</>
)}
{/* select owned nft */}
{mode === "automatic" && (
<FormItem>
<FormDescription className="!mt-0 mb-5">
Select the NFT you want to list for sale
</FormDescription>
{!isSupportedChain ? (
<div className="flex flex-col gap-4 mb-4">
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
<p className="text-orange-800 dark:text-orange-900">
This chain is not supported by our NFT API yet, please enter
the contract address of the NFT you want to list.
</p>
</div>
<FormItem>
<FormLabel>Contract address</FormLabel>
<FormControl>
<SolidityInput
client={contract.client}
formContext={form}
solidityType="address"
{...form.register("selected.contractAddress", {
required: "Contract address is required",
})}
placeholder="0x..."
/>
</FormControl>
<FormMessage>
{form.formState.errors.selected?.contractAddress?.message}
</FormMessage>
<FormDescription>
This will display all the NFTs you own from this contract.
</FormDescription>
</FormItem>
</div>
) : null}
{isWalletNFTsLoading ||
(isOwnedNFTsLoading &&
!isSupportedChain &&
form.watch("selected.contractAddress")) ? (
<div className="flex flex-wrap gap-3">
{new Array(8).fill(0).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<Skeleton key={index} className="size-[140px] rounded-md" />
))}
</div>
) : nfts && nfts.length !== 0 ? (
<div className="flex flex-wrap gap-3">
{nfts?.map((nft) => {
function handleClick() {
if (isSelected(nft)) {
form.setValue("selected", undefined);
} else {
form.setValue("selected", nft);
}
}
return (
<ToolTipLabel
key={nft.contractAddress + nft.id}
label={
<ul>
<li>
<strong>Name:</strong> {nft.metadata?.name || "N/A"}
@@ -514,144 +544,173 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
<strong>Token Standard: </strong> {nft.type}
</li>
</ul>
</Card>
}
placement="left-end"
shouldWrapChildren
>
<Box
borderRadius="lg"
cursor="pointer"
onClick={() =>
isSelected(nft)
? form.setValue("selected", undefined)
: form.setValue("selected", nft)
}
outline={isSelected(nft) ? "3px solid" : undefined}
outlineColor={isSelected(nft) ? "purple.500" : undefined}
overflow="hidden"
>
<NFTMediaWithEmptyState
client={contract.client}
height="140px"
metadata={nft.metadata}
requireInteraction
width="140px"
/>
</Box>
</Tooltip>
);
})}
</Flex>
) : nfts && nfts.length === 0 ? (
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
<p className="text-orange-800 dark:text-orange-900">
There are no NFTs owned by this wallet. You need NFTs to create
a listing. You can create NFTs with thirdweb.{" "}
<Link
color="blue.600"
href="/explore/nft"
rel="noopener noreferrer"
target="_blank"
>
Explore NFT contracts
</Link>
.
</p>
{/** biome-ignore lint/a11y/useSemanticElements: ok */}
<div
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleClick();
}
}}
className={cn(
"rounded-lg cursor-pointer overflow-hidden",
isSelected(nft) &&
"ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
onClick={handleClick}
>
<NFTMediaWithEmptyState
client={contract.client}
height="140px"
metadata={nft.metadata}
requireInteraction
width="140px"
className="bg-card"
/>
</div>
</ToolTipLabel>
);
})}
</div>
) : nfts && nfts.length === 0 ? (
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
<p className="text-orange-800 dark:text-orange-900">
There are no NFTs owned by this wallet. You need NFTs to
create a listing. You can create NFTs with thirdweb.{" "}
<Link
className="text-blue-600"
href="/explore/nft"
rel="noopener noreferrer"
target="_blank"
>
Explore NFT contracts
</Link>
.
</p>
</div>
) : null}
</FormItem>
)}
{/* listing currency */}
<FormItem>
<FormLabel>Listing Currency</FormLabel>
<FormControl>
<CurrencySelector
className="bg-card"
contractChainId={chainId}
onChange={(e) =>
form.setValue("currencyContractAddress", e.target.value)
}
value={form.watch("currencyContractAddress")}
/>
</FormControl>
<FormDescription>
The currency you want to sell your tokens for.
</FormDescription>
</FormItem>
{/* listing price */}
<FormItem>
<FormLabel>
{form.watch("listingType") === "auction"
? "Buyout Price Per Token"
: "Listing Price"}
</FormLabel>
<FormControl>
<Input {...form.register("pricePerToken")} className="bg-card" />
</FormControl>
<FormDescription>
{form.watch("listingType") === "auction"
? "The price per token a buyer can pay to instantly buyout the auction."
: "The price of each token you are listing for sale."}
</FormDescription>
</FormItem>
{/* quantity */}
{form.watch("selected")?.type?.toLowerCase() !== "erc721" && (
<FormItem>
<div className="flex flex-row justify-between gap-2">
<FormLabel>Quantity</FormLabel>
</div>
) : null}
</FormControl>
)}
<FormControl>
<Input {...form.register("quantity")} className="bg-card" />
</FormControl>
<FormDescription>
The number of tokens to list for sale.
</FormDescription>
</FormItem>
)}
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
<FormLabel>Listing Currency</FormLabel>
<CurrencySelector
contractChainId={chainId}
onChange={(e) =>
form.setValue("currencyContractAddress", e.target.value)
}
value={form.watch("currencyContractAddress")}
/>
<FormHelperText>
The currency you want to sell your tokens for.
</FormHelperText>
</FormControl>
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
<FormLabel>
{form.watch("listingType") === "auction"
? "Buyout Price Per Token"
: "Listing Price"}
</FormLabel>
<Input {...form.register("pricePerToken")} />
<FormHelperText>
{form.watch("listingType") === "auction"
? "The price per token a buyer can pay to instantly buyout the auction."
: "The price of each token you are listing for sale."}
</FormHelperText>
</FormControl>
{form.watch("selected")?.type?.toLowerCase() !== "erc721" && (
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
<div className="flex flex-row justify-between gap-2">
<FormLabel>Quantity</FormLabel>
</div>
<Input {...form.register("quantity")} />
<FormHelperText>
The number of tokens to list for sale.
</FormHelperText>
</FormControl>
)}
{form.watch("listingType") === "auction" && (
<>
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
<FormLabel>Reserve Price Per Token</FormLabel>
<Input {...form.register("reservePricePerToken")} />
<FormHelperText>
The minimum price per token necessary to bid on this auction
</FormHelperText>
</FormControl>
<FormControl isRequired>
<FormLabel>Auction Duration</FormLabel>
<Select {...form.register("listingDurationInSeconds")}>
{auctionTimes.map((time) => (
<option key={time.value} value={time.value}>
{time.label}
</option>
))}
</Select>
<FormHelperText>The duration of this auction.</FormHelperText>
</FormControl>
</>
)}
{/* auction */}
{form.watch("listingType") === "auction" && (
<>
<FormItem>
<FormLabel>Reserve Price Per Token</FormLabel>
<FormControl>
<Input
{...form.register("reservePricePerToken")}
className="bg-card"
/>
</FormControl>
<FormDescription>
The minimum price per token necessary to bid on this auction
</FormDescription>
</FormItem>
<FormItem>
<FormLabel>Auction Duration</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
form.setValue("listingDurationInSeconds", value)
}
value={form.watch("listingDurationInSeconds")?.toString()}
>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{auctionTimes.map((time) => (
<SelectItem
key={time.value}
value={time.value.toString()}
>
{time.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>The duration of this auction.</FormDescription>
</FormItem>
</>
)}
{mode === "automatic" && !form.watch("selected.id") && (
<Alert>
<CircleAlertIcon className="size-4" />
<AlertTitle>No NFT selected</AlertTitle>
</Alert>
)}
{/* Need to pin these at the bottom because this is a very long form */}
<div className="fixed right-6 bottom-4 flex flex-row items-center justify-end gap-3">
<Button
disabled={isFormLoading}
onClick={() => setOpen(false)}
variant="default"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
form={LIST_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={isFormLoading}
transactionCount={2}
txChainID={contract.chain.id}
type="submit"
>
{actionText}
</TransactionButton>
</div>
</form>
{/* Need to pin these at the bottom because this is a very long form */}
<div className="flex items-center justify-end gap-3">
<Button
disabled={isFormLoading}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={isFormLoading}
transactionCount={2}
txChainID={contract.chain.id}
type="submit"
>
{actionText}
</TransactionButton>
</div>
</form>
</Form>
);
};
@@ -1,4 +1,3 @@
import { Flex, GridItem, SimpleGrid, usePrevious } from "@chakra-ui/react";
import { toast } from "sonner";
import type { ThirdwebContract } from "thirdweb";
import type {
@@ -11,169 +10,177 @@ import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card } from "@/components/ui/card";
import { CodeClient } from "@/components/ui/code/code.client";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@/components/ui/table";
import { CancelTab } from "./cancel-tab";
import { LISTING_STATUS } from "./types";
interface NFTDrawerProps {
export function ListingDrawer(props: {
contract: ThirdwebContract;
isOpen: boolean;
onClose: () => void;
data: DirectListing | EnglishAuction | null;
data: DirectListing | EnglishAuction;
isLoggedIn: boolean;
}
export const ListingDrawer: React.FC<NFTDrawerProps> = ({
contract,
isOpen,
onClose,
data,
isLoggedIn,
}) => {
}) {
const { contract, isOpen, onClose, data, isLoggedIn } = props;
const address = useActiveAccount()?.address;
const prevData = usePrevious(data);
const renderData = data || prevData;
const isOwner =
address?.toLowerCase() === renderData?.creatorAddress.toLowerCase();
const tokenId = renderData?.asset.id.toString() || "";
if (!renderData) {
return null;
}
const isOwner = address?.toLowerCase() === data.creatorAddress.toLowerCase();
const tokenId = data.asset.id.toString();
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent className="flex w-full flex-col gap-6 py-6 md:min-w-[600px]">
<div className="flex flex-row gap-6">
<SheetContent className="!w-full !max-w-lg gap-0">
<div className="space-y-4">
<NFTMediaWithEmptyState
client={contract.client}
height="150px"
metadata={renderData.asset.metadata}
height="120px"
metadata={data.asset.metadata}
requireInteraction
width="150px"
width="120px"
className="border rounded-xl"
/>
<Flex flexDir="column" gap={2} w="70%">
<p className="font-bold text-lg">
{renderData.asset.metadata.name}
<div className="space-y-1">
<p className="font-semibold text-2xl leading-tight">
{data.asset.metadata.name}
</p>
<p className="line-clamp-6 truncate text-base leading-normal">
{renderData.asset.metadata.name}
</p>
</Flex>
{data.asset.metadata.description && (
<p className="line-clamp-6 truncate text-sm text-muted-foreground">
{data.asset.metadata.description}
</p>
)}
</div>
</div>
<Flex flexDir="column" gap={4}>
<Card className="flex flex-col gap-3 p-4">
<SimpleGrid columns={12} placeItems="center left" rowGap={3}>
<GridItem colSpan={3}>
<p className="font-bold">Asset contract address</p>
</GridItem>
<GridItem colSpan={9}>
<Table className="my-6 border-y border-dashed">
<TableBody>
{/* NFT contract address */}
<TableRow className="border-dashed">
<StyledTableHead>NFT contract address</StyledTableHead>
<StyledTableCell>
<CopyAddressButton
address={renderData.assetContractAddress}
address={data.assetContractAddress}
copyIconPosition="right"
/>
</GridItem>
<GridItem colSpan={3}>
<p className="font-bold">Token ID</p>
</GridItem>
<GridItem colSpan={9}>
</StyledTableCell>
</TableRow>
{/* Token ID */}
<TableRow className="border-dashed">
<StyledTableHead>Token ID</StyledTableHead>
<StyledTableCell>
<CopyTextButton
copyIconPosition="right"
className="gap-4"
textToCopy={tokenId}
textToShow={tokenId}
tooltip="Token ID"
/>
</GridItem>
<GridItem colSpan={3}>
<p className="font-bold">Seller</p>
</GridItem>
<GridItem colSpan={9}>
</StyledTableCell>
</TableRow>
{/* Seller */}
<TableRow className="border-dashed">
<StyledTableHead>Seller</StyledTableHead>
<StyledTableCell>
<WalletAddress
address={renderData.creatorAddress}
address={data.creatorAddress}
client={contract.client}
className="py-1 h-auto"
/>
</GridItem>
<GridItem colSpan={3}>
<p className="font-bold">Listing ID</p>
</GridItem>
<GridItem colSpan={9}>
</StyledTableCell>
</TableRow>
{/* Listing ID */}
<TableRow className="border-dashed">
<StyledTableHead>Listing ID</StyledTableHead>
<StyledTableCell>
<CopyTextButton
className="gap-4"
copyIconPosition="right"
textToCopy={renderData.id.toString()}
textToShow={renderData.id.toString()}
textToCopy={data.id.toString()}
textToShow={data.id.toString()}
tooltip="Listing ID"
/>
</GridItem>
<GridItem colSpan={3}>
<p className="font-bold">Type</p>
</GridItem>
<GridItem colSpan={9}>{renderData.asset.type}</GridItem>
<GridItem colSpan={3}>
<p className="font-bold">Status</p>
</GridItem>
<GridItem colSpan={9}>
</StyledTableCell>
</TableRow>
{/* Type */}
<TableRow className="border-dashed">
<StyledTableHead>Type</StyledTableHead>
<StyledTableCell>{data.asset.type}</StyledTableCell>
</TableRow>
{/* Status */}
<TableRow className="border-dashed">
<StyledTableHead>Status</StyledTableHead>
<StyledTableCell>
<Badge className="uppercase">
{LISTING_STATUS[renderData.status]}
{LISTING_STATUS[data.status]}
</Badge>
</GridItem>
<GridItem colSpan={3}>
<p className="font-bold">Quantity</p>
</GridItem>
<GridItem colSpan={9}>
{(renderData.quantity || 0n).toString()}{" "}
</StyledTableCell>
</TableRow>
{/* Quantity */}
<TableRow className="border-dashed">
<StyledTableHead>Quantity</StyledTableHead>
<StyledTableCell>
{(data.quantity || 0n).toString()}{" "}
{/* For listings that are completed, the `quantity` would be `0`
So we show this text to make it clear */}
{LISTING_STATUS[renderData.status] === "Completed"
So we show this text to make it clear */}
{LISTING_STATUS[data.status] === "Completed"
? "(Sold out)"
: ""}
</GridItem>
</StyledTableCell>
</TableRow>
{/* Price */}
{data.type === "direct-listing" && (
<TableRow className="border-dashed">
<StyledTableHead>Price</StyledTableHead>
<StyledTableCell>
{data.currencyValuePerToken.displayValue}{" "}
{data.currencyValuePerToken.symbol}
</StyledTableCell>
</TableRow>
)}
</TableBody>
</Table>
{data?.asset.metadata.properties ? (
<div className="space-y-1">
<p className="font-bold">Attributes</p>
<CodeClient
code={
JSON.stringify(data.asset.metadata.properties, null, 2) || ""
}
lang="json"
/>
</div>
) : null}
{renderData.type === "direct-listing" && (
<>
<GridItem colSpan={3}>
<p className="font-bold">Price</p>
</GridItem>
<GridItem colSpan={9}>
{renderData.currencyValuePerToken.displayValue}{" "}
{renderData.currencyValuePerToken.symbol}
</GridItem>
</>
)}
</SimpleGrid>
</Card>
{data?.asset.metadata.properties ? (
<Card className="flex flex-col gap-3 p-4">
<p className="font-bold">Attributes</p>
<CodeClient
code={
JSON.stringify(data.asset.metadata.properties, null, 2) || ""
}
lang="json"
/>
</Card>
) : null}
</Flex>
{isOwner && (
<CancelTab
contract={contract}
id={renderData.id.toString()}
isAuction={renderData.type === "english-auction"}
id={data.id.toString()}
isAuction={data.type === "english-auction"}
isLoggedIn={isLoggedIn}
/>
)}
{!isOwner && renderData.status === "ACTIVE" && (
{!isOwner && data.status === "ACTIVE" && (
<BuyDirectListingButton
chain={contract.chain}
className="w-full"
client={contract.client}
contractAddress={renderData.assetContractAddress}
listingId={renderData.id}
contractAddress={data.assetContractAddress}
listingId={data.id}
onError={(error) => {
toast.error("Failed to buy listing", {
description: error.message,
@@ -184,10 +191,18 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
}}
quantity={1n}
>
Buy Listing
Buy NFT
</BuyDirectListingButton>
)}
</SheetContent>
</Sheet>
);
};
}
function StyledTableHead({ children }: { children: React.ReactNode }) {
return <TableHead className="px-0 py-3.5">{children}</TableHead>;
}
function StyledTableCell({ children }: { children: React.ReactNode }) {
return <TableCell className="px-0 py-3.5">{children}</TableCell>;
}
@@ -1,49 +1,29 @@
// biome-ignore-all lint/nursery/noNestedComponentDefinitions: TODO
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
import {
IconButton,
Select,
Skeleton,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
usePrevious,
} from "@chakra-ui/react";
import type { UseQueryResult } from "@tanstack/react-query";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
MoveRightIcon,
} from "lucide-react";
import {
type Dispatch,
type SetStateAction,
useEffect,
useMemo,
useState,
} from "react";
import { type Cell, type Column, usePagination, useTable } from "react-table";
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
import type { ThirdwebContract } from "thirdweb";
import type {
DirectListing,
EnglishAuction,
} from "thirdweb/extensions/marketplace";
import { min } from "thirdweb/utils";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { MediaCell } from "@/components/contracts/media-cell";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ListingDrawer } from "./listing-drawer";
import { LISTING_STATUS } from "./types";
interface MarketplaceTableProps {
export function MarketplaceTable(props: {
contract: ThirdwebContract;
getAllQueryResult: UseQueryResult<DirectListing[] | EnglishAuction[]>;
getValidQueryResult: UseQueryResult<DirectListing[] | EnglishAuction[]>;
@@ -59,279 +39,217 @@ interface MarketplaceTableProps {
}>
>;
isLoggedIn: boolean;
}
title: string;
cta: React.ReactNode;
}) {
const {
contract,
getAllQueryResult,
getValidQueryResult,
totalCountQuery,
queryParams,
setQueryParams,
isLoggedIn,
cta,
title,
} = props;
const DEFAULT_QUERY_STATE = { count: 50, start: 0 };
export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
contract,
getAllQueryResult,
getValidQueryResult,
totalCountQuery,
queryParams,
setQueryParams,
isLoggedIn,
}) => {
const [listingsToShow, setListingsToShow_] = useState<"all" | "valid">("all");
const setListingsToShow = (value: "all" | "valid") => {
setQueryParams(DEFAULT_QUERY_STATE);
setQueryParams({ count: 50, start: 0 });
setListingsToShow_(value);
};
const prevData = usePrevious(
listingsToShow === "all"
? getAllQueryResult?.data
: getValidQueryResult?.data,
);
const renderData = useMemo(() => {
if (listingsToShow === "all") {
return getAllQueryResult?.data || prevData;
return getAllQueryResult?.data;
}
return getValidQueryResult?.data || prevData;
}, [getAllQueryResult, getValidQueryResult, listingsToShow, prevData]);
return getValidQueryResult?.data;
}, [getAllQueryResult, getValidQueryResult, listingsToShow]);
const tableColumns: Column<DirectListing | EnglishAuction>[] = useMemo(() => {
return [
{
accessor: (row) => row.id.toString(),
Header: "Listing Id",
},
{
accessor: (row) => row.asset.metadata,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
Cell: (cell: any) => <MediaCell cell={cell} client={contract.client} />,
Header: "Media",
},
{
accessor: (row) => row.asset.metadata.name ?? "N/A",
Header: "Name",
},
{
accessor: (row) => row.creatorAddress,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
Cell: ({ cell }: { cell: Cell<any, string> }) => (
<WalletAddress address={cell.value} client={contract.client} />
),
Header: "Creator",
},
{
accessor: (row) =>
(row as DirectListing)?.currencyValuePerToken ||
(row as EnglishAuction)?.buyoutCurrencyValue,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
Cell: ({ cell }: { cell: Cell<any, any> }) => {
return (
<p className="whitespace-nowrap text-muted-foreground">
{cell.value.displayValue} {cell.value.symbol}
</p>
);
},
Header: "Price",
},
{
accessor: (row) => LISTING_STATUS[row.status],
Header: "Status",
},
];
}, [contract.client]);
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
page,
canPreviousPage,
canNextPage,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable(
{
columns: tableColumns,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
data: (renderData as any) || [],
initialState: {
pageIndex: 0,
pageSize: queryParams.count,
},
manualPagination: true,
pageCount: Math.max(
Math.ceil(
Number(
// To avoid overflow issue
min(totalCountQuery.data || 0n, BigInt(Number.MAX_SAFE_INTEGER)),
) / queryParams.count,
),
1,
),
},
// FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
const pageSize = queryParams.count;
const currentPage = Math.floor(queryParams.start / pageSize) + 1; // PaginationButtons uses 1-based indexing
const totalCount = Number(
min(totalCountQuery.data || 0n, BigInt(Number.MAX_SAFE_INTEGER)),
);
const totalPages = Math.max(Math.ceil(totalCount / pageSize), 1);
const showPagination = totalPages > 1;
// FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setQueryParams({ count: pageSize, start: pageIndex * pageSize });
}, [pageIndex, pageSize, setQueryParams]);
// Pagination handler for PaginationButtons
const handlePageChange = (page: number) => {
setQueryParams({ count: pageSize, start: (page - 1) * pageSize });
};
const [tokenRow, setTokenRow] = useState<
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedToken, setSelectedToken] = useState<
DirectListing | EnglishAuction | null
>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row">
<Button
className="w-18 rounded-r-none"
onClick={() => setListingsToShow("all")}
variant={listingsToShow === "all" ? "default" : "outline"}
>
All
</Button>
<Button
className="rounded-l-none"
onClick={() => setListingsToShow("valid")}
variant={listingsToShow === "valid" ? "default" : "outline"}
>
Valid
</Button>
</div>
<TableContainer className="relative" maxW="100%">
{((listingsToShow === "all" && getAllQueryResult.isFetching) ||
(listingsToShow === "valid" && getValidQueryResult.isFetching)) && (
<Spinner
color="primary"
position="absolute"
right={4}
size="xs"
top={2}
/>
)}
<ListingDrawer
contract={contract}
data={tokenRow}
isLoggedIn={isLoggedIn}
isOpen={!!tokenRow}
onClose={() => setTokenRow(null)}
/>
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup, headerGroupIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
{headerGroup.headers.map((column, columnIndex) => (
<Th
{...column.getHeaderProps()}
border="none"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={columnIndex}
>
<p className="text-muted-foreground">
{column.render("Header")}
</p>
</Th>
))}
{/* // Need to add an empty header for the drawer button */}
<Th border="none" key="drawer" />
</Tr>
))}
</Thead>
<Tbody {...getTableBodyProps()} position="relative">
{page.map((row, rowIndex) => {
prepareRow(row);
return (
<Tr
{...row.getRowProps()}
_last={{ borderBottomWidth: 0 }}
borderBottomWidth={1}
borderColor="borderColor"
className="hover:bg-card"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={rowIndex}
onClick={() => setTokenRow(row.original)}
role="group"
style={{ cursor: "pointer" }}
>
{row.cells.map((cell, cellIndex) => (
<Td
{...cell.getCellProps()}
borderBottomWidth="inherit"
borderColor="borderColor"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
))}
<Td borderBottomWidth="inherit" borderColor="borderColor">
<MoveRightIcon className="size-3" />
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
<div className="flex w-full items-center justify-center">
<div className="flex flex-row items-center gap-2">
<IconButton
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
isDisabled={!canPreviousPage || totalCountQuery.isPending}
onClick={() => gotoPage(0)}
/>
<IconButton
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
isDisabled={!canPreviousPage || totalCountQuery.isPending}
onClick={() => previousPage()}
/>
<p className="whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<Skeleton
as="span"
display="inline"
isLoaded={totalCountQuery.isSuccess}
>
<strong>{pageCount}</strong>
</Skeleton>
</p>
<IconButton
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
isDisabled={!canNextPage || totalCountQuery.isPending}
onClick={() => nextPage()}
/>
<IconButton
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
isDisabled={!canNextPage || totalCountQuery.isPending}
onClick={() => gotoPage(pageCount - 1)}
/>
const isFetching =
(listingsToShow === "all" && getAllQueryResult.isFetching) ||
(listingsToShow === "valid" && getValidQueryResult.isFetching);
<Select
isDisabled={totalCountQuery.isPending}
onChange={(e) => {
setPageSize(Number.parseInt(e.target.value as string, 10));
}}
value={pageSize}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</Select>
return (
<div>
<div className="flex justify-between flex-col lg:flex-row gap-3 items-end">
<h2 className="font-semibold text-2xl tracking-tight">{title}</h2>
<div className="flex gap-4">
<div className="flex flex-row">
<Button
size="sm"
className="rounded-r-none"
onClick={() => setListingsToShow("all")}
variant={listingsToShow === "all" ? "default" : "outline"}
>
All
</Button>
<Button
size="sm"
className="rounded-l-none"
onClick={() => setListingsToShow("valid")}
variant={listingsToShow === "valid" ? "default" : "outline"}
>
Valid
</Button>
</div>
{cta}
</div>
</div>
<div className="h-4" />
<TableContainer>
{renderData?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-muted-foreground">No listings found</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>
<p className="text-muted-foreground">Listing Id</p>
</TableHead>
<TableHead>
<p className="text-muted-foreground">Media</p>
</TableHead>
<TableHead>
<p className="text-muted-foreground">Name</p>
</TableHead>
<TableHead>
<p className="text-muted-foreground">Creator</p>
</TableHead>
<TableHead>
<p className="text-muted-foreground">Price</p>
</TableHead>
<TableHead>
<p className="text-muted-foreground">Status</p>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isFetching &&
Array.from({ length: 5 }).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<SkeletonRow key={index} />
))}
{!isFetching &&
renderData?.map((row, _rowIndex) => (
<TableRow
key={row.id.toString()}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => {
setSelectedToken(row);
setIsDrawerOpen(true);
}}
>
<TableCell>{row.id.toString()}</TableCell>
<TableCell>
<MediaCell
cell={{ value: row.asset.metadata }}
client={contract.client}
/>
</TableCell>
<TableCell>{row.asset.metadata.name ?? "N/A"}</TableCell>
<TableCell>
<WalletAddress
address={row.creatorAddress}
client={contract.client}
/>
</TableCell>
<TableCell>
<p className="whitespace-nowrap text-foreground">
{(row as DirectListing)?.currencyValuePerToken
?.displayValue ||
(row as EnglishAuction)?.buyoutCurrencyValue
?.displayValue ||
"N/A"}{" "}
{(row as DirectListing)?.currencyValuePerToken
?.symbol ||
(row as EnglishAuction)?.buyoutCurrencyValue
?.symbol ||
""}
</p>
</TableCell>
<TableCell>{LISTING_STATUS[row.status]}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</TableContainer>
{showPagination && (
<PaginationButtons
activePage={currentPage}
totalPages={totalPages}
onPageClick={handlePageChange}
/>
)}
{selectedToken && (
<ListingDrawer
contract={contract}
data={selectedToken}
isLoggedIn={isLoggedIn}
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
)}
</div>
);
};
}
function SkeletonRow() {
return (
<TableRow>
{/* listing id */}
<TableCell>
<Skeleton className="h-6 w-10" />
</TableCell>
{/* media */}
<TableCell>
<Skeleton className="size-[120px]" />
</TableCell>
{/* name */}
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
{/* creator */}
<TableCell>
<Skeleton className="h-6 w-40" />
</TableCell>
{/* price */}
<TableCell>
<Skeleton className="h-6 w-20" />
</TableCell>
{/* status */}
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
</TableRow>
);
}
@@ -4,33 +4,24 @@ import type { ThirdwebContract } from "thirdweb";
import { CreateListingButton } from "../components/list-button";
import { DirectListingsTable } from "./components/table";
interface ContractDirectListingsPageProps {
export function ContractDirectListingsPage(props: {
contract: ThirdwebContract;
isLoggedIn: boolean;
isInsightSupported: boolean;
}
export const ContractDirectListingsPage: React.FC<
ContractDirectListingsPageProps
> = ({ contract, isLoggedIn, isInsightSupported }) => {
}) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between">
<h2 className="font-semibold text-2xl tracking-tight">
Direct Listings
</h2>
<div className="flex flex-row gap-4">
<CreateListingButton
contract={contract}
createText="Create Direct Listing"
isInsightSupported={isInsightSupported}
isLoggedIn={isLoggedIn}
type="direct-listings"
/>
</div>
</div>
<DirectListingsTable contract={contract} isLoggedIn={isLoggedIn} />
</div>
<DirectListingsTable
contract={props.contract}
isLoggedIn={props.isLoggedIn}
cta={
<CreateListingButton
contract={props.contract}
createText="Create Direct Listing"
isInsightSupported={props.isInsightSupported}
isLoggedIn={props.isLoggedIn}
type="direct-listings"
/>
}
/>
);
};
}
@@ -10,39 +10,38 @@ import {
import { useReadContract } from "thirdweb/react";
import { MarketplaceTable } from "../../components/marketplace-table";
interface DirectListingsTableProps {
export function DirectListingsTable(props: {
contract: ThirdwebContract;
isLoggedIn: boolean;
}
const DEFAULT_QUERY_STATE = { count: 50, start: 0 };
export const DirectListingsTable: React.FC<DirectListingsTableProps> = ({
contract,
isLoggedIn,
}) => {
const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_STATE);
cta: React.ReactNode;
}) {
const [queryParams, setQueryParams] = useState({ count: 50, start: 0 });
const getAllQueryResult = useReadContract(getAllListings, {
contract,
contract: props.contract,
count: BigInt(queryParams.count),
start: queryParams.start,
});
const getValidQueryResult = useReadContract(getAllValidListings, {
contract,
contract: props.contract,
count: BigInt(queryParams.count),
start: queryParams.start,
});
const totalCountQuery = useReadContract(totalListings, { contract });
const totalCountQuery = useReadContract(totalListings, {
contract: props.contract,
});
return (
<MarketplaceTable
contract={contract}
contract={props.contract}
getAllQueryResult={getAllQueryResult}
getValidQueryResult={getValidQueryResult}
isLoggedIn={isLoggedIn}
isLoggedIn={props.isLoggedIn}
queryParams={queryParams}
setQueryParams={setQueryParams}
totalCountQuery={totalCountQuery}
cta={props.cta}
title="Direct Listings"
/>
);
};
}
@@ -4,33 +4,24 @@ import type { ThirdwebContract } from "thirdweb";
import { CreateListingButton } from "../components/list-button";
import { EnglishAuctionsTable } from "./components/table";
interface ContractEnglishAuctionsProps {
export function ContractEnglishAuctionsPage(props: {
contract: ThirdwebContract;
isLoggedIn: boolean;
isInsightSupported: boolean;
}
export const ContractEnglishAuctionsPage: React.FC<
ContractEnglishAuctionsProps
> = ({ contract, isLoggedIn, isInsightSupported }) => {
}) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between">
<h2 className="font-semibold text-2xl tracking-tight">
English Auctions
</h2>
<div className="flex flex-row gap-4">
<CreateListingButton
contract={contract}
createText="Create English Auction"
isInsightSupported={isInsightSupported}
isLoggedIn={isLoggedIn}
type="english-auctions"
/>
</div>
</div>
<EnglishAuctionsTable contract={contract} isLoggedIn={isLoggedIn} />
</div>
<EnglishAuctionsTable
contract={props.contract}
isLoggedIn={props.isLoggedIn}
cta={
<CreateListingButton
contract={props.contract}
createText="Create English Auction"
isInsightSupported={props.isInsightSupported}
isLoggedIn={props.isLoggedIn}
type="english-auctions"
/>
}
/>
);
};
}
@@ -10,39 +10,37 @@ import {
import { useReadContract } from "thirdweb/react";
import { MarketplaceTable } from "../../components/marketplace-table";
interface EnglishAuctionsTableProps {
export function EnglishAuctionsTable(props: {
contract: ThirdwebContract;
isLoggedIn: boolean;
}
const DEFAULT_QUERY_STATE = { count: 50, start: 0 };
export const EnglishAuctionsTable: React.FC<EnglishAuctionsTableProps> = ({
contract,
isLoggedIn,
}) => {
const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_STATE);
cta: React.ReactNode;
}) {
const [queryParams, setQueryParams] = useState({ count: 50, start: 0 });
const getAllQueryResult = useReadContract(getAllAuctions, {
contract,
contract: props.contract,
count: BigInt(queryParams.count),
start: queryParams.start,
});
const getValidQueryResult = useReadContract(getAllValidAuctions, {
contract,
contract: props.contract,
count: BigInt(queryParams.count),
start: queryParams.start,
});
const totalCountQuery = useReadContract(totalAuctions, { contract });
const totalCountQuery = useReadContract(totalAuctions, {
contract: props.contract,
});
return (
<MarketplaceTable
contract={contract}
contract={props.contract}
getAllQueryResult={getAllQueryResult}
getValidQueryResult={getValidQueryResult}
isLoggedIn={isLoggedIn}
isLoggedIn={props.isLoggedIn}
queryParams={queryParams}
setQueryParams={setQueryParams}
totalCountQuery={totalCountQuery}
cta={props.cta}
title="English Auctions"
/>
);
};
}
@@ -1,47 +1,40 @@
import { Box, Flex } from "@chakra-ui/react";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { CurrencySelector } from "@/components/blocks/CurrencySelector";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { cn } from "@/lib/utils";
import { PriceInput } from "../../price-input";
import { useClaimConditionsFormContext } from "..";
import { CustomFormControl } from "../common";
/**
* Allows the user to select how much they want to charge to claim each NFT
*/
export const ClaimPriceInput = (props: { contractChainId: number }) => {
const {
formDisabled,
isErc20,
form,
phaseIndex,
field,
isColumn,
claimConditionType,
} = useClaimConditionsFormContext();
const { formDisabled, isErc20, form, phaseIndex, field, claimConditionType } =
useClaimConditionsFormContext();
if (claimConditionType === "creator") {
return null;
}
return (
<CustomFormControl
disabled={formDisabled}
error={
<FormFieldSetup
isRequired={false}
errorMessage={
form.getFieldState(`phases.${phaseIndex}.price`, form.formState).error
?.message
}
label={`How much do you want to charge to claim each ${
isErc20 ? "token" : "NFT"
}?`}
>
<Flex flexDir={{ base: "column", md: "row" }} gap={2}>
<Box minW="70px" w={{ base: "100%", md: "50%" }}>
<PriceInput
onChange={(val) => form.setValue(`phases.${phaseIndex}.price`, val)}
value={field.price?.toString() || ""}
w="full"
/>
</Box>
<Box w={{ base: "100%", md: isColumn ? "50%" : "100%" }}>
<div className={cn("flex flex-col md:flex-row gap-3")}>
<PriceInput
onChange={(val) => form.setValue(`phases.${phaseIndex}.price`, val)}
value={field.price?.toString() || ""}
disabled={formDisabled}
className="max-w-48"
/>
<div className="grow max-w-md">
<CurrencySelector
contractChainId={props.contractChainId}
isDisabled={formDisabled}
@@ -53,8 +46,8 @@ export const ClaimPriceInput = (props: { contractChainId: number }) => {
}
value={field?.currencyAddress || NATIVE_TOKEN_ADDRESS}
/>
</Box>
</Flex>
</CustomFormControl>
</div>
</div>
</FormFieldSetup>
);
};
@@ -1,8 +1,15 @@
import { Box, Flex, Select } from "@chakra-ui/react";
import { UploadIcon } from "lucide-react";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Button } from "@/components/ui/button";
import { useClaimConditionsFormContext } from "..";
import { CustomFormControl } from "../common";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useClaimConditionsFormContext } from "../index";
/**
* Allows the user to
@@ -19,12 +26,11 @@ export const ClaimerSelection = () => {
isErc20,
setOpenSnapshotIndex: setOpenIndex,
isAdmin,
isColumn,
claimConditionType,
} = useClaimConditionsFormContext();
const handleClaimerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.currentTarget.value as "any" | "specific" | "overrides";
const handleClaimerChange = (value: string) => {
const val = value as "any" | "specific" | "overrides";
if (val === "any") {
form.setValue(`phases.${phaseIndex}.snapshot`, undefined);
@@ -80,79 +86,69 @@ export const ClaimerSelection = () => {
: `Who can claim ${isErc20 ? "tokens" : "NFTs"} during this phase?`;
return (
<CustomFormControl
disabled={formDisabled}
error={
<FormFieldSetup
errorMessage={
form.getFieldState(`phases.${phaseIndex}.snapshot`, form.formState)
.error
?.error?.message
}
helperText={helperText}
label={label}
isRequired={false}
>
<Flex direction={{ base: "column", md: "row" }} gap={4}>
<div className="flex flex-col md:flex-row gap-4">
{claimConditionType === "overrides" ||
claimConditionType === "specific" ? null : (
<Select
isDisabled={formDisabled}
onChange={handleClaimerChange}
disabled={formDisabled}
onValueChange={handleClaimerChange}
value={dropType}
w={{ base: "100%", md: "50%" }}
>
<option value="any">Any wallet</option>
<option value="overrides">Any wallet (with overrides)</option>
<option value="specific">Only specific wallets</option>
<SelectTrigger className="w-full md:w-1/2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any wallet</SelectItem>
<SelectItem value="overrides">
Any wallet (with overrides)
</SelectItem>
<SelectItem value="specific">Only specific wallets</SelectItem>
</SelectContent>
</Select>
)}
{/* Edit or See Snapshot */}
{field.snapshot ? (
<Flex
direction={{
base: "column",
md: isColumn ? "column" : "row",
}}
gap={1.5}
>
<div className="flex items-center gap-3">
{/* disable the "Edit" button when form is disabled, but not when it's a "See" button */}
<Button
className="gap-2 rounded-md"
disabled={disabledSnapshotButton}
onClick={() => setOpenIndex(phaseIndex)}
variant="primary"
size="sm"
>
{isAdmin ? "Edit" : "See"} Claimer Snapshot
<UploadIcon className="size-4" />
</Button>
<Flex
_light={{
color: field.snapshot?.length === 0 ? "red.500" : "green.500",
}}
align="center"
color={field.snapshot?.length === 0 ? "red.400" : "green.400"}
direction="row"
gap={2}
justify="center"
ml={2}
opacity={disabledSnapshotButton ? 0.5 : 1}
<div
className={cn(
"flex gap-2 items-center",
field.snapshot?.length === 0
? "text-muted-foreground"
: "text-green-600 dark:text-green-500",
disabledSnapshotButton ? "opacity-50" : "",
)}
>
<p>
●{" "}
<strong>
{field.snapshot?.length} address
{field.snapshot?.length === 1 ? "" : "es"}
</strong>{" "}
in snapshot
</p>
</Flex>
</Flex>
) : (
<Box
display={{ base: "none", md: "block" }}
w={{ base: "100%", md: "50%" }}
/>
)}
</Flex>
</CustomFormControl>
<div className="size-2 bg-current rounded-full" />
<span className="text-sm">
{field.snapshot?.length}{" "}
{field.snapshot?.length === 1 ? "address" : "addresses"} in
snapshot
</span>
</div>
</div>
) : null}
</div>
</FormFieldSetup>
);
};
@@ -1,6 +1,6 @@
import { useActiveAccount } from "thirdweb/react";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Input } from "@/components/ui/input";
import { CustomFormControl } from "../common";
import { useClaimConditionsFormContext } from "../index";
/**
@@ -14,8 +14,7 @@ interface CreatorInputProps {
export const CreatorInput: React.FC<CreatorInputProps> = ({
creatorAddress,
}) => {
const { formDisabled, claimConditionType, isAdmin } =
useClaimConditionsFormContext();
const { claimConditionType, isAdmin } = useClaimConditionsFormContext();
const walletAddress = useActiveAccount()?.address;
if (claimConditionType !== "creator") {
@@ -23,8 +22,9 @@ export const CreatorInput: React.FC<CreatorInputProps> = ({
}
return (
<CustomFormControl
disabled={formDisabled}
<FormFieldSetup
isRequired={false}
errorMessage={undefined}
helperText={
<>
This wallet address will be able to indefinitely claim.{" "}
@@ -34,7 +34,12 @@ export const CreatorInput: React.FC<CreatorInputProps> = ({
}
label="Creator address"
>
<Input disabled readOnly value={creatorAddress || walletAddress} />
</CustomFormControl>
<Input
disabled
readOnly
value={creatorAddress || walletAddress}
className="disabled:opacity-100 max-w-sm"
/>
</FormFieldSetup>
);
};
@@ -1,6 +1,6 @@
import Link from "next/link";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { QuantityInputWithUnlimited } from "../../quantity-input-with-unlimited";
import { CustomFormControl } from "../common";
import { useClaimConditionsFormContext } from "../index";
/**
@@ -23,13 +23,13 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
}
return (
<CustomFormControl
disabled={formDisabled}
error={
<FormFieldSetup
isRequired={true}
errorMessage={
form.getFieldState(
`phases.${phaseIndex}.maxClaimablePerWallet`,
form.formState,
).error
).error?.message
}
helperText={
<>
@@ -39,14 +39,13 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
: ". "}
Limits are set per wallets and not per user, sophisticated actors
could get around wallet restrictions.{" "}
<Link
className="text-blue-500"
<UnderlineLink
href="https://portal.thirdweb.com/contracts/design/Drop#sybil-attacks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</Link>
</UnderlineLink>
.
</>
}
@@ -66,6 +65,6 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
}
value={field?.maxClaimablePerWallet?.toString() || "0"}
/>
</CustomFormControl>
</FormFieldSetup>
);
};
@@ -1,6 +1,6 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { QuantityInputWithUnlimited } from "../../quantity-input-with-unlimited";
import { useClaimConditionsFormContext } from "..";
import { CustomFormControl } from "../common";
/**
* Allows the user to select how many NFTs will be dropped in a phase
@@ -21,13 +21,13 @@ export const MaxClaimableSupplyInput: React.FC = () => {
}
return (
<CustomFormControl
disabled={formDisabled}
error={
<FormFieldSetup
isRequired={true}
errorMessage={
form.getFieldState(
`phases.${phaseIndex}.maxClaimableSupply`,
form.formState,
).error
).error?.message
}
label={`How many ${
isErc20 ? "tokens" : "NFTs"
@@ -45,6 +45,6 @@ export const MaxClaimableSupplyInput: React.FC = () => {
}
value={field.maxClaimableSupply?.toString() || "0"}
/>
</CustomFormControl>
</FormFieldSetup>
);
};
@@ -1,5 +1,5 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Input } from "@/components/ui/input";
import { CustomFormControl } from "../common";
import { useClaimConditionsFormContext } from "../index";
/**
@@ -12,13 +12,15 @@ export const PhaseNameInput: React.FC = () => {
const inputValue = field.metadata?.name;
return (
<CustomFormControl
disabled={formDisabled}
<FormFieldSetup
isRequired={false}
errorMessage={undefined}
helperText="This does not affect how your claim phase functions and is for organizational purposes only."
label="Name"
>
<Input
disabled={formDisabled}
className="max-w-xs"
onChange={(e) => {
form.setValue(`phases.${phaseIndex}.metadata.name`, e.target.value);
}}
@@ -26,6 +28,6 @@ export const PhaseNameInput: React.FC = () => {
type="text"
value={inputValue}
/>
</CustomFormControl>
</FormFieldSetup>
);
};
@@ -1,6 +1,6 @@
import { Input } from "@chakra-ui/react";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Input } from "@/components/ui/input";
import { toDateTimeLocal } from "@/utils/date-utils";
import { CustomFormControl } from "../common";
import { useClaimConditionsFormContext } from "../index";
/**
@@ -10,16 +10,18 @@ export const PhaseStartTimeInput: React.FC = () => {
const { form, phaseIndex, field, formDisabled } =
useClaimConditionsFormContext();
return (
<CustomFormControl
disabled={formDisabled}
error={
<FormFieldSetup
isRequired={true}
errorMessage={
form.getFieldState(`phases.${phaseIndex}.startTime`, form.formState)
.error
.error?.message
}
helperText="This time is in your local timezone."
label="When will this phase start?"
>
<Input
disabled={formDisabled}
className="max-w-sm"
onChange={(e) =>
form.setValue(
`phases.${phaseIndex}.startTime`,
@@ -29,6 +31,6 @@ export const PhaseStartTimeInput: React.FC = () => {
type="datetime-local"
value={toDateTimeLocal(field.startTime)}
/>
</CustomFormControl>
</FormFieldSetup>
);
};
@@ -1,49 +0,0 @@
import { Flex, FormControl } from "@chakra-ui/react";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import type { FieldError } from "react-hook-form";
import type { ComponentWithChildren } from "@/types/component-with-children";
import { useClaimConditionsFormContext } from ".";
interface CustomFormControlProps {
disabled: boolean;
label: string;
error?: FieldError;
helperText?: React.ReactNode;
}
export const CustomFormControl: ComponentWithChildren<
CustomFormControlProps
> = (props) => {
return (
<FormControl isDisabled={props.disabled} isInvalid={!!props.error}>
{/* label */}
<FormLabel className="font-bold">{props.label}</FormLabel>
{/* input */}
{props.children}
{/* error message */}
{props.error && (
<FormErrorMessage>{props.error.message}</FormErrorMessage>
)}
{/* helper text */}
{props.helperText && <FormHelperText>{props.helperText}</FormHelperText>}
</FormControl>
);
};
export const CustomFormGroup: ComponentWithChildren = ({ children }) => {
const { isColumn } = useClaimConditionsFormContext();
return (
<Flex
direction={{
base: "column",
md: isColumn ? "column" : "row",
}}
gap={4}
>
{children}
</Flex>
);
};
@@ -1,21 +1,11 @@
"use client";
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Flex,
Menu,
MenuButton,
MenuItem,
MenuList,
} from "@chakra-ui/react";
import { Button } from "chakra/button";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { CircleHelpIcon, PlusIcon } from "lucide-react";
ArrowDownToLineIcon,
CircleAlertIcon,
CircleHelpIcon,
PlusIcon,
} from "lucide-react";
import { createContext, Fragment, useContext, useMemo, useState } from "react";
import {
type UseFieldArrayReturn,
@@ -38,8 +28,16 @@ import {
import invariant from "tiny-invariant";
import * as z from "zod";
import { ZodError } from "zod";
import { AdminOnly } from "@/components/contracts/roles/admin-only";
import { TransactionButton } from "@/components/tx-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Form } from "@/components/ui/form";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useIsAdmin } from "@/hooks/useContractRoles";
@@ -286,7 +284,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
values: { phases: transformedQueryData },
});
const { append, remove, fields } = useFieldArray({
const formFields = useFieldArray({
control: form.control,
name: "phases",
});
@@ -296,14 +294,14 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
switch (type) {
case "public":
append({
formFields.append({
...DEFAULT_PHASE,
metadata: { name },
});
break;
case "specific":
append({
formFields.append({
...DEFAULT_PHASE,
maxClaimablePerWallet: "0",
metadata: { name },
@@ -312,7 +310,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
break;
case "overrides":
append({
formFields.append({
...DEFAULT_PHASE,
maxClaimablePerWallet: "1",
metadata: { name },
@@ -321,7 +319,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
break;
case "creator":
append({
formFields.append({
...DEFAULT_PHASE,
maxClaimablePerWallet: "0",
maxClaimableSupply: "unlimited",
@@ -340,19 +338,15 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
break;
default:
append({
formFields.append({
...DEFAULT_PHASE,
metadata: { name },
});
}
};
const removePhase = (index: number) => {
remove(index);
};
const phases = form.watch("phases");
const controlledFields = fields.map((field, index) => {
const controlledFields = formFields.fields.map((field, index) => {
return {
...field,
...phases[index],
@@ -428,25 +422,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
return phaseId;
}, [controlledFields]);
const { hasAddedPhases, hasRemovedPhases } = useMemo(() => {
const initialPhases = claimConditionsQuery.data || [];
const currentPhases = controlledFields;
const _hasAddedPhases =
currentPhases.length > initialPhases.length &&
claimConditionsQuery?.data?.length === 0 &&
controlledFields?.length > 0;
const _hasRemovedPhases =
currentPhases.length < initialPhases.length &&
isMultiPhase &&
controlledFields?.length === 0;
return {
hasAddedPhases: _hasAddedPhases,
hasRemovedPhases: _hasRemovedPhases,
};
}, [claimConditionsQuery.data, controlledFields, isMultiPhase]);
if (claimConditionsQuery.isPending) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
@@ -465,12 +440,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}
return (
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
<Flex direction="column" gap={6}>
<Form {...form}>
<form onSubmit={handleFormSubmit} className="space-y-5">
{/* Show the reason why the form is disabled */}
{!isAdmin && (
<Text>Connect with admin wallet to edit claim conditions.</Text>
)}
{!isAdmin && <p>Connect with admin wallet to edit claim conditions.</p>}
{controlledFields.map((field, index) => {
const dropType: DropType = field.snapshot
? field.maxClaimablePerWallet?.toString() === "0"
@@ -535,7 +509,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
contract={contract}
isPending={sendTx.isPending}
onRemove={() => {
removePhase(index);
formFields.remove(index);
}}
/>
</ClaimsConditionFormContext.Provider>
@@ -544,45 +518,42 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
})}
{phases?.length === 0 && (
<Alert borderRadius="md" status="warning">
<AlertIcon />
<div className="flex flex-col">
<AlertTitle as={Heading} size="label.lg">
{isMultiPhase
? "Missing Claim Phases"
: "Missing Claim Conditions"}
</AlertTitle>
<AlertDescription as={Text}>
{isMultiPhase
? "You need to set at least one claim phase for people to claim this drop."
: "You need to set claim conditions for people to claim this drop."}
</AlertDescription>
</div>
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>
{isMultiPhase
? "Missing Claim Phases"
: "Missing Claim Conditions"}
</AlertTitle>
<AlertDescription>
{isMultiPhase
? "You need to set at least one claim phase for people to claim this drop."
: "You need to set claim conditions for people to claim this drop."}
</AlertDescription>
</Alert>
)}
<Flex
flexDir={{ base: "column", md: "row" }}
gap={2}
justifyContent="space-between"
>
<div className="flex flex-row gap-2">
<AdminOnly contract={contract}>
<Menu>
<MenuButton
as={Button}
borderRadius="md"
colorScheme="primary"
isDisabled={
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
}
leftIcon={<PlusIcon className="size-5" />}
size="sm"
variant={phases?.length > 0 ? "outline" : "solid"}
{isAdmin && (
<div className="flex gap-3 justify-between flex-wrap">
<div className="flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
}
size="sm"
variant="outline"
className="bg-card"
>
<PlusIcon className="size-3.5 mr-2" />
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg w-56"
sideOffset={10}
>
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
</MenuButton>
<MenuList borderRadius="lg" overflow="hidden" zIndex="overlay">
{Object.keys(ClaimConditionTypeData).map((key) => {
const type = key as ClaimConditionType;
@@ -591,7 +562,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}
return (
<MenuItem
<DropdownMenuItem
key={type}
onClick={() => {
addPhase(type);
@@ -600,72 +571,54 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
>
<div className="flex items-center gap-1">
{ClaimConditionTypeData[type].name}
<TooltipBox
content={
<Text size="body.md">
<ToolTipLabel
label={
<p className="text-sm">
{ClaimConditionTypeData[type].description}
</Text>
</p>
}
/>
>
<CircleHelpIcon className="size-4 text-muted-foreground" />
</ToolTipLabel>
</div>
</MenuItem>
</DropdownMenuItem>
);
})}
</MenuList>
</Menu>
</AdminOnly>
</DropdownMenuContent>
</DropdownMenu>
</div>
{controlledFields.some((field) => field.fromSdk) && (
<ResetClaimEligibility
contract={contract}
isErc20={isErc20}
<div className="flex flex-row gap-3">
<TransactionButton
size="sm"
variant="default"
client={contract.client}
disabled={claimConditionsQuery.isPending}
isLoggedIn={isLoggedIn}
isMultiphase={isMultiPhase}
tokenId={tokenId}
/>
)}
</div>
isPending={sendTx.isPending}
transactionCount={undefined}
txChainID={contract.chain.id}
type="submit"
>
<ArrowDownToLineIcon className="size-3.5" />
{claimConditionsQuery.isPending
? "Saving Phases"
: "Save Phases"}
</TransactionButton>
<div className="flex flex-row">
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
<Flex alignItems="center" gap={3} justifyContent="center">
{(hasRemovedPhases || hasAddedPhases) && (
<Text color="red.500" fontWeight="bold">
You have unsaved changes
</Text>
)}
{controlledFields.length > 0 ||
hasRemovedPhases ||
!isMultiPhase ? (
<TransactionButton
client={contract.client}
disabled={claimConditionsQuery.isPending}
isLoggedIn={isLoggedIn}
isPending={sendTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
{claimConditionsQuery.isPending
? "Saving Phases"
: "Save Phases"}
</TransactionButton>
) : null}
</Flex>
</AdminOnly>
{controlledFields.some((field) => field.fromSdk) && (
<ResetClaimEligibility
contract={contract}
isErc20={isErc20}
isLoggedIn={isLoggedIn}
isMultiphase={isMultiPhase}
tokenId={tokenId}
/>
)}
</div>
</div>
</Flex>
</Flex>
</Flex>
);
};
const TooltipBox: React.FC<{
content: React.ReactNode;
}> = ({ content }) => {
return (
<ToolTipLabel label={<div>{content}</div>}>
<CircleHelpIcon className="size-4 text-muted-foreground" />
</ToolTipLabel>
)}
</form>
</Form>
);
};
@@ -1,12 +1,12 @@
import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
import { formatDate } from "date-fns";
import { ChevronDownIcon, XIcon } from "lucide-react";
import type { ThirdwebContract } from "thirdweb";
import { AdminOnly } from "@/components/contracts/roles/admin-only";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { cn } from "@/lib/utils";
import { PricePreview } from "../price-preview";
import { ClaimConditionTypeData, useClaimConditionsFormContext } from ".";
import { CustomFormGroup } from "./common";
import { ClaimerSelection } from "./Inputs/ClaimerSelection";
import { ClaimPriceInput } from "./Inputs/ClaimPriceInput";
import { CreatorInput } from "./Inputs/CreatorInput";
@@ -42,114 +42,130 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
};
return (
<Card className="relative flex flex-col gap-8 p-8">
<div className="absolute top-3 right-3 flex flex-row items-start justify-between gap-1">
<Button
className="gap-2"
onClick={toggleEditing}
size="sm"
variant="ghost"
>
{field.isEditing ? "Collapse" : isAdmin ? "Edit" : "See Phase"}
{field.isEditing ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</Button>
<AdminOnly contract={contract}>
<Button
className="gap-2 text-red-300"
disabled={isPending}
onClick={onRemove}
size="sm"
variant="ghost"
>
Remove <XIcon className="size-4" />
</Button>
</AdminOnly>
</div>
<div className="mt-4 flex flex-col gap-2 md:mt-0">
<div className="flex flex-row items-center gap-3">
<p className="font-bold text-lg text-muted-foreground">
{ClaimConditionTypeData[claimConditionType].name}
</p>
{isActive && (
<Badge className="rounded-lg p-1.5" variant="success">
Currently active
</Badge>
)}
</div>
<p className="text-muted-foreground">
{ClaimConditionTypeData[claimConditionType].description}
</p>
</div>
{!field.isEditing ? (
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="flex flex-col">
<p className="font-bold text-muted-foreground">Phase start</p>
<p className="text-muted-foreground">
{field.startTime?.toLocaleString()}
</p>
</div>
<div className="flex flex-col">
<p className="font-bold text-muted-foreground">
{isErc20 ? "Tokens" : "NFTs"} to drop
</p>
<p className="text-muted-foreground capitalize">
{field.maxClaimableSupply}
</p>
</div>
<PricePreview
contractChainId={contract.chain.id}
currencyAddress={field.currencyAddress}
price={field.price}
/>
<div className="flex flex-col">
<p className="font-bold text-muted-foreground">Limit per wallet</p>
{claimConditionType === "specific" ? (
<p>Set in the snapshot</p>
) : claimConditionType === "creator" ? (
<p>Unlimited</p>
) : (
<p className="text-muted-foreground capitalize">
{field.maxClaimablePerWallet}
</p>
<DynamicHeight transition="height 0.3s ease">
<div className="border border-border rounded-lg bg-card relative">
<div className="p-4 lg:p-6">
<div className="mb-4">
{isActive && (
<div className="lg:absolute lg:right-6 lg:top-6 mb-4 border-b pb-4 border-dashed lg:border-none lg:pb-0">
<Badge
variant="secondary"
className="h-auto py-1.5 border border-border bg-background gap-1.5"
>
<div className="size-2 bg-primary rounded-full" />
Currently Active
</Badge>
</div>
)}
<h3 className="font-semibold text-lg tracking-tight text-foreground mb-0.5">
{ClaimConditionTypeData[claimConditionType].name}
</h3>
<p className="text-muted-foreground text-sm max-w-xl">
{ClaimConditionTypeData[claimConditionType].description}
</p>
</div>
</div>
) : (
<>
<CustomFormGroup>
{/* Phase Name Input / Form Title */}
{isMultiPhase ? <PhaseNameInput /> : null}
<PhaseStartTimeInput />
</CustomFormGroup>
<CreatorInput
creatorAddress={
(field.snapshot?.[0] as { address: string })?.address
}
/>
{!field.isEditing && (
<div className="flex flex-col gap-4 md:flex-row md:gap-12 text-sm">
{/* Phase start */}
<div className="space-y-0.5">
<p className="font-medium text-foreground">Phase start</p>
<p className="text-muted-foreground">
{field.startTime
? formatDate(field.startTime, "MMM d, yyyy hh:mm a")
: "N/A"}
</p>
</div>
<CustomFormGroup>
<MaxClaimableSupplyInput />
<ClaimPriceInput contractChainId={contract.chain.id} />
</CustomFormGroup>
{/* Tokens to drop */}
<div className="space-y-0.5">
<p className="font-medium text-foreground">
{isErc20 ? "Tokens" : "NFTs"} to drop
</p>
<p className="text-muted-foreground capitalize">
{field.maxClaimableSupply}
</p>
</div>
{claimConditionType === "specific" ||
claimConditionType === "creator" ? null : (
<CustomFormGroup>
<MaxClaimablePerWalletInput />
</CustomFormGroup>
{/* Price */}
<PricePreview
contractChainId={contract.chain.id}
currencyAddress={field.currencyAddress}
price={field.price}
/>
{/* Limit per wallet */}
<div className="space-y-0.5">
<p className="font-medium text-foreground">Limit per wallet</p>
{claimConditionType === "specific" ? (
<p className="text-muted-foreground"> Set in the snapshot</p>
) : claimConditionType === "creator" ? (
<p className="text-muted-foreground">Unlimited</p>
) : (
<p className="text-muted-foreground capitalize">
{field.maxClaimablePerWallet}
</p>
)}
</div>
</div>
)}
<ClaimerSelection />
</>
)}
</Card>
{field.isEditing && (
<div className="space-y-6">
{/* Phase Name Input / Form Title */}
{isMultiPhase ? <PhaseNameInput /> : null}
<PhaseStartTimeInput />
<CreatorInput
creatorAddress={
(field.snapshot?.[0] as { address: string })?.address
}
/>
<MaxClaimableSupplyInput />
<ClaimPriceInput contractChainId={contract.chain.id} />
{claimConditionType === "specific" ||
claimConditionType === "creator" ? null : (
<MaxClaimablePerWalletInput />
)}
<ClaimerSelection />
</div>
)}
</div>
<div className="flex flex-row items-start justify-end gap-3 border-t border-dashed p-4 lg:px-6 py-5">
<Button
className="gap-2 rounded-full"
onClick={toggleEditing}
size="sm"
variant="outline"
>
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform text-muted-foreground",
field.isEditing && "rotate-180",
)}
/>
{field.isEditing ? "Collapse" : isAdmin ? "Edit" : "Expand"}
</Button>
{isAdmin && (
<Button
className="gap-2 rounded-full"
disabled={isPending}
onClick={onRemove}
size="sm"
variant="outline"
>
<XIcon className="size-3.5 text-muted-foreground" />
Remove
</Button>
)}
</div>
</div>
</DynamicHeight>
);
};
@@ -20,14 +20,14 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
isMultiphase,
}) => {
return (
<div className="flex w-full flex-col gap-6">
<div className="space-y-5">
<section>
<h2 className="mb-1 font-semibold text-xl tracking-tight">
<h2 className="mb-0.5 font-semibold text-2xl tracking-tight">
Set Claim Conditions
</h2>
<p className="text-muted-foreground text-sm">
Control when the {isERC20 ? "tokens" : "NFTs"} get dropped, how much
they cost, and more.
they cost, and more
</p>
</section>
@@ -1,15 +1,9 @@
import {
InputGroup,
NumberInput,
NumberInputField,
type NumberInputProps,
} from "@chakra-ui/react";
import { DecimalInput } from "@/components/ui/decimal-input";
type InputProps = React.ComponentProps<typeof DecimalInput>;
interface PriceInputProps
extends Omit<
NumberInputProps,
"onChange" | "value" | "onBlur" | "max" | "min"
> {
extends Omit<InputProps, "onChange" | "value" | "onBlur" | "max" | "min"> {
value: string;
onChange: (value: string) => void;
}
@@ -20,17 +14,15 @@ export const PriceInput: React.FC<PriceInputProps> = ({
...restInputProps
}) => {
return (
<InputGroup>
<NumberInput defaultValue={0} min={0} value={value} {...restInputProps}>
<NumberInputField
onChange={(e) => {
if (e.target.value === "" || Number(e.target.value) < 0) {
return onChange("0");
}
onChange(e.target.value);
}}
/>
</NumberInput>
</InputGroup>
<DecimalInput
{...restInputProps}
onChange={(value) => {
if (value === "" || Number(value) < 0) {
return onChange("0");
}
onChange(value);
}}
value={value}
/>
);
};
@@ -24,12 +24,12 @@ export const PricePreview: React.FC<PricePreviewProps> = ({
);
return (
<div className="flex flex-col text-muted-foreground">
<p className="font-bold">Default price</p>
<div className="flex flex-col space-y-0.5">
<p className="font-medium text-foreground">Default price</p>
{Number(price) === 0 ? (
<p>Free</p>
<p className="text-muted-foreground text-sm">Free</p>
) : (
<p>
<p className="text-muted-foreground text-sm">
{price}{" "}
{foundCurrency
? foundCurrency.symbol
@@ -2,25 +2,23 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface QuantityInputWithUnlimitedProps {
export function QuantityInputWithUnlimited(props: {
value: string;
onChange: (value: string) => void;
hideMaxButton?: true;
decimals?: number;
isDisabled: boolean;
isRequired: boolean;
}
}) {
const {
value = "0",
onChange,
hideMaxButton,
isDisabled,
isRequired,
decimals,
} = props;
export const QuantityInputWithUnlimited: React.FC<
QuantityInputWithUnlimitedProps
> = ({
value = "0",
onChange,
hideMaxButton,
isDisabled,
isRequired,
decimals,
}) => {
const [stringValue, setStringValue] = useState<string>(
Number.isNaN(Number(value)) ? "0" : value.toString(),
);
@@ -45,7 +43,7 @@ export const QuantityInputWithUnlimited: React.FC<
};
return (
<div className="flex flex-row items-center rounded-md border border-border">
<div className="flex flex-row items-center rounded-md border border-border pr-1 bg-background max-w-sm">
<Input
className="border-none"
disabled={isDisabled}
@@ -64,7 +62,7 @@ export const QuantityInputWithUnlimited: React.FC<
/>
{hideMaxButton ? null : (
<Button
className="mr-1 text-primary"
className="text-muted-foreground bg-transparent hover:accent"
disabled={isDisabled}
onClick={() => {
updateValue("unlimited");
@@ -77,4 +75,4 @@ export const QuantityInputWithUnlimited: React.FC<
)}
</div>
);
};
}
@@ -1,31 +1,28 @@
"use client";
import { CircleHelpIcon } from "lucide-react";
import { CircleHelpIcon, RefreshCcwIcon } from "lucide-react";
import type { ThirdwebContract } from "thirdweb";
import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { useSendAndConfirmTransaction } from "thirdweb/react";
import { AdminOnly } from "@/components/contracts/roles/admin-only";
import { TransactionButton } from "@/components/tx-button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useTxNotifications } from "@/hooks/useTxNotifications";
interface ResetClaimEligibilityProps {
isErc20: boolean;
contract: ThirdwebContract;
tokenId?: string;
isLoggedIn: boolean;
isMultiphase: boolean;
}
export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
export function ResetClaimEligibility({
contract,
tokenId,
isErc20,
isLoggedIn,
isMultiphase,
}) => {
}: {
isErc20: boolean;
contract: ThirdwebContract;
tokenId?: string;
isLoggedIn: boolean;
isMultiphase: boolean;
}) {
const sendTxMutation = useSendAndConfirmTransaction();
const txNotification = useTxNotifications(
@@ -70,38 +67,39 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
}
return (
<AdminOnly contract={contract} fallback={<div className="pb-5" />}>
<TransactionButton
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={sendTxMutation.isPending}
onClick={handleResetClaimEligibility}
size="sm"
transactionCount={1}
txChainID={contract.chain.id}
type="button"
>
{sendTxMutation.isPending ? (
"Resetting Eligibility"
) : (
<div className="flex items-center gap-2">
Reset Eligibility
<ToolTipLabel
label={
<span className="text-left">
This {`contract's`} claim eligibility stores who has already
claimed {isErc20 ? "tokens" : "NFTs"} from this contract and
carries across claim phases. Resetting claim eligibility will
reset this state permanently, and wallets that have already
claimed to their limit will be able to claim again.
</span>
}
>
<CircleHelpIcon className="size-4" />
</ToolTipLabel>
</div>
)}
</TransactionButton>
</AdminOnly>
<TransactionButton
client={contract.client}
isLoggedIn={isLoggedIn}
isPending={sendTxMutation.isPending}
onClick={handleResetClaimEligibility}
size="sm"
variant="outline"
transactionCount={undefined}
txChainID={contract.chain.id}
type="button"
className="text-destructive-text bg-card"
>
{sendTxMutation.isPending ? (
"Resetting Eligibility"
) : (
<div className="flex items-center gap-2">
<RefreshCcwIcon className="size-3.5" />
Reset Eligibility
<ToolTipLabel
label={
<span className="text-left">
This {`contract's`} claim eligibility stores who has already
claimed {isErc20 ? "tokens" : "NFTs"} from this contract and
carries across claim phases. Resetting claim eligibility will
reset this state permanently, and wallets that have already
claimed to their limit will be able to claim again.
</span>
}
>
<CircleHelpIcon className="size-3.5" />
</ToolTipLabel>
</div>
)}
</TransactionButton>
);
};
}
@@ -1,11 +1,14 @@
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
import { useRef } from "react";
import { useDropzone } from "react-dropzone";
import type { Column } from "react-table";
import {
ArrowRightIcon,
CircleAlertIcon,
CircleSlashIcon,
RotateCcwIcon,
} from "lucide-react";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { DownloadableCode } from "@/components/blocks/code/downloadable-code";
import { DropZone } from "@/components/blocks/drop-zone/drop-zone";
import { Button } from "@/components/ui/button";
import { InlineCode } from "@/components/ui/inline-code";
import { UnorderedList } from "@/components/ui/List/List";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
Sheet,
@@ -13,10 +16,17 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useCsvUpload } from "@/hooks/useCsvUpload";
import { cn } from "@/lib/utils";
import { CsvDataTable } from "../csv-data-table";
interface SnapshotAddressInput {
address: string;
@@ -45,6 +55,72 @@ const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
.filter(({ address }) => address !== "");
};
function SnapshotDataTable({ data }: { data: SnapshotAddressInput[] }) {
return (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Max claimable</TableHead>
<TableHead>Price</TableHead>
<TableHead>Currency Address</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.address}>
<TableCell>
{item.isValid ? (
item.address
) : (
<ToolTipLabel
label={
item.address === ZERO_ADDRESS
? "Cannot send tokens to ZERO_ADDRESS"
: item.address.startsWith("0x")
? "Address is not valid"
: "Address couldn't be resolved"
}
>
<div className="flex flex-row items-center gap-2">
<CircleAlertIcon className="size-4 text-red-500" />
<div className="cursor-default font-bold text-red-500">
{item.address}
</div>
</div>
</ToolTipLabel>
)}
</TableCell>
<TableCell>
{item.maxClaimable === "0" || !item.maxClaimable
? "Default"
: item.maxClaimable === "unlimited"
? "Unlimited"
: item.maxClaimable}
</TableCell>
<TableCell>
{item.price === "0"
? "Free"
: !item.price || item.price === "unlimited"
? "Default"
: item.price}
</TableCell>
<TableCell>
{item.currencyAddress ===
"0x0000000000000000000000000000000000000000" ||
!item.currencyAddress
? "Default"
: item.currencyAddress}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
setSnapshot,
dropType,
@@ -59,11 +135,6 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
defaultRawData: value,
});
const dropzone = useDropzone({
onDrop: csvUpload.setFiles,
});
const paginationPortalRef = useRef<HTMLDivElement>(null);
const normalizeData = csvUpload.normalizeQuery.data;
if (!normalizeData) {
@@ -88,174 +159,122 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
onClose();
};
const columns = [
{
accessor: ({ address, isValid }) => {
if (isValid) {
return address;
}
return (
<ToolTipLabel
label={
address === ZERO_ADDRESS
? "Cannot send tokens to ZERO_ADDRESS"
: address.startsWith("0x")
? "Address is not valid"
: "Address couldn't be resolved"
}
>
<div className="flex flex-row items-center gap-2">
<CircleAlertIcon className="size-4 text-red-500" />
<div className="cursor-default font-bold text-red-500">
{address}
</div>
</div>
</ToolTipLabel>
);
},
Header: "Address",
},
{
accessor: ({ maxClaimable }) => {
return maxClaimable === "0" || !maxClaimable
? "Default"
: maxClaimable === "unlimited"
? "Unlimited"
: maxClaimable;
},
Header: "Max claimable",
},
{
accessor: ({ price }) => {
return price === "0"
? "Free"
: !price || price === "unlimited"
? "Default"
: price;
},
Header: "Price",
},
{
accessor: ({ currencyAddress }) => {
return currencyAddress ===
"0x0000000000000000000000000000000000000000" || !currencyAddress
? "Default"
: currencyAddress;
},
Header: "Currency Address",
},
] as Column<SnapshotAddressInput>[];
return (
<div className="flex flex-col gap-6">
<div className="space-y-6">
{csvUpload.rawData.length > 0 ? (
<div>
<CsvDataTable<SnapshotAddressInput>
columns={columns}
data={csvUpload.normalizeQuery.data.result}
portalRef={paginationPortalRef}
/>
<SnapshotDataTable data={csvUpload.normalizeQuery.data.result} />
<div className="flex justify-end gap-3 border-t py-6 border-dashed">
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.reset();
}}
variant="outline"
>
<RotateCcwIcon className="size-4" />
Reset
</Button>
{csvUpload.normalizeQuery.data?.invalidFound ? (
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.removeInvalid();
}}
>
<CircleSlashIcon className="size-4" />
Remove invalid
</Button>
) : (
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={onSave}
>
Next
<ArrowRightIcon className="size-4" />
</Button>
)}
</div>
</div>
) : (
<div className="flex grow flex-col items-center gap-8 overflow-auto">
<div className="relative aspect-[21/9] w-full">
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
csvUpload.noCsv ? "bg-red-200" : "bg-background",
)}
{...dropzone.getRootProps()}
>
<input {...dropzone.getInputProps()} />
<div className="!m-auto flex flex-col">
<UploadIcon
className={cn(
"mx-auto mb-2 size-8",
csvUpload.noCsv ? "text-red-500" : "text-gray-600",
)}
/>
{dropzone.isDragActive ? (
<p>Drop the files here</p>
) : (
<p
className={
csvUpload.noCsv ? "text-red-500" : "text-gray-600"
}
>
{csvUpload.noCsv
? `No valid CSV file found, make sure your CSV includes the "address" column.`
: "Drag & Drop a CSV file here"}
</p>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-lg">Requirements</p>
<UnorderedList>
<div>
<DropZone
className="py-20"
onDrop={csvUpload.setFiles}
isError={csvUpload.noCsv}
title={
csvUpload.noCsv
? "Invalid CSV file"
: "Drag & Drop a CSV file here"
}
description={
"The CSV file must follow the format specified in the requirements below"
}
resetButton={{ label: "Reset", onClick: () => csvUpload.reset() }}
accept=".csv"
/>
<div className="flex flex-col gap-2 mt-6">
<p className="text-base font-medium">Requirements</p>
<div className="space-y-2 text-muted-foreground text-sm">
{dropType === "specific" ? (
<>
<li>
<p>
Files <em>must</em> contain one .csv file with a list of
addresses and their <InlineCode code="maxClaimable" />.
(amount each wallet is allowed to claim)
<br />
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</a>
</li>
<li>
</p>
<DownloadableCode
code={snapshotWithMaxClaimable}
lang="csv"
fileNameWithExtension="snapshot-with-maxclaimable.csv"
/>
<p>
You may optionally add <InlineCode code="price" /> and
<InlineCode code="currencyAddress" /> overrides as well.
This lets you override the currency and price you would like
to charge per wallet you specified
<br />
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</a>
</li>
</p>
<DownloadableCode
code={snapshotWithOverrides}
lang="csv"
fileNameWithExtension="snapshot-with-overrides.csv"
/>
</>
) : (
<>
<li>
<p>
Files <em>must</em> contain one .csv file with a list of
addresses.
<br />
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/snapshot.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</a>
</li>
<li>
</p>
<DownloadableCode
code={snapshotCSV}
lang="csv"
fileNameWithExtension="snapshot.csv"
/>
<p>
You may optionally add a <InlineCode code="maxClaimable" />
column override. (amount each wallet is allowed to claim) If
not specified, the default value is the one you have set on
your claim phase.
<br />
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</a>
</li>
<li>
</p>
<DownloadableCode
code={snapshotWithMaxClaimable}
lang="csv"
fileNameWithExtension="snapshot-with-maxclaimable.csv"
/>
<p>
You may optionally add <InlineCode code="price" /> and
<InlineCode code="currencyAddress" /> overrides. This lets
you override the currency and price you would like to charge
@@ -264,67 +283,27 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
When defining a custom currency address, you must also
define a price override.
</strong>
<br />
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</a>
</li>
</p>
<DownloadableCode
code={snapshotWithOverrides}
lang="csv"
fileNameWithExtension="snapshot-with-overrides.csv"
/>
</>
)}
<li>
<p>
Repeated addresses will be removed and only the first found will
be kept.
</li>
<li>
</p>
<p>
The limit you set is for the maximum amount of NFTs a wallet can
claim, not how many they can receive in total.
</li>
</UnorderedList>
</p>
</div>
</div>
</div>
)}
<div className="mt-4 flex flex-col items-center justify-between border-t p-0 md:mt-0 md:flex-row md:p-4">
<div ref={paginationPortalRef} />
{!isDisabled && (
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
<Button
className="w-full rounded-md md:w-auto"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.reset();
}}
>
Reset
</Button>
{csvUpload.normalizeQuery.data?.invalidFound ? (
<Button
className="w-full rounded-md md:w-auto"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.removeInvalid();
}}
variant="primary"
>
Remove invalid
</Button>
) : (
<Button
className="w-full rounded-md md:w-auto"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={onSave}
variant="primary"
>
Next
</Button>
)}
</div>
)}
</div>
</div>
);
};
@@ -343,8 +322,8 @@ export function SnapshotViewerSheet(
}}
open={props.isOpen}
>
<SheetContent className="flex w-full flex-col overflow-y-auto sm:min-w-[540px] lg:min-w-[900px]">
<SheetHeader>
<SheetContent className="!w-full !max-w-4xl overflow-y-auto">
<SheetHeader className="mb-3">
<SheetTitle className="text-left">Snapshot</SheetTitle>
</SheetHeader>
<SnapshotViewerSheetContent {...props} />
@@ -352,3 +331,18 @@ export function SnapshotViewerSheet(
</Sheet>
);
}
const snapshotWithMaxClaimable = `\
address,maxClaimable
0x0000000000000000000000000000000000000000,2
0x000000000000000000000000000000000000dEaD,5`;
const snapshotWithOverrides = `\
address,maxClaimable,price,currencyAddress
0x0000000000000000000000000000000000000000,2,0.1,0x0000000000000000000000000000000000000000
0x000000000000000000000000000000000000dEaD,5,2.5,0x0000000000000000000000000000000000000000`;
const snapshotCSV = `\
address
0x0000000000000000000000000000000000000000
0x000000000000000000000000000000000000dEaD`;
@@ -1,161 +0,0 @@
import {
IconButton,
Portal,
Select,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { type Column, usePagination, useTable } from "react-table";
import { TableContainer } from "@/components/ui/table";
interface CsvDataTableProps<T extends object> {
data: T[];
portalRef: React.RefObject<HTMLDivElement | null>;
columns: Column<T>[];
}
/**
* Display the data uploaded from useCsvUpload, using react-table
*/
export function CsvDataTable<T extends object>({
data,
portalRef,
columns,
}: CsvDataTableProps<T>) {
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
// Instead of using 'rows', we'll use page,
page,
// which has only the rows for the active page
// The rest of these things are super handy, too ;)
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable(
{
columns,
data,
initialState: {
pageIndex: 0,
pageSize: 50,
},
},
// old package: this will be removed
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
);
return (
<>
<TableContainer>
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup, headerGroupIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
{headerGroup.headers.map((column, columnIndex) => (
<Th
{...column.getHeaderProps()}
border="none"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={columnIndex}
>
<p className="text-muted-foreground">
{column.render("Header")}
</p>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody {...getTableBodyProps()}>
{page.map((row, rowIndex) => {
prepareRow(row);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...row.getRowProps()} key={rowIndex}>
{row.cells.map((cell, cellIndex) => (
<Td
{...cell.getCellProps()}
borderColor="borderColor"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
))}
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
{/* Only need to show the Pagination components if we have more than 25 records */}
{data.length > 0 && (
<Portal containerRef={portalRef}>
<div className="flex w-full items-center justify-center">
<div className="flex flex-row gap-1">
<IconButton
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
isDisabled={!canPreviousPage}
onClick={() => gotoPage(0)}
/>
<IconButton
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
isDisabled={!canPreviousPage}
onClick={() => previousPage()}
/>
<p className="my-auto whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
isDisabled={!canNextPage}
onClick={() => nextPage()}
/>
<IconButton
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
isDisabled={!canNextPage}
onClick={() => gotoPage(pageCount - 1)}
/>
<Select
onChange={(e) => {
setPageSize(Number.parseInt(e.target.value as string, 10));
}}
value={pageSize}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</Select>
</div>
</div>
</Portal>
)}
</>
);
}
@@ -1,5 +1,4 @@
import { DeprecatedAlert } from "@app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/DeprecatedAlert";
import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import type { SidebarLink } from "@/components/blocks/Sidebar";
@@ -36,7 +35,7 @@ export function ContractPageLayout(props: {
} = props;
return (
<ChakraProviderSetup>
<div>
<div className="border-border border-b py-8">
<div className="container flex flex-col gap-4">
<div className="flex flex-col justify-between gap-4 md:flex-row">
@@ -67,6 +66,6 @@ export function ContractPageLayout(props: {
{props.children}
<div className="h-20" />
</SidebarLayout>
</ChakraProviderSetup>
</div>
);
}

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