Compare commits

..
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
2579525ec5 Version Packages (#5333)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-07 10:31:32 -08:00
joaquim-verges 1db950e678 fix: make encryption key optional for custom auth wallets (#5341)
## Problem solved

fixes CNCT-2269

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on making the `encryptionKey` optional for in-app and ecosystem wallets' custom authentication methods, enhancing security by discouraging hardcoding sensitive information.

### Detailed summary
- Updated `SingleStepAuthArgsType` to make `encryptionKey` optional for both `jwt` and `auth_endpoint` strategies.
- Removed hardcoded `encryptionKey` from various authentication examples in documentation.
- Emphasized the use of environment variables or user input for `encryptionKey`.

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

<!-- end pr-codex -->
2024-11-07 17:49:53 +00:00
gregfromstl 3a65faea2b [Portal] Docs: adds SiteEmbed and SiteLink to docs (#5334)
CNCT-2266

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds a new section titled "Advanced" to the sidebar in the `sidebar.tsx` file, which includes links to UI components. This enhances the navigation structure, providing users with easier access to specific features.

### Detailed summary
- Added a new section `Advanced` to the sidebar.
- Set `isCollapsible` to `false` for the `Advanced` section.
- Included a `links` array under `Advanced` with a link to `UI Components`.
- Mapped `SiteEmbed` and `SiteLink` to create individual links with names, hrefs, and icons.

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

<!-- end pr-codex -->
2024-11-07 17:32:43 +00:00
kien-ngo 164b70b9f4 [Dashboard] Fix falsy logic when updating Royalties and PlatformFees (#5342)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining the validation logic for platform fees and royalties in the settings components by removing default values and error handling that were previously in place. This aims to enhance clarity and enforce valid user input.

### Detailed summary
- Removed default values for `platform_fee_basis_points` and `platform_fee_recipient` in `platform-fees.tsx`.
- Removed error handling for invalid `platform_fee_basis_points` and `platform_fee_recipient` in `platform-fees.tsx`.
- Removed default values for `seller_fee_basis_points` and `fee_recipient` in `royalties.tsx`.
- Removed error handling for invalid `seller_fee_basis_points` and `fee_recipient` in `royalties.tsx`.

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

<!-- end pr-codex -->
2024-11-07 17:07:38 +00:00
kien-ngo b2adc8d671 [Dashboard] Remove tw-components Drawer (#5337)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on replacing the `Drawer` component with a `Sheet` component in the `TransactionDetailsDrawer`. It also updates the props and layout to accommodate this change, enhancing the user interface for displaying transaction details.

### Detailed summary
- Deleted `drawer.tsx` file.
- Removed `export * from "./drawer"` from `index.ts`.
- Replaced `Drawer` component with `Sheet` in `TransactionDetailsDrawer`.
- Updated props to include `setSelectedTransaction`.
- Adjusted layout and styling for the new `Sheet` component.
- Maintained functionality for transaction details display.

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

<!-- end pr-codex -->
2024-11-07 22:45:25 +07:00
kien-ngo 7b88a08610 [Dashboard] Remove Drawer from Snapshot upload (#5336)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the `SnapshotUpload` component by refactoring its structure, enhancing the CSV upload functionality, and updating the UI to utilize a `Sheet` component instead of a `Drawer`. It also modifies the `CsvDataTable` for better rendering.

### Detailed summary
- Replaced `Drawer` with `Sheet` in `SnapshotUpload`.
- Added `snapshotIndex`, `index`, and `setOpenSnapshotIndex` props to `SnapshotUpload`.
- Updated the `CsvDataTable` rendering logic for better structure.
- Enhanced the CSV upload instructions and requirements display.
- Modified button styles for consistency and responsiveness.

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

<!-- end pr-codex -->
2024-11-07 22:45:24 +07:00
kien-ngo 5216befe60 [Dashboard] Abstract code for CSV upload form (#5324)
DASH-372

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the CSV upload functionality by refactoring components to use a new `useCsvUpload` hook, which centralizes CSV processing logic. It introduces a `CsvDataTable` component for displaying data and improves address validation in the airdrop and snapshot upload processes.

### Detailed summary
- Introduced `CsvDataTable` component for displaying CSV data.
- Refactored `AirdropUpload` and `SnapshotUpload` to use `useCsvUpload` hook.
- Centralized CSV parsing logic in `csvParser` functions.
- Improved address validation and error handling in CSV uploads.
- Removed redundant address checking functions from `AirdropUpload` and `SnapshotUpload`.
- Enhanced pagination and UI updates in CSV data tables.

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

<!-- end pr-codex -->
2024-11-07 22:45:24 +07:00
ElasticBottle f81c1afeef [Dashboard]: enable download of all user data at once (#5320)
https://linear.app/thirdweb/issue/DASH-398/enish-cant-download-wallet-addresses-from-dashboard

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `useEmbeddedWallets` hook by adding a new function, `fetchAccountList`, and introduces a new hook, `useAllEmbeddedWallets`, for fetching wallet data. It also improves the CSV download functionality in the `InAppWalletUsersPageContent` component.

### Detailed summary
- Added `fetchAccountList` function to handle API calls for fetching wallet accounts.
- Introduced `useAllEmbeddedWallets` hook for batch fetching wallet data across multiple pages.
- Updated `useEmbeddedWallets` to use `fetchAccountList`.
- Enhanced CSV download logic to handle cases when no wallets are available.
- Integrated loading spinner in the download button to indicate pending actions.

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

<!-- end pr-codex -->
2024-11-07 12:57:15 +00:00
kien-ngo 022e403125 [Dashboard] Remove MenuItem from tw-components (#5339)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on the removal of the `MenuItem` component from various files and updates related to `buttonSizesMap` and `PossibleButtonSize` type definitions.

### Detailed summary
- Deleted `MenuItem` export from `tw-components/menu.tsx`.
- Removed `MenuItem` import from `tw-components` in `DowngradeDialog.tsx` and `claim-conditions-form/index.tsx`.
- Changed `export const buttonSizesMap` to `const buttonSizesMap` in `button.tsx`.
- Updated `export type PossibleButtonSize` to `type PossibleButtonSize` in `button.tsx`.

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

<!-- end pr-codex -->
2024-11-07 05:31:09 +00:00
kien-ngo ea2815d33f [Dashboard] Remove TableContainer from tw-components (#5338)
DASH-417
2024-11-07 05:04:37 +00:00
kien-ngo 734707e28b [Dashboard] Remove Drawer from UpdateProfile section (#5323)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining the profile editing interface in the dashboard. It replaces the `Drawer` component with a `Sheet` component for a more modern design and updates the profile avatar size, among other improvements in the form handling and UI elements.

### Detailed summary
- Changed `className` of `PublisherAvatar` from `"size-30"` to `"size-20"`.
- Replaced `Drawer` with `Sheet` for editing profiles.
- Updated button components and form structure for better usability.
- Enhanced error handling and notifications using `toast`.
- Simplified state management for the profile editing UI.

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

<!-- end pr-codex -->
2024-11-06 23:34:39 +00:00
kien-ngo 1d3d016e0d [Dashboard] Remove <Drawer /> from SetSharedMetadata form (#5322)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `NFTSharedMetadataButton` and `SharedMetadataForm` components to replace the `Drawer` component from `@chakra-ui/react` with a `Sheet` component from `@/components/ui/sheet`. This change enhances the UI/UX for setting NFT metadata.

### Detailed summary
- Replaced `Drawer` with `Sheet` in `NFTSharedMetadataButton`.
- Updated state management from `useDisclosure` to `useState` for controlling visibility.
- Modified `SharedMetadataForm` to accept a `setOpen` prop for closing the `Sheet`.
- Removed `Drawer` specific components and replaced them with `Sheet` components.
- Updated transaction handling to use `sendAndConfirmTx` instead of `mutate`.
- Enhanced error handling with `toast` notifications for user feedback.

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

<!-- end pr-codex -->
2024-11-06 23:34:38 +00:00
Stanley SzetoandGitHub d831cbf5ca Add UI for Mintable, Royalty, Claimable, Transferable, BatchMetadata and OpenEdition modules (#5219)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the functionality and UI of various components in the dashboard application, including forms, modules, and UI elements, while also introducing new dependencies and improving existing code structure.

### Detailed summary
- Updated `ContractEditModulesPageClient` to check for `localhost` chain.
- Added `gap-3` to `FormControl` class.
- Introduced `publisherAddress` in `useForm` default values.
- Refactored `CustomConnectWallet` for better prop handling.
- Added `helperText` to `FormFieldSetup`.
- Improved error message display spacing.
- Updated `DatePickerWithRange` for consistent styling.
- Changed `TransactionError` type to export.
- Enhanced error handling with `ErrorBoundary` in `ModuleCard`.
- Introduced new `useAllModuleContractInfo` hook for fetching module data.
- Added `PropertiesFormControl` for managing NFT attributes.
- Implemented various storybook components for UI consistency.
- Integrated `OpenEditionMetadata` and `Royalty` modules with improved form handling.
- Added loading states and notifications for asynchronous operations.
- Introduced `BatchMetadata` and `Claimable` modules with enhanced functionality.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/BatchMetadata.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Transferable.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Mintable.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Royalty.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/Claimable.tsx`, `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2024-11-06 14:54:43 -08:00
jnsdls 0ad80db089 fix postbuild cache (#5332) 2024-11-06 14:24:14 -08:00
MananTank ad77293e06 Fix missing description meta tag, add og:site_name in portal (#5331)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds a `description` field and a `siteName` property to the metadata returned by the `createMetadata` function in the `metadata.ts` file.

### Detailed summary
- Added `description: obj.description` to the returned object in `createMetadata`.
- Added `siteName: "thirdweb docs"` to the returned object in `createMetadata`.

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

<!-- end pr-codex -->
2024-11-06 22:10:16 +00:00
gregfromstl f5f5ae63b4 [SDK] Feature: Adds SiteLink component (#5326)
Completes CNCT-2259

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the `SiteLink` component to facilitate wallet-aware linking between thirdweb-enabled sites, enhancing user experience by sharing wallet state seamlessly. It also updates the `SiteEmbed` component documentation and includes tests for the new `SiteLink`.

### Detailed summary
- Updated comment for `SiteEmbed` to reflect wallet connection usage.
- Added `SiteLink` component for creating links with wallet parameters.
- Provided usage example for `SiteLink` in documentation.
- Implemented tests for `SiteLink` to verify functionality and error handling.

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

<!-- end pr-codex -->
2024-11-06 21:57:03 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
15358d7a0f Bump happy-dom from 15.10.1 to 15.10.2 in the npm_and_yarn group (#5329)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-06 12:54:26 -08:00
jnsdls 11d287b65b fix: DASH-410 (#5328)
FIXES: DASH-410

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on improving error handling and code clarity in the `apps/dashboard` project. It modifies the `package.json` script for development, enhances JWT response validation, and adjusts value handling in a hook for better consistency.

### Detailed summary
- Updated the `dev` script in `apps/dashboard/package.json` to remove `--turbopack`.
- Added error handling for missing JWT in `apps/dashboard/src/@/constants/thirdweb.client.ts`.
- Changed return value handling to ensure it returns a `BigInt` in `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/claim-conditions-form/hooks.ts`.

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

<!-- end pr-codex -->
2024-11-06 19:40:18 +00:00
Jonas DanielsandGitHub 562978fdc4 update dependencies (#5318) 2024-11-06 11:27:26 -08:00
kien-ngo d31e3dabf8 [Dashboard] Update supported chains for Moralis (#5319)
DASH-404
2024-11-06 18:05:08 +00:00
MananTank 882f59b24d Fix Select selection not working on mobile device - radix fix no longer required (#5321)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on simplifying the `ref` handling in the `SelectPrimitive.Content` component of the `select.tsx` file by removing a custom `ref` function and replacing it with a direct assignment. It also enhances the component's class names for better styling based on its state.

### Detailed summary
- Removed custom `ref` handling logic.
- Directly assigned `ref` to `SelectPrimitive.Content`.
- Updated `className` with various state-based styles for animations and positioning.

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

<!-- end pr-codex -->
2024-11-06 16:37:51 +00:00
kien-ngo d8e7fae9c7 [SDK] Test coverage (#5316)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on adding new tests and enhancing the `padBytes` function in the `to-bytes.ts` file, along with implementing various utility functions for byte manipulation. It includes comprehensive test cases for hex strings, Uint8Arrays, and other conversions.

### Detailed summary
- Added JSDoc comment to `padBytes` function and exported it for testing.
- Introduced tests for `byteSize`, `assertSize`, `charCodeToBase16`, and `isHex` functions.
- Created tests for `padBytes`, `toBytes`, `boolToBytes`, `hexToBytes`, `numberToBytes`, and `stringToBytes` in `to-bytes.test.ts`.
- Enhanced test coverage for hex string conversions and error handling in byte manipulation functions.

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

<!-- end pr-codex -->
2024-11-06 07:08:46 +00:00
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
a0454793c9 Version Packages (#5310)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-06 20:00:04 +13:00
Joaquim VergesandGitHub b633293ef1 feat: auto-migrate in-app wallets to enclave system (#5217) 2024-11-06 19:12:45 +13:00
kien-ngo d65387bb3d [Dashboard] Fix ERC20 claim conditions (#5284)
Fix various issues with ERC20 claim conditions since we up'd it to v5 on the dashboard

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the handling of `tokenDecimals` for ERC20 tokens in the claim conditions form, ensuring that the application correctly manages and displays the maximum claimable supply and per wallet limits based on the token's decimal precision.

### Detailed summary
- Updated `MaxClaimableSupplyInput` and `MaxClaimablePerWalletInput` to disable inputs if `tokenDecimals` is not available.
- Changed `tokenDecimals` type from `number` to `number | undefined`.
- Enhanced fetching logic for `tokenDecimals` in the claim conditions form.
- Added error handling for missing `tokenDecimals` when submitting forms.
- Introduced conversion functions for handling ERC20 values in wei and display format.
- Updated `toBigInt` function to return `bigint | undefined`.
- Adjusted `toUnlimited` function to maintain clarity on unlimited values.

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

<!-- end pr-codex -->
2024-11-06 05:26:50 +00:00
kien-ngo 418e3e487d [Dashboard] Update simplehash supported networks (#5317)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `alchemySupportedChainIdsMap` in the `apps/dashboard/src/lib/wallet/nfts/types.ts` file by adding new supported chains.

### Detailed summary
- Added new entries to `alchemySupportedChainIdsMap`:
  - `"sei"` with key `[1329]`
  - `"sei-atlantic-2"` with key `[1328]`
  - `"shape"` with key `[360]`
  - `"apechain"` with key `[33139]`

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

<!-- end pr-codex -->
2024-11-06 03:48:47 +00:00
kien-ngo 9fb2a713c4 [Dashoard] Remove modular contracts beta tags (#5314)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `displayName` and removing the `isBeta` flag for the `modular-contracts` entry in the `explore.ts` file, indicating a shift from a beta status to a more stable release.

### Detailed summary
- Changed `displayName` from "Modular Contracts (Beta)" to "Modular Contracts".
- Removed the `isBeta` property set to `true`.

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

<!-- end pr-codex -->
2024-11-06 01:06:02 +00:00
ElasticBottle 87e736dacc [js] fix enclave wallet signing (#5315)
https://linear.app/thirdweb/issue/CNCT-2099/enclaves-malform-contract-call-transactions

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on fixing an error related to ecosystem signing with data in the `thirdweb` wallet implementation. It adjusts how transaction details are processed, particularly the `to` and `data` fields.

### Detailed summary
- Updated the `to` field in the transaction object to use `getAddress(tx.to)` instead of a fallback to `undefined`.
- Changed the `data` field assignment to directly use `tx.data` without transformation.
- The rest of the transaction fields remain unchanged.

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

<!-- end pr-codex -->
2024-11-06 00:58:34 +00:00
saminaandGitHub d7f161bcd3 added features and deprecated interact (#5313) 2024-11-05 16:06:41 -08:00
gregfromstl 880c3453ee [Dashboard] Feature: Overview Page Empty State (#5312)
Completes:
- CNCT-2181
- CNCT-2175

Adds the Project Overview page empty state, set to "Coming Soon" copy for now

![Screenshot 2024-11-05 at 12 44 44 PM](https://github.com/user-attachments/assets/25289323-777c-41a6-b51d-2a7cf795bb37)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on enhancing UI components, improving type exports, and refining styles across various files in the application. It introduces new features and fixes, particularly in the `ProjectOverviewHeader` and `EmptyState` components, while updating SVG assets.

### Detailed summary
- Updated `className` styles in `DatePickerWithRange` and `app-footer`.
- Exported `DurationId` type in `date-range-selector`.
- Added background styling in `layout.tsx`.
- Made `projectStub` function exportable.
- Adjusted CSS variables for light and dark themes in `globals.css`.
- Modified routing in `TeamOverviewPage` and `ProjectOverviewPage`.
- Enhanced `RangeSelector` to handle computed ranges.
- Introduced `EmptyState` and `ProjectOverviewHeader` components with new UI elements.
- Added new SVG assets for various icons.

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

<!-- end pr-codex -->
2024-11-05 21:53:29 +00:00
joaquim-verges 180d563139 feat(dashboard): add default account factories table (#5308)
## Problem solved

Fixes CNCT-2225

![CleanShot 2024-11-05 at [email protected]](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/GGPGC1k4LEcpDtkOsVBe/d6321c9a-f6d9-42cd-af14-41165c2ace6d.png)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `AccountFactories` component by introducing a section for default account factories and a table to display them. It improves user guidance on deploying custom factories and organizes the layout for better clarity.

### Detailed summary
- Added a section for default account factories with a description and link.
- Introduced `TWTable` to display default account factories (`AccountFactory (v0.6)` and `AccountFactory (v0.7)`).
- Created a new section for user account factories with relevant information and a link.
- Defined `DefaultFactory` type and created table columns using `createColumnHelper`.

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

<!-- end pr-codex -->
2024-11-05 20:30:22 +00:00
kien-ngo 43b7c27b34 [Dashboard] Improve faucet claim (#5226)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the functionality of the `Faucet` feature in the dashboard application. It introduces new environment variables, updates modal components to allow for state management, and improves API endpoints for better user authentication and request handling.

### Detailed summary
- Added `REDIS_URL` and updated `TURNSTILE_SECRET_KEY` in `.env.example`.
- Updated `turbo.json` to include new environment variables.
- Modified `OnboardingModal` to accept `onOpenChange` prop for state management.
- Enhanced API routes to check for email verification before allowing faucet claims.
- Improved error handling for wallet connections and request limits.
- Updated `FaucetButton` to show onboarding modal for email verification.
- Added caching logic for user requests to prevent abuse.

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

<!-- end pr-codex -->
2024-11-05 20:17:54 +00:00
MananTank 6469fd5236 Fix newly added pages not awaiting the params and searchParams (#5311) 2024-11-05 18:36:39 +00:00
kien-ngo 6030bf17c2 [Dashboard] Fix NFT supply info (#5309)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `SupplyCards` component by updating how supply data is fetched and displayed, specifically by introducing the `startTokenId` and refining the calculation of unclaimed supply.

### Detailed summary
- Added `startTokenId` to the imports from `thirdweb/extensions/erc721`.
- Changed `claimedSupplyQuery` to `nextTokenIdQuery`.
- Introduced `startTokenIdQuery` for calculating the real total supply.
- Updated unclaimed supply calculation to use `realTotalSupply`.
- Adjusted loading states and displayed values for total supply and unclaimed supply.

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

<!-- end pr-codex -->
2024-11-05 17:35:04 +00:00
gregfromstl 41158b17af [Actions] Chore: Removes outdated stale github action (#5283)
This github action is no longer used and has been replaced by `workflows/stale.yml`

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on the removal of the `.github/stale.yml` configuration file, which is used to manage stale issues and pull requests in the repository.

### Detailed summary
- Deleted the file `.github/stale.yml`
- This action may affect how stale issues and pull requests are tracked and managed in the repository.

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

<!-- end pr-codex -->
2024-11-05 17:00:38 +00:00
gregfromstl 5cc5c9327f [React SDK] Feature: Adds SiteEmbed component (#5298)
CNCT-2213

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `SiteEmbed` React component that allows embedding thirdweb-supported sites with wallet connection support. It enhances the user experience by maintaining wallet connection states across different sites.

### Detailed summary
- Added `SiteEmbed` component for embedding thirdweb sites.
- Updated `getUrlToken` to include `authCookie`.
- Enhanced `useAutoConnectCore` to save `authCookie` if available.
- Created tests for `SiteEmbed` component, ensuring iframe rendering and error handling.

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

<!-- end pr-codex -->
2024-11-05 16:46:09 +00:00
arcoraven cc47b7bca4 feat: Add smart backend wallet types (#5303)
https://linear.app/thirdweb/issue/INFRA-404/add-sbw-to-dashboard

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the backend wallet functionality within the application. It introduces new wallet types, updates wallet configuration handling, and refines the user interface for better usability.

### Detailed summary
- Added `"SMART_BACKEND_WALLETS"` to the wallet type constants.
- Introduced new `EngineBackendWalletType` options for smart wallets.
- Updated `EngineBackendWalletOptions` with smart wallet configurations.
- Changed `tabContent` to use `Partial<Record<EngineBackendWalletType, React.ReactNode>>`.
- Enhanced wallet type selection logic based on smart wallet support.
- Added `FormDescription` to provide information on backend wallet types.
- Updated form validation to include smart wallet configurations.

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

<!-- end pr-codex -->
2024-11-05 08:09:01 +00:00
jnsdls 6b04d75bb2 update dependencies (#5306) 2024-11-05 07:36:08 +00:00
kien-ngo 93a382be92 [Dashboard] Add function comments to Explorer (#5273)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/VtULqGKSp33n0VHOzNJK/0d2b9601-7778-4773-83e0-39ae17820bbe.png)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new component, `ContractFunctionComment`, which fetches and displays comments for contract functions. It also refactors the `contract-function.tsx` file to utilize this new component, improving the organization and clarity of the code.

### Detailed summary
- Added `ContractFunctionComment` component to fetch and display function comments.
- Integrated `ContractFunctionComment` into `ContractFunctionInner` in `contract-function.tsx`.
- Imported `lazy` from `react` for lazy loading of `ContractFunctionComment`.
- Implemented logic to extract comments from Solidity source code using regex.

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

<!-- end pr-codex -->
2024-11-05 06:47:03 +00:00
Jonas DanielsGitHubJoaquim Vergesgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
0d23638db0 next15 (#5152)
Co-authored-by: Joaquim Verges <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-04 22:07:21 -08:00
jnsdls 0a3b59cd9c remove hackathon pages (#5305) 2024-11-05 04:37:59 +00:00
jnsdls 2b730b2496 update dependencies (#5304)
<!-- start pr-codex -->

## PR-Codex overview
This PR primarily focuses on updating various package dependencies across multiple applications and packages to their latest versions, enhancing stability and security.

### Detailed summary
- Updated `@tanstack/react-query` from `5.59.16` to `5.59.19` in multiple packages.
- Updated `@aws-sdk/client-kms`, `@aws-sdk/client-lambda`, and `@aws-sdk/credential-providers` to newer versions.
- Updated `@hookform/resolvers` from `^3.9.0` to `^3.9.1`.
- Updated `cmdk` from `^1.0.0` to `^1.0.4`.
- Updated `sonner` from `^1.5.0` to `^1.7.0`.
- Updated `framer-motion` from `11.11.10` to `11.11.11`.
- Updated `input-otp` from `^1.2.4` to `^1.4.1`.
- Updated `next-plausible` from `^3.12.0` to `^3.12.3`.
- Updated `react-dropzone` from `^14.2.9` to `^14.3.5`.
- Updated `recharts` from `^2.13.0` to `^2.13.3`.
- Updated `@storybook` packages from `8.3.6` to `8.4.1`.
- Updated `@coinbase/wallet-sdk` from `4.1.0` to `4.2.0`.
- Updated `@mobile-wallet-protocol/client` from `0.1.1` to `0.1.2`.
- Updated `knip` from `5.34.2` to `5.36.2`.
- Updated `happy-dom` from `^15.7.4` to `^15.9.0`.
- Updated `msw` from `^2.5.2` to `^2.6.0`.
- Updated `typedoc` from `0.26.10` to `0.26.11`.

> 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 -->
2024-11-05 03:21:42 +00:00
373 changed files with 11049 additions and 8314 deletions
-17
View File
@@ -1,17 +0,0 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 14
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
+2 -1
View File
@@ -91,4 +91,5 @@ API_SERVER_SECRET=""
# Used for the Faucet page (/<chain_id>)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
TURNSTILE_SECRET_KEY=""
REDIS_URL=""
-1
View File
@@ -59,7 +59,6 @@ module.exports = {
"FormHelperText",
"FormErrorMessage",
"MenuGroup",
"MenuItem",
"VStack",
"HStack",
"AspectRatio",
-1
View File
@@ -158,7 +158,6 @@ const moduleExports = {
serverSourceMaps: false,
},
cacheMaxMemorySize: 0,
swcMinify: true,
compiler: {
emotion: true,
},
+34 -32
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "biome format ./src --write",
@@ -27,9 +27,10 @@
"@chakra-ui/theme-tools": "^2.1.2",
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@hookform/resolvers": "^3.9.0",
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.0.2",
"@n8tb1t/use-scroll-position": "^2.0.3",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
@@ -45,13 +46,14 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "1.1.3",
"@sentry/nextjs": "8.35.0",
"@shazow/whatsabi": "^0.15.4",
"@sentry/nextjs": "8.37.1",
"@shazow/whatsabi": "^0.16.0",
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^3.5.0",
"@tanstack/react-query": "5.59.16",
"@tanstack/react-query": "5.59.19",
"@tanstack/react-table": "^8.17.3",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "^1.4.2",
"@vercel/og": "^0.6.2",
"abitype": "1.0.6",
"chakra-react-select": "^4.7.6",
@@ -61,17 +63,17 @@
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"flat": "^6.0.1",
"framer-motion": "11.11.10",
"framer-motion": "11.11.11",
"fuse.js": "7.0.0",
"input-otp": "^1.2.4",
"input-otp": "^1.4.1",
"ioredis": "^5.4.1",
"ipaddr.js": "^2.2.0",
"lottie-react": "^2.4.0",
"lucide-react": "0.454.0",
"next": "14.2.15",
"next-plausible": "^3.12.0",
"next": "15.0.2",
"next-plausible": "^3.12.3",
"next-seo": "^6.5.0",
"next-themes": "^0.3.0",
"next-themes": "^0.4.3",
"nextjs-toploader": "^1.6.12",
"papaparse": "^5.4.1",
"pluralize": "^8.0.0",
@@ -79,20 +81,20 @@
"prism-react-renderer": "^2.3.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.3.1",
"react": "19.0.0-rc-69d4b800-20241021",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-dropzone": "^14.2.9",
"react-dom": "19.0.0-rc-69d4b800-20241021",
"react-dropzone": "^14.3.5",
"react-error-boundary": "^4.1.2",
"react-hook-form": "7.52.0",
"react-markdown": "^9.0.1",
"react-responsive-carousel": "^3.2.23",
"react-table": "^7.8.0",
"recharts": "^2.13.0",
"recharts": "^2.13.3",
"remark-gfm": "^4.0.0",
"server-only": "^0.0.1",
"sonner": "^1.5.0",
"sonner": "^1.7.0",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.17.14",
"tailwind-merge": "^2.5.4",
@@ -104,26 +106,26 @@
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "2.0.2",
"@next/bundle-analyzer": "14.2.15",
"@next/eslint-plugin-next": "14.2.15",
"@chromatic-com/storybook": "3.2.2",
"@next/bundle-analyzer": "15.0.2",
"@next/eslint-plugin-next": "15.0.2",
"@playwright/test": "1.48.2",
"@storybook/addon-essentials": "8.3.6",
"@storybook/addon-interactions": "8.3.6",
"@storybook/addon-links": "8.3.6",
"@storybook/addon-onboarding": "8.3.6",
"@storybook/addon-viewport": "8.3.6",
"@storybook/blocks": "8.3.6",
"@storybook/nextjs": "8.3.6",
"@storybook/react": "8.3.6",
"@storybook/test": "8.3.6",
"@storybook/addon-essentials": "8.4.2",
"@storybook/addon-interactions": "8.4.2",
"@storybook/addon-links": "8.4.2",
"@storybook/addon-onboarding": "8.4.2",
"@storybook/addon-viewport": "8.4.2",
"@storybook/blocks": "8.4.2",
"@storybook/nextjs": "8.4.2",
"@storybook/react": "8.4.2",
"@storybook/test": "8.4.2",
"@types/color": "4.2.0",
"@types/node": "20.14.9",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/react": "npm:[email protected].1",
"@types/react-dom": "npm:[email protected].1",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^4.18.3",
@@ -133,12 +135,12 @@
"checkly": "^4.8.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.3",
"eslint-plugin-react-compiler": "0.0.0-experimental-fa06e2c-20241014",
"eslint-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
"eslint-plugin-storybook": "^0.9.0",
"knip": "5.34.2",
"knip": "5.36.2",
"next-sitemap": "^4.2.3",
"postcss": "8.4.47",
"storybook": "8.3.6",
"storybook": "8.4.2",
"tailwindcss": "3.4.14",
"typescript": "5.6.3"
}
@@ -0,0 +1,26 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12.813" y="18.824" width="13.4583" height="9.33069" rx="1.49536" transform="rotate(-80 12.813 18.824)" fill="url(#paint0_linear_176_19664)"/>
<g filter="url(#filter0_bi_176_19664)">
<path d="M8.63443 22.4473C9.10456 22.866 9.75478 22.9806 10.3397 22.748C12.1658 22.0219 16.0712 20.0922 16.6714 16.6883L17.3498 12.8408C17.4946 12.0198 17.1149 11.1951 16.397 10.7712L12.6731 8.57281C12.2567 8.32698 11.7653 8.24034 11.29 8.32893L7.0388 9.12114C6.21922 9.27387 5.58034 9.91897 5.43557 10.74L4.75715 14.5875C4.15696 17.9914 7.1668 21.1405 8.63443 22.4473Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<path d="M10.6767 15.8535C11.6031 16.0169 12.4866 15.3983 12.6499 14.4719C12.8133 13.5454 12.1947 12.662 11.2683 12.4986C10.3418 12.3353 9.45839 12.9539 9.29504 13.8803C9.13169 14.8067 9.75028 15.6902 10.6767 15.8535Z" fill="white"/>
<path d="M10.8049 15.1276C11.13 15.1849 11.3459 15.5036 11.2867 15.8396L10.8578 18.2722C10.7986 18.6079 10.4869 18.8339 10.1615 18.7765C9.83634 18.7192 9.6204 18.4005 9.67966 18.0644L10.1086 15.6319C10.1678 15.2962 10.4794 15.0702 10.8049 15.1276Z" fill="white"/>
<defs>
<filter id="filter0_bi_176_19664" x="2.6792" y="6.29507" width="16.7012" height="18.5715" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19664"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19664" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.2"/>
<feGaussianBlur stdDeviation="0.05"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19664"/>
</filter>
<linearGradient id="paint0_linear_176_19664" x1="23.6039" y1="22.7328" x2="13.5455" y2="27.1317" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,27 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_176_19666)">
<path d="M10.2684 4.78857C11.0382 3.45524 12.9627 3.45524 13.7325 4.78857L20.7761 16.9885C21.5459 18.3218 20.5837 19.9885 19.0441 19.9885H4.95684C3.41724 19.9885 2.45499 18.3218 3.22479 16.9885L10.2684 4.78857Z" fill="url(#paint0_linear_176_19666)"/>
<g filter="url(#filter0_i_176_19666)">
<path d="M11.1341 13.3799C11.519 12.7132 12.4812 12.7132 12.8661 13.3799L15.811 18.4806C16.1959 19.1473 15.7148 19.9806 14.945 19.9806H9.05519C8.28539 19.9806 7.80426 19.1473 8.18916 18.4806L11.1341 13.3799Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
</g>
<defs>
<filter id="filter0_i_176_19666" x="8.05371" y="12.8799" width="7.89258" height="7.20071" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.2"/>
<feGaussianBlur stdDeviation="0.05"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_176_19666"/>
</filter>
<linearGradient id="paint0_linear_176_19666" x1="3.35247" y1="20.8199" x2="21.8623" y2="12.4791" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<clipPath id="clip0_176_19666">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,31 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_176_19660)">
<circle cx="18.6199" cy="17.2472" r="5" transform="rotate(-10 18.6199 17.2472)" fill="url(#paint0_linear_176_19660)"/>
<g filter="url(#filter0_bi_176_19660)">
<path d="M3.83469 8.17681C3.69084 7.36097 4.23559 6.58298 5.05143 6.43913L17.8539 4.1817C18.6698 4.03785 19.4478 4.5826 19.5916 5.39844L21.1544 14.2617C21.2983 15.0776 20.7536 15.8555 19.9377 15.9994L7.13521 18.2568C6.31937 18.4007 5.54138 17.8559 5.39752 17.0401L3.83469 8.17681Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<rect x="6.65332" y="12.7048" width="3.54886" height="3.54886" rx="1" transform="rotate(-10 6.65332 12.7048)" fill="white"/>
<path d="M4.09668 9.64545L19.8505 6.86762" stroke="white"/>
</g>
<defs>
<filter id="filter0_bi_176_19660" x="1.81152" y="2.15866" width="21.3657" height="18.1212" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19660"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19660" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.2"/>
<feGaussianBlur stdDeviation="0.05"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19660"/>
</filter>
<linearGradient id="paint0_linear_176_19660" x1="21.6379" y1="16.4364" x2="13.4844" y2="18.9086" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<clipPath id="clip0_176_19660">
<rect width="24" height="24" fill="white" transform="translate(0.0986328 4.26613) rotate(-10)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,30 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_176_19662)">
<rect x="12.7549" y="5.24113" width="13.4583" height="9.33069" rx="1.49536" transform="rotate(20 12.7549 5.24113)" fill="url(#paint0_linear_176_19662)"/>
<g filter="url(#filter0_bi_176_19662)">
<path d="M8.56889 10.1027C8.94667 9.06477 10.0944 8.5296 11.1323 8.90738L24.288 13.6957C25.326 14.0735 25.8611 15.2211 25.4834 16.2591L22.0632 25.656C21.6854 26.694 20.5377 27.2291 19.4997 26.8514L6.34403 22.0631C5.30608 21.6853 4.7709 20.5376 5.14869 19.4997L8.56889 10.1027Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<path d="M11.0957 12.0434L15.3474 18.4228L22.4439 16.1739" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_bi_176_19662" x="3.02734" y="6.78622" width="24.5771" height="22.1863" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19662"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19662" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.2"/>
<feGaussianBlur stdDeviation="0.05"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19662"/>
</filter>
<linearGradient id="paint0_linear_176_19662" x1="23.5457" y1="9.14994" x2="13.4874" y2="13.5488" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<clipPath id="clip0_176_19662">
<rect width="24" height="24" fill="white" transform="translate(8.82812 0.619431) rotate(20)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,28 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_176_19658)">
<rect x="24.7207" y="12.0584" width="16" height="10" rx="1.5" transform="rotate(132.102 24.7207 12.0584)" fill="url(#paint0_linear_176_19658)"/>
<g filter="url(#filter0_i_176_19658)">
<path d="M7.17965 18.0792C6.80186 17.0413 7.33703 15.8936 8.37499 15.5158L19.6513 11.4116C20.6893 11.0338 21.8369 11.569 22.2147 12.6069L24.9509 20.1245C25.3287 21.1624 24.7935 22.3101 23.7555 22.6879L12.4792 26.7921C11.4413 27.1699 10.2936 26.6347 9.91581 25.5968L7.17965 18.0792Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<path d="M9.40967 18.3986L12.344 17.3306C12.8956 17.1298 13.5112 17.2202 13.9819 17.5709V17.5709C14.9874 18.3203 16.4315 17.7947 16.72 16.5744V16.5744C16.8551 16.0031 17.2686 15.5382 17.8202 15.3374L20.712 14.2849" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_i_176_19658" x="7.05859" y="11.2904" width="18.0137" height="15.7229" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.2"/>
<feGaussianBlur stdDeviation="0.05"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_176_19658"/>
</filter>
<linearGradient id="paint0_linear_176_19658" x1="37.5495" y1="16.2476" x2="26.0184" y2="21.8418" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<clipPath id="clip0_176_19658">
<rect width="24" height="24" fill="white" transform="translate(0.619629 8.82776) rotate(-20)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+2 -2
View File
@@ -21,7 +21,7 @@ export type Project = {
};
export async function getProjects(teamSlug: string) {
const cookiesManager = cookies();
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
@@ -46,7 +46,7 @@ export async function getProjects(teamSlug: string) {
}
export async function getProject(teamSlug: string, projectSlug: string) {
const cookiesManager = cookies();
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
+1 -1
View File
@@ -25,7 +25,7 @@ export type TeamMember = {
};
export async function getMembers(teamSlug: string) {
const token = getAuthToken();
const token = await getAuthToken();
if (!token) {
return undefined;
+2 -2
View File
@@ -23,7 +23,7 @@ export type Team = {
};
export async function getTeamBySlug(slug: string) {
const cookiesManager = cookies();
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
@@ -45,7 +45,7 @@ export async function getTeamBySlug(slug: string) {
}
export async function getTeams() {
const cookiesManager = cookies();
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
@@ -9,6 +9,7 @@ export function FormFieldSetup(props: {
children: React.ReactNode;
tooltip?: React.ReactNode;
isRequired: boolean;
helperText?: React.ReactNode;
}) {
return (
<div>
@@ -26,8 +27,13 @@ export function FormFieldSetup(props: {
)}
</div>
{props.children}
{props.helperText && (
<p className="mt-2 text-muted-foreground text-sm">{props.helperText}</p>
)}
{props.errorMessage && (
<p className="mt-1 text-destructive-text text-sm">
<p className="mt-2 text-destructive-text text-sm">
{props.errorMessage}
</p>
)}
@@ -19,7 +19,7 @@ export function AppFooter(props: AppFooterProps) {
return (
<footer
className={cn(
"w-full border-border border-t bg-muted/50 py-6 md:py-8",
"w-full border-border border-t bg-white py-6 md:py-8 dark:bg-muted/50",
props.className,
)}
>
@@ -1,7 +1,7 @@
"use client";
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";
import type { ThemeProviderProps } from "next-themes";
import { useEffect } from "react";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
@@ -50,7 +50,7 @@ export function DatePickerWithRange(props: {
id="date"
variant="outline"
className={cn(
"justify-start gap-2 text-left font-normal",
"justify-start gap-2 bg-card text-left font-normal",
props.className,
)}
>
@@ -69,14 +69,14 @@ export function DatePickerWithRange(props: {
<DynamicHeight>
<div>
{!isValid && (
<p className="flex items-center justify-center gap-2 pt-2 text-center text-destructive-text text-sm">
<p className="flex items-center justify-center gap-2 py-2 text-center text-destructive-text text-sm">
<CalendarX2Icon className="h-4 w-4" />
Invalid date range
</p>
)}
{props.header}
<div className="px-4">
<div className={cn("px-4", !props.header && "py-4")}>
<TabButtons
tabClassName="!text-sm"
activeTabClassName="!bg-inverted !text-inverted-foreground"
@@ -0,0 +1,58 @@
"use client";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-border border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+1 -13
View File
@@ -73,19 +73,7 @@ const SelectContent = React.forwardRef<
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
// Fixes https://github.com/radix-ui/primitives/issues/1658
ref={(instance) => {
if (typeof ref === "function") {
ref(instance);
} else if (ref) {
ref.current = instance;
}
if (!instance) return;
instance.ontouchstart = (e) => {
e.preventDefault();
};
}}
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
position === "popper" &&
+4 -4
View File
@@ -3,10 +3,10 @@ import { cookies } from "next/headers";
export const COOKIE_ACTIVE_ACCOUNT = "tw_active_account";
export const COOKIE_PREFIX_TOKEN = "tw_token_";
export function getActiveAccountCookie() {
return cookies().get(COOKIE_ACTIVE_ACCOUNT)?.value;
export async function getActiveAccountCookie() {
return (await cookies()).get(COOKIE_ACTIVE_ACCOUNT)?.value;
}
export function getJWTCookie(address: string) {
return cookies().get(COOKIE_PREFIX_TOKEN + address)?.value;
export async function getJWTCookie(address: string) {
return (await cookies()).get(COOKIE_PREFIX_TOKEN + address)?.value;
}
+10 -3
View File
@@ -8,9 +8,6 @@ export const IPFS_GATEWAY_URL =
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
export const THIRDWEB_ENGINE_FAUCET_WALLET =
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
export const isProd =
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
"production";
@@ -24,5 +21,15 @@ export const DASHBOARD_STORAGE_URL =
export const API_SERVER_URL =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
/**
* Faucet stuff
*/
// Cloudflare Turnstile Site key
export const TURNSTILE_SITE_KEY =
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
export const THIRDWEB_ENGINE_FAUCET_WALLET =
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
// Comma-separated list of chain IDs to disable faucet for.
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
@@ -24,7 +24,10 @@ export function useThirdwebClient(jwt?: string) {
throw new Error("Failed to get auth token");
}
const json = (await res.json()) as GetAuthTokenResponse;
return json.jwt || undefined;
if (!json.jwt) {
throw new Error("No JWT in response");
}
return json.jwt;
},
});
+2 -2
View File
@@ -6,7 +6,7 @@
:root,
[data-theme="light"] {
/* bg - neutral */
--background: 0 0% 100%;
--background: 0 0% 98%;
--popover: 0 0% 100%;
--card: 0 0% 100%;
--secondary: 0 0% 92%;
@@ -46,7 +46,7 @@
[data-theme="dark"] {
/* bg - neutral */
--background: 0 0% 0%;
--card: 0 0% 0%;
--card: 0 0% 5%;
--popover: 0 0% 0%;
--secondary: 0 0% 10%;
--muted: 0 0% 9%;
@@ -34,6 +34,7 @@ import { popularChains } from "../popularChains";
export const CustomConnectWallet = (props: {
loginRequired?: boolean;
connectButtonClassName?: string;
signInLinkButtonClassName?: string;
detailsButtonClassName?: string;
}) => {
const thirdwebClient = useThirdwebClient();
@@ -134,7 +135,12 @@ export const CustomConnectWallet = (props: {
if (!isLoggedIn && loginRequired) {
return (
<>
<Button asChild variant="default" className="gap-2" size="lg">
<Button
asChild
variant="default"
className={props.signInLinkButtonClassName}
size="lg"
>
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
@@ -5,6 +5,8 @@ import {
import type { ThirdwebContract } from "thirdweb";
import type { ComponentWithChildren } from "types/component-with-children";
import type { JSX } from "react";
interface AdminOnlyProps {
contract: ThirdwebContract;
failOpen?: boolean;
@@ -1,9 +1,43 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { THIRDWEB_EWS_API_HOST } from "constants/urls";
import type { WalletUser } from "thirdweb/wallets";
import { embeddedWalletsKeys } from "../cache-keys";
import { useLoggedInUser } from "./useLoggedInUser";
const fetchAccountList = ({
jwt,
clientId,
pageNumber,
}: { jwt: string; clientId: string; pageNumber: number }) => {
return async () => {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
url.searchParams.append("clientId", clientId);
url.searchParams.append("page", pageNumber.toString());
const res = await fetch(url.href, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
});
if (!res.ok) {
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
}
const json = await res.json();
return json as {
users: WalletUser[];
totalPages: number;
};
};
};
export function useEmbeddedWallets(clientId: string, page: number) {
const { user, isLoggedIn } = useLoggedInUser();
@@ -13,31 +47,44 @@ export function useEmbeddedWallets(clientId: string, page: number) {
clientId,
page,
),
queryFn: async () => {
const url = new URL(
`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`,
);
url.searchParams.append("clientId", clientId);
url.searchParams.append("page", page.toString());
const res = await fetch(url.href, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${user?.jwt}`,
},
});
if (!res.ok) {
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
}
const json = await res.json();
return json as {
users: WalletUser[];
totalPages: number;
};
},
queryFn: fetchAccountList({
jwt: user?.jwt ?? "",
clientId,
pageNumber: page,
}),
placeholderData: keepPreviousData,
enabled: !!user?.address && isLoggedIn && !!clientId,
});
}
export function useAllEmbeddedWallets() {
const queryClient = useQueryClient();
const { user } = useLoggedInUser();
return useMutation({
mutationFn: async ({
clientId,
totalPages,
}: { clientId: string; totalPages: number }) => {
const walletRes = [];
for (let page = 1; page <= totalPages; page++) {
const res = queryClient.fetchQuery<{
users: WalletUser[];
totalPages: number;
}>({
queryKey: embeddedWalletsKeys.embeddedWallets(
user?.address as string,
clientId,
page,
),
queryFn: fetchAccountList({
jwt: user?.jwt ?? "",
clientId,
pageNumber: page,
}),
});
walletRes.push(res);
}
return (await Promise.all(walletRes)).flatMap((res) => res.users);
},
});
}
@@ -116,7 +116,8 @@ type EngineFeature =
| "KEYPAIR_AUTH"
| "CONTRACT_SUBSCRIPTIONS"
| "IP_ALLOWLIST"
| "HETEROGENEOUS_WALLET_TYPES";
| "HETEROGENEOUS_WALLET_TYPES"
| "SMART_BACKEND_WALLETS";
interface EngineSystemHealth {
status: string;
@@ -1,5 +1,6 @@
"use client";
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
@@ -9,11 +10,15 @@ import {
} from "@/constants/env";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { Turnstile } from "@marsidev/react-turnstile";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
import { Onboarding } from "components/onboarding";
import { mapV4ChainToV5Chain } from "contexts/map-chains";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { toUnits } from "thirdweb";
@@ -113,6 +118,10 @@ export function FaucetButton({
},
});
const accountQuery = useAccount();
const userQuery = useLoggedInUser();
const [showOnboarding, setShowOnBoarding] = useState(false);
const canClaimFaucetQuery = useQuery({
queryKey: ["testnet-faucet-can-claim", chainId],
queryFn: async () => {
@@ -133,6 +142,32 @@ export function FaucetButton({
const form = useForm<z.infer<typeof claimFaucetSchema>>();
// Force users to log in to claim the faucet
if (!address || !userQuery.user) {
return (
<CustomConnectWallet
loginRequired={true}
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
/>
);
}
if (accountQuery.isPending) {
return (
<Button variant="outline" className="w-full gap-2">
Loading account <Spinner className="size-3" />
</Button>
);
}
if (!accountQuery.data) {
return (
<Button variant="outline" className="w-full gap-2" disabled>
Failed to load account
</Button>
);
}
// loading state
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
return (
@@ -145,7 +180,7 @@ export function FaucetButton({
// faucet is empty
if (isFaucetEmpty) {
return (
<Button variant="outline" disabled className="!opacity-100 w-full ">
<Button variant="outline" disabled className="!opacity-100 w-full">
Faucet is empty right now
</Button>
);
@@ -168,12 +203,24 @@ export function FaucetButton({
);
}
if (!address) {
// Email verification is required to claim from the faucet
if (accountQuery.data.status === "noCustomer") {
return (
<CustomConnectWallet
loginRequired={false}
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
/>
<>
<Button
variant="outline"
className="!opacity-100 w-full"
onClick={() => setShowOnBoarding(true)}
>
Verify your Email
</Button>
{/* We will show the modal only if the user click on it, because this is a public page */}
{showOnboarding && (
<ChakraProviderSetup>
<Onboarding onOpenChange={setShowOnBoarding} />
</ChakraProviderSetup>
)}
</>
);
}
@@ -32,9 +32,10 @@ import { ChainHeader } from "./components/server/chain-header";
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
export async function generateMetadata({
params,
}: { params: { chain_id: string } }): Promise<Metadata> {
export async function generateMetadata(props: {
params: Promise<{ chain_id: string }>;
}): Promise<Metadata> {
const params = await props.params;
const chain = await getChain(params.chain_id);
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
const title = `${sanitizedChainName}: RPC and Chain Settings`;
@@ -54,13 +55,12 @@ export async function generateMetadata({
}
// this is the dashboard layout file
export default async function ChainPageLayout({
children,
params,
}: {
export default async function ChainPageLayout(props: {
children: React.ReactNode;
params: { chain_id: string };
params: Promise<{ chain_id: string }>;
}) {
const params = await props.params;
const { children } = props;
const chain = await getChain(params.chain_id);
if (params.chain_id !== chain.slug) {
@@ -13,9 +13,10 @@ import { ChainCTA } from "./components/server/cta-card";
import { ExplorersSection } from "./components/server/explorer-section";
export default async function Page(props: {
params: { chain_id: string };
params: Promise<{ chain_id: string }>;
}) {
const chain = await getChain(props.params.chain_id);
const params = await props.params;
const chain = await getChain(params.chain_id);
const chainMetadata = await getChainMetadata(chain.chainId);
const isDeprecated = chain.status === "deprecated";
@@ -3,18 +3,17 @@ import { CircleAlertIcon } from "lucide-react";
import { TrendingContractSection } from "../../../../trending/components/trending-table";
import { getChain } from "../../../utils";
// we're using searchParams here - use dynamic rendering
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: { chain_id: string };
searchParams: { page?: number; sortBy?: SortBy };
params: Promise<{ chain_id: string }>;
searchParams: Promise<{ page?: number; sortBy?: SortBy }>;
}) {
const chain = await getChain(props.params.chain_id);
const searchParams = await props.searchParams;
const params = await props.params;
const chain = await getChain(params.chain_id);
const topContracts = await fetchTopContracts({
chainId: chain.chainId,
page: props.searchParams.page,
sortBy: props.searchParams.sortBy,
page: searchParams.page,
sortBy: searchParams.sortBy,
perPage: 15,
timeRange: "month",
});
@@ -24,22 +23,18 @@ export default async function Page(props: {
<h2 className="mb-2 font-semibold text-2xl tracking-tighter">
Popular Contracts
</h2>
<p className="text-muted-foreground text-sm">
Explore contracts on Ethereum and sort them by your preferred metrics
</p>
<div className="h-8" />
{topContracts.length > 0 && (
<TrendingContractSection
topContracts={topContracts}
chainId={chain.chainId}
searchParams={props.searchParams}
searchParams={searchParams}
showPagination={true}
/>
)}
{topContracts.length === 0 && (
<div className="flex h-[200px] items-center justify-center rounded-lg border border-border text-lg text-muted-foreground">
<div className="flex items-center gap-2">
@@ -6,12 +6,13 @@ import { ContractDirectListingsPage } from "./ContractDirectListingsPage";
import { ContractDirectListingsPageClient } from "./ContractDirectListingsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -26,7 +27,7 @@ export default async function Page(props: {
);
if (!isDirectListingSupported) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <ContractDirectListingsPage contract={info.contract} />;
@@ -6,12 +6,13 @@ import { ContractEnglishAuctionsPage } from "./ContractEnglishAuctionsPage";
import { ContractEnglishAuctionsPageClient } from "./ContractEnglishAuctionsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -26,7 +27,7 @@ export default async function Page(props: {
);
if (!isEnglishAuctionSupported) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <ContractEnglishAuctionsPage contract={info.contract} />;
@@ -54,7 +54,9 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
<QuantityInputWithUnlimited
isRequired
decimals={tokenDecimals}
isDisabled={dropType === "specific" || formDisabled}
isDisabled={
dropType === "specific" || formDisabled || (isErc20 && !tokenDecimals)
}
value={field?.maxClaimablePerWallet?.toString() || "0"}
onChange={(value) =>
form.setValue(
@@ -35,7 +35,7 @@ export const MaxClaimableSupplyInput: React.FC = () => {
>
<QuantityInputWithUnlimited
isRequired
isDisabled={formDisabled}
isDisabled={formDisabled || (isErc20 && !tokenDecimals)}
decimals={tokenDecimals}
value={field.maxClaimableSupply?.toString() || "0"}
onChange={(value) =>
@@ -2,6 +2,7 @@ import {
type BaseTransactionOptions,
type ThirdwebClient,
toTokens,
toUnits,
} from "thirdweb";
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
@@ -42,6 +43,7 @@ type CombinedClaimCondition = Prettify<
type Options =
| {
type: "erc20";
decimals?: number;
}
| {
type: "erc721";
@@ -91,12 +93,24 @@ export async function getClaimPhasesInLegacyFormat(
startTime: new Date(Number(condition.startTimestamp * 1000n)),
currencyAddress: condition.currency,
price: condition.pricePerToken,
maxClaimableSupply: toUnlimited(condition.maxClaimableSupply),
maxClaimableSupply:
options.type === "erc20"
? convertERC20ValueToDisplayValue(
condition.maxClaimableSupply,
options.decimals,
)
: toUnlimited(condition.maxClaimableSupply),
currencyMetadata,
currentMintSupply: (
condition.maxClaimableSupply - condition.supplyClaimed
).toString(),
maxClaimablePerWallet: toUnlimited(condition.quantityLimitPerWallet),
maxClaimablePerWallet:
options.type === "erc20"
? convertERC20ValueToDisplayValue(
condition.quantityLimitPerWallet,
options.decimals,
)
: toUnlimited(condition.quantityLimitPerWallet),
merkleRootHash: condition.merkleRoot,
metadata,
snapshot,
@@ -114,8 +128,20 @@ export function setClaimPhasesTx(
const phases = rawPhases.map((phase) => {
return {
startTime: toDate(phase.startTime),
maxClaimableSupply: toBigInt(phase.maxClaimableSupply),
maxClaimablePerWallet: toBigInt(phase.maxClaimablePerWallet),
maxClaimableSupply:
baseOptions.type === "erc20"
? convertERC20ValueToWei(
phase.maxClaimableSupply,
baseOptions.decimals,
)
: toBigInt(phase.maxClaimableSupply),
maxClaimablePerWallet:
baseOptions.type === "erc20"
? convertERC20ValueToWei(
phase.maxClaimablePerWallet,
baseOptions.decimals,
)
: toBigInt(phase.maxClaimablePerWallet),
merkleRootHash: phase.merkleRootHash as string | undefined,
overrideList: phase.snapshot?.length
? snapshotToOverrides(phase.snapshot)
@@ -175,7 +201,7 @@ function toDate(timestamp: number | Date | undefined) {
}
return new Date(timestamp);
}
function toBigInt(value: string | number | undefined) {
function toBigInt(value: string | number | undefined): bigint | undefined {
if (value === undefined) {
return undefined;
}
@@ -185,8 +211,47 @@ function toBigInt(value: string | number | undefined) {
return BigInt(value);
}
// The input from client-side is non-wei, but the extension is expecting value in wei
// so we need to convert it using toUnits
function convertERC20ValueToWei(
value: string | number | undefined,
decimals?: number,
) {
if (value === undefined) {
return undefined;
}
if (value === "unlimited") {
return maxUint256;
}
// The ERC20Claim condition extension in v5 does not convert to wei for us
// so we have to, manually
if (decimals) {
return toUnits(value.toString(), decimals);
}
return BigInt(value);
}
// This value we get from ERC20Ext.getClaimConditions is in wei
// so we have to convert it using toTokens for readability, and for users to update
// (when user updates this value, we convert it back to wei - see `function setClaimPhasesTx`)
function convertERC20ValueToDisplayValue(
value: bigint,
decimals?: number,
): string {
if (value === maxUint256) {
return "unlimited";
}
if (decimals) {
return toTokens(value, decimals);
}
return value.toString();
}
function toUnlimited(value: bigint) {
return value === maxUint256 ? "unlimited" : value.toString();
if (value === maxUint256) {
return "unlimited";
}
return value.toString();
}
async function fetchSnapshot(
@@ -13,6 +13,7 @@ import {
Flex,
Menu,
MenuButton,
MenuItem,
MenuList,
} from "@chakra-ui/react";
import { TransactionButton } from "components/buttons/TransactionButton";
@@ -26,6 +27,7 @@ import {
useFieldArray,
useForm,
} from "react-hook-form";
import { toast } from "sonner";
import {
NATIVE_TOKEN_ADDRESS,
type ThirdwebContract,
@@ -38,7 +40,7 @@ import {
useSendAndConfirmTransaction,
} from "thirdweb/react";
import invariant from "tiny-invariant";
import { Button, Heading, MenuItem, Text } from "tw-components";
import { Button, Heading, Text } from "tw-components";
import * as z from "zod";
import { ZodError } from "zod";
import {
@@ -152,7 +154,7 @@ interface ClaimsConditionFormContextData {
field: ControlledField;
phaseIndex: number;
formDisabled: boolean;
tokenDecimals: number;
tokenDecimals: number | undefined;
isMultiPhase: boolean;
isActive: boolean;
dropType: DropType;
@@ -210,7 +212,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
enabled: isErc20,
},
});
const tokenDecimalsData = tokenDecimals.data ?? 0;
const saveClaimPhaseNotification = useTxNotifications(
"Saved claim phases",
"Failed to save claim phases",
@@ -219,7 +220,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
const claimConditionsQuery = useReadContract(getClaimPhasesInLegacyFormat, {
contract,
...(isErc20
? { type: "erc20" }
? { type: "erc20", decimals: tokenDecimals.data }
: isErc721
? { type: "erc721" }
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
@@ -259,7 +260,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
);
}, [claimConditionsQuery.data, isMultiPhase]);
const isFetchingData = claimConditionsQuery.isFetching || sendTx.isPending;
const isFetchingData =
claimConditionsQuery.isFetching ||
sendTx.isPending ||
// Need to make sure the tokenDecimals.data is present when interacting with ERC20 claim conditions
(isErc20 && tokenDecimals.isLoading);
const canEditForm = isAdmin && !isFetchingData;
@@ -353,13 +358,17 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
action: "set-claim-conditions",
label: "attempt",
});
if (isErc20 && !tokenDecimals.data) {
return toast.error(
`Could not fetch token decimals for contract ${contract.address}`,
);
}
try {
const tx = setClaimPhasesTx(
{
contract,
...(isErc20
? { type: "erc20" }
? { type: "erc20", decimals: tokenDecimals.data }
: isErc721
? { type: "erc721" }
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
@@ -453,6 +462,15 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
);
}
// Do not proceed if fails to load the tokenDecimals.data - for ERC20 drop contracts specifically
if (isErc20 && tokenDecimals.data === undefined) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
Failed to load token decimals
</div>
);
}
return (
<>
<Flex onSubmit={handleFormSubmit} direction="column" as="form" gap={10}>
@@ -492,8 +510,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
<Fragment key={`snapshot_${field.id}_${index}`}>
<SnapshotUpload
dropType={dropType}
isOpen={openSnapshotIndex === index}
onClose={() => setOpenSnapshotIndex(-1)}
snapshotIndex={openSnapshotIndex}
index={index}
setOpenSnapshotIndex={setOpenSnapshotIndex}
value={snapshotValue}
setSnapshot={(snapshot) =>
form.setValue(`phases.${index}.snapshot`, snapshot)
@@ -508,7 +527,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
phaseIndex: index,
formDisabled: !canEditForm,
isErc20,
tokenDecimals: tokenDecimalsData,
tokenDecimals: tokenDecimals.data,
dropType,
setOpenSnapshotIndex,
isAdmin,
@@ -53,7 +53,7 @@ export const QuantityInputWithUnlimited: React.FC<
<InputGroup {...restInputProps}>
<Input
isRequired={isRequired}
isDisabled={decimals === undefined || isDisabled}
isDisabled={isDisabled}
value={stringValue === "unlimited" ? "Unlimited" : stringValue}
onChange={(e) => updateValue(e.currentTarget.value)}
onBlur={() => {
@@ -69,7 +69,7 @@ export const QuantityInputWithUnlimited: React.FC<
{hideMaxButton ? null : (
<InputRightElement w="auto">
<Button
isDisabled={decimals === undefined || isDisabled}
isDisabled={isDisabled}
colorScheme="primary"
variant="ghost"
size="sm"
@@ -1,43 +1,21 @@
import { UnorderedList } from "@/components/ui/List/List";
import { Button } from "@/components/ui/button";
import { InlineCode } from "@/components/ui/inline-code";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
Box,
Container,
Flex,
IconButton,
Link,
ListItem,
Portal,
Select,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tooltip,
Tr,
UnorderedList,
} from "@chakra-ui/react";
import { Logo } from "components/logo";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleAlertIcon,
DownloadIcon,
UploadIcon,
} from "lucide-react";
import Papa from "papaparse";
import { useCallback, useEffect, useRef, useState } from "react";
import { type DropzoneOptions, useDropzone } from "react-dropzone";
import { type Column, usePagination, useTable } from "react-table";
import { isAddress } from "thirdweb";
import { resolveAddress } from "thirdweb/extensions/ens";
import { Button, Drawer, Heading, Text } from "tw-components";
import { csvMimeTypes } from "utils/batch";
import { Box, Flex, Link } from "@chakra-ui/react";
import { useCsvUpload } from "hooks/useCsvUpload";
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
import { type Dispatch, type SetStateAction, useRef } from "react";
import type { Column } from "react-table";
import { ZERO_ADDRESS } from "thirdweb";
import { CsvDataTable } from "../csv-data-table";
interface SnapshotAddressInput {
address: string;
@@ -48,324 +26,271 @@ interface SnapshotAddressInput {
}
interface SnapshotUploadProps {
setSnapshot: (snapshot: SnapshotAddressInput[]) => void;
isOpen: boolean;
onClose: () => void;
value?:
| {
address: string;
maxClaimable: string;
price?: string | undefined;
currencyAddress?: string | undefined;
}[]
| null;
snapshotIndex: number;
setOpenSnapshotIndex: Dispatch<SetStateAction<number>>;
index: number;
dropType: "specific" | "any" | "overrides";
isDisabled: boolean;
value?: SnapshotAddressInput[] | undefined;
}
const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
return items
.map(({ address, maxClaimable, price, currencyAddress }) => ({
address: (address || "").trim(),
maxClaimable: (maxClaimable || "0").trim().toLowerCase(),
price: (price || "").trim() || undefined,
currencyAddress: (currencyAddress || "").trim() || undefined,
}))
.filter(({ address }) => address !== "");
};
export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
setSnapshot,
isOpen,
onClose,
value,
snapshotIndex,
index,
setOpenSnapshotIndex,
dropType,
isDisabled,
value,
}) => {
const client = useThirdwebClient();
const [validSnapshot, setValidSnapshot] = useState<SnapshotAddressInput[]>(
value || [],
);
const [snapshotData, setSnapshotData] = useState<SnapshotAddressInput[]>([]);
const [noCsv, setNoCsv] = useState(false);
const [invalidFound, setInvalidFound] = useState(false);
const reset = useCallback(() => {
setValidSnapshot([]);
setNoCsv(false);
}, []);
const _onClose = useCallback(() => {
onClose();
}, [onClose]);
const onDrop = useCallback<Required<DropzoneOptions>["onDrop"]>(
(acceptedFiles) => {
setNoCsv(false);
const csv = acceptedFiles.find(
(f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"),
);
if (!csv) {
console.error(
"No valid CSV file found, make sure you have an address column.",
);
setNoCsv(true);
return;
}
Papa.parse(csv, {
header: true,
complete: (results) => {
const data: SnapshotAddressInput[] = (
results.data as SnapshotAddressInput[]
)
.map(({ address, maxClaimable, price, currencyAddress }) => ({
address: (address || "").trim(),
maxClaimable: (maxClaimable || "0").trim().toLowerCase(),
price: (price || "").trim() || undefined,
currencyAddress: (currencyAddress || "").trim() || undefined,
}))
.filter(({ address }) => address !== "");
if (!data[0]?.address) {
setNoCsv(true);
return;
}
setValidSnapshot(data);
},
});
},
[],
);
// FIXME: this can be a mutation or query instead!
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (validSnapshot.length === 0) {
return setSnapshotData([]);
}
const normalizeAddresses = async (snapshot: SnapshotAddressInput[]) => {
const normalized = await Promise.all(
snapshot.map(async ({ address, ...rest }) => {
let isValid = true;
let resolvedAddress = address;
try {
resolvedAddress = isAddress(address)
? address
: await resolveAddress({ client, name: address });
isValid = !!resolvedAddress;
} catch {
isValid = false;
}
return {
address,
resolvedAddress,
isValid,
...rest,
};
}),
);
const seen = new Set();
const filteredData = normalized.filter((el) => {
const duplicate = seen.has(el.resolvedAddress);
seen.add(el.resolvedAddress);
return !duplicate;
});
const valid = filteredData.filter(({ isValid }) => isValid);
const invalid = filteredData.filter(({ isValid }) => !isValid);
if (invalid?.length > 0) {
setInvalidFound(true);
}
const ordered = [...invalid, ...valid];
setSnapshotData(ordered);
};
normalizeAddresses(validSnapshot);
}, [validSnapshot, client]);
const removeInvalid = useCallback(() => {
const filteredData = snapshotData.filter(({ isValid }) => isValid);
setValidSnapshot(filteredData);
setInvalidFound(false);
}, [snapshotData]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
});
const {
normalizeQuery,
getInputProps,
getRootProps,
isDragActive,
rawData,
noCsv,
reset,
removeInvalid,
} = useCsvUpload<SnapshotAddressInput>({ csvParser, defaultRawData: value });
const paginationPortalRef = useRef<HTMLDivElement>(null);
const onSave = () => {
setSnapshot(snapshotData);
onClose();
setSnapshot(normalizeQuery.data.result);
setOpenSnapshotIndex(-1);
};
const columns = [
{
Header: "Address",
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: "Max claimable",
accessor: ({ maxClaimable }) => {
return maxClaimable === "0" || !maxClaimable
? "Default"
: maxClaimable === "unlimited"
? "Unlimited"
: maxClaimable;
},
},
{
Header: "Price",
accessor: ({ price }) => {
return price === "0"
? "Free"
: !price || price === "unlimited"
? "Default"
: price;
},
},
{
Header: "Currency Address",
accessor: ({ currencyAddress }) => {
return currencyAddress ===
"0x0000000000000000000000000000000000000000" || !currencyAddress
? "Default"
: currencyAddress;
},
},
] as Column<SnapshotAddressInput>[];
const handleOpenChange = (isOpen: boolean) => {
setOpenSnapshotIndex(isOpen ? snapshotIndex : -1);
};
return (
<Drawer
allowPinchZoom
preserveScrollBarGap
size="xl"
onClose={_onClose}
isOpen={isOpen}
>
<Flex direction="column" gap={6} h="100%">
<Flex shadow="sm">
<Container maxW="container.page">
<Flex align="center" justify="space-between" p={4}>
<div className="flex flex-row gap-2">
<Logo hideWordmark />
<Heading size="title.md">
{validSnapshot.length ? "Edit" : "Upload"} Snapshot
</Heading>
</div>
</Flex>
</Container>
</Flex>
{validSnapshot.length > 0 ? (
<SnapshotTable portalRef={paginationPortalRef} data={snapshotData} />
) : (
<Flex flexGrow={1} align="center" overflow="auto">
<Container maxW="container.page">
<Flex gap={8} flexDir="column">
<div className="relative aspect-[21/9] w-full">
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
noCsv ? "bg-red-200" : "bg-card",
)}
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="!m-auto flex flex-col">
<UploadIcon
className={cn(
"mx-auto mb-2 size-8",
noCsv ? "text-red-500" : "text-gray-600",
)}
/>
{isDragActive ? (
<Heading as={Text} size="label.md">
Drop the files here
</Heading>
) : (
<Heading
as={Text}
size="label.md"
color={noCsv ? "red.500" : "gray.600"}
>
{noCsv
? `No valid CSV file found, make sure your CSV includes the "address" column.`
: "Drag & Drop a CSV file here"}
</Heading>
<Sheet open={snapshotIndex === index} onOpenChange={handleOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[900px]">
<SheetHeader>
<SheetTitle className="text-left">
{rawData.length ? "Edit" : "Upload"} Snapshot
</SheetTitle>
</SheetHeader>
<div className="mt-6 flex flex-col gap-6">
{rawData.length > 0 ? (
<CsvDataTable<SnapshotAddressInput>
portalRef={paginationPortalRef}
data={normalizeQuery.data.result}
columns={columns}
/>
) : (
<Flex
flexGrow={1}
align="center"
overflow="auto"
gap={8}
flexDir="column"
>
<div className="relative aspect-[21/9] w-full">
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
noCsv ? "bg-red-200" : "bg-card",
)}
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="!m-auto flex flex-col">
<UploadIcon
className={cn(
"mx-auto mb-2 size-8",
noCsv ? "text-red-500" : "text-gray-600",
)}
</div>
/>
{isDragActive ? (
<p>Drop the files here</p>
) : (
<p className={noCsv ? "text-red-500" : "text-gray-600"}>
{noCsv
? `No valid CSV file found, make sure your CSV includes the "address" column.`
: "Drag & Drop a CSV file here"}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Heading size="label.md">Requirements</Heading>
<UnorderedList spacing={1}>
{dropType === "specific" ? (
<>
<Text as={ListItem}>
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 />
<Link
download
color="blue.500"
href="/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</Text>
<Text as={ListItem}>
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 />
<Link
download
color="blue.500"
href="/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</Text>
</>
) : (
<>
<Text as={ListItem}>
Files <em>must</em> contain one .csv file with a list
of addresses.
<br />
<Link download color="blue.500" href="/snapshot.csv">
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</Text>
<Text as={ListItem}>
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 />
<Link
download
color="blue.500"
href="/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</Text>
<Text as={ListItem}>
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 per wallet you specified.{" "}
<strong>
When defining a custom currency address, you must
also define a price override.
</strong>
<br />
<Link
download
color="blue.500"
href="/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</Text>
</>
)}
<Text as={ListItem}>
Repeated addresses will be removed and only the first
found will be kept.
</Text>
<Text as={ListItem}>
The limit you set is for the maximum amount of NFTs a
wallet can claim, not how many they can receive in total.
</Text>
</UnorderedList>
</div>
</Flex>
</Container>
</Flex>
)}
<Flex borderTop="1px solid" borderTopColor="borderColor">
<Container maxW="container.page">
<Flex
align="center"
justify="space-between"
p={{ base: 0, md: 4 }}
flexDir={{ base: "column", md: "row" }}
mt={{ base: 4, md: 0 }}
>
<Box ref={paginationPortalRef} />
</div>
<div className="flex flex-col gap-2">
<p className="text-lg">Requirements</p>
<UnorderedList>
{dropType === "specific" ? (
<>
<li>
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 />
<Link
download
color="blue.500"
href="/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</li>
<li>
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 />
<Link
download
color="blue.500"
href="/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</li>
</>
) : (
<>
<li>
Files <em>must</em> contain one .csv file with a list of
addresses.
<br />
<Link download color="blue.500" href="/snapshot.csv">
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</li>
<li>
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 />
<Link
download
color="blue.500"
href="/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</li>
<li>
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 per wallet you specified.{" "}
<strong>
When defining a custom currency address, you must also
define a price override.
</strong>
<br />
<Link
download
color="blue.500"
href="/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
</Link>
</li>
</>
)}
<li>
Repeated addresses will be removed and only the first found
will be kept.
</li>
<li>
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>
</div>
</Flex>
)}
<Flex
align="center"
justify="space-between"
p={{ base: 0, md: 4 }}
flexDir={{ base: "column", md: "row" }}
mt={{ base: 4, md: 0 }}
borderTop="1px solid"
borderTopColor="borderColor"
>
<Box ref={paginationPortalRef} />
{!isDisabled && (
<Flex
gap={2}
align="center"
@@ -373,235 +298,40 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
w={{ base: "100%", md: "auto" }}
>
<Button
borderRadius="md"
disabled={isDisabled || validSnapshot.length === 0}
className="w-full rounded-md md:w-auto"
disabled={isDisabled || rawData.length === 0}
onClick={() => {
reset();
}}
w={{ base: "100%", md: "auto" }}
>
Reset
</Button>
{invalidFound ? (
{normalizeQuery.data.invalidFound ? (
<Button
borderRadius="md"
colorScheme="primary"
disabled={isDisabled || validSnapshot.length === 0}
className="w-full rounded-md md:w-auto"
variant="primary"
disabled={isDisabled || rawData.length === 0}
onClick={() => {
removeInvalid();
}}
w={{ base: "100%", md: "auto" }}
>
Remove invalid
</Button>
) : (
<Button
borderRadius="md"
colorScheme="primary"
className="w-full rounded-md md:w-auto"
variant="primary"
onClick={onSave}
w={{ base: "100%", md: "auto" }}
isDisabled={isDisabled || validSnapshot.length === 0}
disabled={isDisabled || rawData.length === 0}
>
Next
</Button>
)}
</Flex>
</Flex>
</Container>
</Flex>
</Flex>
</Drawer>
);
};
interface SnapshotTableProps {
data: SnapshotAddressInput[];
portalRef: React.RefObject<HTMLDivElement>;
}
const SnapshotTableColumns = [
{
Header: "Address",
accessor: ({ address, isValid }) => {
if (isValid) {
return address;
}
return (
<div className="flex flex-row items-center gap-2">
<Tooltip
label={
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" />
<Text fontWeight="bold" color="red.500" cursor="default">
{address}
</Text>
</div>
</Tooltip>
</div>
);
},
},
{
Header: "Max claimable",
accessor: ({ maxClaimable }) => {
return maxClaimable === "0" || !maxClaimable
? "Default"
: maxClaimable === "unlimited"
? "Unlimited"
: maxClaimable;
},
},
{
Header: "Price",
accessor: ({ price }) => {
return price === "0"
? "Free"
: !price || price === "unlimited"
? "Default"
: price;
},
},
{
Header: "Currency Address",
accessor: ({ currencyAddress }) => {
return currencyAddress === "0x0000000000000000000000000000000000000000" ||
!currencyAddress
? "Default"
: currencyAddress;
},
},
] as Column<SnapshotAddressInput>[];
const SnapshotTable: React.FC<SnapshotTableProps> = ({ data, portalRef }) => {
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: SnapshotTableColumns,
data,
initialState: {
pageSize: 50,
pageIndex: 0,
},
},
// old package, this will be removed
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
);
// Render the UI for your table
return (
<Flex flexGrow={1}>
<TableContainer>
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup, headerGroupIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
{headerGroup.headers.map((column, columnIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Th {...column.getHeaderProps()} key={columnIndex}>
<Text as="label" size="label.sm" color="faded">
{column.render("Header")}
</Text>
</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) => {
return (
<Td
{...cell.getCellProps()}
borderColor="borderColor"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
);
})}
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
<Portal containerRef={portalRef}>
<div className="flex w-full items-center justify-center">
<div className="flex flex-row gap-1">
<IconButton
isDisabled={!canPreviousPage}
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
onClick={() => gotoPage(0)}
/>
<IconButton
isDisabled={!canPreviousPage}
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
onClick={() => previousPage()}
/>
<p className="my-auto whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
isDisabled={!canNextPage}
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
onClick={() => nextPage()}
/>
<IconButton
isDisabled={!canNextPage}
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
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>
</Flex>
)}
</Flex>
</div>
</SheetContent>
</Sheet>
);
};
@@ -0,0 +1,162 @@
import { TableContainer } from "@/components/ui/table";
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 { Text } from "tw-components";
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: {
pageSize: 50,
pageIndex: 0,
},
},
// 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}
>
<Text as="label" size="label.sm" color="faded">
{column.render("Header")}
</Text>
</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
isDisabled={!canPreviousPage}
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
onClick={() => gotoPage(0)}
/>
<IconButton
isDisabled={!canPreviousPage}
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
onClick={() => previousPage()}
/>
<p className="my-auto whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
isDisabled={!canNextPage}
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
onClick={() => nextPage()}
/>
<IconButton
isDisabled={!canNextPage}
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
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>
)}
</>
);
}
@@ -13,14 +13,9 @@ export async function getContractPageParamsInfo(params: {
}) {
const contractAddress = getCheckSummedAddress(params.contractAddress);
const chainSlugOrId = params.chain_id;
let chainMetadata: ChainMetadata | null = null;
try {
chainMetadata = await fetchChain(chainSlugOrId);
} catch {
// move on
}
let chainMetadata = await fetchChain(chainSlugOrId).catch(() => null);
const cookieStore = cookies();
const cookieStore = await cookies();
const localChainStoreValue = cookieStore.get(TW_LOCAL_CHAIN_STORE)?.value;
if (localChainStoreValue) {
@@ -47,7 +42,7 @@ export async function getContractPageParamsInfo(params: {
}
// attempt to get the auth token
const token = getAuthToken();
const token = await getAuthToken();
const contract = getContract({
address: contractAddress,
@@ -6,12 +6,13 @@ import { AccountSignersClient } from "./AccountSigners.client";
import { AccountSigners } from "./components/account-signers";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -27,7 +28,7 @@ export default async function Page(props: {
await getContractPageMetadata(contract);
if (!isAccountPermissionsSupported) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <AccountSigners contract={contract} />;
@@ -6,12 +6,13 @@ import { AccountPage } from "./AccountPage";
import { AccountPageClient } from "./AccountPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -27,7 +28,7 @@ export default async function Page(props: {
const { isAccount } = await getContractPageMetadata(contract);
if (!isAccount) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <AccountPage contract={contract} chainMetadata={chainMetadata} />;
@@ -6,12 +6,13 @@ import { AccountsPage } from "./AccountsPage";
import { AccountsPageClient } from "./AccountsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -26,7 +27,7 @@ export default async function Page(props: {
const { isAccountFactory } = await getContractPageMetadata(contract);
if (!isAccountFactory) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <AccountsPage contract={contract} />;
@@ -6,12 +6,13 @@ import { ContractAnalyticsPage } from "./ContractAnalyticsPage";
import { ContractAnalyticsPageClient } from "./ContractAnalyticsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -24,7 +25,7 @@ export default async function Page(props: {
const { isAnalyticsSupported } = await getContractPageMetadata(info.contract);
if (!isAnalyticsSupported) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <ContractAnalyticsPage contract={info.contract} />;
@@ -6,12 +6,13 @@ import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
import { ClaimConditionsClient } from "./ClaimConditions.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -29,7 +30,7 @@ export default async function Page(props: {
} = await getContractPageMetadata(contract);
if (!isERC20ClaimConditionsSupported && !isERC721ClaimConditionsSupported) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return (
@@ -6,12 +6,13 @@ import { ContractCodePage } from "./contract-code-page";
import { ContractCodePageClient } from "./contract-code-page.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -6,12 +6,13 @@ import { EmbedSetupClient } from "./EmbedSetup.client";
import { EmbedSetup } from "./embed-setup";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -25,7 +26,7 @@ export default async function Page(props: {
const { embedType } = await getContractPageMetadata(contract);
if (embedType === null) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <EmbedSetup contract={contract} ercOrMarketplace={embedType} />;
@@ -3,12 +3,13 @@ import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
import { EventsFeed } from "./events-feed";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -6,12 +6,13 @@ import { ContractExplorerPage } from "./ContractExplorerPage";
import { ContractExplorerPageClient } from "./ContractExplorerPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -15,20 +15,21 @@ import { getContractPageMetadata } from "./_utils/getContractPageMetadata";
import { getContractPageSidebarLinks } from "./_utils/getContractPageSidebarLinks";
export default async function Layout(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
children: React.ReactNode;
}) {
if (!isAddress(props.params.contractAddress)) {
const params = await props.params;
if (!isAddress(params.contractAddress)) {
return notFound();
}
const info = await getContractPageParamsInfo(props.params);
const info = await getContractPageParamsInfo(params);
if (!info) {
return <ConfigureCustomChain chainSlug={props.params.chain_id} />;
return <ConfigureCustomChain chainSlug={params.chain_id} />;
}
const { contract, chainMetadata } = info;
@@ -78,14 +79,13 @@ export default async function Layout(props: {
);
}
export async function generateMetadata({
params,
}: {
params: {
export async function generateMetadata(props: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}): Promise<Metadata> {
const params = await props.params;
try {
const info = await getContractPageParamsInfo(params);
@@ -0,0 +1,291 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTxNotifications } from "hooks/useTxNotifications";
import { CircleAlertIcon } from "lucide-react";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { sendAndConfirmTransaction } from "thirdweb";
import { BatchMetadataERC721, BatchMetadataERC1155 } from "thirdweb/modules";
import { parseAttributes } from "utils/parseAttributes";
import { z } from "zod";
import { fileBufferOrStringSchema } from "../zod-schemas";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
import type { ModuleInstanceProps } from "./module-instance";
import { AdvancedNFTMetadataFormGroup } from "./nft/AdvancedNFTMetadataFormGroup";
import { NFTMediaFormGroup } from "./nft/NFTMediaFormGroup";
import { PropertiesFormControl } from "./nft/PropertiesFormControl";
const uploadMetadataFormSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
image: fileBufferOrStringSchema.optional(),
animationUri: fileBufferOrStringSchema.optional(),
external_url: fileBufferOrStringSchema.optional(),
customImage: z.string().optional(),
customAnimationUrl: z.string().optional(),
background_color: z
.string()
.refine(
(c) => {
return /^#[0-9a-f]{6}$/i.test(c.toLowerCase());
},
{
message: "Invalid Hex Color",
},
)
.optional(),
attributes: z.array(
z.object({
trait_type: z.string().min(1),
value: z.string().min(1),
}),
),
});
export type UploadMetadataFormValues = z.infer<typeof uploadMetadataFormSchema>;
function BatchMetadataModule(props: ModuleInstanceProps) {
const { contract, ownerAccount } = props;
const isErc721 = props.contractInfo.name === "BatchMetadataERC721";
const uploadMetadata = useCallback(
async (values: UploadMetadataFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
const nft = parseAttributes(values);
const uploadMetadata = isErc721
? BatchMetadataERC721.uploadMetadata
: BatchMetadataERC1155.uploadMetadata;
const uploadMetadataTx = uploadMetadata({
contract,
metadatas: [nft],
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: uploadMetadataTx,
});
},
[contract, ownerAccount, isErc721],
);
return (
<BatchMetadataModuleUI
{...props}
uploadMetadata={uploadMetadata}
isOwnerAccount={!!ownerAccount}
contractChainId={contract.chain.id}
/>
);
}
export function BatchMetadataModuleUI(
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
isOwnerAccount: boolean;
uploadMetadata: (values: UploadMetadataFormValues) => Promise<void>;
contractChainId: number;
},
) {
return (
<ModuleCardUI {...props}>
<div className="flex flex-col gap-4">
<Accordion type="single" collapsible className="-mx-1">
{/* uploadMetadata */}
<AccordionItem value="metadata" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Upload NFT Metadata
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount && (
<UploadMetadataNFTSection
uploadMetadata={props.uploadMetadata}
contractChainId={props.contractChainId}
/>
)}
{!props.isOwnerAccount && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to upload metadata on this
contract
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
{/* batchMetadata */}
<AccordionItem value="batch-metadata" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Batch Upload NFT Metadata
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount && <BatchMetadataNFTSection />}
{!props.isOwnerAccount && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to upload metadata on this
contract
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</ModuleCardUI>
);
}
function UploadMetadataNFTSection(props: {
uploadMetadata: (values: UploadMetadataFormValues) => Promise<void>;
contractChainId: number;
}) {
const form = useForm<UploadMetadataFormValues>({
resolver: zodResolver(uploadMetadataFormSchema),
values: {
name: "",
attributes: [],
},
reValidateMode: "onChange",
});
const uploadNotifications = useTxNotifications(
"NFT metadata uploaded successfully",
"Failed to uploadMetadata NFT metadata",
);
const uploadMetadataMutation = useMutation({
mutationFn: props.uploadMetadata,
onSuccess: uploadNotifications.onSuccess,
onError: uploadNotifications.onError,
});
const onSubmit = async () => {
uploadMetadataMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row">
{/* Left */}
<div className="shrink-0 lg:w-[300px]">
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
</div>
{/* Right */}
<div className="flex grow flex-col gap-6">
{/* name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PropertiesFormControl form={form} />
{/* Advanced options */}
<Accordion
type="single"
collapsible={
!(
form.formState.errors.background_color ||
form.formState.errors.external_url
)
}
>
<AccordionItem
value="advanced-options"
className="-mx-1 border-y"
>
<AccordionTrigger className="px-1">
Advanced Options
</AccordionTrigger>
<AccordionContent className="px-1">
<AdvancedNFTMetadataFormGroup form={form} />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
<div className="flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={uploadMetadataMutation.isPending}
type="submit"
isLoading={uploadMetadataMutation.isPending}
txChainID={props.contractChainId}
transactionCount={1}
colorScheme="primary"
>
Upload
</TransactionButton>
</div>
</div>
</form>
</Form>
);
}
function BatchMetadataNFTSection() {
return (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>Coming soon!</AlertTitle>
</Alert>
);
}
export default BatchMetadataModule;
@@ -0,0 +1,873 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { addDays, fromUnixTime } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { useTxNotifications } from "hooks/useTxNotifications";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useCallback } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import {
type PreparedTransaction,
ZERO_ADDRESS,
getContract,
sendAndConfirmTransaction,
toTokens,
} from "thirdweb";
import { decimals } from "thirdweb/extensions/erc20";
import { ClaimableERC721, ClaimableERC1155 } from "thirdweb/modules";
import { useActiveAccount, useReadContract } from "thirdweb/react";
import { z } from "zod";
import { addressSchema } from "../zod-schemas";
import { CurrencySelector } from "./CurrencySelector";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
import type { ModuleInstanceProps } from "./module-instance";
export type ClaimConditionValue = {
availableSupply: bigint;
allowlistMerkleRoot: `0x${string}`;
pricePerUnit: bigint;
currency: string;
maxMintPerWallet: bigint;
startTimestamp: number;
endTimestamp: number;
auxData: string;
};
type ClaimCondition = {
availableSupply: bigint;
allowlistMerkleRoot: `0x${string}`;
pricePerUnit: bigint;
currency: string;
maxMintPerWallet: bigint;
startTimestamp: number;
endTimestamp: number;
auxData: string;
};
function ClaimableModule(props: ModuleInstanceProps) {
const { contract, ownerAccount } = props;
const account = useActiveAccount();
const isErc721 = props.contractInfo.name === "ClaimableERC721";
const primarySaleRecipientQuery = useReadContract(
isErc721 ? ClaimableERC721.getSaleConfig : ClaimableERC1155.getSaleConfig,
{
contract: contract,
},
);
const claimConditionQuery = useReadContract(
ClaimableERC721.getClaimCondition,
{
contract: contract,
queryOptions: {
enabled: isErc721,
},
},
);
const getClaimConditionErc1155 = (tokenId: string) =>
ClaimableERC1155.getClaimCondition({
contract: contract,
tokenId: BigInt(tokenId),
});
const currencyContract = getContract({
address: claimConditionQuery.data?.currency || "",
chain: props.contract.chain,
client: props.contract.client,
});
const shouldFetchTokenDecimals =
isErc721 &&
claimConditionQuery.data &&
claimConditionQuery.data?.currency !== ZERO_ADDRESS;
const tokenDecimalsQuery = useReadContract(decimals, {
contract: currencyContract,
queryOptions: {
enabled: shouldFetchTokenDecimals,
},
});
const mint = useCallback(
async (values: MintFormValues) => {
if (!account) {
throw new Error("Wallet not connected");
}
let mintTx: PreparedTransaction;
if (isErc721) {
mintTx = ClaimableERC721.mint({
contract,
to: values.recipient,
quantity: values.quantity,
});
} else if (values.tokenId) {
mintTx = ClaimableERC1155.mint({
contract,
to: values.recipient,
quantity: values.quantity,
tokenId: BigInt(values.tokenId),
});
} else {
throw new Error("Invalid tokenId");
}
await sendAndConfirmTransaction({
account: account,
transaction: mintTx,
});
},
[contract, account, isErc721],
);
const setClaimCondition = useCallback(
async (values: ClaimConditionFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
let setClaimConditionTx: PreparedTransaction;
if (isErc721) {
setClaimConditionTx = ClaimableERC721.setClaimCondition({
...values,
contract,
allowList:
values.allowList && values.allowList.length > 0
? values.allowList.map(({ address }) => address)
: undefined,
});
} else if (values.tokenId) {
setClaimConditionTx = ClaimableERC1155.setClaimCondition({
...values,
contract,
allowList:
values.allowList && values.allowList.length > 0
? values.allowList.map(({ address }) => address)
: undefined,
tokenId: BigInt(values.tokenId),
});
} else {
throw new Error("Invalid tokenId");
}
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: setClaimConditionTx,
});
},
[contract, ownerAccount, isErc721],
);
const setPrimarySaleRecipient = useCallback(
async (values: PrimarySaleRecipientFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
const setSaleConfig = isErc721
? ClaimableERC721.setSaleConfig
: ClaimableERC1155.setSaleConfig;
const setSaleConfigTx = setSaleConfig({
contract: contract,
primarySaleRecipient: values.primarySaleRecipient,
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: setSaleConfigTx,
});
},
[contract, ownerAccount, isErc721],
);
return (
<ClaimableModuleUI
{...props}
primarySaleRecipientSection={{
data: primarySaleRecipientQuery.data
? { primarySaleRecipient: primarySaleRecipientQuery.data }
: undefined,
setPrimarySaleRecipient,
}}
claimConditionSection={{
data:
// claim condition is common for all tokens
isErc721 &&
// claim conditions is fetched
claimConditionQuery.isFetched &&
// token decimals is fetched if it should be fetched
(shouldFetchTokenDecimals ? tokenDecimalsQuery.isFetched : true)
? {
claimCondition: claimConditionQuery.data,
tokenDecimals: tokenDecimalsQuery.data,
}
: undefined,
setClaimCondition,
getClaimConditionErc1155,
}}
isOwnerAccount={!!ownerAccount}
isErc721={isErc721}
contractChainId={props.contract.chain.id}
mintSection={{
mint,
}}
/>
);
}
export function ClaimableModuleUI(
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
isOwnerAccount: boolean;
isErc721: boolean;
contractChainId: number;
primarySaleRecipientSection: {
setPrimarySaleRecipient: (
values: PrimarySaleRecipientFormValues,
) => Promise<void>;
data:
| {
primarySaleRecipient: string;
}
| undefined;
};
mintSection: {
mint: (values: MintFormValues) => Promise<void>;
};
claimConditionSection: {
setClaimCondition: (values: ClaimConditionFormValues) => Promise<void>;
getClaimConditionErc1155: (tokenId: string) => Promise<ClaimCondition>;
data:
| {
claimCondition: ClaimConditionValue | undefined;
tokenDecimals: number | undefined;
}
| undefined;
};
},
) {
return (
<ModuleCardUI {...props}>
<div className="h-1" />
<div className="flex flex-col gap-4">
{/* Mint NFT */}
<Accordion type="single" collapsible className="-mx-1">
<AccordionItem value="metadata" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Mint NFT
</AccordionTrigger>
<AccordionContent className="px-1">
<MintNFTSection
mint={props.mintSection.mint}
isErc721={props.isErc721}
contractChainId={props.contractChainId}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="claim-conditions" className="border-none ">
<AccordionTrigger className="border-border border-t px-1 text-left">
Claim Conditions
</AccordionTrigger>
<AccordionContent className="px-1">
{!props.isErc721 || props.claimConditionSection.data ? (
<ClaimConditionSection
isOwnerAccount={props.isOwnerAccount}
claimCondition={
props.claimConditionSection.data?.claimCondition
}
update={props.claimConditionSection.setClaimCondition}
isErc721={props.isErc721}
chainId={props.contractChainId}
tokenDecimals={
props.claimConditionSection.data?.tokenDecimals
}
getClaimConditionErc1155={
props.claimConditionSection.getClaimConditionErc1155
}
/>
) : (
<Skeleton className="h-[350px]" />
)}
</AccordionContent>
</AccordionItem>
<AccordionItem
value="primary-sale-recipient"
className="border-none "
>
<AccordionTrigger className="border-border border-t px-1 text-left">
Primary Sale Recipient
</AccordionTrigger>
<AccordionContent className="px-1">
{props.primarySaleRecipientSection.data ? (
<PrimarySaleRecipientSection
isOwnerAccount={props.isOwnerAccount}
primarySaleRecipient={
props.primarySaleRecipientSection.data.primarySaleRecipient
}
update={
props.primarySaleRecipientSection.setPrimarySaleRecipient
}
contractChainId={props.contractChainId}
/>
) : (
<Skeleton className="h-[74px]" />
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</ModuleCardUI>
);
}
const claimConditionFormSchema = z.object({
tokenId: z.string().refine((v) => BigInt(v) >= 0n, {
message: "Invalid tokenId",
}),
pricePerToken: z.coerce
.number()
.min(0, { message: "Invalid price per token" })
.optional(),
currencyAddress: z.string().optional(),
maxClaimableSupply: z
.string()
.refine((v) => v.length === 0 || Number(v) >= 0, {
message: "Invalid max claimable supply",
}),
maxClaimablePerWallet: z
.string()
.refine((v) => v.length === 0 || Number(v) >= 0, {
message: "Invalid max claimable per wallet",
}),
startTime: z.date(),
endTime: z.date(),
allowList: z.array(z.object({ address: addressSchema })).optional(),
});
export type ClaimConditionFormValues = z.infer<typeof claimConditionFormSchema>;
const MAX_UINT_256 =
"115792089237316195423570985008687907853269984665640564039457584007913129639935";
const defaultStartDate = addDays(new Date(), 7);
const defaultEndDate = addDays(new Date(), 14);
function ClaimConditionSection(props: {
claimCondition?: ClaimConditionValue;
update: (values: ClaimConditionFormValues) => Promise<void>;
isOwnerAccount: boolean;
isErc721: boolean;
chainId: number;
tokenDecimals?: number;
getClaimConditionErc1155: (tokenId: string) => Promise<ClaimCondition>;
}) {
const { idToChain } = useAllChainsData();
const chain = idToChain.get(props.chainId);
const { claimCondition } = props;
const tokenIdForm = useForm<{ tokenId: string }>({
defaultValues: {
tokenId: "",
},
});
const tokenId = tokenIdForm.watch("tokenId");
const claimConditionErc1155Query = useQuery({
queryKey: ["claimConditionErc1155", props.chainId, tokenId],
queryFn: () => props.getClaimConditionErc1155(tokenId),
enabled: !props.isErc721 && BigInt(tokenId) >= 0n,
});
const conditions = props.isErc721
? claimCondition
: claimConditionErc1155Query.data;
const form = useForm<ClaimConditionFormValues>({
resolver: zodResolver(claimConditionFormSchema),
values: {
tokenId,
currencyAddress:
conditions?.currency === ZERO_ADDRESS ? "" : conditions?.currency,
pricePerToken:
conditions?.pricePerUnit &&
conditions?.currency !== ZERO_ADDRESS &&
props.tokenDecimals
? Number(toTokens(conditions?.pricePerUnit, props.tokenDecimals))
: 0,
maxClaimableSupply:
conditions?.availableSupply.toString() === "0" ||
conditions?.availableSupply.toString() === MAX_UINT_256
? ""
: conditions?.availableSupply.toString() || "",
maxClaimablePerWallet:
conditions?.maxMintPerWallet.toString() === "0" ||
conditions?.maxMintPerWallet.toString() === MAX_UINT_256
? ""
: conditions?.maxMintPerWallet.toString() || "",
startTime: conditions?.startTimestamp
? fromUnixTime(conditions?.startTimestamp)
: defaultStartDate,
endTime: conditions?.endTimestamp
? fromUnixTime(conditions?.endTimestamp)
: defaultEndDate,
allowList: [],
},
});
const allowListFields = useFieldArray({
control: form.control,
name: "allowList",
});
const updateNotifications = useTxNotifications(
"Successfully updated claim conditions",
"Failed to update claim conditions",
);
const [startTime, endTime] = form.watch(["startTime", "endTime"]);
const updateMutation = useMutation({
mutationFn: props.update,
onSuccess: updateNotifications.onSuccess,
onError: updateNotifications.onError,
});
const onSubmit = async () => {
const values = form.getValues();
if (!props.isErc721 && !values.tokenId) {
form.setError("tokenId", { message: "Token ID is required" });
return;
}
updateMutation.mutateAsync(values);
};
return (
<div className="flex flex-col gap-6">
{!props.isErc721 && (
<Form {...tokenIdForm}>
<form>
<FormField
control={tokenIdForm.control}
name="tokenId"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Token ID</FormLabel>
<FormControl>
<Input {...field} disabled={!props.isOwnerAccount} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{!props.isErc721 &&
tokenId !== "" &&
BigInt(tokenId) >= 0n &&
claimConditionErc1155Query.isPending && (
<Skeleton className="h-[350px]" />
)}
{(props.isErc721 ||
(tokenId !== "" &&
BigInt(tokenId) >= 0n &&
!claimConditionErc1155Query.isPending)) && (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="pricePerToken"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Price Per Token</FormLabel>
<FormControl>
<Input {...field} disabled={!props.isOwnerAccount} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currencyAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Currency</FormLabel>
<CurrencySelector chain={chain} field={field} />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="maxClaimableSupply"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Max Available Supply</FormLabel>
<FormControl>
<Input {...field} disabled={!props.isOwnerAccount} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxClaimablePerWallet"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Maximum number of mints per wallet</FormLabel>
<FormControl>
<Input {...field} disabled={!props.isOwnerAccount} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormFieldSetup
htmlFor="duration"
label="Duration"
isRequired
errorMessage={
form.formState.errors?.startTime?.message ||
form.formState.errors?.endTime?.message
}
>
<div>
<DatePickerWithRange
from={startTime}
to={endTime}
setFrom={(from: Date) => form.setValue("startTime", from)}
setTo={(to: Date) => form.setValue("endTime", to)}
/>
</div>
</FormFieldSetup>
<Separator />
<div className="w-full space-y-2">
<FormLabel>Allowlist</FormLabel>
<div className="flex flex-col gap-3">
{allowListFields.fields.map((fieldItem, index) => (
<div className="flex items-start gap-3" key={fieldItem.id}>
<FormField
control={form.control}
name={`allowList.${index}.address`}
render={({ field }) => (
<FormItem className="grow">
<FormControl>
<Input
placeholder="0x..."
{...field}
disabled={!props.isOwnerAccount}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ToolTipLabel label="Remove address">
<Button
variant="outline"
className="!text-destructive-text bg-background"
onClick={() => {
allowListFields.remove(index);
}}
disabled={!props.isOwnerAccount}
>
<Trash2Icon className="size-4" />
</Button>
</ToolTipLabel>
</div>
))}
{allowListFields.fields.length === 0 && (
<Alert variant="warning">
<CircleAlertIcon className="size-5 max-sm:hidden" />
<AlertTitle className="max-sm:!pl-0">
No allowlist configured
</AlertTitle>
</Alert>
)}
</div>
<div className="h-1" />
<div className="flex gap-3">
<Button
variant="outline"
size="sm"
onClick={() => {
// add admin by default if adding the first input
allowListFields.append({
address: "",
});
}}
className="gap-2"
disabled={!props.isOwnerAccount}
>
<PlusIcon className="size-3" />
Add Address
</Button>
</div>
</div>
<div className="flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={updateMutation.isPending || !props.isOwnerAccount}
type="submit"
isLoading={updateMutation.isPending}
txChainID={props.chainId}
transactionCount={1}
colorScheme="primary"
>
Update
</TransactionButton>
</div>
</div>
)}
</form>{" "}
</Form>
</div>
);
}
const primarySaleRecipientFormSchema = z.object({
primarySaleRecipient: addressSchema,
});
export type PrimarySaleRecipientFormValues = z.infer<
typeof primarySaleRecipientFormSchema
>;
function PrimarySaleRecipientSection(props: {
primarySaleRecipient: string | undefined;
update: (values: PrimarySaleRecipientFormValues) => Promise<void>;
isOwnerAccount: boolean;
contractChainId: number;
}) {
const form = useForm<PrimarySaleRecipientFormValues>({
resolver: zodResolver(primarySaleRecipientFormSchema),
values: {
primarySaleRecipient: props.primarySaleRecipient || "",
},
});
const updateNotifications = useTxNotifications(
"Successfully update primary sale recipient",
"Failed to update primary sale recipient",
);
const updateMutation = useMutation({
mutationFn: props.update,
onSuccess: updateNotifications.onSuccess,
onError: updateNotifications.onError,
});
const onSubmit = async () => {
updateMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="primarySaleRecipient"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Sale Recipient</FormLabel>
<FormControl>
<Input
placeholder="0x..."
{...field}
disabled={!props.isOwnerAccount}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="h-5" />
<div className="flex justify-end">
<TransactionButton
size="sm"
className="min-w-24 gap-2"
disabled={
updateMutation.isPending ||
!props.isOwnerAccount ||
!form.formState.isDirty
}
type="submit"
isLoading={updateMutation.isPending}
txChainID={props.contractChainId}
transactionCount={1}
colorScheme="primary"
>
Update
</TransactionButton>
</div>
</form>{" "}
</Form>
);
}
const mintFormSchema = z.object({
tokenId: z.string().refine((v) => v.length === 0 || Number(v) >= 0, {
message: "Invalid tokenId",
}),
quantity: z.coerce.number().min(0, { message: "Invalid quantity" }),
recipient: addressSchema,
});
export type MintFormValues = z.infer<typeof mintFormSchema>;
function MintNFTSection(props: {
mint: (values: MintFormValues) => Promise<void>;
isErc721: boolean;
contractChainId: number;
}) {
const form = useForm<MintFormValues>({
resolver: zodResolver(mintFormSchema),
values: {
tokenId: "",
quantity: 0,
recipient: "",
},
reValidateMode: "onChange",
});
const updateNotifications = useTxNotifications(
"Successfully minted NFT",
"Failed to mint NFT",
);
const mintMutation = useMutation({
mutationFn: props.mint,
onSuccess: updateNotifications.onSuccess,
onError: updateNotifications.onError,
});
const onSubmit = async () => {
const values = form.getValues();
if (!props.isErc721 && !values.tokenId) {
form.setError("tokenId", { message: "Token ID is required" });
return;
}
mintMutation.mutateAsync(values);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="recipient"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Recipient Address</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>quantity</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!props.isErc721 && (
<FormField
control={form.control}
name="tokenId"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Token ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end">
<TransactionButton
size="sm"
className="min-w-24 gap-2"
disabled={mintMutation.isPending}
type="submit"
isLoading={mintMutation.isPending}
txChainID={props.contractChainId}
transactionCount={1}
colorScheme="primary"
>
Mint
</TransactionButton>
</div>
</div>
</form>
</Form>
);
}
export default ClaimableModule;
@@ -0,0 +1,189 @@
import { Button } from "@/components/ui/button";
import { FormControl } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CURRENCIES, type CurrencyMetadata } from "constants/currencies";
import { useMemo, useState } from "react";
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
import type { StoredChain } from "stores/chainStores";
import { NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS, isAddress } from "thirdweb";
interface CurrencySelectorProps<
TFieldValues extends FieldValues = FieldValues,
> {
small?: boolean;
hideDefaultCurrencies?: boolean;
showCustomCurrency?: boolean;
isPaymentsSelector?: boolean;
defaultCurrencies?: CurrencyMetadata[];
chain?: StoredChain;
field: ControllerRenderProps<TFieldValues>;
className?: string;
}
export function CurrencySelector<
TFieldValues extends FieldValues = FieldValues,
>({
hideDefaultCurrencies,
showCustomCurrency = true,
isPaymentsSelector = false,
defaultCurrencies = [],
className,
field,
chain,
}: CurrencySelectorProps<TFieldValues>) {
const { chainId } = chain || {};
const helperCurrencies =
defaultCurrencies.length > 0
? defaultCurrencies
: chainId
? CURRENCIES[chainId] || []
: [];
const [isAddingCurrency, setIsAddingCurrency] = useState(false);
const [editCustomCurrency, setEditCustomCurrency] = useState("");
const [customCurrency, setCustomCurrency] = useState("");
const [initialValue] = useState(field.value);
const isCustomCurrency: boolean = useMemo(() => {
if (initialValue && chainId && initialValue !== customCurrency) {
if (chainId in CURRENCIES) {
return !CURRENCIES[chainId]?.find(
(currency: CurrencyMetadata) =>
currency.address.toLowerCase() === initialValue.toLowerCase(),
);
}
// for non-default chains
return true;
}
return false;
}, [chainId, customCurrency, initialValue]);
const currencyOptions: CurrencyMetadata[] = [
...(isPaymentsSelector
? []
: [
{
address: NATIVE_TOKEN_ADDRESS.toLowerCase(),
name: chain?.nativeCurrency.name || "Native Token",
symbol: chain?.nativeCurrency.symbol || "",
},
]),
...(hideDefaultCurrencies ? [] : helperCurrencies),
];
const addCustomCurrency = () => {
if (!isAddress(editCustomCurrency)) {
return;
}
if (editCustomCurrency) {
setCustomCurrency(editCustomCurrency);
if (field.onChange) {
field.onChange({
target: { value: editCustomCurrency },
// biome-ignore lint/suspicious/noExplicitAny: FIXME
} as any);
}
} else {
setEditCustomCurrency(customCurrency);
}
setIsAddingCurrency(false);
setEditCustomCurrency("");
};
if (isAddingCurrency && !hideDefaultCurrencies) {
return (
<div className="flex items-center">
<Button
className="rounded-r-none rounded-l-lg"
onClick={() => setIsAddingCurrency(false)}
>
&lt;-
</Button>
<Input
{...field}
className="w-full rounded-none"
required
placeholder="ERC20 Address"
value={editCustomCurrency}
onChange={(e) => setEditCustomCurrency(e.target.value)}
/>
<Button
className="rounded-r-lg rounded-l-none"
onClick={addCustomCurrency}
disabled={!isAddress(editCustomCurrency)}
>
Save
</Button>
</div>
);
}
return (
<div className={cn("flex flex-col", className)}>
<Select
onValueChange={(value) =>
value === "custom" ? setIsAddingCurrency(true) : field.onChange(value)
}
value={
isPaymentsSelector
? field.value
: field.value?.toLowerCase() === ZERO_ADDRESS.toLowerCase()
? NATIVE_TOKEN_ADDRESS.toLowerCase()
: field.value?.toLowerCase()
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select Currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
{chainId &&
!hideDefaultCurrencies &&
currencyOptions.map((currency: CurrencyMetadata, idx: number) => (
<SelectItem
key={`${currency.address}-${idx}`}
value={
isPaymentsSelector
? currency.symbol
: currency.address.toLowerCase()
}
>
{currency.symbol} ({currency.name})
</SelectItem>
))}
{isCustomCurrency &&
!isPaymentsSelector &&
initialValue !== NATIVE_TOKEN_ADDRESS.toLowerCase() && (
<SelectItem key={initialValue} value={initialValue}>
{initialValue}
</SelectItem>
)}
{customCurrency && (
<SelectItem
key={customCurrency}
value={customCurrency.toLowerCase()}
>
{customCurrency}
</SelectItem>
)}
{!hideDefaultCurrencies && showCustomCurrency && (
<SelectItem value="custom">Custom ERC20</SelectItem>
)}
</SelectContent>
</Select>
</div>
);
}
@@ -6,6 +6,7 @@ import { CircleSlash } from "lucide-react";
import type { ContractOptions } from "thirdweb";
import type { Account } from "thirdweb/wallets";
import { ModuleCard } from "./module-card";
import { useAllModuleContractInfo } from "./moduleContractInfo";
export const InstalledModulesTable = (props: {
contract: ContractOptions;
@@ -18,6 +19,11 @@ export const InstalledModulesTable = (props: {
}) => {
const { installedModules, ownerAccount } = props;
const allModuleContractInfo = useAllModuleContractInfo(
installedModules.data || [],
props.contract,
);
const sectionTitle = (
<h2 className="mb-3 font-bold text-2xl tracking-tight">
Installed Modules
@@ -47,6 +53,7 @@ export const InstalledModulesTable = (props: {
<ModuleCard
key={moduleAddress}
moduleAddress={moduleAddress}
allModuleContractInfo={allModuleContractInfo?.data || []}
contract={props.contract}
onRemoveModule={props.refetchModules}
ownerAccount={ownerAccount}
@@ -0,0 +1,531 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertTitle } from "@/components/ui/alert";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTxNotifications } from "hooks/useTxNotifications";
import { CircleAlertIcon } from "lucide-react";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import {
type PreparedTransaction,
prepareContractCall,
sendAndConfirmTransaction,
} from "thirdweb";
import { MintableERC721, MintableERC1155 } from "thirdweb/modules";
import { useReadContract } from "thirdweb/react";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
import { z } from "zod";
import { addressSchema } from "../zod-schemas";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
import type { ModuleInstanceProps } from "./module-instance";
import { AdvancedNFTMetadataFormGroup } from "./nft/AdvancedNFTMetadataFormGroup";
import { NFTMediaFormGroup } from "./nft/NFTMediaFormGroup";
import { PropertiesFormControl } from "./nft/PropertiesFormControl";
export type UpdateFormValues = {
primarySaleRecipient: string;
};
export type MintFormValues = NFTMetadataInputLimited & {
useNextTokenId: boolean;
recipient: string;
amount: number;
customImage: string;
customAnimationUrl: string;
attributes: { trait_type: string; value: string }[];
tokenId?: string;
};
const isValidNft = (values: MintFormValues) =>
values.name ||
values.description ||
values.image ||
values.animation_url ||
values.external_url ||
values.customAnimationUrl;
const MINTER_ROLE = 1n;
function MintableModule(props: ModuleInstanceProps) {
const { contract, ownerAccount } = props;
const isErc721 = props.contractInfo.name === "MintableERC721";
const primarySaleRecipientQuery = useReadContract(
isErc721 ? MintableERC721.getSaleConfig : MintableERC1155.getSaleConfig,
{
contract: contract,
},
);
const hasMinterRole = useReadContract({
contract: contract,
method: "function hasAllRoles(address user, uint256 roles) returns (bool)",
params: [ownerAccount?.address || "", MINTER_ROLE],
});
const isBatchMetadataInstalled = !!props.allModuleContractInfo.find(
(module) => module.name.includes("BatchMetadata"),
);
const mint = useCallback(
async (values: MintFormValues) => {
const nft = isValidNft(values) ? parseAttributes(values) : "";
if (!ownerAccount) {
throw new Error("Not an owner account");
}
if (!hasMinterRole.data) {
const grantRoleTx = prepareContractCall({
contract,
method: "function grantRole(address user, uint256 role) public",
params: [ownerAccount.address || "", MINTER_ROLE],
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: grantRoleTx,
});
}
let mintTx: PreparedTransaction;
if (isErc721) {
mintTx = MintableERC721.mintWithRole({
contract,
to: values.recipient,
nfts: [nft],
});
} else if (values.useNextTokenId || values.tokenId) {
mintTx = MintableERC1155.mintWithRole({
contract,
to: values.recipient,
amount: BigInt(values.amount),
tokenId: values.tokenId ? BigInt(values.tokenId) : undefined,
nft,
});
} else {
throw new Error("Invalid token ID");
}
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: mintTx,
});
},
[contract, ownerAccount, isErc721, hasMinterRole.data],
);
const update = useCallback(
async (values: UpdateFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
const setSaleConfig = isErc721
? MintableERC721.setSaleConfig
: MintableERC1155.setSaleConfig;
const setSaleConfigTx = setSaleConfig({
contract: contract,
primarySaleRecipient: values.primarySaleRecipient,
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: setSaleConfigTx,
});
},
[contract, ownerAccount, isErc721],
);
return (
<MintableModuleUI
{...props}
isPending={primarySaleRecipientQuery.isPending}
primarySaleRecipient={primarySaleRecipientQuery.data}
updatePrimaryRecipient={update}
mint={mint}
isOwnerAccount={!!ownerAccount}
isErc721={isErc721}
isBatchMetadataInstalled={isBatchMetadataInstalled}
contractChainId={contract.chain.id}
/>
);
}
export function MintableModuleUI(
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
primarySaleRecipient: string | undefined;
isPending: boolean;
isOwnerAccount: boolean;
updatePrimaryRecipient: (values: UpdateFormValues) => Promise<void>;
mint: (values: MintFormValues) => Promise<void>;
isErc721: boolean;
isBatchMetadataInstalled: boolean;
contractChainId: number;
},
) {
return (
<ModuleCardUI {...props}>
<div className="h-1" />
{props.isPending && <Skeleton className="h-[74px]" />}
{!props.isPending && (
<div className="flex flex-col gap-4">
{/* Mint NFT */}
<Accordion type="single" collapsible className="-mx-1">
<AccordionItem value="metadata" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Mint NFT
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount && (
<MintNFTSection
mint={props.mint}
isErc721={props.isErc721}
isBatchMetadataInstalled={props.isBatchMetadataInstalled}
contractChainId={props.contractChainId}
/>
)}
{!props.isOwnerAccount && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to mint NFTs on this contract
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem
value="primary-sale-recipient"
className="border-none "
>
<AccordionTrigger className="border-border border-t px-1">
Primary Sale Recipient
</AccordionTrigger>
<AccordionContent className="px-1">
<PrimarySalesSection
isOwnerAccount={props.isOwnerAccount}
primarySaleRecipient={props.primarySaleRecipient}
update={props.updatePrimaryRecipient}
contractChainId={props.contractChainId}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
</ModuleCardUI>
);
}
const primarySaleRecipientFormSchema = z.object({
primarySaleRecipient: addressSchema,
});
function PrimarySalesSection(props: {
primarySaleRecipient: string | undefined;
update: (values: UpdateFormValues) => Promise<void>;
isOwnerAccount: boolean;
contractChainId: number;
}) {
const form = useForm<UpdateFormValues>({
resolver: zodResolver(primarySaleRecipientFormSchema),
values: {
primarySaleRecipient: props.primarySaleRecipient ?? "",
},
});
const updateNotifications = useTxNotifications(
"Successfully updated primary sale recipient",
"Failed to update primary sale recipient",
);
const updateMutation = useMutation({
mutationFn: props.update,
onSuccess: updateNotifications.onSuccess,
onError: updateNotifications.onError,
});
const onSubmit = async () => {
updateMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="primarySaleRecipient"
render={({ field }) => (
<FormItem>
<FormDescription>
The wallet address that should receive the revenue from initial
sales of the assets.
</FormDescription>
<FormControl>
<Input
placeholder="0x..."
{...field}
disabled={!props.isOwnerAccount}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={updateMutation.isPending || !props.isOwnerAccount}
type="submit"
isLoading={updateMutation.isPending}
colorScheme="primary"
transactionCount={1}
txChainID={props.contractChainId}
>
Update
</TransactionButton>
</div>
</form>{" "}
</Form>
);
}
const mintFormSchema = z.object({
useNextTokenId: z.boolean(),
customImage: z.string().optional(),
customAnimationUrl: z.string().optional(),
recipient: addressSchema,
tokenId: z.coerce.number().min(0, { message: "Invalid tokenId" }).optional(),
});
function MintNFTSection(props: {
mint: (values: MintFormValues) => Promise<void>;
isErc721: boolean;
isBatchMetadataInstalled: boolean;
contractChainId: number;
}) {
const form = useForm<MintFormValues>({
resolver: zodResolver(mintFormSchema),
values: {
useNextTokenId: false,
customImage: "",
customAnimationUrl: "",
recipient: "",
attributes: [{ trait_type: "", value: "" }],
amount: 1,
},
reValidateMode: "onChange",
});
const mintNotifications = useTxNotifications(
"Successfully minted NFT",
"Failed to mint NFT",
);
const mintMutation = useMutation({
mutationFn: props.mint,
onSuccess: mintNotifications.onSuccess,
onError: mintNotifications.onError,
});
const onSubmit = async () => {
const values = form.getValues();
if (!props.isErc721 && !values.useNextTokenId && !values.tokenId) {
form.setError("tokenId", { message: "Token ID is required" });
return;
}
mintMutation.mutateAsync(values);
};
const useNextTokenId = form.watch("useNextTokenId");
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
{props.isBatchMetadataInstalled && (
<div className="flex flex-col gap-6 lg:flex-row">
{/* Left */}
<div className="shrink-0 lg:w-[300px]">
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
</div>
{/* Right */}
<div className="flex grow flex-col gap-6">
{/* name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PropertiesFormControl form={form} />
{/* Advanced options */}
<Accordion
type="single"
collapsible={
!(
form.formState.errors.background_color ||
form.formState.errors.external_url
)
}
>
<AccordionItem
value="advanced-options"
className="-mx-1 border-t border-b-0"
>
<AccordionTrigger className="px-1">
Advanced Options
</AccordionTrigger>
<AccordionContent className="px-1">
<AdvancedNFTMetadataFormGroup form={form} />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
)}
<Separator />
{/* Other options */}
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="recipient"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Recipient Address</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!props.isErc721 && (
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Amount</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
)}
{!props.isErc721 && (
<div className="relative flex-1">
<FormField
control={form.control}
name="tokenId"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Token ID</FormLabel>
<FormControl>
<Input {...field} disabled={useNextTokenId} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useNextTokenId"
render={({ field }) => (
<FormItem className="absolute top-0 right-0 flex items-center gap-2">
<CheckboxWithLabel>
<Checkbox
className="mt-0"
checked={field.value}
onCheckedChange={field.onChange}
/>
Use next token ID
</CheckboxWithLabel>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<div className="flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={mintMutation.isPending}
type="submit"
isLoading={mintMutation.isPending}
colorScheme="primary"
txChainID={props.contractChainId}
transactionCount={1}
>
Mint
</TransactionButton>
</div>
</div>
</form>
</Form>
);
}
export default MintableModule;
@@ -61,6 +61,7 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
const client = useThirdwebClient();
const form = useForm<FormData>({
defaultValues: {
publisherAddress: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024", // thirdweb publisher address
version: "latest",
},
});
@@ -0,0 +1,229 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTxNotifications } from "hooks/useTxNotifications";
import { CircleAlertIcon } from "lucide-react";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { sendAndConfirmTransaction } from "thirdweb";
import { OpenEditionMetadataERC721 } from "thirdweb/modules";
import { z } from "zod";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
import type { ModuleInstanceProps } from "./module-instance";
const setSharedMetadataFormSchema = z.object({
name: z.string().min(1),
description: z.string().min(1),
imageUri: z.string().optional(),
animationUri: z.string().optional(),
});
export type SetSharedMetadataFormValues = z.infer<
typeof setSharedMetadataFormSchema
>;
function OpenEditionMetadataModule(props: ModuleInstanceProps) {
const { contract, ownerAccount } = props;
const setSharedMetadata = useCallback(
async (values: SetSharedMetadataFormValues) => {
if (!ownerAccount) {
throw new Error("Not an Owner account");
}
const setSharedMetadataTx = OpenEditionMetadataERC721.setSharedMetadata({
contract,
metadata: {
name: values.name,
description: values.description,
imageURI: values.imageUri || "",
animationURI: values.animationUri || "",
},
});
await sendAndConfirmTransaction({
transaction: setSharedMetadataTx,
account: ownerAccount,
});
},
[contract, ownerAccount],
);
return (
<OpenEditionMetadataModuleUI
{...props}
setSharedMetadata={setSharedMetadata}
isOwnerAccount={!!props.ownerAccount}
contractChainId={props.contract.chain.id}
/>
);
}
export function OpenEditionMetadataModuleUI(
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
isOwnerAccount: boolean;
setSharedMetadata: (values: SetSharedMetadataFormValues) => Promise<void>;
contractChainId: number;
},
) {
return (
<ModuleCardUI {...props}>
<div className="h-1" />
<Accordion type="single" collapsible className="-mx-1">
<AccordionItem value="metadata" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Set Shared Metadata
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount && (
<SetSharedMetadataSection
setSharedMetadata={props.setSharedMetadata}
contractChainId={props.contractChainId}
/>
)}
{!props.isOwnerAccount && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to set shared metadata on this
contract
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</ModuleCardUI>
);
}
function SetSharedMetadataSection(props: {
setSharedMetadata: (values: SetSharedMetadataFormValues) => Promise<void>;
contractChainId: number;
}) {
const form = useForm<SetSharedMetadataFormValues>({
resolver: zodResolver(setSharedMetadataFormSchema),
values: {
name: "",
description: "",
imageUri: "",
animationUri: "",
},
reValidateMode: "onChange",
});
const setSharedMetadataNotifications = useTxNotifications(
"Successfully set shared metadata",
"Failed to set shared metadata",
);
const setSharedMetadataMutation = useMutation({
mutationFn: props.setSharedMetadata,
onSuccess: setSharedMetadataNotifications.onSuccess,
onError: setSharedMetadataNotifications.onError,
});
const onSubmit = async () => {
setSharedMetadataMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="imageUri"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Image URI</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="animationUri"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Animation URI</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={setSharedMetadataMutation.isPending}
type="submit"
isLoading={setSharedMetadataMutation.isPending}
colorScheme="primary"
transactionCount={1}
txChainID={props.contractChainId}
>
Update
</TransactionButton>
</div>
</div>
</form>
</Form>
);
}
export default OpenEditionMetadataModule;
@@ -0,0 +1,525 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTxNotifications } from "hooks/useTxNotifications";
import { CircleAlertIcon } from "lucide-react";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { sendAndConfirmTransaction } from "thirdweb";
import { RoyaltyERC721, RoyaltyERC1155 } from "thirdweb/modules";
import { useReadContract } from "thirdweb/react";
import { z } from "zod";
import { addressSchema } from "../zod-schemas";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
import type { ModuleInstanceProps } from "./module-instance";
function RoyaltyModule(props: ModuleInstanceProps) {
const { contract, ownerAccount } = props;
const isErc721 = props.contractInfo.name === "RoyaltyERC721";
const defaultRoyaltyInfoQuery = useReadContract(
isErc721
? RoyaltyERC721.getDefaultRoyaltyInfo
: RoyaltyERC1155.getDefaultRoyaltyInfo,
{
contract: contract,
},
);
const transferValidatorQuery = useReadContract(
isErc721
? RoyaltyERC721.getTransferValidator
: RoyaltyERC1155.getTransferValidator,
{
contract: contract,
},
);
const setRoyaltyInfoForToken = useCallback(
async (values: RoyaltyInfoFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
const setRoyaltyInfoForToken = isErc721
? RoyaltyERC721.setRoyaltyInfoForToken
: RoyaltyERC1155.setRoyaltyInfoForToken;
const setRoyaltyForTokenTx = setRoyaltyInfoForToken({
contract: contract,
recipient: values.recipient,
bps: Number(values.bps),
tokenId: BigInt(values.tokenId),
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: setRoyaltyForTokenTx,
});
},
[contract, ownerAccount, isErc721],
);
const setTransferValidator = useCallback(
async (values: TransferValidatorFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
if (
values.transferValidator &&
values.transferValidator !== transferValidatorQuery.data
) {
const setTransferValidator = isErc721
? RoyaltyERC721.setTransferValidator
: RoyaltyERC1155.setTransferValidator;
const setTransferValidatorTx = setTransferValidator({
contract: contract,
validator: values.transferValidator,
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: setTransferValidatorTx,
});
}
},
[contract, ownerAccount, transferValidatorQuery.data, isErc721],
);
const setDefaultRoyaltyInfo = useCallback(
async (values: DefaultRoyaltyFormValues) => {
if (!ownerAccount) {
throw new Error("Not an owner account");
}
const [defaultRoyaltyRecipient, defaultRoyaltyBps] =
defaultRoyaltyInfoQuery.data || [];
if (
values.recipient &&
values.bps &&
(values.recipient !== defaultRoyaltyRecipient ||
Number(values.bps) !== defaultRoyaltyBps)
) {
const setDefaultRoyaltyInfo = isErc721
? RoyaltyERC721.setDefaultRoyaltyInfo
: RoyaltyERC1155.setDefaultRoyaltyInfo;
const setSaleConfigTx = setDefaultRoyaltyInfo({
contract: contract,
royaltyRecipient: values.recipient,
royaltyBps: Number(values.bps),
});
await sendAndConfirmTransaction({
account: ownerAccount,
transaction: setSaleConfigTx,
});
}
},
[contract, ownerAccount, defaultRoyaltyInfoQuery.data, isErc721],
);
return (
<RoyaltyModuleUI
{...props}
isPending={
transferValidatorQuery.isPending || defaultRoyaltyInfoQuery.isPending
}
transferValidator={transferValidatorQuery.data}
defaultRoyaltyInfo={defaultRoyaltyInfoQuery.data}
setDefaultRoyaltyInfo={setDefaultRoyaltyInfo}
setTransferValidator={setTransferValidator}
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
isOwnerAccount={!!ownerAccount}
contractChainId={props.contract.chain.id}
/>
);
}
export function RoyaltyModuleUI(
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
isPending: boolean;
isOwnerAccount: boolean;
defaultRoyaltyInfo?: readonly [string, number];
transferValidator?: string;
setDefaultRoyaltyInfo: (values: DefaultRoyaltyFormValues) => Promise<void>;
setTransferValidator: (
values: TransferValidatorFormValues,
) => Promise<void>;
setRoyaltyInfoForToken: (values: RoyaltyInfoFormValues) => Promise<void>;
contractChainId: number;
},
) {
return (
<ModuleCardUI {...props}>
<div className="h-1" />
{props.isPending && <Skeleton className="h-[74px]" />}
{!props.isPending && (
<Accordion type="single" collapsible className="-mx-1">
<AccordionItem value="royaltyPerToken" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Royalty Info Per Token
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount ? (
<RoyaltyInfoPerTokenSection
setRoyaltyInfoForToken={props.setRoyaltyInfoForToken}
contractChainId={props.contractChainId}
/>
) : (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to set royalty info per token
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="default-royalty-info" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Default Royalty Info
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount ? (
<DefaultRoyaltyInfoSection
update={props.setDefaultRoyaltyInfo}
defaultRoyaltyInfo={props.defaultRoyaltyInfo}
contractChainId={props.contractChainId}
/>
) : (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to set default royalty info
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="transfer-validator" className="border-none">
<AccordionTrigger className="border-border border-t px-1">
Transfer Validator
</AccordionTrigger>
<AccordionContent className="px-1">
{props.isOwnerAccount ? (
<TransferValidatorSection
update={props.setTransferValidator}
transferValidator={props.transferValidator}
contractChainId={props.contractChainId}
/>
) : (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>
You don't have permission to set transfer validator
</AlertTitle>
</Alert>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</ModuleCardUI>
);
}
const royaltyInfoFormSchema = z.object({
tokenId: z.string().refine((v) => v.length > 0 && Number(v) >= 0, {
message: "Invalid tokenId",
}),
recipient: addressSchema,
bps: z
.string()
.min(1, { message: "Invalid BPS" })
.refine((v) => Number(v) >= 0, { message: "Invalid BPS" }),
});
export type RoyaltyInfoFormValues = z.infer<typeof royaltyInfoFormSchema>;
function RoyaltyInfoPerTokenSection(props: {
setRoyaltyInfoForToken: (values: RoyaltyInfoFormValues) => Promise<void>;
contractChainId: number;
}) {
const form = useForm<RoyaltyInfoFormValues>({
resolver: zodResolver(royaltyInfoFormSchema),
values: {
tokenId: "",
recipient: "",
bps: "",
},
reValidateMode: "onChange",
});
const setRoyaltyInfoForTokenNotifications = useTxNotifications(
"Successfully set royalty info for token",
"Failed to set royalty info for token",
);
const setRoyaltyInfoForTokenMutation = useMutation({
mutationFn: props.setRoyaltyInfoForToken,
onSuccess: setRoyaltyInfoForTokenNotifications.onSuccess,
onError: setRoyaltyInfoForTokenNotifications.onError,
});
const onSubmit = async () => {
setRoyaltyInfoForTokenMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="tokenId"
render={({ field }) => (
<FormItem>
<FormLabel>Token ID</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recipient"
render={({ field }) => (
<FormItem>
<FormLabel>Recipient</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bps"
render={({ field }) => (
<FormItem>
<FormLabel>BPS</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={setRoyaltyInfoForTokenMutation.isPending}
type="submit"
isLoading={setRoyaltyInfoForTokenMutation.isPending}
colorScheme="primary"
transactionCount={1}
txChainID={props.contractChainId}
>
Update
</TransactionButton>
</div>
</div>
</form>
</Form>
);
}
const defaultRoyaltyFormSchema = z.object({
recipient: addressSchema,
bps: z.string().refine((v) => v.length === 0 || Number(v) >= 0, {
message: "Invalid BPS",
}),
});
export type DefaultRoyaltyFormValues = z.infer<typeof defaultRoyaltyFormSchema>;
function DefaultRoyaltyInfoSection(props: {
defaultRoyaltyInfo?: readonly [string, number];
update: (values: DefaultRoyaltyFormValues) => Promise<void>;
contractChainId: number;
}) {
const [defaultRoyaltyRecipient, defaultRoyaltyBps] =
props.defaultRoyaltyInfo || [];
const form = useForm<DefaultRoyaltyFormValues>({
resolver: zodResolver(defaultRoyaltyFormSchema),
values: {
recipient: defaultRoyaltyRecipient || "",
bps: defaultRoyaltyBps ? String(defaultRoyaltyBps) : "",
},
reValidateMode: "onChange",
});
const updateNotifications = useTxNotifications(
"Successfully set default royalty info",
"Failed to update default royalty info",
);
const updateMutation = useMutation({
mutationFn: props.update,
onSuccess: updateNotifications.onSuccess,
onError: updateNotifications.onError,
});
const onSubmit = async () => {
updateMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="recipient"
render={({ field }) => (
<FormItem>
<FormLabel>Default Royalty Recipient</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bps"
render={({ field }) => (
<FormItem>
<FormLabel>Default Royalty BPS</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 flex justify-end">
<TransactionButton
size="sm"
className="min-w-24"
disabled={updateMutation.isPending}
type="submit"
colorScheme="primary"
transactionCount={1}
isLoading={updateMutation.isPending}
txChainID={props.contractChainId}
>
Update
</TransactionButton>
</div>
</div>
</form>
</Form>
);
}
const transferValidatorFormSchema = z.object({
transferValidator: addressSchema,
});
export type TransferValidatorFormValues = z.infer<
typeof transferValidatorFormSchema
>;
function TransferValidatorSection(props: {
transferValidator: string | undefined;
update: (values: TransferValidatorFormValues) => Promise<void>;
contractChainId: number;
}) {
const form = useForm<TransferValidatorFormValues>({
resolver: zodResolver(transferValidatorFormSchema),
values: {
transferValidator: props.transferValidator || "",
},
reValidateMode: "onChange",
});
const updateNotifications = useTxNotifications(
"Successfully set transfer validator",
"Failed to set transfer validator",
);
const updateMutation = useMutation({
mutationFn: props.update,
onSuccess: updateNotifications.onSuccess,
onError: updateNotifications.onError,
});
const onSubmit = async () => {
updateMutation.mutateAsync(form.getValues());
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="h-1" />
<FormField
control={form.control}
name="transferValidator"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Transfer Validator</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="h-2" />
<div className="mt-4 flex justify-end">
<TransactionButton
size="sm"
className="min-w-24 gap-2"
disabled={updateMutation.isPending}
type="submit"
colorScheme="primary"
isLoading={updateMutation.isPending}
transactionCount={1}
txChainID={props.contractChainId}
>
Update
</TransactionButton>
</div>
</form>
</Form>
);
}
export default RoyaltyModule;
@@ -0,0 +1,333 @@
"use client";
import { Alert, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useCallback } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { sendAndConfirmTransaction } from "thirdweb";
import { TransferableERC721, TransferableERC1155 } from "thirdweb/modules";
import { useReadContract } from "thirdweb/react";
import { z } from "zod";
import { addressSchema } from "../zod-schemas";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
import type { ModuleInstanceProps } from "./module-instance";
const formSchema = z.object({
allowList: z.array(
z.object({
address: addressSchema,
}),
),
isRestricted: z.boolean(),
});
export type TransferableModuleFormValues = z.infer<typeof formSchema>;
function TransferableModule(props: ModuleInstanceProps) {
const { contract, ownerAccount } = props;
const isErc721 = props.contractInfo.name === "TransferableERC721";
const isTransferEnabledQuery = useReadContract(
isErc721
? TransferableERC721.isTransferEnabled
: TransferableERC1155.isTransferEnabled,
{
contract,
},
);
const update = useCallback(
async (values: TransferableModuleFormValues) => {
if (!ownerAccount) {
throw new Error("Not an Owner account");
}
// isRestricted is the opposite of transferEnabled
const transferEnabled = !values.isRestricted;
if (isTransferEnabledQuery.data !== transferEnabled) {
const setTransferable = isErc721
? TransferableERC721.setTransferable
: TransferableERC1155.setTransferable;
const setTransferableTx = setTransferable({
contract,
enableTransfer: transferEnabled,
});
await sendAndConfirmTransaction({
transaction: setTransferableTx,
account: ownerAccount,
});
}
await Promise.all(
values.allowList.map(async ({ address }) => {
const setTransferableFor = isErc721
? TransferableERC721.setTransferableFor
: TransferableERC1155.setTransferableFor;
const setTransferableForTx = setTransferableFor({
contract,
enableTransfer: true,
target: address,
});
await sendAndConfirmTransaction({
transaction: setTransferableForTx,
account: ownerAccount,
});
}),
);
},
[contract, ownerAccount, isTransferEnabledQuery.data, isErc721],
);
return (
<TransferableModuleUI
{...props}
isPending={isTransferEnabledQuery.isPending}
isRestricted={!!isTransferEnabledQuery.data}
adminAddress={props.ownerAccount?.address || ""}
update={update}
isOwnerAccount={!!props.ownerAccount}
contractChainId={props.contract.chain.id}
/>
);
}
export function TransferableModuleUI(
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
// allowList: string[]; // TODO: add this back in when we have a way to get the list of addresses
isRestricted: boolean;
isPending: boolean;
adminAddress: string;
isOwnerAccount: boolean;
update: (values: TransferableModuleFormValues) => Promise<void>;
contractChainId: number;
},
) {
const form = useForm<TransferableModuleFormValues>({
resolver: zodResolver(formSchema),
values: {
allowList: [],
isRestricted: props.isRestricted,
},
reValidateMode: "onChange",
});
const updateMutation = useMutation({
mutationFn: props.update,
});
const formFields = useFieldArray({
control: form.control,
name: "allowList",
});
const onSubmit = async () => {
const _values = form.getValues();
const values = { ..._values };
// clear the allowlist if no restrictions
if (!_values.isRestricted) {
values.allowList = [];
}
const promise = updateMutation.mutateAsync(values);
toast.promise(promise, {
success: "Successfully updated transfer restrictions",
error: (error) => `Failed to update transfer restrictions: ${error}`,
});
};
const isRestricted = form.watch("isRestricted");
const allowListLength = form.watch("allowList").length;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<ModuleCardUI
{...props}
updateButton={() => {
return (
<TransactionButton
size="sm"
className="min-w-24 gap-2"
type="submit"
disabled={
props.isPending ||
!props.isOwnerAccount ||
!form.formState.isDirty
}
isLoading={updateMutation.isPending}
colorScheme="primary"
transactionCount={
// if already restricted, only need to send the allowlist txs
// else - need to send one more
(props.isRestricted ? 0 : 1) + allowListLength
}
txChainID={props.contractChainId}
>
Update
</TransactionButton>
);
}}
>
{props.isPending && <Skeleton className="h-[90px]" />}
{!props.isPending && (
<div>
<div className="flex items-center gap-4">
{/* Switch */}
<FormField
control={form.control}
name="isRestricted"
render={({ field }) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { value, ...restField } = field;
return (
<FormItem className="flex items-center gap-3">
<FormLabel>Restrict Transfers</FormLabel>
<FormControl>
<Switch
{...restField}
checked={field.value}
disabled={!props.isOwnerAccount}
className="!m-0"
onCheckedChange={(v) => {
field.onChange(v);
// if enabling restrictions and allowlist is empty, add the admin address by default
if (
v === true &&
formFields.fields.length === 0
) {
formFields.append({
address: props.adminAddress || "",
});
}
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<div className="h-3" />
{isRestricted && (
<div className="w-full">
{/* Warning - TODO add later */}
{/* {formFields.fields.length === 0 && (
<Alert variant="warning">
<CircleAlertIcon className="size-5 max-sm:hidden" />
<AlertTitle className="max-sm:!pl-0">
Nobody has permission to transfer tokens on this
contract
</AlertTitle>
</Alert>
)} */}
<div className="flex flex-col gap-3">
{/* Addresses */}
{formFields.fields.map((fieldItem, index) => (
<div
className="flex items-start gap-3"
key={fieldItem.id}
>
<FormField
control={form.control}
name={`allowList.${index}.address`}
render={({ field }) => (
<FormItem className="grow">
<FormControl>
<Input
placeholder="0x..."
{...field}
disabled={!props.isOwnerAccount}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ToolTipLabel label="Remove address">
<Button
variant="outline"
className="!text-destructive-text bg-background"
onClick={() => {
formFields.remove(index);
}}
disabled={!props.isOwnerAccount}
>
<Trash2Icon className="size-4" />
</Button>
</ToolTipLabel>
</div>
))}
</div>
<div className="h-3" />
{/* Add Addresses Actions */}
<div className="flex gap-3">
<Button
variant="outline"
size="sm"
onClick={() => {
// add admin by default if adding the first input
formFields.append({
address:
formFields.fields.length === 0
? props.adminAddress
: "",
});
}}
className="gap-2"
disabled={!props.isOwnerAccount}
>
<PlusIcon className="size-3" />
Add Address
</Button>
</div>
</div>
)}
{!isRestricted && (
<Alert variant="info">
<CircleAlertIcon className="size-5 max-sm:hidden" />
<AlertTitle className="max-sm:!pl-0">
Transferring tokens in this contract is not restricted.
Everyone is free to transfer their tokens.
</AlertTitle>
</Alert>
)}
</div>
)}
</ModuleCardUI>
</form>
</Form>
);
}
export default TransferableModule;
@@ -0,0 +1,144 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster, toast } from "sonner";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ZERO_ADDRESS } from "thirdweb";
import { ThirdwebProvider } from "thirdweb/react";
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
import {
ErrorProvider,
type TransactionError,
} from "../../../../../../../contexts/error-handler";
import {
BatchMetadataModuleUI,
type UploadMetadataFormValues,
} from "./BatchMetadata";
const meta = {
title: "Modules/BatchMetadata",
component: Component,
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Component() {
const [isOwner, setIsOwner] = useState(true);
async function uploadMetadataStub(values: UploadMetadataFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
const error = new Error("Upload failed");
(error as TransactionError).reason = "This is a test reason error";
(error as TransactionError).info = {
from: ZERO_ADDRESS,
to: ZERO_ADDRESS,
network: {
name: "test",
chainId: 1,
},
};
throw error;
}
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
onSuccess() {
toast.success("Module removed successfully");
},
});
const contractInfo = {
name: "Module Name",
description:
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
version: "1.0.0",
};
// TODO - remove ChakraProviderSetup after converting the TransactionButton to tailwind+shadcn
return (
<ThirdwebProvider>
<ChakraProviderSetup>
<ErrorProvider>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<div>
<CustomConnectWallet loginRequired={false} />
</div>
<div className="flex items-center gap-5">
<CheckboxWithLabel
value={isOwner}
onChange={setIsOwner}
id="isOwner"
label="Is Owner"
/>
</div>
<BadgeContainer label="Default">
<BatchMetadataModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
uploadMetadata={uploadMetadataStub}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<Toaster richColors />
</div>
</ErrorProvider>
</ChakraProviderSetup>
</ThirdwebProvider>
);
}
function CheckboxWithLabel(props: {
value: boolean;
onChange: (value: boolean) => void;
id: string;
label: string;
}) {
return (
<div className="items-top flex space-x-2">
<Checkbox
id={props.id}
checked={props.value}
onCheckedChange={(v) => props.onChange(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={props.id}
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{props.label}
</label>
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { subDays } from "date-fns";
import { useState } from "react";
import { Toaster, toast } from "sonner";
import { mobileViewport } from "stories/utils";
import { NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb";
import { ThirdwebProvider } from "thirdweb/react";
import { checksumAddress } from "thirdweb/utils";
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
import {
type ClaimConditionFormValues,
type ClaimConditionValue,
ClaimableModuleUI,
type MintFormValues,
type PrimarySaleRecipientFormValues,
} from "./Claimable";
const meta = {
title: "Modules/Claimable",
component: Component,
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
const claimCondition = {
availableSupply: BigInt(100),
maxMintPerWallet: BigInt(10),
pricePerUnit: 10n,
// we get checksummed NATIVE_TOKEN_ADDRESS from claim condition query for native token
currency: checksumAddress(NATIVE_TOKEN_ADDRESS),
// last week
startTimestamp: subDays(new Date(), 7).getTime() / 1000,
endTimestamp: new Date().getTime() / 1000,
allowlistMerkleRoot: ZERO_ADDRESS,
auxData: "0x",
} as ClaimConditionValue;
function Component() {
const [isOwner, setIsOwner] = useState(true);
const [isErc721, setIsErc721] = useState(false);
const [isClaimConditionLoading, setIsClaimConditionLoading] = useState(false);
const [isPrimarySaleRecipientLoading, setIsPrimarySaleRecipientLoading] =
useState(false);
async function updatePrimarySaleRecipientStub(
values: PrimarySaleRecipientFormValues,
) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async function updateClaimConditionStub(values: ClaimConditionFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async function mintStub(values: MintFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async function getClaimConditionErc1155Stub() {
console.log("claim condition in stub: ", claimCondition);
await new Promise((resolve) => setTimeout(resolve, 1500));
return claimCondition;
}
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
onSuccess() {
toast.success("Module removed successfully");
},
});
const contractInfo = {
name: "Module Name",
description:
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
version: "1.0.0",
};
// Todo - remove chakra provider after converting Transaction Button
return (
<ChakraProviderSetup>
<ThirdwebProvider>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<div>
<CustomConnectWallet loginRequired={false} />
</div>
<div className="flex flex-wrap items-center gap-5">
<CheckboxWithLabel
value={isOwner}
onChange={setIsOwner}
id="isOwner"
label="Is Owner"
/>
<CheckboxWithLabel
value={isErc721}
onChange={setIsErc721}
id="isErc721"
label="isErc721"
/>
<CheckboxWithLabel
value={isClaimConditionLoading}
onChange={setIsClaimConditionLoading}
id="isClaimConditionLoading"
label="Claim Condition Section Loading"
/>
<CheckboxWithLabel
value={isPrimarySaleRecipientLoading}
onChange={setIsPrimarySaleRecipientLoading}
id="isPrimarySaleRecipientLoading"
label="Primary Sale Recipient Section Loading"
/>
</div>
<ClaimableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
primarySaleRecipientSection={{
data: isPrimarySaleRecipientLoading
? undefined
: {
primarySaleRecipient: testAddress1,
},
setPrimarySaleRecipient: updatePrimarySaleRecipientStub,
}}
claimConditionSection={{
data: isClaimConditionLoading
? undefined
: {
claimCondition,
tokenDecimals: 18,
},
setClaimCondition: updateClaimConditionStub,
getClaimConditionErc1155: getClaimConditionErc1155Stub,
}}
mintSection={{
mint: mintStub,
}}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
isErc721={isErc721}
contractChainId={1}
/>
<Toaster richColors />
</div>
</ThirdwebProvider>
</ChakraProviderSetup>
);
}
function CheckboxWithLabel(props: {
value: boolean;
onChange: (value: boolean) => void;
id: string;
label: string;
}) {
return (
<div className="items-top flex space-x-2">
<Checkbox
id={props.id}
checked={props.value}
onCheckedChange={(v) => props.onChange(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={props.id}
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{props.label}
</label>
</div>
</div>
);
}
@@ -0,0 +1,195 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster, toast } from "sonner";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ThirdwebProvider } from "thirdweb/react";
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
import {
type MintFormValues,
MintableModuleUI,
type UpdateFormValues,
} from "./Mintable";
const meta = {
title: "Modules/Mintable",
component: Component,
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
function Component() {
const [isOwner, setIsOwner] = useState(true);
const [isErc721, setIsErc721] = useState(false);
const [isBatchMetadataInstalled, setIsBatchMetadataInstalled] =
useState(false);
async function updatePrimaryRecipientStub(values: UpdateFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async function mintStub(values: MintFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
onSuccess() {
toast.success("Module removed successfully");
},
});
const contractInfo = {
name: "Module Name",
description:
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
version: "1.0.0",
};
// TODO - remove ChakraProviderSetup after converting the TransactionButton to tailwind+shadcn
return (
<ChakraProviderSetup>
<ThirdwebProvider>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<div>
<CustomConnectWallet loginRequired={false} />
</div>
<div className="flex flex-wrap items-center gap-5">
<CheckboxWithLabel
value={isOwner}
onChange={setIsOwner}
id="isOwner"
label="Is Owner"
/>
<CheckboxWithLabel
value={isErc721}
onChange={setIsErc721}
id="isErc721"
label="isErc721"
/>
<CheckboxWithLabel
value={isBatchMetadataInstalled}
onChange={setIsBatchMetadataInstalled}
id="isBatchMetadataInstalled"
label="isBatchMetadataInstalled"
/>
</div>
<BadgeContainer label="Empty Primary Sale Recipient">
<MintableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
primarySaleRecipient={""}
updatePrimaryRecipient={updatePrimaryRecipientStub}
mint={mintStub}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
isErc721={isErc721}
isBatchMetadataInstalled={isBatchMetadataInstalled}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Filled Primary Sale Recipient">
<MintableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
primarySaleRecipient={testAddress1}
updatePrimaryRecipient={updatePrimaryRecipientStub}
mint={mintStub}
uninstallButton={{
onClick: () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
isErc721={isErc721}
isBatchMetadataInstalled={isBatchMetadataInstalled}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Pending">
<MintableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={true}
primarySaleRecipient={testAddress1}
updatePrimaryRecipient={updatePrimaryRecipientStub}
mint={mintStub}
uninstallButton={{
onClick: () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
isErc721={isErc721}
isBatchMetadataInstalled={isBatchMetadataInstalled}
contractChainId={1}
/>
</BadgeContainer>
<Toaster richColors />
</div>
</ThirdwebProvider>
</ChakraProviderSetup>
);
}
function CheckboxWithLabel(props: {
value: boolean;
onChange: (value: boolean) => void;
id: string;
label: string;
}) {
return (
<div className="items-top flex space-x-2">
<Checkbox
id={props.id}
checked={props.value}
onCheckedChange={(v) => props.onChange(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={props.id}
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{props.label}
</label>
</div>
</div>
);
}
@@ -1,5 +1,7 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster } from "sonner";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ThirdwebProvider } from "thirdweb/react";
@@ -28,6 +30,8 @@ export const Mobile: Story = {
};
function Component() {
const [isOwner, setIsOwner] = useState(true);
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -51,7 +55,23 @@ function Component() {
return (
<ThirdwebProvider>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<BadgeContainer label="No Update, No Children, Owner Account">
<div className="flex gap-2">
<Checkbox
id="terms1"
checked={isOwner}
onCheckedChange={(v) => setIsOwner(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="terms1"
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Is Owner
</label>
</div>
</div>
<BadgeContainer label="No Update, No Children">
<ModuleCardUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
@@ -59,23 +79,11 @@ function Component() {
onClick: () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={true}
isOwnerAccount={isOwner}
/>
</BadgeContainer>
<BadgeContainer label="No Update, No Children, Not Owner Account">
<ModuleCardUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
uninstallButton={{
onClick: () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={false}
/>
</BadgeContainer>
<BadgeContainer label="Update Button (disabled), No Children, Owner">
<BadgeContainer label="Update Button (disabled), No Children">
<ModuleCardUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
@@ -88,7 +96,7 @@ function Component() {
isPending: updateMutation.isPending,
onClick: () => updateMutation.mutateAsync(),
}}
isOwnerAccount={true}
isOwnerAccount={isOwner}
/>
</BadgeContainer>
@@ -105,7 +113,7 @@ function Component() {
isPending: updateMutation.isPending,
onClick: () => updateMutation.mutateAsync(),
}}
isOwnerAccount={true}
isOwnerAccount={isOwner}
>
<div className="flex h-36 items-center justify-center rounded-lg bg-muted text-muted-foreground text-sm">
CHILDREN
@@ -1,3 +1,5 @@
"use client";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
@@ -12,10 +14,12 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import * as Sentry from "@sentry/nextjs";
import { useMutation } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { InfoIcon } from "lucide-react";
import { useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
import { toast } from "sonner";
import {
type ContractOptions,
@@ -25,16 +29,23 @@ import {
} from "thirdweb";
import { uninstallModuleByProxy } from "thirdweb/modules";
import type { Account } from "thirdweb/wallets";
import { ModuleInstance } from "./module-instance";
import { useModuleContractInfo } from "./moduleContractInfo";
type ModuleProps = {
export type ModuleCardProps = {
moduleAddress: string;
contract: ContractOptions;
onRemoveModule: () => void;
ownerAccount: Account | undefined;
allModuleContractInfo: {
name: string;
description?: string;
version?: string;
publisher?: string;
}[];
};
export function ModuleCard(props: ModuleProps) {
export function ModuleCard(props: ModuleCardProps) {
const { contract, moduleAddress, ownerAccount } = props;
const [isUninstallModalOpen, setIsUninstallModalOpen] = useState(false);
@@ -83,27 +94,37 @@ export function ModuleCard(props: ModuleProps) {
};
if (!contractInfo) {
return <Skeleton className="h-[190px] w-full border border-border" />;
return <GenericModuleLoadingSkeleton />;
}
return (
<>
<ModuleCardUI
contractInfo={{
name: contractInfo.name,
description: contractInfo.description,
publisher: contractInfo.publisher,
version: contractInfo.version,
}}
isOwnerAccount={!!ownerAccount}
uninstallButton={{
onClick: () => {
setIsUninstallModalOpen(true);
},
isPending: uninstallMutation.isPending,
}}
moduleAddress={moduleAddress}
/>
<Suspense fallback={<GenericModuleLoadingSkeleton />}>
<ErrorBoundary
FallbackComponent={(p) => (
<ModuleErrorBoundary moduleName={contractInfo.name} {...p} />
)}
>
<ModuleInstance
contract={contract}
contractInfo={{
name: contractInfo.name,
description: contractInfo.description,
publisher: contractInfo.publisher,
version: contractInfo.version,
}}
ownerAccount={ownerAccount}
uninstallButton={{
onClick: () => {
setIsUninstallModalOpen(true);
},
isPending: uninstallMutation.isPending,
}}
moduleAddress={moduleAddress}
allModuleContractInfo={props.allModuleContractInfo}
/>
</ErrorBoundary>
</Suspense>
<Dialog
open={isUninstallModalOpen}
@@ -168,11 +189,13 @@ export type ModuleCardUIProps = {
onClick: () => void;
isPending: boolean;
};
updateButton?: {
onClick: () => void;
isPending: boolean;
isDisabled: boolean;
};
updateButton?:
| {
onClick?: () => void;
isPending: boolean;
isDisabled: boolean;
}
| React.FC;
};
export function ModuleCardUI(props: ModuleCardUIProps) {
@@ -270,19 +293,51 @@ export function ModuleCardUI(props: ModuleCardUIProps) {
Uninstall
</Button>
{props.isOwnerAccount && props.updateButton && (
<Button
size="sm"
className="min-w-24 gap-2"
type="submit"
onClick={props.updateButton.onClick}
disabled={props.updateButton.isPending || !props.isOwnerAccount}
>
{props.updateButton.isPending && <Spinner className="size-4" />}
Update
</Button>
{props.isOwnerAccount &&
props.updateButton &&
typeof props.updateButton !== "function" && (
<Button
size="sm"
className="min-w-24 gap-2"
type="submit"
onClick={props.updateButton.onClick}
disabled={props.updateButton.isPending || !props.isOwnerAccount}
>
{props.updateButton.isPending && <Spinner className="size-4" />}
Update
</Button>
)}
{props.isOwnerAccount && typeof props.updateButton === "function" && (
<props.updateButton />
)}
</div>
</section>
);
}
function GenericModuleLoadingSkeleton() {
return <Skeleton className="h-[190px] w-full border border-border" />;
}
function ModuleErrorBoundary(
props: FallbackProps & {
moduleName: string;
},
) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
Sentry.withScope((scope) => {
scope.setTag("component-crashed", "true");
scope.setTag("component-crashed-boundary", "ModuleErrorBoundary");
scope.setLevel("fatal");
Sentry.captureException(props.error);
});
}, [props.error]);
return (
<div className="flex h-[190px] w-full items-center justify-center border border-border">
Failed to render {props.moduleName} module
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { lazy } from "react";
import type { ThirdwebContract } from "thirdweb";
import type { Account } from "thirdweb/wallets";
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
const MintableModule = lazy(() => import("./Mintable"));
const TransferableModule = lazy(() => import("./Transferable"));
const RoyaltyModule = lazy(() => import("./Royalty"));
const BatchMetadataModule = lazy(() => import("./BatchMetadata"));
const ClaimableModule = lazy(() => import("./Claimable"));
const OpenEditionMetadataModule = lazy(() => import("./OpenEditionMetadata"));
export type ModuleInstanceProps = Omit<
ModuleCardUIProps,
"children" | "updateButton" | "isOwnerAccount"
> & {
contract: ThirdwebContract;
ownerAccount: Account | undefined;
allModuleContractInfo: {
name: string;
description?: string;
version?: string;
publisher?: string;
}[];
};
export function ModuleInstance(props: ModuleInstanceProps) {
if (props.contractInfo.name.includes("Transferable")) {
return <TransferableModule {...props} />;
}
if (props.contractInfo.name.includes("Mintable-ignore")) {
return <MintableModule {...props} />;
}
if (props.contractInfo.name.includes("Royalty")) {
return <RoyaltyModule {...props} />;
}
if (props.contractInfo.name.includes("Claimable-ignore")) {
return <ClaimableModule {...props} />;
}
if (props.contractInfo.name.includes("BatchMetadata")) {
return <BatchMetadataModule {...props} />;
}
if (props.contractInfo.name.includes("OpenEditionMetadata")) {
return <OpenEditionMetadataModule {...props} />;
}
return <ModuleCardUI {...props} isOwnerAccount={!!props.ownerAccount} />;
}
@@ -1,9 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { usePublishedContractsFromDeploy } from "components/contract-components/hooks";
import { THIRDWEB_DEPLOYER_ADDRESS } from "constants/addresses";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
import { type ThirdwebContract, getContract } from "thirdweb";
import { getCompilerMetadata } from "thirdweb/contract";
import { useActiveAccount } from "thirdweb/react";
async function getAllModuleContractInfo(
installedModules: string[],
contract: ThirdwebContract,
) {
return Promise.all(
installedModules.map(async (moduleAddress) => {
const moduleContract = getContract({
address: moduleAddress,
chain: contract.chain,
client: contract.client,
});
const moduleMetadata = await getCompilerMetadata(moduleContract);
return moduleMetadata;
}),
);
}
export function useAllModuleContractInfo(
installedModules: string[],
contract: ThirdwebContract,
) {
return useQuery({
queryKey: ["all-module-contract-info", installedModules, contract],
queryFn: () => getAllModuleContractInfo(installedModules || [], contract),
enabled: !!contract,
});
}
export function useModuleContractInfo(contract: ThirdwebContract) {
const address = useActiveAccount()?.address;
@@ -0,0 +1,110 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { UseFormReturn } from "react-hook-form";
import type { NFTInput } from "thirdweb/utils";
type AdvancedNFTMetadataFormGroupValues = {
customImage?: string;
customAnimationUrl?: string;
background_color?: NFTInput["background_color"];
external_url?: NFTInput["external_url"];
};
export function AdvancedNFTMetadataFormGroup<
T extends AdvancedNFTMetadataFormGroupValues,
>(props: {
form: UseFormReturn<T>;
}) {
// T contains all properties of AdvancedNFTMetadataFormGroupValues, so this is a-ok
const form =
props.form as unknown as UseFormReturn<AdvancedNFTMetadataFormGroupValues>;
const externalUrl = form.watch("external_url");
const externalIsTextFile =
externalUrl instanceof File &&
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
return (
<div className="flex flex-col gap-5">
<FormField
control={form.control}
name="background_color"
render={({ field }) => (
<FormItem>
<FormLabel>Background Color</FormLabel>
<FormControl>
{/* # + 6 */}
<Input maxLength={7} {...field} />
</FormControl>
<FormDescription>
Must be a six-character hexadecimal with a pre-pended #.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customImage"
render={({ field }) => (
<FormItem>
<FormLabel>Image URI</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
If you already have your NFT image pre-uploaded, you can set the
URL or URI here.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customAnimationUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Animation URI</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
If you already have your NFT Animation URL preuploaded, you can
set the URL or URI here.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!externalIsTextFile && (
<FormFieldSetup
errorMessage={form.formState.errors.external_url?.message}
htmlFor="external-url"
isRequired={false}
label="External URL"
helperText="This is the URL that will appear below the asset's image on OpenSea and will allow users to leave OpenSea and view the item on your site."
>
<Input
placeholder="https://"
{...form.register("external_url")}
type="url"
/>
</FormFieldSetup>
)}
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { FileInput } from "components/shared/FileInput";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import type { UseFormReturn } from "react-hook-form";
import type { NFTInput } from "thirdweb/utils";
type NFTMediaFormGroupValues = {
image?: NFTInput["image"];
animation_url?: NFTInput["animation_url"];
external_url?: NFTInput["external_url"];
};
export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
form: UseFormReturn<T>;
previewMaxWidth?: string;
}) {
// T contains all properties of NFTMediaFormGroupValues, so this is a-ok
const form = props.form as unknown as UseFormReturn<NFTMediaFormGroupValues>;
const setFile = (file: File) => {
const external_url = form.watch("external_url");
const animation_url = form.watch("animation_url");
if (file.type.includes("image")) {
form.setValue("image", file);
if (external_url instanceof File) {
form.setValue("external_url", undefined);
}
if (animation_url instanceof File) {
form.setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/*"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
form.setValue("animation_url", file);
if (external_url instanceof File) {
form.setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
form.setValue("external_url", file);
if (animation_url instanceof File) {
form.setValue("animation_url", undefined);
}
}
};
const external_url = form.watch("external_url");
const animation_url = form.watch("animation_url");
const image = form.watch("image");
const errors = form.formState.errors;
const imageUrl = useImageFileOrUrl(image as File | string);
const showCoverImageUpload =
animation_url instanceof File || external_url instanceof File;
const mediaFileUrl =
animation_url instanceof File
? animation_url
: external_url instanceof File
? external_url
: image instanceof File
? imageUrl
: undefined;
const mediaFileError =
animation_url instanceof File
? errors?.animation_url
: external_url instanceof File
? errors?.external_url
: image instanceof File
? errors?.image
: undefined;
const previewMaxWidth = props.previewMaxWidth ?? "200px";
return (
<div className="flex flex-col gap-6">
<FormFieldSetup
errorMessage={mediaFileError?.message}
htmlFor="media"
label="Media"
isRequired={false}
>
<p className="mb-2 text-muted-foreground text-sm">
You can upload image, audio, video, html, text, pdf, or 3d model file
</p>
<FileInput
previewMaxWidth={previewMaxWidth}
value={mediaFileUrl as File | string}
showPreview={true}
setValue={setFile}
className="max-sm:!max-w-full rounded border border-border transition-all duration-200"
selectOrUpload="Upload"
helperText="Media"
/>
</FormFieldSetup>
{showCoverImageUpload && (
<FormFieldSetup
errorMessage={form.formState.errors?.image?.message}
htmlFor="cover-image"
label="Cover Image"
tooltip="You can optionally upload an image as the cover of your NFT."
isRequired
>
<FileInput
previewMaxWidth={previewMaxWidth}
accept={{ "image/*": [] }}
value={imageUrl}
showUploadButton
setValue={(file) => {
form.setValue("image", file);
}}
className="rounded border border-border transition-all"
/>
</FormFieldSetup>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { type UseFormReturn, useFieldArray } from "react-hook-form";
type PropertiesFormValues = {
attributes?: {
trait_type: string;
value: string;
}[];
};
export function PropertiesFormControl<T extends PropertiesFormValues>(props: {
form: UseFormReturn<T>;
}) {
// T contains all properties of PropertiesFormValues, so this correct
const form = props.form as unknown as UseFormReturn<PropertiesFormValues>;
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "attributes",
});
return (
<div className="flex flex-col gap-4">
<h4>Attributes</h4>
{fields.length > 0 && (
<div className="flex flex-col gap-3">
{/* Addresses */}
{fields.map((fieldItem, index) => (
<div className="flex items-start gap-3" key={fieldItem.id}>
<FormField
control={form.control}
name={`attributes.${index}.trait_type`}
render={({ field }) => (
<FormItem className="grow">
<FormControl>
<Input {...field} placeholder="Trait Type" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`attributes.${index}.value`}
render={({ field }) => (
<FormItem className="grow">
<FormControl>
<Input {...field} placeholder="Value" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
className="!text-destructive-text bg-background"
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
))}
</div>
)}
<div className="flex flex-row gap-3">
<Button
size="sm"
variant="outline"
className="flex items-center gap-2"
onClick={() => append({ trait_type: "", value: "" })}
>
<PlusIcon className="size-4" />
Add Attribute
</Button>
{fields.length > 0 && (
<Button
className="flex items-center gap-2"
variant="outline"
size="sm"
onClick={() => form.setValue("attributes", [])}
>
Reset
<RotateCcwIcon className="size-4" />
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,107 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster, toast } from "sonner";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ThirdwebProvider } from "thirdweb/react";
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
import {
OpenEditionMetadataModuleUI,
type SetSharedMetadataFormValues,
} from "./OpenEditionMetadata";
const meta = {
title: "Modules/OpenEditionMetadata",
component: Component,
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Component() {
const [isOwner, setIsOwner] = useState(true);
async function setSharedMetadataStub(values: SetSharedMetadataFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
onSuccess() {
toast.success("Module removed successfully");
},
});
const contractInfo = {
name: "Module Name",
description:
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
version: "1.0.0",
};
return (
<ThirdwebProvider>
<ChakraProviderSetup>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<div>
<CustomConnectWallet loginRequired={false} />
</div>
<div className="flex gap-2">
<Checkbox
id="terms1"
checked={isOwner}
onCheckedChange={(v) => setIsOwner(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="terms1"
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Is Owner
</label>
</div>
</div>
<BadgeContainer label="Default">
<OpenEditionMetadataModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
setSharedMetadata={setSharedMetadataStub}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<Toaster richColors />
</div>
</ChakraProviderSetup>
</ThirdwebProvider>
);
}
@@ -0,0 +1,199 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster, toast } from "sonner";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ThirdwebProvider } from "thirdweb/react";
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
import {
type DefaultRoyaltyFormValues,
type RoyaltyInfoFormValues,
RoyaltyModuleUI,
type TransferValidatorFormValues,
} from "./Royalty";
const meta = {
title: "Modules/Royalty",
component: Component,
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
const _testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
function Component() {
const [isOwner, setIsOwner] = useState(true);
async function setRoyaltyInfoForToken(values: RoyaltyInfoFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async function setDefaultRoyaltyInfoStub(values: DefaultRoyaltyFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async function setTransferValidatorStub(values: TransferValidatorFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
onSuccess() {
toast.success("Module removed successfully");
},
});
const contractInfo = {
name: "Module Name",
description:
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
version: "1.0.0",
};
// TODO - remove ChakraProviderSetup after converting the TransactionButton to tailwind+shadcn
return (
<ChakraProviderSetup>
<ThirdwebProvider>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<div>
<CustomConnectWallet loginRequired={false} />
</div>
<div className="flex items-center gap-5">
<CheckboxWithLabel
value={isOwner}
onChange={setIsOwner}
id="isOwner"
label="Is Owner"
/>
</div>
<BadgeContainer label="Empty Transfer Validator & Default Royalty Info">
<RoyaltyModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
setTransferValidator={setTransferValidatorStub}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Empty Transfer Validator & Non-Empty Default Royalty Info">
<RoyaltyModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
setTransferValidator={setTransferValidatorStub}
defaultRoyaltyInfo={[_testAddress1, 100]}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Non-Empty Transfer Validator & Empty Default Royalty Info">
<RoyaltyModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
setTransferValidator={setTransferValidatorStub}
transferValidator={"0x0000000000000000000000000000000000000000"}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Non-Empty Transfer Validator & Default Royalty Info">
<RoyaltyModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
setTransferValidator={setTransferValidatorStub}
defaultRoyaltyInfo={[_testAddress1, 100]}
transferValidator={"0x0000000000000000000000000000000000000000"}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<Toaster richColors />
</div>
</ThirdwebProvider>
</ChakraProviderSetup>
);
}
function CheckboxWithLabel(props: {
value: boolean;
onChange: (value: boolean) => void;
id: string;
label: string;
}) {
return (
<div className="items-top flex space-x-2">
<Checkbox
id={props.id}
checked={props.value}
onCheckedChange={(v) => props.onChange(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={props.id}
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{props.label}
</label>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster, toast } from "sonner";
import { BadgeContainer, mobileViewport } from "stories/utils";
import { ThirdwebProvider } from "thirdweb/react";
import { ChakraProviderSetup } from "../../../../../../../@/components/ChakraProviderSetup";
import {
type TransferableModuleFormValues,
TransferableModuleUI,
} from "./Transferable";
const meta = {
title: "Modules/Transferable",
component: Component,
parameters: {
layout: "centered",
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
function Component() {
const [isOwner, setIsOwner] = useState(true);
async function updateStub(values: TransferableModuleFormValues) {
console.log("submitting", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const removeMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
onSuccess() {
toast.success("Module removed successfully");
},
});
const contractInfo = {
name: "Module Name",
description:
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
version: "1.0.0",
};
return (
<ChakraProviderSetup>
<ThirdwebProvider>
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<div className="flex gap-2">
<Checkbox
id="terms1"
checked={isOwner}
onCheckedChange={(v) => setIsOwner(!!v)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="terms1"
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Is Owner
</label>
</div>
</div>
<BadgeContainer label="Empty AllowList, Not Restricted">
<TransferableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
isRestricted={false}
adminAddress={testAddress1}
update={updateStub}
uninstallButton={{
onClick: async () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Empty AllowList, Restricted">
<TransferableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={false}
isRestricted={true}
adminAddress={testAddress1}
update={updateStub}
uninstallButton={{
onClick: () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<BadgeContainer label="Pending">
<TransferableModuleUI
contractInfo={contractInfo}
moduleAddress="0x0000000000000000000000000000000000000000"
isPending={true}
adminAddress={testAddress1}
isRestricted={false}
update={updateStub}
uninstallButton={{
onClick: () => removeMutation.mutateAsync(),
isPending: removeMutation.isPending,
}}
isOwnerAccount={isOwner}
contractChainId={1}
/>
</BadgeContainer>
<Toaster richColors />
</div>
</ThirdwebProvider>
</ChakraProviderSetup>
);
}
@@ -6,18 +6,20 @@ import { ContractEditModulesPage } from "./ContractEditModulesPage";
import { ContractEditModulesPageClient } from "./ContractEditModulesPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
}
const { contract } = info;
if (contract.chain.id === localhost.id) {
return <ContractEditModulesPageClient contract={contract} />;
}
@@ -25,7 +27,7 @@ export default async function Page(props: {
const { isModularCore } = await getContractPageMetadata(contract);
if (!isModularCore) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <ContractEditModulesPage contract={contract} />;
@@ -0,0 +1,23 @@
import { isAddress } from "thirdweb";
import { z } from "zod";
export const addressSchema = z.string().refine(
(v) => {
// not returning the return value of isAddress directly on purpose to prevent type narrowing to `0x${string}`
if (isAddress(v)) {
return true;
}
return false;
},
{ message: "Invalid Address" },
);
export const fileBufferOrStringSchema = z.union([
z.string(),
z.instanceof(File),
z.instanceof(Uint8Array),
z.object({
data: z.union([z.string(), z.instanceof(Uint8Array)]),
name: z.string(),
}),
]);
@@ -6,41 +6,39 @@ import { TokenIdPageClient } from "./TokenIdPage.client";
import { TokenIdPage } from "./token-id";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
tokenId: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
}
if (!isOnlyNumbers(props.params.tokenId)) {
if (!isOnlyNumbers(params.tokenId)) {
// redirect to nfts index page
redirect(`/${props.params.chain_id}/${props.params.contractAddress}/nfts`);
redirect(`/${params.chain_id}/${params.contractAddress}/nfts`);
}
const { contract } = info;
if (contract.chain.id === localhost.id) {
return (
<TokenIdPageClient contract={contract} tokenId={props.params.tokenId} />
);
return <TokenIdPageClient contract={contract} tokenId={params.tokenId} />;
}
const { supportedERCs } = await getContractPageMetadata(contract);
if (!supportedERCs.isERC721 && !supportedERCs.isERC1155) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return (
<TokenIdPage
contract={contract}
isErc721={supportedERCs.isERC721}
tokenId={props.params.tokenId}
tokenId={params.tokenId}
/>
);
}
@@ -144,7 +144,6 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
height={isMobile ? "100%" : "300px"}
/>
</Card>
<Flex flexDir="column" gap={6} w="full" px={2}>
<Flex flexDir="column" gap={1.5}>
<NFTName value={nft.metadata.name} />
@@ -1,10 +1,16 @@
"use client";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { MinterOnly } from "@3rdweb-sdk/react/components/roles/minter-only";
import { useDisclosure } from "@chakra-ui/react";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebContract } from "thirdweb";
import { Button, Drawer } from "tw-components";
import { Button } from "tw-components";
import { SharedMetadataForm } from "./shared-metadata-form";
interface NFTSharedMetadataButtonProps {
@@ -14,26 +20,26 @@ interface NFTSharedMetadataButtonProps {
export const NFTSharedMetadataButton: React.FC<
NFTSharedMetadataButtonProps
> = ({ contract, ...restButtonProps }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [open, setOpen] = useState(false);
return (
<MinterOnly contract={contract}>
<Drawer
allowPinchZoom
preserveScrollBarGap
size="lg"
onClose={onClose}
isOpen={isOpen}
>
<SharedMetadataForm contract={contract} />
</Drawer>
<Button
colorScheme="primary"
leftIcon={<PlusIcon className="size-5" />}
{...restButtonProps}
onClick={onOpen}
>
Set NFT Metadata
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
colorScheme="primary"
leftIcon={<PlusIcon className="size-5" />}
{...restButtonProps}
>
Set NFT Metadata
</Button>
</SheetTrigger>
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
<SheetHeader>
<SheetTitle className="text-left">Set NFT Metadata</SheetTitle>
</SheetHeader>
<SharedMetadataForm contract={contract} setOpen={setOpen} />
</SheetContent>
</Sheet>
</MinterOnly>
);
};
@@ -7,20 +7,17 @@ import {
AccordionItem,
AccordionPanel,
Divider,
DrawerBody,
DrawerFooter,
DrawerHeader,
FormControl,
Input,
Textarea,
useModalContext,
} from "@chakra-ui/react";
import { TransactionButton } from "components/buttons/TransactionButton";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebContract } from "thirdweb";
import { setSharedMetadata } from "thirdweb/extensions/erc721";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
@@ -38,10 +35,11 @@ const SHARED_METADATA_FORM_ID = "shared-metadata-form";
export const SharedMetadataForm: React.FC<{
contract: ThirdwebContract;
}> = ({ contract }) => {
setOpen: Dispatch<SetStateAction<boolean>>;
}> = ({ contract, setOpen }) => {
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
const { mutate, isPending } = useSendAndConfirmTransaction();
const sendAndConfirmTx = useSendAndConfirmTransaction();
const {
setValue,
register,
@@ -56,13 +54,6 @@ export const SharedMetadataForm: React.FC<{
}
>();
const modalContext = useModalContext();
const { onSuccess, onError } = useTxNotifications(
"NFT Metadata set successfully",
"Failed to set NFT Metadata",
contract,
);
const setFile = (file: File) => {
if (file.type.includes("image")) {
// image files
@@ -125,43 +116,39 @@ export const SharedMetadataForm: React.FC<{
return (
<>
<DrawerHeader>
<Heading>Set NFT Metadata</Heading>
</DrawerHeader>
<DrawerBody>
<form
className="flex flex-col gap-6"
id={SHARED_METADATA_FORM_ID}
onSubmit={handleSubmit((data) => {
if (!address) {
onError("Please connect your wallet.");
return;
}
<form
className="mt-6 flex flex-col gap-6"
id={SHARED_METADATA_FORM_ID}
onSubmit={handleSubmit((data) => {
if (!address) {
toast.error("Please connect your wallet.");
return;
}
const dataWithCustom = {
...data,
image: data.image || data.customImage,
animation_url: data.animation_url || data.customAnimationUrl,
};
const dataWithCustom = {
...data,
image: data.image || data.customImage,
animation_url: data.animation_url || data.customAnimationUrl,
};
trackEvent({
category: "nft",
action: "set-shared-metadata",
label: "attempt",
});
trackEvent({
category: "nft",
action: "set-shared-metadata",
label: "attempt",
});
try {
const transaction = setSharedMetadata({
contract,
nft: parseAttributes(dataWithCustom),
});
mutate(transaction, {
const promise = sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => {
trackEvent({
category: "nft",
action: "set-shared-metadata",
label: "success",
});
onSuccess();
modalContext.onClose();
setOpen(false);
},
// biome-ignore lint/suspicious/noExplicitAny: FIXME
onError: (error: any) => {
@@ -171,118 +158,126 @@ export const SharedMetadataForm: React.FC<{
label: "error",
error,
});
onError(error);
},
});
})}
>
<div className="flex flex-col gap-2">
<Heading size="subtitle.md">Metadata</Heading>
<Divider />
toast.promise(promise, {
loading: "Setting shared metadata",
error: "Error setting NFT metadata",
success: "Shared metadata updated successfully",
});
} catch (err) {
console.error(err);
toast.error("Failed to set shared metadata");
}
})}
>
<div className="flex flex-col gap-2">
<Heading size="subtitle.md">Metadata</Heading>
<Divider />
</div>
<FormControl isRequired isInvalid={!!errors.name}>
<FormLabel>Name</FormLabel>
<Input autoFocus {...register("name")} />
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!mediaFileError}>
<FormLabel>Media</FormLabel>
<div>
<FileInput
previewMaxWidth="200px"
value={mediaFileUrl as File | string}
showUploadButton
showPreview={true}
setValue={setFile}
className="rounded border border-border transition-all duration-200"
selectOrUpload="Upload"
helperText="Media"
/>
</div>
<FormControl isRequired isInvalid={!!errors.name}>
<FormLabel>Name</FormLabel>
<Input autoFocus {...register("name")} />
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!mediaFileError}>
<FormLabel>Media</FormLabel>
<div>
<FileInput
previewMaxWidth="200px"
value={mediaFileUrl as File | string}
showUploadButton
showPreview={true}
setValue={setFile}
className="rounded border border-border transition-all duration-200"
selectOrUpload="Upload"
helperText="Media"
/>
</div>
<FormHelperText>
You can upload image, audio, video, html, text, pdf, and 3d model
files here.
</FormHelperText>
<FormErrorMessage>
{mediaFileError?.message as unknown as string}
</FormErrorMessage>
</FormControl>
{showCoverImageUpload && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
previewMaxWidth="200px"
accept={{ "image/*": [] }}
value={imageUrl}
showUploadButton
setValue={(file) => setValue("image", file)}
className="rounded border border-border transition-all"
/>
<FormHelperText>
You can upload image, audio, video, html, text, pdf, and 3d model
files here.
You can optionally upload an image as the cover of your NFT.
</FormHelperText>
<FormErrorMessage>
{mediaFileError?.message as unknown as string}
{errors?.image?.message as unknown as string}
</FormErrorMessage>
</FormControl>
{showCoverImageUpload && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
previewMaxWidth="200px"
accept={{ "image/*": [] }}
value={imageUrl}
showUploadButton
setValue={(file) => setValue("image", file)}
className="rounded border border-border transition-all"
/>
<FormHelperText>
You can optionally upload an image as the cover of your NFT.
</FormHelperText>
<FormErrorMessage>
{errors?.image?.message as unknown as string}
</FormErrorMessage>
</FormControl>
)}
<FormControl isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} />
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
</FormControl>
<Accordion
allowToggle={!(errors.background_color || errors.external_url)}
index={
errors.background_color || errors.external_url ? [0] : undefined
}
>
<AccordionItem>
<AccordionButton px={0} justifyContent="space-between">
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="flex flex-col gap-6 px-0">
<FormControl isInvalid={!!errors.customImage}>
<FormLabel>Image URL</FormLabel>
<Input max="6" {...register("customImage")} />
<FormHelperText>
If you already have your NFT image preuploaded, you can set
the URL or URI here.
</FormHelperText>
<FormErrorMessage>
{errors?.customImage?.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.customAnimationUrl}>
<FormLabel>Animation URL</FormLabel>
<Input max="6" {...register("customAnimationUrl")} />
<FormHelperText>
If you already have your NFT Animation URL preuploaded, you
can set the URL or URI here.
</FormHelperText>
<FormErrorMessage>
{errors?.customAnimationUrl?.message}
</FormErrorMessage>
</FormControl>
</AccordionPanel>
</AccordionItem>
</Accordion>
</form>
</DrawerBody>
<DrawerFooter>
)}
<FormControl isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} />
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
</FormControl>
<Accordion
allowToggle={!(errors.background_color || errors.external_url)}
index={
errors.background_color || errors.external_url ? [0] : undefined
}
>
<AccordionItem>
<AccordionButton px={0} justifyContent="space-between">
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="flex flex-col gap-6 px-0">
<FormControl isInvalid={!!errors.customImage}>
<FormLabel>Image URL</FormLabel>
<Input max="6" {...register("customImage")} />
<FormHelperText>
If you already have your NFT image preuploaded, you can set
the URL or URI here.
</FormHelperText>
<FormErrorMessage>
{errors?.customImage?.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.customAnimationUrl}>
<FormLabel>Animation URL</FormLabel>
<Input max="6" {...register("customAnimationUrl")} />
<FormHelperText>
If you already have your NFT Animation URL preuploaded, you
can set the URL or URI here.
</FormHelperText>
<FormErrorMessage>
{errors?.customAnimationUrl?.message}
</FormErrorMessage>
</FormControl>
</AccordionPanel>
</AccordionItem>
</Accordion>
</form>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
isDisabled={isPending}
isDisabled={sendAndConfirmTx.isPending}
variant="outline"
mr={3}
onClick={modalContext.onClose}
onClick={() => setOpen(false)}
>
Cancel
</Button>
<TransactionButton
txChainID={contract.chain.id}
transactionCount={1}
isLoading={isPending}
isLoading={sendAndConfirmTx.isPending}
form={SHARED_METADATA_FORM_ID}
type="submit"
colorScheme="primary"
@@ -290,7 +285,7 @@ export const SharedMetadataForm: React.FC<{
>
Set NFT Metadata
</TransactionButton>
</DrawerFooter>
</div>
</>
);
};
@@ -1,8 +1,13 @@
"use client";
import { Skeleton, Stat, StatLabel, StatNumber } from "@chakra-ui/react";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
import { nextTokenIdToMint, totalSupply } from "thirdweb/extensions/erc721";
import {
nextTokenIdToMint,
startTokenId,
totalSupply,
} from "thirdweb/extensions/erc721";
import { useReadContract } from "thirdweb/react";
import { Card } from "tw-components";
@@ -11,35 +16,44 @@ interface SupplyCardsProps {
}
export const SupplyCards: React.FC<SupplyCardsProps> = ({ contract }) => {
const claimedSupplyQuery = useReadContract(totalSupply, {
contract,
});
const totalSupplyQuery = useReadContract(nextTokenIdToMint, {
const nextTokenIdQuery = useReadContract(nextTokenIdToMint, {
contract,
});
const unclaimedSupply = (
(totalSupplyQuery?.data || 0n) - (claimedSupplyQuery?.data || 0n)
).toString();
const totalSupplyQuery = useReadContract(totalSupply, {
contract,
});
const startTokenIdQuery = useReadContract(startTokenId, { contract });
const realTotalSupply = useMemo(
() => (nextTokenIdQuery.data || 0n) - (startTokenIdQuery.data || 0n),
[nextTokenIdQuery.data, startTokenIdQuery.data],
);
const unclaimedSupply = useMemo(
() => (realTotalSupply - (totalSupplyQuery?.data || 0n)).toString(),
[realTotalSupply, totalSupplyQuery.data],
);
return (
<div className="flex flex-col gap-3 md:flex-row md:gap-6">
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Total Supply</StatLabel>
<Skeleton isLoaded={nextTokenIdQuery.isSuccess}>
<StatNumber>{realTotalSupply.toString()}</StatNumber>
</Skeleton>
</Card>
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Claimed Supply</StatLabel>
<Skeleton isLoaded={totalSupplyQuery.isSuccess}>
<StatNumber>{totalSupplyQuery?.data?.toString()}</StatNumber>
</Skeleton>
</Card>
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Claimed Supply</StatLabel>
<Skeleton isLoaded={claimedSupplyQuery.isSuccess}>
<StatNumber>{claimedSupplyQuery?.data?.toString()}</StatNumber>
</Skeleton>
</Card>
<Card as={Stat}>
<StatLabel mb={{ base: 1, md: 0 }}>Unclaimed Supply</StatLabel>
<Skeleton
isLoaded={totalSupplyQuery.isSuccess && claimedSupplyQuery.isSuccess}
isLoaded={totalSupplyQuery.isSuccess && nextTokenIdQuery.isSuccess}
>
<StatNumber>{unclaimedSupply}</StatNumber>
</Skeleton>
@@ -6,12 +6,13 @@ import { ContractNFTPage } from "./ContractNFTPage";
import { ContractNFTPageClient } from "./ContractNFTPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -26,7 +27,7 @@ export default async function Page(props: {
await getContractPageMetadata(contract);
if (!supportedERCs.isERC721 && !supportedERCs.isERC1155) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return (
@@ -9,7 +9,7 @@ interface PublishedByProps {
export const PublishedBy: React.FC<PublishedByProps> = async ({ contract }) => {
const client = getThirdwebClient();
const address = getAuthTokenWalletAddress();
const address = await getAuthTokenWalletAddress();
const props = await getPublishedByCardProps({
address,
contract,
@@ -8,12 +8,13 @@ import { PublishedBy } from "./overview/components/published-by.server";
import { ContractOverviewPageClient } from "./overview/contract-overview-page.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -6,12 +6,13 @@ import { ContractPermissionsPage } from "./ContractPermissionsPage";
import { ContractPermissionsPageClient } from "./ContractPermissionsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -6,12 +6,13 @@ import { ContractProposalsPage } from "./ContractProposalsPage";
import { ContractProposalsPageClient } from "./ContractProposalsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -25,7 +26,7 @@ export default async function Page(props: {
const { isVoteContract } = await getContractPageMetadata(contract);
if (!isVoteContract) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <ContractProposalsPage contract={contract} />;
@@ -10,8 +10,7 @@ import { SolidityInput } from "contract-ui/components/solidity-inputs";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
import type { ThirdwebContract } from "thirdweb";
import {
getPlatformFeeInfo,
setPlatformFeeInfo,
@@ -36,11 +35,11 @@ const CommonPlatformFeeSchema = z.object({
/**
* platform fee basis points
*/
platform_fee_basis_points: BasisPointsSchema.default(0),
platform_fee_basis_points: BasisPointsSchema,
/**
* platform fee recipient address
*/
platform_fee_recipient: AddressOrEnsSchema.default(ZERO_ADDRESS),
platform_fee_recipient: AddressOrEnsSchema,
});
export const SettingsPlatformFees = ({
@@ -84,14 +83,6 @@ export const SettingsPlatformFees = ({
action: "set-platform-fees",
label: "attempt",
});
// In the v4 hook we defaulted recipient to address_zero and bps to `0`
// but let's actually throw an error here for better transparency
if (!data.platform_fee_basis_points) {
return toast.error("Please enter valid basis points.");
}
if (!data.platform_fee_recipient) {
return toast.error("Please enter a valid platform fee recipient.");
}
const transaction = setPlatformFeeInfo({
contract,
platformFeeRecipient: data.platform_fee_recipient,
@@ -10,8 +10,7 @@ import { SolidityInput } from "contract-ui/components/solidity-inputs";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
import type { ThirdwebContract } from "thirdweb";
import {
getDefaultRoyaltyInfo,
setDefaultRoyaltyInfo,
@@ -46,7 +45,7 @@ const CommonRoyaltySchema = z.object({
* @internal
* @remarks used by OpenSea "seller_fee_basis_points"
*/
seller_fee_basis_points: BasisPointsSchema.default(0),
seller_fee_basis_points: BasisPointsSchema,
/**
* The address of the royalty recipient. All royalties will be sent
@@ -54,7 +53,7 @@ const CommonRoyaltySchema = z.object({
* @internal
* @remarks used by OpenSea "fee_recipient"
*/
fee_recipient: AddressOrEnsSchema.default(ZERO_ADDRESS),
fee_recipient: AddressOrEnsSchema,
});
export const SettingsRoyalties = ({
@@ -97,14 +96,6 @@ export const SettingsRoyalties = ({
action: "set-royalty",
label: "attempt",
});
// In the v4 hook we defaulted recipient to address_zero and bps to `0`
// but let's actually throw an error here for better transparency
if (!d.seller_fee_basis_points) {
return toast.error("Please enter valid basis points.");
}
if (!d.fee_recipient) {
return toast.error("Please enter a valid royalty fee recipient.");
}
const transaction = setDefaultRoyaltyInfo({
contract,
royaltyRecipient: d.fee_recipient,
@@ -6,12 +6,13 @@ import { ContractSettingsPage } from "./ContractSettingsPage";
import { ContractSettingsPageClient } from "./ContractSettingsPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -3,12 +3,13 @@ import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
import { ContractSourcesPage } from "./ContractSourcesPage";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -6,12 +6,13 @@ import { ContractSplitPage } from "./ContractSplitPage";
import { ContractSplitPageClient } from "./ContractSplitPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -24,7 +25,7 @@ export default async function Page(props: {
const { isSplitSupported } = await getContractPageMetadata(contract);
if (!isSplitSupported) {
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return <ContractSplitPage contract={contract} />;
@@ -1,37 +1,14 @@
import { UnorderedList } from "@/components/ui/List/List";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { cn } from "@/lib/utils";
import {
IconButton,
Link,
Portal,
Select,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import { useQueries } from "@tanstack/react-query";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleAlertIcon,
UploadIcon,
} from "lucide-react";
import Papa from "papaparse";
import { useCallback, useMemo, useRef, useState } from "react";
import { type DropzoneOptions, useDropzone } from "react-dropzone";
import { type Column, usePagination, useTable } from "react-table";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { resolveAddress } from "thirdweb/extensions/ens";
import { isAddress } from "thirdweb/utils";
import { Link } from "@chakra-ui/react";
import { useCsvUpload } from "hooks/useCsvUpload";
import { CircleAlertIcon, UploadIcon } from "lucide-react";
import { useMemo, useRef } from "react";
import type { Column } from "react-table";
import { ZERO_ADDRESS } from "thirdweb";
import { Button, Heading, Text } from "tw-components";
import { csvMimeTypes } from "utils/batch";
import { CsvDataTable } from "../../_components/csv-data-table";
export interface AirdropAddressInput {
address: string;
@@ -42,145 +19,89 @@ interface AirdropUploadProps {
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
onClose: () => void;
}
async function checkIsAddress(
item: AirdropAddressInput,
thirdwebClient: ThirdwebClient,
) {
const { address, ...rest } = item;
// Sending tokens to the zero address will result in an error
// if user wants to burn, they should use the Burn tab
if (address === ZERO_ADDRESS) {
return {
address,
resolvedAddress: address,
isValid: false,
...rest,
};
}
let isValid = true;
let resolvedAddress = address;
try {
resolvedAddress = isAddress(address)
? address
: await resolveAddress({ name: address, client: thirdwebClient });
isValid = !!resolvedAddress;
} catch {
isValid = false;
}
return {
address,
resolvedAddress,
isValid,
...rest,
};
}
function processAirdropData(
data: ((AirdropAddressInput & { resolvedAddress: string }) | undefined)[],
) {
const seen = new Set();
const filteredData = data
.filter((o) => o !== undefined)
.filter((el) => {
const duplicate = seen.has(el.resolvedAddress);
seen.add(el.resolvedAddress);
return !duplicate;
});
const valid = filteredData.filter(({ isValid }) => isValid);
const invalid = filteredData.filter(({ isValid }) => !isValid);
// Make sure the invalid records are at the top so that users can see
const ordered = [...invalid, ...valid];
return ordered;
}
const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => {
return items
.map(({ address, quantity }) => ({
address: (address || "").trim(),
quantity: (quantity || "1").trim(),
}))
.filter(({ address }) => address !== "");
};
export const AirdropUpload: React.FC<AirdropUploadProps> = ({
setAirdrop,
onClose,
}) => {
const thirdwebClient = useThirdwebClient();
const [validAirdrop, setValidAirdrop] = useState<AirdropAddressInput[]>([]);
const [noCsv, setNoCsv] = useState(false);
const reset = useCallback(() => {
setValidAirdrop([]);
setNoCsv(false);
}, []);
const onDrop = useCallback<Required<DropzoneOptions>["onDrop"]>(
(acceptedFiles) => {
setNoCsv(false);
const csv = acceptedFiles.find(
(f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"),
);
if (!csv) {
console.error(
"No valid CSV file found, make sure you have an address column.",
);
setNoCsv(true);
return;
}
Papa.parse(csv, {
header: true,
complete: (results) => {
const data: AirdropAddressInput[] = (
results.data as AirdropAddressInput[]
)
.map(({ address, quantity }) => ({
address: (address || "").trim(),
quantity: (quantity || "1").trim(),
}))
.filter(({ address }) => address !== "");
if (!data[0]?.address) {
setNoCsv(true);
return;
}
setValidAirdrop(data);
},
});
},
[],
);
const normalizeQuery = useQueries({
queries: validAirdrop.map((o) => ({
queryKey: ["snapshot-check-isAddress", o.address],
queryFn: () => checkIsAddress(o, thirdwebClient),
})),
combine: (results) => {
return {
data: {
result: processAirdropData(results.map((result) => result.data)),
invalidFound: !!results.find((o) => !o.data?.isValid),
},
pending: results.some((result) => result.isPending),
};
},
});
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
});
const {
normalizeQuery,
getInputProps,
getRootProps,
isDragActive,
rawData,
noCsv,
reset,
removeInvalid,
} = useCsvUpload<AirdropAddressInput>({ csvParser });
const paginationPortalRef = useRef<HTMLDivElement>(null);
const onSave = () => {
setAirdrop(normalizeQuery.data.result);
onClose();
};
const removeInvalid = useCallback(() => {
const filteredData = normalizeQuery.data?.result.filter(
({ isValid }) => isValid,
);
setValidAirdrop(filteredData);
}, [normalizeQuery.data.result]);
const columns = useMemo(() => {
return [
{
Header: "Address",
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: "Quantity",
accessor: ({ quantity }: { quantity: string }) => {
return quantity || "1";
},
},
] as Column<AirdropAddressInput>[];
}, []);
return (
<div className="flex w-full flex-col gap-6">
{normalizeQuery.pending && <div>Loading... </div>}
{normalizeQuery.data?.result.length && validAirdrop.length > 0 ? (
{normalizeQuery.data?.result.length && rawData.length > 0 ? (
<>
<AirdropTable
<CsvDataTable<AirdropAddressInput>
portalRef={paginationPortalRef}
data={normalizeQuery.data.result}
columns={columns}
/>
<div className="mt-4 flex flex-col justify-between md:mt-0">
<div ref={paginationPortalRef} />
<div className="mt-3 ml-auto flex w-full flex-row gap-2 md:w-auto">
<Button
borderRadius="md"
disabled={validAirdrop.length === 0}
disabled={rawData.length === 0}
onClick={() => {
reset();
}}
@@ -192,7 +113,7 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
<Button
borderRadius="md"
colorScheme="primary"
disabled={validAirdrop.length === 0}
disabled={rawData.length === 0}
onClick={() => {
removeInvalid();
}}
@@ -206,7 +127,7 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
colorScheme="primary"
onClick={onSave}
w={{ base: "100%", md: "auto" }}
isDisabled={validAirdrop.length === 0}
isDisabled={rawData.length === 0}
>
Next
</Button>
@@ -271,172 +192,3 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
</div>
);
};
interface AirdropTableProps {
data: AirdropAddressInput[];
portalRef: React.RefObject<HTMLDivElement>;
}
const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
const columns = useMemo(() => {
return [
{
Header: "Address",
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: "Quantity",
accessor: ({ quantity }: { quantity: string }) => {
return quantity || "1";
},
},
] as Column<AirdropAddressInput>[];
}, []);
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: {
pageSize: 50,
pageIndex: 0,
},
},
// old package: this will be removed
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
);
// Render the UI for your table
return (
<>
<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}
>
<Text as="label" size="label.sm" color="faded">
{column.render("Header")}
</Text>
</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>
{/* 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
isDisabled={!canPreviousPage}
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
onClick={() => gotoPage(0)}
/>
<IconButton
isDisabled={!canPreviousPage}
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
onClick={() => previousPage()}
/>
<p className="my-auto whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
isDisabled={!canNextPage}
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
onClick={() => nextPage()}
/>
<IconButton
isDisabled={!canNextPage}
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
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>
)}
</>
);
};
@@ -10,12 +10,13 @@ import { ContractTokensPage } from "./ContractTokensPage";
import { ContractTokensPageClient } from "./ContractTokensPage.client";
export default async function Page(props: {
params: {
params: Promise<{
contractAddress: string;
chain_id: string;
};
}>;
}) {
const info = await getContractPageParamsInfo(props.params);
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
@@ -3,6 +3,8 @@
import { redirect } from "next/navigation";
export default function Page(props: { params: { chain_id: string } }) {
redirect(`/${props.params.chain_id}`);
export default async function Page(props: {
params: Promise<{ chain_id: string }>;
}) {
redirect(`/${(await props.params).chain_id}`);
}
@@ -13,11 +13,12 @@ import { hexToNumber, shortenAddress, toEther } from "thirdweb/utils";
import { getChain } from "../../../utils";
export default async function Page(props: {
params: { chain_id: string; txHash: `0x${string}` };
params: Promise<{ chain_id: string; txHash: `0x${string}` }>;
}) {
const params = await props.params;
// consider if we want to pass the JWT here, likely no need to do it but we could?
const client = getThirdwebClient();
const chain = await getChain(props.params.chain_id);
const chain = await getChain(params.chain_id);
const rpcRequest = getRpcClient({
client,
@@ -28,10 +29,10 @@ export default async function Page(props: {
const [transaction, receipt] = await Promise.all([
eth_getTransactionByHash(rpcRequest, {
hash: props.params.txHash,
hash: params.txHash,
}),
eth_getTransactionReceipt(rpcRequest, {
hash: props.params.txHash,
hash: params.txHash,
}),
]);
if (!transaction.blockHash) {
@@ -27,17 +27,18 @@ import {
type SearchParams,
} from "../components/server/chain-table";
export function generateMetadata(props: {
params: { chain_type: "mainnets" | "testnets" };
export async function generateMetadata(props: {
params: Promise<{ chain_type: "mainnets" | "testnets" }>;
}) {
if (props.params.chain_type === "mainnets") {
const params = await props.params;
if (params.chain_type === "mainnets") {
return {
title: "List of Mainnets | Explorers, Popular Contracts & Chain IDs",
description:
"A list of EVM mainnets with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
};
}
if (props.params.chain_type === "testnets") {
if (params.chain_type === "testnets") {
return {
title: "List of Testnets | Explorers, Popular Contracts & Chain IDs",
description:
@@ -51,27 +52,32 @@ export function generateMetadata(props: {
};
}
// we use headers() to determine if we should default to table or grid view by checking viewport
// so this page needs to be forced as dynamic
export const dynamic = "force-dynamic";
export default async function ChainListLayout(props: {
params: { chain_type: "mainnets" | "testnets" };
searchParams: SearchParams;
params: Promise<{ chain_type: "mainnets" | "testnets" }>;
searchParams: Promise<SearchParams>;
}) {
const headersList = headers();
const headersList = await headers();
const viewportWithHint = Number(
headersList.get("Sec-Ch-Viewport-Width") || 0,
);
const [params, sParams] = await Promise.all([
props.params,
props.searchParams,
]);
const searchParams = {
type: params.chain_type,
...sParams,
};
// default is driven by viewport hint
const activeView = props.searchParams.view
? props.searchParams.view
const activeView = searchParams.view
? searchParams.view
: viewportWithHint > 1000
? "table"
: "grid";
const searchParams = { type: props.params.chain_type, ...props.searchParams };
return (
<>
<div className="flex h-14 border-border border-b pl-7">
@@ -85,14 +91,12 @@ export default async function ChainListLayout(props: {
<DropdownMenu>
<Link
href={
props.params.chain_type === "testnets"
params.chain_type === "testnets"
? "/chainlist/testnets"
: "/chainlist/mainnets"
}
>
{props.params.chain_type === "testnets"
? "Testnets"
: "Mainnets"}
{params.chain_type === "testnets" ? "Testnets" : "Mainnets"}
</Link>
<DropdownMenuTrigger className="flex items-center gap-1">
<ChevronDownIcon className="h-4 w-4" />
@@ -116,8 +120,8 @@ export default async function ChainListLayout(props: {
<div className="flex flex-row items-center justify-between gap-4 lg:flex-col lg:justify-start">
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
List of{" "}
{props.params.chain_type.charAt(0).toUpperCase() +
props.params.chain_type.slice(1)}
{params.chain_type.charAt(0).toUpperCase() +
params.chain_type.slice(1)}
</h1>
<AddYourChainButton className="lg:hidden" />
</div>
@@ -6,6 +6,8 @@ import { ChainIcon } from "../../../components/server/chain-icon";
import type { ChainSupportedService } from "../../../types/chain";
import { getChainMetadata } from "../../../utils";
import type { JSX } from "react";
type ChainListCardProps = {
favoriteButton: JSX.Element;
chainId: number;
@@ -14,6 +14,8 @@ import { products } from "../../../components/server/products";
import type { ChainSupportedService } from "../../../types/chain";
import { getChainMetadata } from "../../../utils";
import type { JSX } from "react";
type ChainListRowProps = {
favoriteButton: JSX.Element;
chainId: number;
@@ -17,19 +17,18 @@ export const metadata: Metadata = {
"A list of EVM networks with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
};
// we use headers() to determine if we should default to table or grid view by checking viewport
// so this page needs to be forced as dynamic
export const dynamic = "force-dynamic";
export default function ChainListPage(props: { searchParams: SearchParams }) {
const headersList = headers();
export default async function ChainListPage(props: {
searchParams: Promise<SearchParams>;
}) {
const headersList = await headers();
const viewportWithHint = Number(
headersList.get("Sec-Ch-Viewport-Width") || 0,
);
const searchParams = await props.searchParams;
// default is driven by viewport hint
const activeView = props.searchParams.view
? props.searchParams.view
const activeView = searchParams.view
? searchParams.view
: viewportWithHint > 1000
? "table"
: "grid";
@@ -64,7 +63,7 @@ export default function ChainListPage(props: { searchParams: SearchParams }) {
</header>
<div className="h-10" />
{/* we used to have suspense + spinner here, that feels more jarring than the page loading _minutely_ slower */}
<ChainsData searchParams={props.searchParams} activeView={activeView} />
<ChainsData searchParams={searchParams} activeView={activeView} />
</section>
);
}
@@ -4,13 +4,14 @@ import { DeployContractInfo } from "../../../published-contract/components/contr
import { DeployFormForUri } from "../../../published-contract/components/uri-based-deploy";
type DirectDeployPageProps = {
params: {
params: Promise<{
compiler_uri: string;
};
}>;
};
export default async function DirectDeployPage(props: DirectDeployPageProps) {
const parsedUri = decodeURIComponent(props.params.compiler_uri);
const params = await props.params;
const parsedUri = decodeURIComponent(params.compiler_uri);
const metadata = await fetchDeployMetadata({
client: getThirdwebClient(),
// force `ipfs://` prefix
@@ -27,7 +28,7 @@ export default async function DirectDeployPage(props: DirectDeployPageProps) {
<DeployFormForUri
contractMetadata={metadata}
modules={null}
pathname={`/contracts/deploy/${props.params.compiler_uri}`}
pathname={`/contracts/deploy/${params.compiler_uri}`}
/>
</div>
);
@@ -8,15 +8,16 @@ import { fetchDeployMetadata } from "thirdweb/contract";
import { getPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
type DirectDeployPageProps = {
params: {
params: Promise<{
publish_uri: string;
};
}>;
};
export default async function PublishContractPage(
props: DirectDeployPageProps,
) {
const decodedPublishUri = decodeURIComponent(props.params.publish_uri);
const params = await props.params;
const decodedPublishUri = decodeURIComponent(params.publish_uri);
const publishUri = decodedPublishUri.startsWith("ipfs://")
? decodedPublishUri
: `ipfs://${decodedPublishUri}`;
@@ -28,9 +29,9 @@ export default async function PublishContractPage(
let publishMetadata = publishMetadataFromUri;
const pathname = `/contracts/publish/${props.params.publish_uri}`;
const pathname = `/contracts/publish/${params.publish_uri}`;
const address = getActiveAccountCookie();
const address = await getActiveAccountCookie();
if (!address) {
redirect(`/login?next=${encodeURIComponent(pathname)}`);
}
@@ -62,7 +63,7 @@ export default async function PublishContractPage(
}
}
const token = getJWTCookie(address);
const token = await getJWTCookie(address);
if (!token) {
redirect(`/login?next=${encodeURIComponent(pathname)}`);
}

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