Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
557d42d4f4 Version Packages (#7871)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-18 15:06:25 -07:00
saminaGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
0a662c1f0c Update Payments FAQ (#7845)
Signed-off-by: samina <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-08-18 15:04:21 -07:00
gregandGitHub f78b0a28da Feature: predictAddress token function (#7870) 2025-08-18 15:04:02 -07:00
MananTank e0ab3d5f27 Dashboard: Fix NFT Collection creation error reported twice (#7868)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `CreateNFTPage` component in `create-nft-page.tsx` to streamline contract deployment processes and improve error handling by removing redundant code and optimizing the reporting of contract deployment.

### Detailed summary
- Removed the import of `reportAssetCreationFailed`.
- Simplified the contract deployment logic for `erc721` and `erc1155`.
- Moved the `reportContractDeployed` call directly after contract creation.
- Eliminated redundant error handling in several transaction functions.
- Updated calls to `sendAndConfirmTransaction` to remove try-catch blocks.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- New Features
  - None
- Refactor
  - Streamlined NFT creation flows for a smoother experience: deterministic ERC721/ERC1155 deployment, simplified lazy minting, claim condition setup, and admin assignment.
  - Contracts are automatically linked to the project in the background after deployment.
- Chores
  - Improved post-deployment tracking by recording deployed contract details (address, chain, name, type, publisher) for better observability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-18 19:39:25 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2c7d8411b9 Version Packages (#7863)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-18 12:25:40 -07:00
gregandGitHub 16ab7da64f [Dashboard] Fix: Do NOT use next's image component (#7869) 2025-08-18 12:18:03 -07:00
MananTank 467ba50907 [BLD-125] SDK: Fix missing props for details modal on useWalletDetailsModal hook (#7865)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `useWalletDetailsModal` hook by adding missing properties for better configuration of the details modal, along with adjustments to the UI components for displaying wallet details.

### Detailed summary
- Added missing props for the details modal in the `useWalletDetailsModal` hook.
- Updated `DetailsModal` to include `manageWallet`, `showBalanceInFiat`, and `hiddenWallets` props.
- Modified the `TokenTab` and `NftTab` components to use inline styles instead of utility classes.
- Enhanced storybook stories for `useWalletDetailsModal` with various configurations and examples.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- New Features
  - Wallet Details modal: show balances in fiat, configure manage-wallet options, hide specific wallets, and pass connect options from the switcher.
  - Assets view: default active tab now adapts dynamically based on available tabs.
- Documentation
  - Added comprehensive Storybook examples for the wallet details modal covering configuration, theming, locales, and callbacks.
  - Clarified notes to keep related modal options in sync.
- Style
  - Updated tab label styling to inline CSS for consistent layout.
- Chores
  - Added a patch-level changeset.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-19 00:14:53 +05:30
gregandGitHub 2f627e1483 [Dashboard] Feature: New payments page ui (#7848) 2025-08-18 11:41:49 -07:00
Jonas DanielsandGitHub 6b80f75e3a Make RPC optional in chain infrastructure checkout (#7867) 2025-08-18 11:36:54 -07:00
MananTank c43c686eac SDK, Dashboard: Fix error when airdropping decimal token amount in new token creation flow (#7866)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on changing the data type of the `amount` property in the `DistributeContent` type and related usages from `bigint` to `string` to accommodate string representations of numbers.

### Detailed summary
- Changed `amount` type in `DistributeContent` from `bigint` to `string`.
- Updated `amount` assignment in `create-token-page-impl.tsx` to use `recipient.quantity` directly as a `string`.
- Modified the total amount calculation to sum `recipient.quantity` as `Number` instead of `BigInt`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed inconsistencies in token airdrop amount handling, ensuring accurate totals and smoother approvals.
  * Resolved submission issues caused by mismatched numeric formats during distribution.

* **Refactor**
  * Standardized how token distribution amounts are represented to improve compatibility with forms and API requests.
  * Simplified airdrop calculations for better reliability and user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-18 17:42:55 +00:00
MananTank 92896ddf3f Create shared UI library - @workspace/ui (#7855)
created a @workspace/ui package - shared by dashboard and playground. for now it only has button component for testing

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `postcss` and `tailwind` configurations across multiple applications to centralize styles in the `@workspace/ui` package, along with various updates to components and layouts.

### Detailed summary
- Deleted `postcss.config.js` files and replaced them with `postcss.config.mjs` referencing `@workspace/ui`.
- Updated CSS imports to use `@workspace/ui/global.css`.
- Added `tailwindcss` configuration in `@workspace/ui` and updated references.
- Introduced new utility functions in `packages/ui/src/lib/utils.ts`.
- Modified component imports to utilize centralized components from `@workspace/ui`.
- Updated `package.json` files to include `@workspace/ui` as a dependency.
- Refactored button components to use a centralized button definition in `@workspace/ui`.
- Adjusted TypeScript configurations to align with the new structure.
- Enhanced global styles and configurations across multiple applications to ensure consistency.

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

<!-- end pr-codex -->

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

- New Features
  - Added a shared UI package and unified Button component used across apps.

- Style
  - Apps now load shared global styles and animations from the UI package; global CSS and theme tokens consolidated.

- Bug Fixes
  - AI chat header uses a safe fallback when the client ID is missing.

- Refactor
  - Replaced local UI implementations with shared re-exports and composed Tailwind configs.

- Chores
  - Added UI package dependencies, PostCSS delegation, and enabled Next.js transpilation for the UI package; lint/scan ignore lists updated.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-16 12:58:24 +00:00
gregandGitHub a08b62be9a Feature: swapping guide (#7864) 2025-08-15 19:49:31 -06:00
MananTank 7f116577ab SDK: Fix PayEmbed not rendering anything when mode prop is not set (#7858)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `PayEmbed` component to default to a mode of "fund_wallet" with an amount of "0.01" and chain set to `ethereum` when no mode is specified. It also includes new story examples for the `PayEmbed` component.

### Detailed summary
- Deleted `PayEmbed.stories.ts` and test files.
- Updated `PayEmbed` component to default `mode` to "fund_wallet" and `amount` to "0.01".
- Added handling for `chain` defaulting to `ethereum`.
- Created multiple story functions for various use cases of `PayEmbed`.
- Included stories for different payment modes and configurations.

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

<!-- end pr-codex -->

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

* **Bug Fixes**
  * PayEmbed now defaults to a wallet-funding flow when mode is missing (amount 0.01; Ethereum chain) and tolerates partial prefill data.

* **Enhancements**
  * Buy flow renders more flexibly when mode is omitted or set to fund_wallet.

* **Documentation**
  * Storybook updated/reorganized with comprehensive PayEmbed examples: fund wallet, direct payments, transactions, ERC20, metadata, and theme variants.

* **Chores**
  * Added a changeset for a patch release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-15 23:28:17 +00:00
kumaryash90 d227bc389b Dashboard, SDK: ERC20 assets (#7321)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `thirdweb` package by introducing new functionalities and updates for managing ERC20 assets, including the implementation of various events, functions, and utility methods for asset deployment and interaction.

### Detailed summary
- Added `initialize` functions to various asset-related contracts.
- Introduced event handling for `AssetDistributed`, `RouterUpdated`, and `RewardLockerUpdated`.
- Implemented methods for checking router and reward locker support.
- Enhanced the `distributeToken` function to handle multiple recipients.
- Updated ABI files for new function signatures and events.
- Improved utility functions for encoding parameters and transaction preparation.

> The following files were skipped due to too many changes: `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/initialize.ts`, `packages/thirdweb/src/extensions/assets/__generated__/FeeManager/write/initialize.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/listAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/distributeAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/createAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/sellAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/buyAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/ERC20Asset/write/initialize.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/addImplementation.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/createAssetById.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetInfraDeployer/write/deployInfraProxyDeterministic.ts`, `packages/thirdweb/package.json`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/createAssetByImplementationConfig.ts`, `packages/thirdweb/src/assets/bootstrap.ts`

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

<!-- end pr-codex -->

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

* **New Features**
  * Added on-page Rewards workflow (Claim Rewards page, UI, and sidebar link) and expanded token creation flows for ERC20 assets with dedicated sale/airdrop options.

* **UI / UX**
  * Improved token sale & distribution UIs, grouped recent transfers, loading placeholders, styling tweaks, chain prioritization in network selectors, customizable wallet fallback icon, and chart legend/value display.

* **API / Props**
  * Several component props and callbacks updated to include chain/contract info and router availability.

* **Chores**
  * Release metadata added for an ERC20 assets patch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-15 22:33:20 +00:00
Jonas DanielsandGitHub 6c711e8783 Rename /nebula to /ai and add redirect (#7862) 2025-08-15 15:11:56 -07:00
kumaryash90 6f581c80b3 Update default config (#7859)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on the removal of the `THIRDWEB_PUBLISHER_ADDRESS` constant from the `addresses.ts` file and its associated usage in the `custom-contract.tsx` file, simplifying the logic related to default fee configuration.

### Detailed summary
- Removed the `THIRDWEB_PUBLISHER_ADDRESS` constant from `addresses.ts`.
- Updated `custom-contract.tsx` to eliminate the use of `THIRDWEB_PUBLISHER_ADDRESS`.
- Simplified the `hasInbuiltDefaultFeeConfig` logic by only checking `defaultFeeRecipientFunction`.

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

<!-- end pr-codex -->

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

* **Bug Fixes**
  * Corrected default platform-fee selection during contract deployment: when a default fee recipient is configured, the updated default fee rate is applied regardless of publisher identity, ensuring consistent platform fees on non–zero-fee chains.
* **Chores**
  * Removed an unused publisher address constant from configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-15 15:06:06 +00:00
MananTank b1ac15c2ac Dashboard: use BuyWidget instead of CheckoutWidget in TransactionButton (#7857)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `MismatchButton` component by replacing the `CheckoutWidget` with the `BuyWidget`, streamlining the payment interface.

### Detailed summary
- Replaced `CheckoutWidget` with `BuyWidget` in the render method of the `MismatchButton` component.
- Removed the `name` and `seller` props from the `BuyWidget`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* New Features
  * Introduced a new Buy experience in the pay dialog for adding funds.

* Improvements
  * Streamlined purchase flow with fewer required inputs and a cleaner interface.
  * Pay dialog can be opened and initiated without first connecting a wallet/account.
  * Consistent styling and theming applied across the new purchase widget.

* Chores
  * Updated dependencies to use the latest purchase widget component.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-14 20:05:26 +00:00
MananTank 98eefdf90b Dashboard: Fix No Funds PayEmbed in TransactionButton (#7856)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `MismatchButton` component by replacing the `PayEmbed` with `CheckoutWidget`, adjusting the transition duration in `DynamicHeight`, and streamlining the dialog content structure.

### Detailed summary
- Changed transition duration in `DynamicHeight` from `250ms` to `220ms`.
- Replaced `PayEmbed` with `CheckoutWidget` in the `MismatchButton` component.
- Simplified the dialog content structure by removing the `DynamicHeight` wrapper.
- Adjusted class names for consistency and clarity.

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

<!-- end pr-codex -->

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

- New Features
  - “Not enough funds” flow now uses a new checkout widget and only appears when your account is connected.

- Style
  - Payment dialog is wider and uses full available width for improved readability.
  - Spacing and layout refined for a cleaner experience.
  - Slightly faster height transition animations for snappier UI feedback.

- Refactor
  - Simplified dialog content by removing an extra height wrapper.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-14 18:58:15 +00:00
MananTank 857d4dfc16 Playground: Fix theme toggle UI when sidebar is collapsed (#7853)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining the sidebar component in the application by adjusting styles, modifying the theme toggle functionality, and enhancing the UI elements for better user experience.

### Detailed summary
- Updated `SIDEBAR_WIDTH` and `SIDEBAR_WIDTH_MOBILE` from `"19rem"` to `"18rem"`.
- Removed `ChevronDownIcon` and replaced it with `ChevronRightIcon` in `RenderSidebarSubmenu`.
- Added `<SidebarSeparator />` in `FullWidthSidebarLayoutInner`.
- Replaced `Button` with `SidebarMenu`, `SidebarMenuButton`, and `SidebarMenuItem` in `ThemeToggle`.

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

<!-- end pr-codex -->

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

- New Features
  - Theme toggle now appears as a sidebar menu item with improved loading placeholders for icon and label.
- UI/UX
  - Sidebar width reduced from 19rem to 18rem for a more compact layout.
  - Chevron indicators updated: right-chevron rotates 90° when open for clearer state.
  - Added a visual separator in the sidebar footer for better grouping.
- Bug Fixes
  - Prevented rendering of empty sidebar menus/submenus.
- Refactor
  - Streamlined imports and removed unused variants; maintained existing toggle behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-13 23:35:36 +00:00
MananTank 22aad59e7d Dashboard: Fix Active Users calculation in team and project analytics charts (#7854)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the `aggregateFn` function in two files related to analytics highlights cards, ensuring consistent functionality for aggregating data.

### Detailed summary
- In both `highlights-card.tsx` files, the `aggregateFn` function is updated to include logic for handling the `activeUsers` key.
- The function calculates the maximum value for `activeUsers` and sums values for other keys.

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

<!-- end pr-codex -->

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

* **Bug Fixes**
  * Standardized analytics aggregation across Highlights cards. All metrics, including Active Users, now show the total over the selected period (previously Active Users showed the peak value). Expect updated figures in both Team Analytics Highlights and Project Overview Highlights, ensuring consistency between summary cards and detailed views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-13 18:47:23 +00:00
Joaquim VergesandGitHub a3b2f8f4b0 [Playground] integration guide link in chat ai footer (#7852) 2025-08-12 19:10:00 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
d617bf77b3 Version Packages (#7851)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-12 18:39:30 -07:00
Joaquim VergesandGitHub 4be655dd08 [SDK] Fix onDisconnect callback not being invoked in React Native (#7850) 2025-08-12 18:18:45 -07:00
MananTank 74d4074f42 Dashboard: Add fallback to native token balance on contract/split page when insight is not supported (#7847)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of token balances and recipient percentages in the `ContractSplitPage` and related components, enhancing user experience and error handling.

### Detailed summary
- Updated `transactionCount` logic in `DistributeButton` to handle undefined cases.
- Added error handling for fetching token balances using `tryCatch`.
- Improved UI structure and styling in `ContractSplitPage`.
- Changed variable naming for clarity in `useReadContract`.
- Enhanced loading states for recipient percentages and balances.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved reliability of token balances by falling back to native balance if token data fails to load.
* **Style**
  * Refreshed Split page layout: simplified headers, refined spacing, and updated table containers.
  * Adjusted column widths for Tokens and Addresses for better readability.
  * Enhanced loading skeletons and maintained clear empty/error states.
* **Refactor**
  * Streamlined data flow for recipients and balances to use a unified query object.
* **Chores**
  * Button tweaks: small size on error; hide transaction count when only one transaction.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-12 23:41:08 +00:00
MananTank a36c850b86 [BLD-108] Dashboard: Update Claim conditions UI to handle very large snapshots (#7846)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the handling of CSV uploads and snapshots in the dashboard, improving user feedback during data processing, and refining the management of claim conditions within the application.

### Detailed summary
- Updated `package.json` to remove `p-limit`.
- Improved UI for CSV upload status with additional messages.
- Refactored snapshot management in `ClaimConditionsForm`.
- Added state for `phaseSnapshots` to optimize performance.
- Enhanced snapshot handling in `SnapshotUpload` component.
- Updated data processing logic in `useCsvUpload` to track progress.

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

<!-- end pr-codex -->

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

- New Features
  - CSV upload shows real-time normalization progress and ENS resolution status across relevant upload screens; pending states now display current/total progress.
  - Per-phase snapshot management with centralized state; improved snapshot viewer with view/edit modes, pagination, and clearer labels.
  - Improved validation: invalid addresses highlighted with tooltips and options to remove or proceed.

- Refactor
  - CSV normalization switched to batched processing for smoother, more responsive uploads.
  - Snapshot data moved out of per-field form state to reduce rendering overhead.

- Chores
  - Removed an unused dependency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-12 22:55:13 +00:00
59a8dad48e [Playground] ai chat demo (#7833)
Co-authored-by: Cursor Agent <[email protected]>
2025-08-12 15:17:03 -07:00
MananTank f7aa3e6239 [BLD-80] Dashboard: in-app wallet settings page UI improvements (#7843)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the UI components and improving the country selection functionality in the dashboard application. It includes updates to transitions, checkbox indicators, and country names for better clarity and usability.

### Detailed summary
- Updated transition duration in `DynamicHeight.tsx`.
- Added `MinusIcon` for indeterminate state in `checkbox.tsx`.
- Corrected country names in `utils.ts` for several countries.
- Improved structure and styling of the `CountrySelector` component.
- Added `ChevronDownIcon` and `Button` for toggling tiers in `country-selector.tsx`.
- Refactored `Fieldset` and `FieldsetWithDescription` components for better layout and usability in settings forms.

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

<!-- end pr-codex -->

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

- New Features
  - Checkbox now supports an indeterminate state with a clear visual indicator.
  - Wallet settings sections gain per-section Save buttons with loading spinners.
  - SMS country selection adds collapsible tier cards, partial-selection counters, and improved country tiles with clear selection states.

- Refactor
  - Unified fieldset layouts with consistent headers, descriptions, dividers, and footers.
  - Branding settings reorganized into a responsive two‑column layout with improved flow.
  - Country tier rendering moved to modular TierCard components.

- Bug Fixes
  - Corrected several country display names (e.g., South Korea, North Macedonia, Eswatini, Saint Lucia).

- Style
  - Adjusted a height-transition timing/easing for dynamic-height animations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-12 21:41:49 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
827819b911 Version Packages (#7826)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-12 12:41:39 -06:00
gregfromstl 0d7e5e563d [SDK] Fix: Process undefined checks in vite (#7844)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR addresses the resolution of the SDK in environments where `process` is undefined, ensuring that the checks for development and test environments are more robust.

### Detailed summary
- Updated the `IS_DEV` constant to check if `process` is defined before accessing its properties.
- Modified the `IS_TEST` constant similarly to ensure it only evaluates when `process` is defined.

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

<!-- end pr-codex -->

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

* **Bug Fixes**
  * Guarded environment checks to avoid runtime errors where Node globals are absent, improving stability across browsers, serverless, and edge runtimes.
  * More reliable detection of development and test modes, reducing unexpected crashes during bundling, CI testing, and client-side use.

* **Chores**
  * Added release metadata so the fix will be published as a patch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-12 18:07:25 +00:00
Joaquim VergesandGitHub 8665e8fe14 [Portal] Improve responsive layout for request example header (#7841) 2025-08-12 03:11:29 -07:00
438027a40e [Docs] Replace custom API endpoint with OpenAPI component (#7829)
Co-authored-by: 0xFirekeeper <[email protected]>
2025-08-12 16:28:14 +12:00
FirekeeperandGitHub 66c7df98e4 Add TikTok as auth method for In-App and Ecosystem Wallets (#7817) 2025-08-11 20:02:58 -07:00
gregandGitHub 409ae17c15 Fix: Hide manage contract button when foreign wallet (#7832) 2025-08-11 20:55:47 -06:00
Joaquim VergesandGitHub be28b0c26e [Docs] Add AI Chat API documentation and endpoint metadata (#7828) 2025-08-11 19:45:48 -07:00
gregandGitHub c661a485d0 [Dashboard] Fix: change payment links -> payments (#7831) 2025-08-11 12:43:28 -06:00
gregandGitHub 90f36bc414 [SDK] Refactor: Only show user balances in payment selection screens (#7827) 2025-08-11 09:50:15 -06:00
gregandGitHub a77e98a48a [SDK] Fix: Deprecate bridge routes sortBy (#7825) 2025-08-10 19:03:26 -06:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fde39eb883 Version Packages (#7820)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-10 16:25:15 -07:00
c672a84fc3 Add chinese locale to connect button (#7807)
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: yashjoisar <[email protected]>
2025-08-10 16:17:18 -07:00
Joaquim VergesandGitHub d52855dbda [SDK] Fix supportedTokens address comparison in usePaymentMethods (#7824) 2025-08-10 16:16:11 -07:00
MananTank a3909e1794 Add caching to ContractCard, speed up /explore pageload (#7822)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `ContractRow`, `ContractPublisher`, and `ContractCard` components to streamline their props and improve caching mechanisms for fetching contract data.

### Detailed summary
- Simplified `ContractRow` props by removing the interface.
- Refactored `ContractPublisher` to use props directly instead of an interface.
- Updated `ContractPublisher` to utilize `Img` and `Blobbie` for avatar rendering.
- Introduced caching for `fetchPublishedContractVersion` and `resolvePublisherEnsAndAvatar`.
- Changed how `ContractPublisher` is rendered in `ContractCard` to use new props structure.

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

<!-- end pr-codex -->

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

* **New Features**
  * Improved performance and consistency for contract publisher information by introducing server-side caching for contract version data and publisher ENS details.
  * Enhanced publisher display with avatars and ENS names, including fallback visuals when no avatar is available.

* **Refactor**
  * Streamlined the contract publisher display by replacing the previous component with a simplified implementation accepting explicit props.
  * Updated type definitions for improved code clarity without affecting user-facing behavior.

* **Chores**
  * Minor import and type annotation adjustments for better maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-09 15:59:46 +00:00
nischitpra f92201d353 webhook multiselect topic (#7818)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on modifying the handling of `sigHash` to support both single and multiple signature inputs across various components, improving user experience and functionality.

### Detailed summary
- Changed `sigHash` from a string to an array in multiple components.
- Updated validation and handling of `sigHash` in forms and utilities.
- Enhanced UI to reflect multiple signature selections.
- Adjusted props and methods to support multi-select functionality in `SignatureSelector` and related components.

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

<!-- end pr-codex -->

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

* **New Features**
  * Added support for selecting multiple signature hashes in webhook filters and selectors.
  * Enhanced signature selectors to allow both single-select and multi-select modes, with improved handling of custom signatures.

* **Improvements**
  * Updated UI labels, placeholders, and descriptions to reflect multi-selection capabilities and pluralization.
  * Review and summary steps now display multiple selected signatures with appropriate formatting.
  * Webhook payloads and validation schemas now support arrays of signature hashes for improved flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-08 20:18:28 +00:00
gregandGitHub 45ba811950 [SDK] Fix: Sort payment quotes in ascending order (#7819) 2025-08-08 12:42:53 -07:00
446 changed files with 30781 additions and 3496 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ module.exports = [
"/storage",
// -- end scale category
// -- nebula
"/nebula",
// -- ai
"/ai",
// -- contracts
"/contracts",
"/contracts/modular-contracts",
+4 -1
View File
@@ -10,8 +10,11 @@
"ignoreDependencies": [
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"thirdweb",
"@types/color",
"fast-xml-parser"
"fast-xml-parser",
"@workspace/ui",
"tailwindcss-animate"
],
"next": true,
"project": ["src/**"]
+1 -1
View File
@@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
const baseNextConfig: NextConfig = {
transpilePackages: ["@workspace/ui"],
eslint: {
ignoreDuringBuilds: true,
},
@@ -196,7 +197,6 @@ const baseNextConfig: NextConfig = {
]),
];
},
serverExternalPackages: ["pino-pretty"],
};
function getConfig(): NextConfig {
+1 -1
View File
@@ -28,6 +28,7 @@
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -43,7 +44,6 @@
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.3",
"p-limit": "^6.2.0",
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {},
tailwindcss: {},
},
};
+1
View File
@@ -0,0 +1 @@
export { default } from "@workspace/ui/postcss.config";
+6
View File
@@ -460,6 +460,12 @@ async function redirects() {
permanent: false,
source: "/universal-bridge",
},
// redirect /nebula to /ai
{
destination: "/ai",
permanent: false,
source: "/nebula",
},
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
+36 -6
View File
@@ -224,7 +224,11 @@ export function reportChainConfigurationAdded(properties: {
// ASSETS
// ----------------------------
type AssetContractType = "DropERC20" | "DropERC1155" | "DropERC721";
type AssetContractType =
| "DropERC20"
| "DropERC1155"
| "DropERC721"
| "ERC20Asset";
/**
* ### Why do we need to report this event?
@@ -334,6 +338,15 @@ export function reportAssetCreationSuccessful(properties: {
});
}
type CoinCreationStep =
| "erc20-asset:deploy-contract"
| "erc20-asset:airdrop-tokens"
| "erc20-asset:approve-airdrop-tokens"
| "drop-erc20:deploy-contract"
| "drop-erc20:set-claim-conditions"
| "drop-erc20:mint-tokens"
| "drop-erc20:airdrop-tokens";
/**
* ### Why do we need to report this event?
* - To track number of failed asset creations
@@ -355,11 +368,7 @@ export function reportAssetCreationFailed(
}
| {
assetType: "coin";
step:
| "deploy-contract"
| "set-claim-conditions"
| "mint-tokens"
| "airdrop-tokens";
step: CoinCreationStep;
}
),
) {
@@ -517,3 +526,24 @@ export function reportTokenUpsellClicked(params: {
}) {
posthog.capture("token upsell clicked", params);
}
// ----------------------------
// CHAIN INFRASTRUCTURE CHECKOUT
// ----------------------------
/**
* ### Why do we need to report this event?
* - To record explicit user acknowledgement when proceeding to checkout without RPC
* - To measure how often customers choose to omit RPC while purchasing Insight and/or Account Abstraction
* - To correlate potential support issues arising from missing RPC
*
* ### Who is responsible for this event?
* @jnsdls
*/
export function reportChainInfraRpcOmissionAgreed(properties: {
chainId: number;
frequency: "monthly" | "annual";
includeInsight: boolean;
includeAccountAbstraction: boolean;
}) {
posthog.capture("chain infra checkout rpc omission agreed", properties);
}
@@ -12,6 +12,7 @@ export type AuthOption =
| "google"
| "facebook"
| "x"
| "tiktok"
| "discord"
| "farcaster"
| "telegram"
@@ -29,16 +29,18 @@ export function MultiNetworkSelector(props: {
client: ThirdwebClient;
chainIds?: number[];
}) {
let { allChains, idToChain } = useAllChainsData();
if (props.chainIds && props.chainIds.length > 0) {
allChains = allChains.filter((chain) =>
props.chainIds?.includes(chain.chainId),
);
}
const { allChains, idToChain } = useAllChainsData();
const options = useMemo(() => {
let sortedChains = allChains;
let chains = allChains.filter((chain) => chain.status !== "deprecated");
if (props.chainIds && props.chainIds.length > 0) {
chains = allChains.filter((chain) =>
props.chainIds?.includes(chain.chainId),
);
}
let sortedChains = chains;
if (props.priorityChains) {
const priorityChainsSet = new Set();
@@ -69,7 +71,13 @@ export function MultiNetworkSelector(props: {
value: String(chain.chainId),
};
});
}, [allChains, props.priorityChains, idToChain, props.hideTestnets]);
}, [
allChains,
props.priorityChains,
idToChain,
props.hideTestnets,
props.chainIds,
]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
@@ -155,16 +163,38 @@ export function SingleNetworkSelector(props: {
disableDeprecated?: boolean;
placeholder?: string;
client: ThirdwebClient;
priorityChains?: number[];
}) {
const { allChains, idToChain } = useAllChainsData();
const chainsToShow = useMemo(() => {
let chains = allChains;
chains = chains.filter((chain) => chain.status !== "deprecated");
if (props.disableTestnets) {
chains = chains.filter((chain) => !chain.testnet);
}
if (props.priorityChains) {
const priorityChainsSet = new Set();
for (const chainId of props.priorityChains || []) {
priorityChainsSet.add(chainId);
}
const priorityChains = (props.priorityChains || [])
.map((chainId) => {
return idToChain.get(chainId);
})
.filter((v) => !!v);
const otherChains = allChains.filter(
(chain) => !priorityChainsSet.has(chain.chainId),
);
chains = [...priorityChains, ...otherChains];
}
if (props.chainIds) {
const chainIdSet = new Set(props.chainIds);
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
@@ -180,6 +210,8 @@ export function SingleNetworkSelector(props: {
props.chainIds,
props.disableTestnets,
props.disableDeprecated,
props.priorityChains,
idToChain,
]);
const options = useMemo(() => {
@@ -10,13 +10,14 @@ interface SignatureOption {
interface SignatureSelectorProps {
options: SignatureOption[];
value: string;
onChange: (val: string) => void;
value: string | string[];
onChange: (val: string | string[]) => void;
setAbi?: (abi: string) => void;
placeholder?: string;
disabled?: boolean;
secondaryTextFormatter?: (sig: SignatureOption) => string;
className?: string;
multiSelect?: boolean;
}
export function SignatureSelector({
@@ -28,6 +29,7 @@ export function SignatureSelector({
disabled,
secondaryTextFormatter,
className,
multiSelect = false,
}: SignatureSelectorProps) {
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -42,38 +44,96 @@ export function SignatureSelector({
}));
}, [options, secondaryTextFormatter]);
// Check if the current value is a custom value (not in options)
const isCustomValue = value && !options.some((opt) => opt.value === value);
// Add the custom value as an option if needed
const allOptions = useMemo(() => {
if (isCustomValue && value) {
return [...formattedOptions, { label: value, value }];
// Handle both single and multi-select values
const currentValues = useMemo((): string[] => {
if (multiSelect) {
if (Array.isArray(value)) {
return value.filter(
(val): val is string =>
val !== undefined && val !== null && val !== "",
);
} else {
return value ? [value] : [];
}
} else {
if (Array.isArray(value)) {
return value.length > 0 && value[0] ? [value[0]] : [];
} else {
return value ? [value] : [];
}
}
return formattedOptions;
}, [formattedOptions, isCustomValue, value]);
}, [value, multiSelect]);
// Single-select MultiSelect wrapper
// Check if the current values include custom values (not in options)
const customValues = useMemo((): string[] => {
return currentValues.filter(
(val): val is string =>
val !== undefined &&
val !== null &&
val !== "" &&
!options.some((opt) => opt.value === val),
);
}, [currentValues, options]);
// Add the custom values as options if needed
const allOptions = useMemo(() => {
const customOptions = customValues.map((val) => ({
label: val,
value: val,
}));
return [...formattedOptions, ...customOptions];
}, [formattedOptions, customValues]);
// Multi-select or single-select MultiSelect wrapper
const handleSelectedValuesChange = useCallback(
(selected: string[]) => {
// Always use the last selected value for single-select behavior
const selectedValue =
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
onChange(selectedValue);
const found = options.find((opt) => opt.value === selectedValue);
if (setAbi) {
setAbi(found?.abi || "");
if (multiSelect) {
// Multi-select behavior
onChange(selected);
// For multi-select, we'll use the ABI from the first selected option that has one
const firstOptionWithAbi = selected.find((selectedValue) => {
const found = options.find((opt) => opt.value === selectedValue);
return found?.abi;
});
if (setAbi && firstOptionWithAbi) {
const found = options.find((opt) => opt.value === firstOptionWithAbi);
setAbi(found?.abi || "");
}
} else {
// Single-select behavior (maintain backward compatibility)
const selectedValue =
selected.length > 0 ? (selected[selected.length - 1] ?? "") : "";
onChange(selectedValue);
const found = options.find((opt) => opt.value === selectedValue);
if (setAbi) {
setAbi(found?.abi || "");
}
}
setSearchValue("");
},
[onChange, setAbi, options],
[onChange, setAbi, options, multiSelect],
);
// Handle custom value entry
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && searchValue.trim()) {
if (!options.some((opt) => opt.value === searchValue.trim())) {
onChange(searchValue.trim());
if (multiSelect) {
// Add to existing values for multi-select
const currentArray = Array.isArray(value)
? value
: value
? [value]
: [];
const filteredArray = currentArray.filter(
(val): val is string => val !== undefined && val !== null,
);
const newValues = [...filteredArray, searchValue.trim()];
onChange(newValues);
} else {
// Replace value for single-select
onChange(searchValue.trim());
}
if (setAbi) setAbi("");
setSearchValue("");
// Optionally blur input
@@ -106,7 +166,7 @@ export function SignatureSelector({
customSearchInput={customSearchInput}
customTrigger={null}
disabled={disabled}
maxCount={1}
maxCount={multiSelect ? 100 : 1}
onSelectedValuesChange={handleSelectedValuesChange}
options={allOptions}
overrideSearchFn={(option, searchTerm) =>
@@ -116,11 +176,13 @@ export function SignatureSelector({
placeholder={placeholder}
renderOption={(option) => <span>{option.label}</span>}
searchPlaceholder={placeholder}
selectedValues={value ? [value] : []}
selectedValues={currentValues}
/>
{isCustomValue && (
{customValues.length > 0 && (
<div className="mt-2 rounded border border-warning-200 bg-warning-50 px-2 py-1 text-warning-700 text-xs">
You entered a custom signature. Please provide the ABI below.
{multiSelect
? `You entered ${customValues.length} custom signature${customValues.length > 1 ? "s" : ""}. Please provide the ABI below.`
: "You entered a custom signature. Please provide the ABI below."}
</div>
)}
</div>
@@ -204,7 +204,7 @@ export function TokenSelector(props: {
searchPlaceholder="Search by name or symbol"
showCheck={props.showCheck}
side={props.side}
value={selectedValue}
value={tokensQuery.isPending ? undefined : selectedValue}
/>
);
}
@@ -3,12 +3,15 @@ import { cn } from "@/lib/utils";
export type Segment = {
label: string;
percent: number;
value: string;
color: string;
};
type DistributionBarChartProps = {
segments: Segment[];
title: string;
title?: string;
titleClassName?: string;
barClassName?: string;
};
export function DistributionBarChart(props: DistributionBarChartProps) {
@@ -21,24 +24,36 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
return (
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-sm">{props.title}</h3>
<div
className={cn(
"font-medium text-muted-foreground text-sm",
invalidTotalPercentage && "text-red-500",
)}
>
Total: {totalPercentage}%
{props.title && (
<div className="mb-2 flex items-center justify-between">
<h3 className={cn("font-medium text-sm", props.titleClassName)}>
{props.title}
</h3>
<div
className={cn(
"font-medium text-muted-foreground text-sm",
invalidTotalPercentage && "text-red-500",
)}
>
Total: {totalPercentage}%
</div>
</div>
</div>
)}
{/* Bar */}
<div className="flex h-3 overflow-hidden rounded-lg">
<div
className={cn(
"flex h-3 overflow-hidden rounded-lg",
props.barClassName,
)}
>
{props.segments.map((segment) => {
return (
<div
className="flex h-full items-center justify-center transition-all duration-200"
className={cn(
"flex h-full items-center justify-center transition-all duration-200",
segment.percent > 0 && "border-r-2 border-background",
)}
key={segment.label}
style={{
backgroundColor: segment.color,
@@ -67,7 +82,7 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
"text-destructive-text",
)}
>
{segment.label}: {segment.percent}%
{segment.label}: {segment.value}
</p>
</div>
);
@@ -64,6 +64,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
popoverContentClassName,
showSelectedValuesInModal = false,
customSearchInput,
customTrigger,
...props
},
ref,
@@ -144,13 +145,18 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// Filter out customTrigger from props to avoid passing it to Button
const buttonProps = Object.fromEntries(
Object.entries(props).filter(([key]) => key !== "customTrigger"),
) as React.ButtonHTMLAttributes<HTMLButtonElement>;
return (
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
<PopoverTrigger asChild>
{props.customTrigger || (
{customTrigger || (
<Button
ref={ref}
{...props}
{...buttonProps}
className={cn(
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
@@ -34,7 +34,7 @@ export function MultiStepStatus<T extends string>(props: {
}) {
return (
<DynamicHeight>
<div className="space-y-4">
<div className="space-y-4 overflow-hidden">
{props.steps.map((step) => (
<div className="flex items-start space-x-3 " key={step.label}>
{step.status.type === "completed" ? (
@@ -46,7 +46,7 @@ export function MultiStepStatus<T extends string>(props: {
) : (
<CircleIcon className="mt-0.5 size-5 flex-shrink-0 text-muted-foreground/70" />
)}
<div className="flex-1">
<div className="grow">
<p
className={`font-medium ${
step.status.type === "pending"
@@ -73,7 +73,7 @@ export function MultiStepStatus<T extends string>(props: {
{step.status.type === "error"
? props.renderError?.(step, step.status.message) || (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
<p className="mb-1 text-red-500 text-sm whitespace-pre-wrap break-all">
{step.status.message}
</p>
<Button
@@ -1,5 +1,5 @@
"use client";
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
@@ -23,6 +23,7 @@ export function WalletAddress(props: {
className?: string;
iconClassName?: string;
client: ThirdwebClient;
fallbackIcon?: React.ReactNode;
}) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
@@ -59,9 +60,16 @@ export function WalletAddress(props: {
// special case for zero address
if (address === ZERO_ADDRESS) {
return (
<span className={cn("cursor-pointer font-mono", props.className)}>
{shortenedAddress}
</span>
<div className="flex items-center gap-2 py-2">
<CircleSlashIcon
className={cn("size-6 text-muted-foreground/70", props.iconClassName)}
/>
<span
className={cn("cursor-pointer font-mono text-sm", props.className)}
>
{shortenedAddress}
</span>
</div>
);
}
@@ -82,6 +90,7 @@ export function WalletAddress(props: {
iconClassName={props.iconClassName}
profiles={profiles.data || []}
thirdwebClient={props.client}
fallbackIcon={props.fallbackIcon}
/>
)}
<span className="cursor-pointer font-mono">
@@ -173,6 +182,7 @@ function WalletAvatar(props: {
profiles: SocialProfile[];
thirdwebClient: ThirdwebClient;
iconClassName?: string;
fallbackIcon?: React.ReactNode;
}) {
const avatar = useMemo(() => {
return props.profiles.find(
@@ -199,6 +209,8 @@ function WalletAvatar(props: {
className={cn("size-5 object-cover", props.iconClassName)}
src={resolvedAvatarSrc}
/>
) : props.fallbackIcon ? (
props.fallbackIcon
) : (
<Blobbie
address={props.address}
@@ -300,6 +300,7 @@ const contractTypeToAssetTypeRecord: Record<string, string | undefined> = {
DropERC20: "Coin",
DropERC721: "NFT Collection",
DropERC1155: "NFT Collection",
ERC20Asset: "Coin",
};
const NetworkFilterCell = React.memo(function NetworkFilterCell({
@@ -1,19 +1,20 @@
import { moduleToBase64 } from "app/(app)/(dashboard)/published-contract/utils/module-base-64";
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
import { unstable_cache } from "next/cache";
import Link from "next/link";
import { resolveAvatar } from "thirdweb/extensions/ens";
import { fetchPublishedContractVersion } from "@/api/contract/fetch-contracts-with-versions";
import { ClientOnly } from "@/components/blocks/client-only";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { resolveEns } from "@/lib/ens";
import { replaceDeployerAddress } from "@/lib/publisher-utils";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { ContractPublisher } from "./contract-publisher";
interface ContractCardProps {
type ContractCardProps = {
publisher: string;
contractId: string;
titleOverride?: string;
@@ -29,7 +30,7 @@ interface ContractCardProps {
moduleId: string;
version?: string;
}[];
}
};
function getContractUrl(
{
@@ -74,6 +75,72 @@ function getContractUrl(
return replaceDeployerAddress(pathName);
}
const cached_fetchPublishedContractVersion = unstable_cache(
async (publisher: string, contractId: string, version: string = "latest") => {
const result = await fetchPublishedContractVersion(
publisher,
contractId,
serverThirdwebClient,
version,
).catch(() => null);
if (!result) {
return null;
}
const publisherEnsAndAvatar = result.publisher
? await cached_resolvePublisherEnsAndAvatar(result.publisher)
: undefined;
// Note: Do not return BigInt - it can't be serialized and cached by unstable_cache and will throw an error
return {
name: result.name,
displayName: result.displayName,
description: result.description,
publisher: {
address: result.publisher,
ensName: publisherEnsAndAvatar?.ensName,
ensAvatar: publisherEnsAndAvatar?.ensAvatar,
},
version: result.version,
audit: result.audit,
};
},
["fetchPublishedContractVersion"],
{
revalidate: 3600, // 1 hour
},
);
const cached_resolvePublisherEnsAndAvatar = unstable_cache(
async (_addressOrEns: string) => {
const addressOrEns = replaceDeployerAddress(_addressOrEns);
const [ensNameInfo, ensAvatar] = await Promise.allSettled([
resolveEns(addressOrEns, serverThirdwebClient),
resolveAvatar({
client: serverThirdwebClient,
name: addressOrEns,
}),
]);
return {
ensName:
ensNameInfo.status === "fulfilled"
? ensNameInfo.value?.ensName
: undefined,
address:
ensNameInfo.status === "fulfilled"
? ensNameInfo.value?.address
: undefined,
ensAvatar: ensAvatar.status === "fulfilled" ? ensAvatar.value : undefined,
};
},
["resolvePublisherEnsAndAvatar"],
{
revalidate: 3600, // 1 hour
},
);
export async function ContractCard({
publisher,
contractId,
@@ -83,12 +150,11 @@ export async function ContractCard({
modules = [],
isBeta,
}: ContractCardProps) {
const publishedContractResult = await fetchPublishedContractVersion(
const publishedContractResult = await cached_fetchPublishedContractVersion(
publisher,
contractId,
serverThirdwebClient,
version,
).catch(() => null);
);
if (!publishedContractResult) {
return null;
@@ -186,13 +252,12 @@ export async function ContractCard({
!modules?.length && "mt-auto",
)}
>
{publishedContractResult.publisher && (
<ClientOnly ssr={<Skeleton className="size-5 rounded-full" />}>
<ContractPublisher
addressOrEns={publishedContractResult.publisher}
client={getClientThirdwebClient()}
/>
</ClientOnly>
{publishedContractResult.publisher.address && (
<ContractPublisher
address={publishedContractResult.publisher.address}
ensName={publishedContractResult.publisher.ensName || undefined}
ensAvatar={publishedContractResult.publisher.ensAvatar || undefined}
/>
)}
<div className="flex items-center justify-between">
@@ -1,53 +1,36 @@
"use client";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import {
AccountAddress,
AccountAvatar,
AccountBlobbie,
AccountName,
AccountProvider,
} from "thirdweb/react";
import { Skeleton } from "@/components/ui/skeleton";
import { replaceDeployerAddress } from "@/lib/publisher-utils";
import { Blobbie } from "thirdweb/react";
import { Img } from "@/components/blocks/Img";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { shortenIfAddress } from "@/utils/usedapp-external";
interface ContractPublisherProps {
addressOrEns: string;
client: ThirdwebClient;
}
export const ContractPublisher: React.FC<ContractPublisherProps> = ({
addressOrEns,
client,
}) => {
export function ContractPublisher(props: {
ensName: string | undefined;
address: string;
ensAvatar: string | undefined;
}) {
const displayName = props.ensName || props.address;
return (
<AccountProvider address={addressOrEns} client={client}>
<Link
className="flex shrink-0 items-center gap-1.5 hover:underline"
href={replaceDeployerAddress(`/${addressOrEns}`)}
>
<AccountAvatar
className="size-5 rounded-full object-cover"
fallbackComponent={<AccountBlobbie className="size-5 rounded-full" />}
loadingComponent={<Skeleton className="size-5 rounded-full" />}
/>
<Link
className="flex shrink-0 items-center gap-1.5 hover:underline"
href={`/${displayName}`}
target="_blank"
>
<Img
className="size-5 rounded-full object-cover"
src={
resolveSchemeWithErrorHandler({
client: getClientThirdwebClient(),
uri: props.ensAvatar,
}) || ""
}
fallback={<Blobbie address={props.address} />}
/>
<AccountName
className="text-xs"
fallbackComponent={
<AccountAddress
className="text-xs"
formatFn={(addr) =>
shortenIfAddress(replaceDeployerAddress(addr))
}
/>
}
formatFn={(name) => replaceDeployerAddress(name)}
loadingComponent={<Skeleton className="h-4 w-40" />}
/>
</Link>
</AccountProvider>
<span className="text-xs"> {shortenIfAddress(displayName)}</span>
</Link>
);
};
}
@@ -1,5 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogDescription } from "@radix-ui/react-dialog";
import { useMutation } from "@tanstack/react-query";
import type { ProjectService } from "@thirdweb-dev/service-utils";
import { SERVICES } from "@thirdweb-dev/service-utils";
@@ -18,6 +17,7 @@ import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -1,7 +1,7 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import type { ThirdwebClient } from "thirdweb";
import { analyticsServerProxy } from "@/actions/proxies";
import {
type SponsoredTransaction,
@@ -18,7 +18,7 @@ import {
} from "thirdweb";
import { type Chain, type ChainMetadata, localhost } from "thirdweb/chains";
import {
PayEmbed,
BuyWidget,
useActiveAccount,
useActiveWallet,
useActiveWalletChain,
@@ -29,7 +29,6 @@ import {
import { privateKeyToAccount, type Wallet } from "thirdweb/wallets";
import { apiServerProxy } from "@/actions/proxies";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Dialog,
DialogContent,
@@ -251,39 +250,32 @@ export const MismatchButton = forwardRef<
>
<DialogContent
className={cn(
"gap-0 p-0",
dialog === "no-funds" && "md:!max-w-[480px]",
dialog === "pay" && "md:!max-w-[360px] border-none bg-transparent",
"gap-0 p-0 md:!max-w-[480px]",
dialog === "pay" && "border-none bg-transparent",
)}
dialogCloseClassName="focus:ring-0"
>
<DynamicHeight>
{dialog === "no-funds" && (
<NoFundsDialogContent
chain={txChain}
client={props.client}
isLoggedIn={props.isLoggedIn}
onCloseModal={() => setDialog(undefined)}
openPayModal={() => {
setDialog("pay");
}}
/>
)}
{dialog === "no-funds" && (
<NoFundsDialogContent
chain={txChain}
client={props.client}
isLoggedIn={props.isLoggedIn}
onCloseModal={() => setDialog(undefined)}
openPayModal={() => {
setDialog("pay");
}}
/>
)}
{dialog === "pay" && (
<PayEmbed
className="!w-auto"
client={props.client}
payOptions={{
prefillBuy: {
amount: "0.01",
chain: txChain,
},
}}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
)}
</DynamicHeight>
{dialog === "pay" && (
<BuyWidget
className="!w-full"
client={props.client}
chain={txChain}
amount="0.01"
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
)}
</DialogContent>
</Dialog>
</>
@@ -362,11 +354,7 @@ function NoFundsDialogContent(props: {
(x) => x.enabled && x.service === "pay",
) ? (
// pay case
<Button
className="w-full"
onClick={props.openPayModal}
variant="primary"
>
<Button className="w-full" onClick={props.openPayModal}>
Buy Funds
</Button>
) : // no funds options available
@@ -25,6 +25,7 @@ export function CopyAddressButton(props: {
copyIconPosition={props.copyIconPosition}
textToCopy={props.address}
textToShow={shortenedAddress}
iconClassName={props.iconClassName}
tooltip={props.tooltip || "Copy Address"}
variant={props.variant}
/>
@@ -17,9 +17,7 @@ export function DynamicHeight(props: {
boxSizing: "border-box",
height: height ? `${height}px` : "auto",
overflow: "hidden",
transition:
props.transition ||
"height 210ms cubic-bezier(0.175, 0.885, 0.32, 1.1)",
transition: props.transition || "height 220ms ease",
}}
>
<div
+3 -83
View File
@@ -1,83 +1,3 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-10 px-4 py-2",
icon: "h-10 w-10",
lg: "h-11 rounded-md px-8",
sm: "h-9 rounded-md px-3",
},
variant: {
default: "bg-foreground text-background hover:bg-foreground/90",
destructive:
"bg-destructive hover:bg-destructive/90 text-destructive-foreground ",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
secondary:
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
upsell:
"bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
},
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
// "button" elements automatically handle the `disabled` attribute.
// For non-button elements rendered via `asChild` (e.g. <a>), we still want
// to visually convey the disabled state and prevent user interaction.
// We do that by conditionally adding the same utility classes that the
// `disabled:` pseudo-variant would normally apply and by setting
// `aria-disabled` for accessibility.
const disabledClass = disabled ? "pointer-events-none opacity-50" : "";
const btnOnlyProps =
Comp === "button"
? {
type:
(props as React.ButtonHTMLAttributes<HTMLButtonElement>).type ||
("button" as const),
}
: undefined;
return (
<Comp
aria-disabled={disabled ? true : undefined}
className={cn(
buttonVariants({ className, size, variant }),
disabledClass,
)}
disabled={disabled}
ref={ref}
{...props}
{...btnOnlyProps}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
"use client";
export type { ButtonProps } from "@workspace/ui/components/button";
export { Button, buttonVariants } from "@workspace/ui/components/button";
@@ -1,7 +1,7 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { CheckIcon, MinusIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
@@ -21,7 +21,11 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckIcon className="size-4" />
{props.checked === "indeterminate" ? (
<MinusIcon className="size-3" />
) : (
<CheckIcon className="size-4" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
+7 -1
View File
@@ -98,6 +98,7 @@ export function TabButtons(props: {
shadowColor?: string;
tabIconClassName?: string;
hideBottomLine?: boolean;
bottomLineClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLButtonElement>();
@@ -106,7 +107,12 @@ export function TabButtons(props: {
<div className={cn("relative", props.containerClassName)}>
{/* Bottom line */}
{!props.hideBottomLine && (
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
<div
className={cn(
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
props.bottomLineClassName,
)}
/>
)}
<ScrollShadow
@@ -1,9 +1,6 @@
export const THIRDWEB_DEPLOYER_ADDRESS =
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
export const THIRDWEB_PUBLISHER_ADDRESS =
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
export const DEFAULT_FEE_RECIPIENT =
"0x1af20c6b23373350ad464700b5965ce4b0d2ad94";
@@ -1,5 +1,4 @@
"use client";
import { CircleAlertIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { createContext, useCallback, useContext, useState } from "react";
@@ -11,7 +11,12 @@ export function useAddContractToProject() {
contractAddress: string;
chainId: string;
deploymentType: "asset" | undefined;
contractType: "DropERC20" | "DropERC721" | "DropERC1155" | undefined;
contractType:
| "ERC20Asset"
| "DropERC721"
| "DropERC1155"
| "DropERC20"
| undefined;
}) => {
const res = await apiServerProxy({
body: JSON.stringify({
+34 -14
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import pLimit from "p-limit";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Papa from "papaparse";
import { useCallback, useState } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
@@ -95,6 +94,7 @@ export function useCsvUpload<
// Always gonna need the wallet address
T extends { address: string },
>(props: Props<T>) {
const queryClient = useQueryClient();
const [rawData, setRawData] = useState<
T[] | Array<T & { [key in string]: unknown }>
>(props.defaultRawData || []);
@@ -132,16 +132,29 @@ export function useCsvUpload<
[props.csvParser],
);
const [normalizeProgress, setNormalizeProgress] = useState({
total: 0,
current: 0,
});
const normalizeQuery = useQuery({
queryFn: async () => {
const limit = pLimit(50);
const results = await Promise.all(
rawData.map((item) => {
return limit(() =>
const batchSize = 50;
const results = [];
for (let i = 0; i < rawData.length; i += batchSize) {
const batch = rawData.slice(i, i + batchSize);
setNormalizeProgress({
total: rawData.length,
current: i,
});
const batchResults = await Promise.all(
batch.map((item) =>
checkIsAddress({ item: item, thirdwebClient: props.client }),
);
}),
);
),
);
results.push(...batchResults);
}
return {
invalidFound: !!results.find((item) => !item?.isValid),
result: processAirdropData(results),
@@ -153,12 +166,18 @@ export function useCsvUpload<
const removeInvalid = useCallback(() => {
const filteredData = normalizeQuery.data?.result.filter(
({ isValid }) => isValid,
(d) => d.isValid && d.resolvedAddress !== ZERO_ADDRESS,
);
// double type assertion is save here because we don't really use this variable (only check for its length)
// Also filteredData's type is the superset of T[]
setRawData(filteredData as unknown as T[]);
}, [normalizeQuery.data?.result]);
if (filteredData && normalizeQuery.data) {
// Directly update the query result instead of setting new state to avoid triggering refetch
queryClient.setQueryData(["snapshot-check-isAddress", rawData], {
...normalizeQuery.data,
result: filteredData,
invalidFound: false, // Since we removed all invalid items
});
}
}, [normalizeQuery.data, queryClient, rawData]);
const processData = useCallback(
(data: T[]) => {
@@ -181,5 +200,6 @@ export function useCsvUpload<
removeInvalid,
reset,
setFiles,
normalizeProgress,
};
}
+30 -1
View File
@@ -7,15 +7,19 @@ import {
import { toast } from "sonner";
import {
type Chain,
NATIVE_TOKEN_ADDRESS,
sendAndConfirmTransaction,
type ThirdwebClient,
type ThirdwebContract,
} from "thirdweb";
import type { GetBalanceResult } from "thirdweb/extensions/erc20";
import { distribute, distributeByToken } from "thirdweb/extensions/split";
import { getOwnedTokens } from "thirdweb/insight";
import { useActiveAccount } from "thirdweb/react";
import { getWalletBalance } from "thirdweb/wallets";
import invariant from "tiny-invariant";
import { parseError } from "../utils/errorParser";
import { tryCatch } from "../utils/try-catch";
function getTokenBalancesQuery(params: {
ownerAddress: string;
@@ -24,7 +28,7 @@ function getTokenBalancesQuery(params: {
}) {
return queryOptions({
queryFn: async () => {
return getOwnedTokens({
const ownedTokenBalancePromise = getOwnedTokens({
client: params.client,
chains: [params.chain],
ownerAddress: params.ownerAddress,
@@ -32,6 +36,31 @@ function getTokenBalancesQuery(params: {
include_native: "true",
},
});
const result = await tryCatch(ownedTokenBalancePromise);
// fallback to fetch native token balance with rpc
if (result.error) {
const walletBalance = await getWalletBalance({
address: params.ownerAddress,
client: params.client,
chain: params.chain,
});
const nativeTokenBalance: GetBalanceResult = {
name: walletBalance.name,
value: walletBalance.value,
decimals: walletBalance.decimals,
displayValue: walletBalance.displayValue,
symbol: walletBalance.symbol,
chainId: params.chain.id,
tokenAddress: NATIVE_TOKEN_ADDRESS,
};
return [nativeTokenBalance];
}
return result.data;
},
queryKey: ["getOwnedTokens", params.chain.id, params.ownerAddress],
retry: false,
@@ -1,5 +1,4 @@
// FIXME: export listing status type
import type { ListingStatus } from "thirdweb/dist/types/extensions/marketplace/types";
import type { ListingStatus } from "thirdweb/extensions/marketplace";
export const LISTING_STATUS: Record<ListingStatus, string> = {
ACTIVE: "Active",
@@ -27,13 +27,15 @@ export const ClaimerSelection = () => {
setOpenSnapshotIndex: setOpenIndex,
isAdmin,
claimConditionType,
phaseSnapshots,
setPhaseSnapshot,
} = useClaimConditionsFormContext();
const handleClaimerChange = (value: string) => {
const val = value as "any" | "specific" | "overrides";
if (val === "any") {
form.setValue(`phases.${phaseIndex}.snapshot`, undefined);
setPhaseSnapshot(phaseIndex, undefined);
} else {
if (val === "specific") {
form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 0);
@@ -41,7 +43,7 @@ export const ClaimerSelection = () => {
if (val === "overrides" && field.maxClaimablePerWallet !== 1) {
form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 1);
}
form.setValue(`phases.${phaseIndex}.snapshot`, []);
setPhaseSnapshot(phaseIndex, []);
setOpenIndex(phaseIndex);
}
};
@@ -49,6 +51,7 @@ export const ClaimerSelection = () => {
let helperText: React.ReactNode;
const disabledSnapshotButton = isAdmin && formDisabled;
const snapshot = phaseSnapshots[phaseIndex];
if (dropType === "specific") {
helperText = (
@@ -87,10 +90,7 @@ export const ClaimerSelection = () => {
return (
<FormFieldSetup
errorMessage={
form.getFieldState(`phases.${phaseIndex}.snapshot`, form.formState)
?.error?.message
}
errorMessage={undefined}
helperText={helperText}
label={label}
isRequired={false}
@@ -117,7 +117,7 @@ export const ClaimerSelection = () => {
)}
{/* Edit or See Snapshot */}
{field.snapshot ? (
{snapshot ? (
<div className="flex items-center gap-3">
{/* disable the "Edit" button when form is disabled, but not when it's a "See" button */}
<Button
@@ -133,7 +133,7 @@ export const ClaimerSelection = () => {
<div
className={cn(
"flex gap-2 items-center",
field.snapshot?.length === 0
snapshot?.length === 0
? "text-muted-foreground"
: "text-green-600 dark:text-green-500",
disabledSnapshotButton ? "opacity-50" : "",
@@ -141,9 +141,8 @@ export const ClaimerSelection = () => {
>
<div className="size-2 bg-current rounded-full" />
<span className="text-sm">
{field.snapshot?.length}{" "}
{field.snapshot?.length === 1 ? "address" : "addresses"} in
snapshot
{snapshot?.length}{" "}
{snapshot?.length === 1 ? "address" : "addresses"} in snapshot
</span>
</div>
</div>
@@ -5,13 +5,12 @@ import {
toUnits,
} from "thirdweb";
import { getContract } from "thirdweb/contract";
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
import { getContractMetadata } from "thirdweb/extensions/common";
import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { download } from "thirdweb/storage";
import type { OverrideEntry } from "thirdweb/utils";
import { maxUint256 } from "thirdweb/utils";
import type { z } from "zod";
import type {
@@ -26,20 +25,15 @@ type SnapshotEntry = {
currencyAddress?: string | undefined;
};
type CombinedClaimCondition = Prettify<
Omit<
LegacyClaimCondition,
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
> & {
price: bigint;
currencyMetadata: Omit<
LegacyClaimCondition["currencyMetadata"],
"value"
> & {
value: bigint;
};
}
>;
type CombinedClaimCondition = Omit<
LegacyClaimCondition,
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
> & {
price: bigint;
currencyMetadata: Omit<LegacyClaimCondition["currencyMetadata"], "value"> & {
value: bigint;
};
};
type Options =
| {
@@ -6,7 +6,15 @@ import {
CircleHelpIcon,
PlusIcon,
} from "lucide-react";
import { createContext, Fragment, useContext, useMemo, useState } from "react";
import {
createContext,
Fragment,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type UseFieldArrayReturn,
type UseFormReturn,
@@ -120,21 +128,19 @@ export const ClaimConditionTypeData: Record<
});
const getClaimConditionTypeFromPhase = (
phase: ClaimConditionInput,
phase: Omit<ClaimConditionInput, "snapshot">,
snapshot: SnapshotEntry[] | undefined,
): ClaimConditionType => {
if (!phase.snapshot) {
if (!snapshot) {
return "public";
}
if (phase.snapshot) {
if (snapshot) {
if (
phase.maxClaimablePerWallet?.toString() === "0" &&
phase.price === "0" &&
typeof phase.snapshot !== "string" &&
phase.snapshot.length === 1 &&
phase.snapshot.some(
(a) => (a as SnapshotEntry).maxClaimable === "unlimited",
)
snapshot.length === 1 &&
snapshot.some((a) => a.maxClaimable === "unlimited")
) {
return "creator";
}
@@ -165,6 +171,11 @@ interface ClaimsConditionFormContextData {
isColumn?: boolean;
isErc20: boolean;
claimConditionType: ClaimConditionType;
phaseSnapshots: Record<number, SnapshotEntry[] | undefined>;
setPhaseSnapshot: (
phaseIndex: number,
snapshot: SnapshotEntry[] | undefined,
) => void;
}
// legacy, but we SHOULD remove this and instead pass down props!
@@ -207,6 +218,10 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
const isAdmin = useIsAdmin(contract);
const [openSnapshotIndex, setOpenSnapshotIndex] = useState(-1);
const [phaseSnapshots, setPhaseSnapshots] = useState<
Record<number, SnapshotEntry[] | undefined>
>({});
const sendTx = useSendAndConfirmTransaction();
const tokenDecimals = useReadContract(decimals, {
@@ -233,35 +248,56 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}),
});
const isSnapshotInitialized = useRef(false);
// save snapshots in a separate state instead of form state because it makes rendering very slow for large snapshots (10k, 100k rows etc)
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (isSnapshotInitialized.current || !claimConditionsQuery.data) {
return;
}
const newPhaseSnapshots: Record<number, SnapshotEntry[] | undefined> = {};
claimConditionsQuery.data.forEach((phase, idx) => {
const snapshot = phase.snapshot?.map(
({ address, maxClaimable, price, currencyAddress }) => ({
address,
currencyAddress: currencyAddress || undefined,
maxClaimable: maxClaimable || "0",
price: price || undefined,
}),
);
newPhaseSnapshots[idx] = snapshot;
});
setPhaseSnapshots(newPhaseSnapshots);
isSnapshotInitialized.current = true;
}, [claimConditionsQuery.data]);
const transformedQueryData = useMemo(() => {
return (claimConditionsQuery.data || [])
.map((phase, idx) => ({
...phase,
currencyAddress: phase.currencyAddress?.toLowerCase() || "0",
currencyMetadata: {
...phase.currencyMetadata,
value: phase.currencyMetadata.value?.toString() || "0",
},
fromSdk: true,
isEditing: false,
maxClaimablePerWallet: phase.maxClaimablePerWallet?.toString() || "0",
maxClaimableSupply: phase.maxClaimableSupply?.toString() || "0",
merkleRootHash: (phase.merkleRootHash || "") as string,
metadata: {
...phase.metadata,
name: phase?.metadata?.name || `Phase ${idx + 1}`,
},
price: phase.currencyMetadata.displayValue,
snapshot: phase.snapshot?.map(
({ address, maxClaimable, price, currencyAddress }) => ({
address,
currencyAddress: currencyAddress || undefined,
maxClaimable: maxClaimable || "0",
price: price || undefined,
}),
),
startTime: new Date(phase.startTime),
}))
.map((phase, idx) => {
return {
...phase,
currencyAddress: phase.currencyAddress?.toLowerCase() || "0",
currencyMetadata: {
...phase.currencyMetadata,
value: phase.currencyMetadata.value?.toString() || "0",
},
fromSdk: true,
isEditing: false,
maxClaimablePerWallet: phase.maxClaimablePerWallet?.toString() || "0",
maxClaimableSupply: phase.maxClaimableSupply?.toString() || "0",
merkleRootHash: (phase.merkleRootHash || "") as string,
metadata: {
...phase.metadata,
name: phase?.metadata?.name || `Phase ${idx + 1}`,
},
price: phase.currencyMetadata.displayValue,
snapshot: undefined, // Do not save snapshot in form data - its slow
startTime: new Date(phase.startTime),
};
})
.filter(
(phase) => isMultiPhase || Number(phase.maxClaimableSupply) !== 0,
);
@@ -289,8 +325,19 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
name: "phases",
});
const setPhaseSnapshot = (
phaseIndex: number,
snapshot: SnapshotEntry[] | undefined,
) => {
setPhaseSnapshots((prev) => ({
...prev,
[phaseIndex]: snapshot,
}));
};
const addPhase = (type: ClaimConditionType) => {
const name = `${ClaimConditionTypeData[type].name} phase`;
const newPhaseIndex = formFields.fields.length;
switch (type) {
case "public":
@@ -305,8 +352,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
...DEFAULT_PHASE,
maxClaimablePerWallet: "0",
metadata: { name },
snapshot: [],
snapshot: undefined,
});
setPhaseSnapshot(newPhaseIndex, []);
break;
case "overrides":
@@ -314,8 +362,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
...DEFAULT_PHASE,
maxClaimablePerWallet: "1",
metadata: { name },
snapshot: [],
snapshot: undefined,
});
setPhaseSnapshot(newPhaseIndex, []);
break;
case "creator":
@@ -325,16 +374,17 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
maxClaimableSupply: "unlimited",
metadata: { name },
price: "0",
snapshot: walletAddress
? [
{
address: walletAddress,
maxClaimable: "unlimited",
price: "0",
},
]
: [],
snapshot: undefined,
});
if (walletAddress) {
setPhaseSnapshot(newPhaseIndex, [
{
address: walletAddress,
maxClaimable: "unlimited",
price: "0",
},
]);
}
break;
default:
@@ -360,6 +410,12 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
);
}
try {
// Merge snapshots back into phases for submission
const phasesWithSnapshots = d.phases.map((phase, index) => ({
...phase,
snapshot: phaseSnapshots[index] || undefined,
}));
const tx = setClaimPhasesTx(
{
contract,
@@ -370,19 +426,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
? { type: "erc721" }
: { tokenId: BigInt(tokenId || 0), type: "erc1155" }),
},
d.phases,
phasesWithSnapshots,
);
await sendTx.mutateAsync(tx);
saveClaimPhaseNotification.onSuccess();
const newPhases = d.phases.map((phase) => ({
...phase,
fromSdk: true,
isEditing: false,
}));
form.setValue("phases", newPhases);
} catch (error) {
console.error(error);
@@ -446,17 +494,21 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
{!isAdmin && <p>Connect with admin wallet to edit claim conditions.</p>}
{controlledFields.map((field, index) => {
const dropType: DropType = field.snapshot
const snapshot = phaseSnapshots[index];
const dropType: DropType = snapshot
? field.maxClaimablePerWallet?.toString() === "0"
? "specific"
: "overrides"
: "any";
const claimConditionType = getClaimConditionTypeFromPhase(field);
const claimConditionType = getClaimConditionTypeFromPhase(
field,
snapshot,
);
const isActive = activePhaseId === field.id;
const snapshotValue = field.snapshot?.map((v) =>
const snapshotValue = snapshot?.map((v) =>
typeof v === "string"
? {
address: v,
@@ -482,9 +534,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
onClose={() => {
setOpenSnapshotIndex(-1);
}}
setSnapshot={(snapshot) =>
form.setValue(`phases.${index}.snapshot`, snapshot)
}
setSnapshot={(snapshot) => setPhaseSnapshot(index, snapshot)}
value={snapshotValue}
/>
@@ -503,6 +553,8 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
phaseIndex: index,
setOpenSnapshotIndex,
tokenDecimals: tokenDecimals.data,
phaseSnapshots,
setPhaseSnapshot,
}}
>
<ClaimConditionsPhase
@@ -510,6 +562,8 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
isPending={sendTx.isPending}
onRemove={() => {
formFields.remove(index);
// Clean up snapshot when phase is removed
setPhaseSnapshot(index, undefined);
}}
/>
</ClaimsConditionFormContext.Provider>
@@ -35,12 +35,15 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
isActive,
isMultiPhase,
phaseIndex,
phaseSnapshots,
} = useClaimConditionsFormContext();
const toggleEditing = () => {
form.setValue(`phases.${phaseIndex}.isEditing`, !field.isEditing);
};
const snapshot = phaseSnapshots[phaseIndex];
return (
<DynamicHeight transition="height 0.3s ease">
<div className="border border-border rounded-lg bg-card relative">
@@ -118,9 +121,7 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
<PhaseStartTimeInput />
<CreatorInput
creatorAddress={
(field.snapshot?.[0] as { address: string })?.address
}
creatorAddress={(snapshot?.[0] as { address: string })?.address}
/>
<MaxClaimableSupplyInput />
@@ -4,9 +4,11 @@ import {
CircleSlashIcon,
RotateCcwIcon,
} from "lucide-react";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { useState } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { DownloadableCode } from "@/components/blocks/code/downloadable-code";
import { DropZone } from "@/components/blocks/drop-zone/drop-zone";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Button } from "@/components/ui/button";
import { InlineCode } from "@/components/ui/inline-code";
import { Spinner } from "@/components/ui/Spinner/Spinner";
@@ -27,24 +29,18 @@ import {
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useCsvUpload } from "@/hooks/useCsvUpload";
import type { SnapshotEntry } from "./legacy-zod-schema";
interface SnapshotAddressInput {
address: string;
maxClaimable?: string;
price?: string;
currencyAddress?: string;
isValid?: boolean;
}
interface SnapshotUploadProps {
setSnapshot: (snapshot: SnapshotAddressInput[]) => void;
setSnapshot: (snapshot: (SnapshotEntry & { ensName?: string })[]) => void;
dropType: "specific" | "any" | "overrides";
isDisabled: boolean;
value?: SnapshotAddressInput[] | undefined;
value?: (SnapshotEntry & { ensName?: string })[] | undefined;
onClose: () => void;
client: ThirdwebClient;
}
const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
const csvParser = (items: SnapshotEntry[]): SnapshotEntry[] => {
return items
.map(({ address, maxClaimable, price, currencyAddress }) => ({
address: (address || "").trim(),
@@ -55,92 +51,125 @@ const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
.filter(({ address }) => address !== "");
};
function SnapshotDataTable({ data }: { data: SnapshotAddressInput[] }) {
function SnapshotDataTable({
data,
}: {
data: (SnapshotEntry & {
ensName?: string;
})[];
}) {
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const totalPages = Math.ceil(data.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentData = data.slice(startIndex, endIndex);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Max claimable</TableHead>
<TableHead>Price</TableHead>
<TableHead>Currency Address</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.address}>
<TableCell>
{item.isValid ? (
item.address
) : (
<ToolTipLabel
label={
item.address === ZERO_ADDRESS
? "Cannot send tokens to ZERO_ADDRESS"
: item.address.startsWith("0x")
? "Address is not valid"
: "Address couldn't be resolved"
}
>
<div className="flex flex-row items-center gap-2">
<CircleAlertIcon className="size-4 text-red-500" />
<div className="cursor-default font-bold text-red-500">
{item.address}
</div>
</div>
</ToolTipLabel>
)}
</TableCell>
<TableCell>
{item.maxClaimable === "0" || !item.maxClaimable
? "Default"
: item.maxClaimable === "unlimited"
? "Unlimited"
: item.maxClaimable}
</TableCell>
<TableCell>
{item.price === "0"
? "Free"
: !item.price || item.price === "unlimited"
? "Default"
: item.price}
</TableCell>
<TableCell>
{item.currencyAddress ===
"0x0000000000000000000000000000000000000000" ||
!item.currencyAddress
? "Default"
: item.currencyAddress}
</TableCell>
<div className="border bg-card rounded-lg overflow-hidden">
<TableContainer className="border-none rounded-none">
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Max claimable</TableHead>
<TableHead>Price</TableHead>
<TableHead>Currency Address</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</TableHeader>
<TableBody>
{currentData.map((item) => (
<TableRow key={item.address}>
<TableCell>
{isAddress(item.address) && item.address !== ZERO_ADDRESS ? (
item.ensName || item.address
) : (
<ToolTipLabel
label={
item.address === ZERO_ADDRESS
? "Cannot send tokens to ZERO_ADDRESS"
: item.address.startsWith("0x")
? "Address is not valid"
: "Address couldn't be resolved"
}
>
<div className="flex flex-row items-center gap-2">
<CircleAlertIcon className="size-4 text-red-500" />
<div className="cursor-default font-bold text-red-500">
{item.ensName || item.address}
</div>
</div>
</ToolTipLabel>
)}
</TableCell>
<TableCell>
{item.maxClaimable === "0" || !item.maxClaimable
? "Default"
: item.maxClaimable === "unlimited"
? "Unlimited"
: item.maxClaimable}
</TableCell>
<TableCell>
{item.price === "0"
? "Free"
: !item.price || item.price === "unlimited"
? "Default"
: item.price}
</TableCell>
<TableCell>
{item.currencyAddress ===
"0x0000000000000000000000000000000000000000" ||
!item.currencyAddress
? "Default"
: item.currencyAddress}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{totalPages > 1 && (
<div className="border-t p-4 lg:px-6">
<PaginationButtons
activePage={currentPage}
totalPages={totalPages}
onPageClick={handlePageChange}
/>
</div>
)}
</div>
);
}
const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
const UploadSnapshot: React.FC<Omit<SnapshotUploadProps, "value">> = ({
setSnapshot,
dropType,
isDisabled,
value,
onClose,
client,
}) => {
const csvUpload = useCsvUpload<SnapshotAddressInput>({
const csvUpload = useCsvUpload<SnapshotEntry>({
client,
csvParser,
defaultRawData: value,
});
const normalizeData = csvUpload.normalizeQuery.data;
if (!normalizeData) {
return (
<div className="flex min-h-[400px] w-full grow items-center justify-center">
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center">
<Spinner className="size-10" />
<p className="text-base text-foreground mt-5">Resolving ENS</p>
<p className="text-sm text-muted-foreground mt-2">
{csvUpload.normalizeProgress.current} /{" "}
{csvUpload.normalizeProgress.total}
</p>
</div>
);
}
@@ -154,6 +183,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
currencyAddress: o.currencyAddress,
maxClaimable: o.maxClaimable,
price: o.price,
ensName: o.address.startsWith("0x") ? undefined : o.address,
})),
);
onClose();
@@ -163,41 +193,58 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<div className="space-y-6">
{csvUpload.rawData.length > 0 ? (
<div>
<SnapshotDataTable data={csvUpload.normalizeQuery.data.result} />
<div className="flex justify-end gap-3 border-t py-6 border-dashed">
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.reset();
}}
variant="outline"
>
<RotateCcwIcon className="size-4" />
Reset
</Button>
<SnapshotDataTable
data={csvUpload.normalizeQuery.data.result.map((x) => ({
address: x.resolvedAddress,
ensName: x.address.startsWith("0x") ? undefined : x.address,
maxClaimable: x.maxClaimable,
currencyAddress: x.currencyAddress,
price: x.price,
}))}
/>
{csvUpload.normalizeQuery.data?.invalidFound ? (
<div className="py-6">
{csvUpload.normalizeQuery.data.invalidFound && (
<p className="text-sm text-destructive-text mb-3">
Invalid addresses found, please remove from the list to continue
</p>
)}
<div className="flex justify-start items-center gap-3">
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.removeInvalid();
csvUpload.reset();
}}
variant="outline"
>
<CircleSlashIcon className="size-4" />
Remove invalid
<RotateCcwIcon className="size-4" />
Reset
</Button>
) : (
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={onSave}
>
Next
<ArrowRightIcon className="size-4" />
</Button>
)}
{csvUpload.normalizeQuery.data?.invalidFound ? (
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
csvUpload.removeInvalid();
}}
>
<CircleSlashIcon className="size-4" />
Remove invalid
</Button>
) : (
<Button
className="gap-2"
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={onSave}
>
Next
<ArrowRightIcon className="size-4" />
</Button>
)}
</div>
</div>
</div>
) : (
@@ -308,6 +355,23 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
);
};
function ViewSnapshot(props: {
data: (SnapshotEntry & { ensName?: string })[];
onReset: () => void;
}) {
return (
<div>
<SnapshotDataTable data={props.data} />
<div className="py-6">
<Button className="gap-2" onClick={props.onReset} variant="outline">
<RotateCcwIcon className="size-4" />
Reset
</Button>
</div>
</div>
);
}
export function SnapshotViewerSheet(
props: SnapshotUploadProps & {
isOpen: boolean;
@@ -322,7 +386,7 @@ export function SnapshotViewerSheet(
}}
open={props.isOpen}
>
<SheetContent className="!w-full !max-w-4xl overflow-y-auto">
<SheetContent className="!w-full !max-w-4xl overflow-y-auto flex flex-col">
<SheetHeader className="mb-3">
<SheetTitle className="text-left">Snapshot</SheetTitle>
</SheetHeader>
@@ -332,6 +396,33 @@ export function SnapshotViewerSheet(
);
}
function SnapshotViewerSheetContent(props: SnapshotUploadProps) {
const [showEditMode, setShowEditMode] = useState(false);
if (showEditMode) {
return (
<UploadSnapshot
setSnapshot={props.setSnapshot}
dropType={props.dropType}
isDisabled={props.isDisabled}
onClose={props.onClose}
client={props.client}
/>
);
}
if (props.value && props.value.length > 0) {
return (
<ViewSnapshot
data={props.value || []}
onReset={() => setShowEditMode(true)}
/>
);
}
return <UploadSnapshot {...props} />;
}
const snapshotWithMaxClaimable = `\
address,maxClaimable
0x0000000000000000000000000000000000000000,2
@@ -25,6 +25,7 @@ export type ContractPageMetadata = {
isAccount: boolean;
isAccountPermissionsSupported: boolean;
functionSelectors: string[];
showClaimRewards: boolean;
};
export async function getContractPageMetadata(contract: ThirdwebContract) {
@@ -1,6 +1,8 @@
import type { ThirdwebContract } from "thirdweb";
import { contractType as getContractType } from "thirdweb/extensions/thirdweb";
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { getValidReward } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards";
import {
isERC20ClaimConditionsSupported,
isERC721ClaimConditionsSupported,
@@ -43,6 +45,7 @@ type ContractPageMetadata = {
isAccount: boolean;
isAccountPermissionsSupported: boolean;
functionSelectors: string[];
showClaimRewards: boolean;
};
export async function getContractPageMetadataSetup(
@@ -53,10 +56,14 @@ export async function getContractPageMetadataSetup(
functionSelectorsResult,
isInsightSupportedResult,
contractTypeResult,
claimRewardResult,
] = await Promise.allSettled([
resolveFunctionSelectors(contract),
isAnalyticsSupportedFn(contract.chain.id),
getContractType({ contract }),
isClaimRewardsSupported({
assetContract: contract,
}),
]);
const functionSelectors =
@@ -72,6 +79,11 @@ export async function getContractPageMetadataSetup(
const contractType =
contractTypeResult.status === "fulfilled" ? contractTypeResult.value : null;
const showClaimRewards =
claimRewardResult.status === "fulfilled"
? !!claimRewardResult.value
: false;
return {
embedType: getEmbedTypeToShow(functionSelectors),
functionSelectors,
@@ -93,5 +105,30 @@ export async function getContractPageMetadataSetup(
isSplitSupported: contractType === "Split",
isVoteContract: contractType === "VoteERC20",
supportedERCs: supportedERCs(functionSelectors),
showClaimRewards,
};
}
async function isClaimRewardsSupported(params: {
assetContract: ThirdwebContract;
}): Promise<boolean> {
try {
const entrypointContract = await getDeployedEntrypointERC20({
chain: params.assetContract.chain,
client: params.assetContract.client,
});
if (!entrypointContract) {
return false;
}
const reward = await getValidReward({
assetContract: params.assetContract,
entrypointContract,
});
return !!reward;
} catch {
return false;
}
}
@@ -140,6 +140,12 @@ export function getContractPageSidebarLinks(data: {
href: `${layoutPrefix}/permissions`,
label: "Permissions",
},
{
exactMatch: true,
hide: !data.metadata.showClaimRewards,
href: `${layoutPrefix}/rewards`,
label: "Rewards",
},
];
const extensionsToShow = extensionsLinks.filter((l) => !l.hide);
@@ -1,3 +1,4 @@
"use client";
import {
ExternalLinkIcon,
GlobeIcon,
@@ -8,6 +9,7 @@ import Link from "next/link";
import { useMemo } from "react";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { useActiveAccount } from "thirdweb/react";
import { Img } from "@/components/blocks/Img";
import { Button } from "@/components/ui/button";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
@@ -64,6 +66,7 @@ export function ContractHeaderUI(props: {
return socialUrlsValue;
}, [props.socialUrls]);
const activeAccount = useActiveAccount();
const cleanedChainName = props.chainMetadata?.name
?.replace("Mainnet", "")
@@ -169,29 +172,32 @@ export function ContractHeaderUI(props: {
variant="outline"
/>
<ToolTipLabel
contentClassName="max-w-[300px]"
label={
<>
View this contract in thirdweb dashboard to view contract
management interface
</>
}
>
<Button
asChild
className="rounded-full bg-card gap-1.5 text-xs py-1.5 px-2.5 h-auto"
size="sm"
variant="outline"
{props.contractCreator?.toLowerCase() ===
activeAccount?.address?.toLowerCase() && (
<ToolTipLabel
contentClassName="max-w-[300px]"
label={
<>
View this contract in thirdweb dashboard to view contract
management interface
</>
}
>
<Link
href={`/team/~/~/contract/${props.chainMetadata.slug}/${props.clientContract.address}`}
<Button
asChild
className="rounded-full bg-card gap-1.5 text-xs py-1.5 px-2.5 h-auto"
size="sm"
variant="outline"
>
<Settings2Icon className="size-3.5 text-muted-foreground" />
Manage Contract
</Link>
</Button>
</ToolTipLabel>
<Link
href={`/team/~/~/contract/${props.chainMetadata.slug}/${props.clientContract.address}`}
>
<Settings2Icon className="size-3.5 text-muted-foreground" />
Manage Contract
</Link>
</Button>
</ToolTipLabel>
)}
{validBlockExplorer && (
<BadgeLink
@@ -1,11 +1,12 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
ArrowRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
ExternalLinkIcon,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { type ThirdwebClient, type ThirdwebContract, toTokens } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -20,7 +21,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
type TokenTransfersData,
useTokenTransfers,
@@ -45,6 +45,32 @@ function RecentTransfersUI(props: {
explorerUrl: string;
client: ThirdwebClient;
}) {
const groupedData = useMemo(() => {
const data: Array<{
group: TokenTransfersData[];
transactionHash: string;
blockTimestamp: string;
}> = [];
for (const transfer of props.data) {
const existingGroup = data.find(
(group) => group.transactionHash === transfer.transaction_hash,
);
if (existingGroup) {
existingGroup.group.push(transfer);
} else {
data.push({
group: [transfer],
transactionHash: transfer.transaction_hash,
blockTimestamp: transfer.block_timestamp,
});
}
}
return data;
}, [props.data]);
return (
<div>
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
@@ -74,76 +100,89 @@ function RecentTransfersUI(props: {
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={index} />
))
: props.data.map((transfer) => (
: groupedData.map((group) => (
<TableRow
className="fade-in-0 animate-in duration-300"
key={
transfer.transaction_hash +
transfer.amount +
transfer.block_number +
transfer.from_address
}
key={group.transactionHash}
>
<TableCell className="text-sm">
<WalletAddress
address={transfer.from_address}
client={props.client}
/>
{/* From */}
<TableCell className="relative space-y-1">
{group.group.map((transfer) => (
<div
className="h-10 flex items-center gap-6 w-[150px]"
key={transfer.log_index}
>
<WalletAddress
address={transfer.from_address}
client={props.client}
iconClassName="size-4.5"
/>
<ArrowRightIcon className="size-4 text-muted-foreground/50 absolute -right-1 lg:right-3" />
</div>
))}
</TableCell>
<TableCell className="text-sm">
<WalletAddress
address={transfer.to_address}
client={props.client}
/>
{/* To */}
<TableCell className="relative space-y-1">
{group.group.map((transfer) => (
<div
className="h-10 flex items-center gap-6 w-[150px]"
key={transfer.log_index}
>
<WalletAddress
address={transfer.to_address}
client={props.client}
key={transfer.log_index}
iconClassName="size-4.5"
/>
</div>
))}
</TableCell>
<TableCell className="text-sm ">
<div className="flex items-center gap-1.5">
<span>
{tokenAmountFormatter.format(
Number(
toTokens(
BigInt(transfer.amount),
props.tokenMetadata.decimals,
),
),
)}
</span>
<span className="text-muted-foreground text-xs">
{props.tokenMetadata.symbol}
</span>
{/* Amount */}
<TableCell className="space-y-1">
{group.group.map((transfer) => (
<div
className="h-10 flex items-center"
key={transfer.log_index}
>
<TokenAmount
amount={transfer.amount}
decimals={props.tokenMetadata.decimals}
symbol={props.tokenMetadata.symbol}
/>
</div>
))}
</TableCell>
{/* timestamp */}
<TableCell>
<div
key={group.blockTimestamp}
className="capitalize text-muted-foreground text-sm"
>
{timestamp(group.blockTimestamp)}
</div>
</TableCell>
<TableCell className="text-sm">
{formatDistanceToNow(
new Date(
transfer.block_timestamp.endsWith("Z")
? transfer.block_timestamp
: `${transfer.block_timestamp}Z`,
),
{
addSuffix: true,
},
)}
</TableCell>
{/* transaction */}
<TableCell>
<Button
asChild
className="h-8 w-8 p-0"
size="sm"
variant="ghost"
>
<a
className={cn(
"flex items-center justify-center",
"hover:bg-accent hover:text-accent-foreground",
)}
href={`${props.explorerUrl}/tx/${transfer.transaction_hash}`}
rel="noopener noreferrer"
target="_blank"
<div className="flex items-center justify-center">
<Button
asChild
size="sm"
variant="outline"
className="text-muted-foreground hover:text-foreground rounded-full size-9 p-0 flex items-center justify-center"
>
<ExternalLinkIcon className="h-4 w-4" />
</a>
</Button>
<a
href={`${props.explorerUrl}/tx/${group.transactionHash}`}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLinkIcon className="size-3.5" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -183,6 +222,34 @@ function RecentTransfersUI(props: {
);
}
function timestamp(block_timestamp: string) {
return formatDistanceToNow(
new Date(
block_timestamp.endsWith("Z") ? block_timestamp : `${block_timestamp}Z`,
),
{
addSuffix: true,
},
);
}
function TokenAmount(props: {
amount: string;
decimals: number;
symbol: string;
}) {
return (
<div className="flex items-center gap-1.5">
<span>
{tokenAmountFormatter.format(
Number(toTokens(BigInt(props.amount), props.decimals)),
)}
</span>
<span className="text-muted-foreground text-xs">{props.symbol}</span>
</div>
);
}
function SkeletonRow() {
return (
<TableRow className="fade-in-0 h-[73px] animate-in duration-300">
@@ -199,7 +266,7 @@ function SkeletonRow() {
<Skeleton className="h-6 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6" />
<Skeleton className="size-9 rounded-full" />
</TableCell>
</TableRow>
);
@@ -29,7 +29,7 @@ export function ContractSplitPage({
}) {
const address = useActiveAccount()?.address;
const { data: allRecipientsPercentages } = useReadContract(
const allRecipientsPercentagesQuery = useReadContract(
getAllRecipientsPercentages,
{ contract },
);
@@ -39,156 +39,163 @@ export function ContractSplitPage({
chain: contract.chain,
});
const activeRecipient = (allRecipientsPercentages || []).find(
const activeRecipient = (allRecipientsPercentagesQuery.data || []).find(
(r) => r.address.toLowerCase() === address?.toLowerCase(),
);
return (
<div>
{/* header */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center justify-between mb-6 lg:mb-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight mb-1">
Balances
</h2>
<p className="text-muted-foreground">
The Split can receive funds in the native token or in any ERC20
</p>
</div>
<div className="mb-4">
<h2 className="text-2xl font-semibold tracking-tight">Balances</h2>
<p className="text-muted-foreground">
The Split can receive funds in the native token or in any ERC20
</p>
</div>
{/* balances table */}
<div className="mb-10">
<div className="border rounded-lg overflow-hidden">
<TableContainer className="border-none">
<Table>
<TableHeader>
<TableRow>
<TableHead>Token</TableHead>
<TableHead>Balance</TableHead>
{activeRecipient && <TableHead>Your Share</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{balanceQuery.isPending
? new Array(3).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TableRow key={index}>
<TableCell>
<Skeleton className="h-5 w-52" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16" />
</TableCell>
{activeRecipient && (
<TableCell>
<Skeleton className="h-5 w-16" />
</TableCell>
)}
</TableRow>
))
: balanceQuery.data?.map((tokenBalance) => (
<TableRow key={tokenBalance.tokenAddress}>
{/* token */}
<TableCell className="font-medium">
<Button
asChild
variant="ghost"
className="flex items-center gap-2 w-fit h-auto py-1 px-2 -translate-x-2"
>
{tokenBalance.tokenAddress ===
NATIVE_TOKEN_ADDRESS ? (
<span>{tokenBalance.symbol}</span>
) : (
<Link
href={`https://thirdweb.com/${tokenBalance.chainId}/${tokenBalance.tokenAddress}`}
target="_blank"
>
{tokenBalance.name}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
)}
</Button>
</TableCell>
{/* balance */}
<TableCell>
{tokenBalance.displayValue} {tokenBalance.symbol}
</TableCell>
{/* your share percent */}
{activeRecipient && (
<TableCell>
{activeRecipient.splitPercentage}%
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{balanceQuery.isError && (
<div className="text-red-500 px-4 flex justify-center items-center py-20 text-muted-foreground">
{balanceQuery.error.message}
</div>
)}
{!balanceQuery.isPending && balanceQuery.data?.length === 0 && (
<div className="px-4 flex justify-center items-center py-20 text-muted-foreground">
No funds received yet
</div>
)}
</TableContainer>
{balanceQuery.data && balanceQuery.data.length > 0 && (
<div className="border-t p-4 lg:px-6 flex justify-end bg-card">
<DistributeButton
balances={balanceQuery.data || []}
balancesIsError={balanceQuery.isError}
balancesIsPending={balanceQuery.isPending}
contract={contract}
isLoggedIn={isLoggedIn}
/>
</div>
)}
</div>
</div>
{/* recipients table */}
<div>
<div className="mb-4">
<h3 className="text-2xl font-semibold tracking-tight mb-1">
Split Recipients
</h3>
<p className="text-muted-foreground">
List of addresses that can receive funds from the Split and their
percentage share.
</p>
</div>
<TableContainer>
<div className="bg-card border rounded-lg overflow-hidden">
<TableContainer className="border-none rounded-none">
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Percentage</TableHead>
<TableHead className="w-[240px] lg:w-[320px]">Token</TableHead>
<TableHead>Balance</TableHead>
{activeRecipient && <TableHead>Your Share</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{(allRecipientsPercentages || []).map((split) => (
<TableRow key={split.address}>
<TableCell>
<WalletAddress
address={split.address}
client={contract.client}
/>
</TableCell>
<TableCell>{split.splitPercentage}%</TableCell>
</TableRow>
))}
{balanceQuery.isPending
? new Array(3).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TableRow key={index}>
<TableCell>
<Skeleton className="h-5 w-52" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16" />
</TableCell>
{activeRecipient && (
<TableCell>
<Skeleton className="h-5 w-16" />
</TableCell>
)}
</TableRow>
))
: balanceQuery.data?.map((tokenBalance) => (
<TableRow key={tokenBalance.tokenAddress}>
{/* token */}
<TableCell className="font-medium">
<Button
asChild
variant="ghost"
className="flex items-center gap-2 w-fit h-auto py-1 px-2 -translate-x-2"
>
{tokenBalance.tokenAddress ===
NATIVE_TOKEN_ADDRESS ? (
<span>{tokenBalance.symbol}</span>
) : (
<Link
href={`https://thirdweb.com/${tokenBalance.chainId}/${tokenBalance.tokenAddress}`}
target="_blank"
>
{tokenBalance.name}
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
</Link>
)}
</Button>
</TableCell>
{/* balance */}
<TableCell>
{tokenBalance.displayValue} {tokenBalance.symbol}
</TableCell>
{/* your share percent */}
{activeRecipient && (
<TableCell>
{activeRecipient.splitPercentage}%
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{balanceQuery.isError && (
<div className="text-red-500 px-4 flex justify-center items-center py-20 text-muted-foreground">
{balanceQuery.error.message}
</div>
)}
{!balanceQuery.isPending && balanceQuery.data?.length === 0 && (
<div className="px-4 flex justify-center items-center py-20 text-muted-foreground">
No funds received yet
</div>
)}
</TableContainer>
{balanceQuery.data && balanceQuery.data.length > 0 && (
<div className="border-t p-4 lg:py-5 lg:px-6 flex justify-end bg-card">
<DistributeButton
balances={balanceQuery.data || []}
balancesIsError={balanceQuery.isError}
balancesIsPending={balanceQuery.isPending}
contract={contract}
isLoggedIn={isLoggedIn}
/>
</div>
)}
</div>
<div className="h-10" />
<div className="mb-4">
<h3 className="text-2xl font-semibold tracking-tight">
Split Recipients
</h3>
<p className="text-muted-foreground">
List of addresses that can receive funds from the Split and their
percentage share.
</p>
</div>
{/* recipients table */}
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[240px] lg:w-[320px]">Address</TableHead>
<TableHead>Percentage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allRecipientsPercentagesQuery.isPending
? new Array(3).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TableRow key={index}>
<TableCell>
<Skeleton className="h-5 w-52" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16" />
</TableCell>
</TableRow>
))
: (allRecipientsPercentagesQuery.data || []).map((split) => (
<TableRow key={split.address}>
<TableCell>
<WalletAddress
address={split.address}
client={contract.client}
className="h-auto py-1"
/>
</TableCell>
<TableCell>{split.splitPercentage}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
@@ -61,6 +61,7 @@ export const DistributeButton = ({
isLoggedIn={isLoggedIn}
isPending={mutation.isPending}
onClick={distributeFunds}
size="sm"
transactionCount={undefined}
// if we fail to get the balances, we can't know how many transactions there are going to be
txChainID={contract.chain.id}
@@ -87,7 +88,7 @@ export const DistributeButton = ({
isLoggedIn={isLoggedIn}
isPending={mutation.isPending}
onClick={distributeFunds}
transactionCount={numTransactions}
transactionCount={numTransactions === 1 ? undefined : numTransactions}
txChainID={contract.chain.id}
variant="default"
size="sm"
@@ -21,12 +21,29 @@ export function AirdropUpload(props: {
client: props.client,
csvParser,
});
if (csvUpload.normalizeQuery.isPending) {
return (
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center">
<Spinner className="size-10" />
<p className="text-base text-foreground mt-5">Resolving ENS</p>
<p className="text-sm text-muted-foreground mt-2">
{csvUpload.normalizeProgress.current} /{" "}
{csvUpload.normalizeProgress.total}
</p>
</div>
);
}
const normalizeData = csvUpload.normalizeQuery.data;
if (!normalizeData) {
return (
<div className="flex min-h-[400px] w-full grow items-center justify-center rounded-lg border border-border">
<Spinner className="size-10" />
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center text-center">
<p className="text-base text-foreground">Failed to resolve ENS</p>
<p className="text-sm text-muted-foreground mt-2">
{String(csvUpload.normalizeQuery.error)}
</p>
</div>
);
}
@@ -7,11 +7,7 @@ import {
} from "@/components/contracts/contract-card/contract-card";
import type { ExploreCategory } from "../../data";
interface ContractRowProps {
category: ExploreCategory;
}
export function ContractRow({ category }: ContractRowProps) {
export function ContractRow({ category }: { category: ExploreCategory }) {
return (
<section>
{/* Title, Description + View all link */}
@@ -47,7 +47,6 @@ import {
DEFAULT_FEE_BPS,
DEFAULT_FEE_BPS_NEW,
DEFAULT_FEE_RECIPIENT,
THIRDWEB_PUBLISHER_ADDRESS,
} from "@/constants/addresses";
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
@@ -218,9 +217,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
const defaultFeeRecipientFunction = metadata.abi.find(
(a) => a.type === "function" && a.name === "DEFAULT_FEE_RECIPIENT",
);
const hasInbuiltDefaultFeeConfig =
defaultFeeRecipientFunction &&
metadata.publisher === THIRDWEB_PUBLISHER_ADDRESS;
const hasInbuiltDefaultFeeConfig = defaultFeeRecipientFunction;
const isFeeExempt =
walletChain?.id && ZERO_FEE_CHAINS.includes(walletChain.id);
+1 -1
View File
@@ -1,4 +1,4 @@
import "../../global.css";
import "@workspace/ui/global.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import NextTopLoader from "nextjs-toploader";
@@ -76,9 +76,6 @@ export function TeamHighlightsCard({
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
@@ -48,6 +48,7 @@ const authOptions = [
"google",
"facebook",
"x",
"tiktok",
"discord",
"farcaster",
"telegram",
@@ -1,11 +1,20 @@
"use client";
import { useQueryState } from "nuqs";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { getChainInfraCheckoutURL } from "@/actions/billing";
import { reportChainInfraRpcOmissionAgreed } from "@/analytics/report";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { InsightIcon } from "@/icons/InsightIcon";
import { RPCIcon } from "@/icons/RPCIcon";
@@ -44,7 +53,7 @@ const SERVICE_CONFIG = {
icon: "RPCIcon",
label: "RPC",
monthlyPrice: 1500,
required: true,
required: false,
sku: "chain:infra:rpc" as const,
},
} satisfies Record<
@@ -80,19 +89,26 @@ export function DeployInfrastructureForm(props: {
searchParams.addons.withOptions({ history: "replace", startTransition }),
);
const [rpcParam, setRpcParam] = useQueryState(
"rpc",
searchParams.rpc.withOptions({ history: "replace", startTransition }),
);
const addons = useMemo(() => {
return addonsStr ? addonsStr.split(",").filter(Boolean) : [];
}, [addonsStr]);
const includeInsight = addons.includes("insight");
const includeAA = addons.includes("aa");
const includeRPC = rpcParam === "on";
const selectedOrder = useMemo(() => {
const arr: (keyof typeof SERVICE_CONFIG)[] = ["rpc"];
const arr: (keyof typeof SERVICE_CONFIG)[] = [];
if (includeRPC) arr.push("rpc");
if (includeInsight) arr.push("insight");
if (includeAA) arr.push("accountAbstraction");
return arr;
}, [includeInsight, includeAA]);
}, [includeInsight, includeAA, includeRPC]);
// NEW: count selected services and prepare bundle discount hint
const selectedCount = selectedOrder.length;
@@ -112,9 +128,9 @@ export function DeployInfrastructureForm(props: {
return {
accountAbstraction: includeAA,
insight: includeInsight,
rpc: true,
rpc: includeRPC,
} as const;
}, [includeInsight, includeAA]);
}, [includeInsight, includeAA, includeRPC]);
const pricePerService = useMemo(() => {
const isAnnual = frequency === "annual";
@@ -172,10 +188,13 @@ export function DeployInfrastructureForm(props: {
const chainId = props.chain.chainId;
const checkout = () => {
const [showRpcWarning, setShowRpcWarning] = useState(false);
const proceedToCheckout = () => {
startTransition(async () => {
try {
const skus: ChainInfraSKU[] = [SERVICE_CONFIG.rpc.sku];
const skus: ChainInfraSKU[] = [];
if (includeRPC) skus.push(SERVICE_CONFIG.rpc.sku);
if (includeInsight) skus.push(SERVICE_CONFIG.insight.sku);
if (includeAA) skus.push(SERVICE_CONFIG.accountAbstraction.sku);
@@ -186,11 +205,9 @@ export function DeployInfrastructureForm(props: {
teamSlug: props.teamSlug,
});
// If the action returns, it means redirect did not happen and we have an error
if (res.status === "error") {
toast.error(res.error);
} else if (res.status === "success") {
// replace the current page with the checkout page (which will then redirect back to us)
window.location.href = res.data;
}
} catch (err) {
@@ -202,6 +219,15 @@ export function DeployInfrastructureForm(props: {
});
};
const checkout = () => {
const hasAddons = includeInsight || includeAA;
if (!includeRPC && hasAddons) {
setShowRpcWarning(true);
return;
}
proceedToCheckout();
};
const periodLabel = frequency === "annual" ? "/yr" : "/mo";
const isAnnual = frequency === "annual";
@@ -211,21 +237,22 @@ export function DeployInfrastructureForm(props: {
<div className="flex flex-col gap-4">
<h3 className="text-lg font-semibold">Select Services</h3>
{/* Required service */}
{/* RPC (now optional) */}
<div className="flex flex-col gap-2 mb-6">
<ServiceCard
description={SERVICE_CONFIG.rpc.description}
disabled
icon={SERVICE_CONFIG.rpc.icon}
label={SERVICE_CONFIG.rpc.label}
onToggle={() => {}}
onToggle={() => {
const newVal = !includeRPC;
setRpcParam(newVal ? "on" : "off");
}}
originalPrice={
isAnnual ? SERVICE_CONFIG.rpc.monthlyPrice * 12 : undefined
}
periodLabel={periodLabel}
price={pricePerService.rpc}
required
selected
selected={includeRPC}
/>
</div>
@@ -350,7 +377,11 @@ export function DeployInfrastructureForm(props: {
<Button
className="w-full"
disabled={isTransitionPending || !props.isOwner}
disabled={
isTransitionPending ||
!props.isOwner ||
selectedOrder.length === 0
}
onClick={checkout}
>
Proceed to Checkout
@@ -362,6 +393,54 @@ export function DeployInfrastructureForm(props: {
)}
</div>
</div>
{/* RPC Omission Warning Modal */}
<Dialog open={showRpcWarning} onOpenChange={setShowRpcWarning}>
<DialogContent>
<DialogHeader>
<DialogTitle>Proceed without RPC (not recommended)</DialogTitle>
<DialogDescription className="space-y-3">
<p>
RPC powers core functionality used by <strong>Insight</strong>{" "}
and <strong>Account Abstraction</strong>.
</p>
<div className="space-y-1">
<p>Without RPC, you may experience:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Delayed or missing data in Insight</li>
<li>
Transaction failures or degraded reliability for Account
Abstraction
</li>
<li>Limited or unsupported features across both services</li>
</ul>
</div>
<p>
thirdweb <strong>cannot guarantee</strong> that Insight or
Account Abstraction will work as expected without RPC. To ensure
reliability, keep RPC enabled.
</p>
<p>If you still want to continue, confirm below.</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
onClick={() => {
reportChainInfraRpcOmissionAgreed({
chainId,
frequency: frequency === "annual" ? "annual" : "monthly",
includeInsight,
includeAccountAbstraction: includeAA,
});
setShowRpcWarning(false);
proceedToCheckout();
}}
variant="destructive"
>
I understand — proceed without RPC
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -3,4 +3,5 @@ import { parseAsString, parseAsStringEnum } from "nuqs/server";
export const searchParams = {
addons: parseAsString.withDefault(""),
freq: parseAsStringEnum(["monthly", "annual"]).withDefault("monthly"),
rpc: parseAsStringEnum(["on", "off"]).withDefault("on"),
};
@@ -1,8 +1,8 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -70,9 +70,6 @@ export function ProjectHighlightsCard(props: {
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
@@ -1,6 +1,6 @@
"use client";
import { CodeIcon, WebhookIcon } from "lucide-react";
import { ArrowRightLeftIcon, CodeIcon, WebhookIcon } from "lucide-react";
import { FeatureCard } from "./FeatureCard.client";
export function AdvancedSection({
@@ -45,6 +45,20 @@ export function AdvancedSection({
label: "Setup Webhooks",
}}
/>
<FeatureCard
title="Swap Tokens"
icon={ArrowRightLeftIcon}
setupTime={5}
color="green"
id="swap_tokens"
features={["Swap any token", "Cross-chain swaps"]}
description="Swap tokens cross-chain with dedicated swapping endpoints."
link={{
href: `https://portal.thirdweb.com/payments/swap`,
label: "Setup Swaps",
target: "_blank",
}}
/>
</div>
</section>
);
@@ -20,8 +20,8 @@ export function QuickStartSection({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard
title="Create Payment Links"
description="Create shareable URLs to receive any token in seconds."
title="Create Payments"
description="Create hosted payment UIs to receive any token in seconds."
icon={LinkIcon}
id="payment_links"
color="violet"
@@ -59,7 +59,7 @@ export function QuickStartSection({
/>
<FeatureCard
title="UI Components"
description="Instantly add payments to your React app with prebuild components."
description="Instantly add payments to your React app with prebuilt components."
icon={CodeIcon}
id="components"
color="violet"
@@ -100,7 +100,7 @@ export function RecentPaymentsSection(props: {
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/payments/links`}
>
Create Payment Link
Create Payment
<ArrowRightIcon className="size-4" />
</Link>
</Button>,
@@ -50,7 +50,7 @@ export default async function Layout(props: {
},
{
exactMatch: true,
name: "Links",
name: "Create",
path: `${payLayoutPath}/links`,
},
{
@@ -107,7 +107,7 @@ export function CreatePaymentLinkButton(
return result;
},
onSuccess: () => {
toast.success("Payment link created successfully.");
toast.success("Payment created successfully.");
return queryClient.invalidateQueries({
queryKey: ["payment-links", props.clientId, props.teamId],
});
@@ -169,7 +169,7 @@ export function CreatePaymentLinkButton(
)}
>
<DialogHeader>
<DialogTitle>Create a Payment Link</DialogTitle>
<DialogTitle>Create a Payment</DialogTitle>
<DialogDescription>
Get paid in any token on any chain.
</DialogDescription>
@@ -101,8 +101,8 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
return (
<EmptyState
icon={LinkIcon}
title="No payment links configured yet"
description="Create a payment link to receive any token in seconds."
title="No payments configured yet"
description="Create a payment to receive any token in seconds."
buttons={[
<CreatePaymentLinkButton
key="create-payment-link"
@@ -111,7 +111,7 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
>
<Button className="gap-1" variant="default" size="sm">
<PlusIcon className="size-4" />
Create Payment Link
Create Payment
</Button>
</CreatePaymentLinkButton>,
]}
@@ -125,7 +125,7 @@ export function PaymentLinksTable(props: { clientId: string; teamId: string }) {
<Table>
<TableHeader>
<TableRow>
<TableHead>Payment Link</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Usages</TableHead>
@@ -274,7 +274,7 @@ function DeletePaymentLinkButton(
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
payment link.
payment.
</DialogDescription>
</DialogHeader>
@@ -290,7 +290,7 @@ function DeletePaymentLinkButton(
});
},
onSuccess: () => {
toast.success("Payment link deleted successfully.");
toast.success("Payment deleted successfully.");
setOpen(false);
return queryClient.invalidateQueries({
queryKey: ["payment-links", props.clientId, props.teamId],
@@ -23,10 +23,10 @@ export default async function Page(props: {
<div className="w-full flex flex-col md:flex-row justify-between gap-4 items-start">
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Payment Links
Create a Payment
</h2>
<p className="text-muted-foreground text-sm">
Make money in any token instantly with hosted payment links
Make money in any token instantly with a hosted payments UI.
</p>
</div>
<CreatePaymentLinkButton
@@ -36,7 +36,7 @@ export default async function Page(props: {
>
<Button className="gap-1" variant="default" size="sm">
<PlusIcon className="size-4" />
Create Payment Link
Create Payment
</Button>
</CreatePaymentLinkButton>
</div>
@@ -0,0 +1,23 @@
import type { ThirdwebClient } from "thirdweb";
import { isProd } from "@/constants/env-utils";
export async function createTokenOnUniversalBridge(params: {
chainId: number;
tokenAddress: string;
client: ThirdwebClient;
}) {
const domain = isProd ? "thirdweb.com" : "thirdweb-dev.com";
const res = await fetch(`https://bridge.${domain}/v1/tokens`, {
body: JSON.stringify({
chainId: params.chainId.toString(),
tokenAddress: params.tokenAddress,
}),
headers: {
"Content-Type": "application/json",
"x-client-id": params.client.clientId,
},
method: "POST",
});
return res;
}
@@ -34,7 +34,7 @@ export function SocialUrlsFieldset<T extends WithSocialUrls>(props: {
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
{fields.length > 0 && (
<div className="mb-4 space-y-3">
<div className="mb-4 space-y-4">
{fields.map((field, index) => (
<div
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
@@ -1,4 +1,4 @@
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import type { ThirdwebClient } from "thirdweb";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
@@ -35,7 +35,7 @@ export function StepCard(props: {
{props.children}
{(props.prevButton || props.nextButton) && (
<div className="flex justify-end gap-3 border-t p-6">
<div className="flex justify-end gap-3 border-t p-4 md:p-6">
{props.prevButton && (
<Button
className="gap-2"
@@ -9,6 +9,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
@@ -95,7 +96,7 @@ export function NFTCollectionInfoFieldset(props: {
isRequired
label="Chain"
>
<ClientOnly ssr={null}>
<ClientOnly ssr={<Skeleton className="h-10" />}>
<SingleNetworkSelector
chainId={Number(form.watch("chain"))}
className="bg-background"
@@ -23,13 +23,9 @@ import { useActiveAccount } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
reportContractDeployed,
} from "@/analytics/report";
import { reportContractDeployed } from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import { useAddContractToProject } from "@/hooks/project-contracts";
import { parseError } from "@/utils/errorParser";
import type { CreateNFTCollectionAllValues } from "./_common/form";
import { CreateNFTPageUI } from "./create-nft-page-ui";
@@ -96,81 +92,67 @@ export function CreateNFTPage(props: {
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(collectionInfo.chain));
try {
let contractAddress: string;
let contractAddress: string;
if (ercType === "erc721") {
contractAddress = await deployERC721Contract({
account,
chain: chain,
client: props.client,
params: {
description: collectionInfo.description,
image: collectionInfo.image,
name: collectionInfo.name,
royaltyBps: BigInt(sales.royaltyBps),
royaltyRecipient: sales.royaltyRecipient,
saleRecipient: sales.primarySaleRecipient,
social_urls: transformSocialUrls(collectionInfo.socialUrls),
symbol: collectionInfo.symbol,
},
type: "DropERC721",
});
} else {
contractAddress = await deployERC1155Contract({
account,
chain: chain,
client: props.client,
params: {
description: collectionInfo.description,
image: collectionInfo.image,
name: collectionInfo.name,
royaltyBps: BigInt(sales.royaltyBps),
royaltyRecipient: sales.royaltyRecipient,
saleRecipient: sales.primarySaleRecipient,
social_urls: transformSocialUrls(collectionInfo.socialUrls),
symbol: collectionInfo.symbol,
},
type: "DropERC1155",
});
}
reportContractDeployed({
address: contractAddress,
chainId: Number(collectionInfo.chain),
contractName: contractType,
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
if (ercType === "erc721") {
contractAddress = await deployERC721Contract({
account,
chain: chain,
client: props.client,
params: {
description: collectionInfo.description,
image: collectionInfo.image,
name: collectionInfo.name,
royaltyBps: BigInt(sales.royaltyBps),
royaltyRecipient: sales.royaltyRecipient,
saleRecipient: sales.primarySaleRecipient,
social_urls: transformSocialUrls(collectionInfo.socialUrls),
symbol: collectionInfo.symbol,
},
type: "DropERC721",
});
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: collectionInfo.chain,
contractAddress: contractAddress,
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
} else {
contractAddress = await deployERC1155Contract({
account,
chain: chain,
client: props.client,
params: {
description: collectionInfo.description,
image: collectionInfo.image,
name: collectionInfo.name,
royaltyBps: BigInt(sales.royaltyBps),
royaltyRecipient: sales.royaltyRecipient,
saleRecipient: sales.primarySaleRecipient,
social_urls: transformSocialUrls(collectionInfo.socialUrls),
symbol: collectionInfo.symbol,
},
type: "DropERC1155",
});
return {
contractAddress,
};
} catch (error) {
const errorMessage = parseError(error);
console.error(errorMessage);
reportAssetCreationFailed({
assetType: "nft",
contractType,
error: errorMessage,
step: "deploy-contract",
});
throw error;
}
reportContractDeployed({
address: contractAddress,
chainId: Number(collectionInfo.chain),
contractName: contractType,
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: collectionInfo.chain,
contractAddress: contractAddress,
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
return {
contractAddress,
};
}
async function handleLazyMintNFTs(params: {
@@ -179,8 +161,6 @@ export function CreateNFTPage(props: {
gasless: boolean;
}) {
const { values, ercType } = params;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
@@ -197,24 +177,10 @@ export function CreateNFTPage(props: {
nfts: values.nfts,
});
try {
await sendAndConfirmTransaction({
account,
transaction,
});
} catch (error) {
const errorMessage = parseError(error);
console.error(error);
reportAssetCreationFailed({
assetType: "nft",
contractType,
error: errorMessage,
step: "mint-nfts",
});
throw error;
}
await sendAndConfirmTransaction({
account,
transaction,
});
}
async function handleSetClaimConditionsERC721(params: {
@@ -251,24 +217,10 @@ export function CreateNFTPage(props: {
],
});
try {
await sendAndConfirmTransaction({
account,
transaction,
});
} catch (error) {
const errorMessage = parseError(error);
console.error(errorMessage);
reportAssetCreationFailed({
assetType: "nft",
contractType: "DropERC721",
error: errorMessage,
step: "set-claim-conditions",
});
throw error;
}
await sendAndConfirmTransaction({
account,
transaction,
});
}
async function handleSetClaimConditionsERC1155(params: {
@@ -336,24 +288,10 @@ export function CreateNFTPage(props: {
data: encodedTransactions,
});
try {
await sendAndConfirmTransaction({
account,
transaction: tx,
});
} catch (error) {
const errorMessage = parseError(error);
console.error(errorMessage);
reportAssetCreationFailed({
assetType: "nft",
contractType: "DropERC1155",
error: errorMessage,
step: "set-claim-conditions",
});
throw error;
}
await sendAndConfirmTransaction({
account,
transaction: tx,
});
}
async function handleSetAdmins(params: {
@@ -395,24 +333,10 @@ export function CreateNFTPage(props: {
data: encodedTxs,
});
try {
await sendAndConfirmTransaction({
account,
transaction: tx,
});
} catch (e) {
const errorMessage = parseError(e);
console.error(errorMessage);
reportAssetCreationFailed({
assetType: "nft",
contractType: params.contractType,
error: errorMessage,
step: "set-admins",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: tx,
});
}
return (
@@ -1,6 +1,7 @@
import type { UseFormReturn } from "react-hook-form";
import * as z from "zod";
import { addressSchema, socialUrlsSchema } from "../../_common/schema";
import { getInitialTickValue, isValidTickValue } from "../utils/calculate-tick";
export const tokenInfoFormSchema = z.object({
chain: z.string().min(1, "Chain is required"),
@@ -15,38 +16,69 @@ export const tokenInfoFormSchema = z.object({
.max(10, "Symbol must be 10 characters or less"),
});
const priceAmountSchema = z.string().refine(
(value) => {
const number = Number(value);
return !Number.isNaN(number) && number >= 0;
},
{
message: "Amount must be number larger than or equal to 0",
},
);
export const tokenDistributionFormSchema = z.object({
// airdrop
airdropAddresses: z.array(
z.object({
address: addressSchema,
quantity: z.string(),
}),
),
// UI states
airdropEnabled: z.boolean(),
saleAllocationPercentage: z.string().refine(
(value) => {
const number = Number(value);
if (Number.isNaN(number)) {
// sales ---
erc20Asset_poolMode: z.object({
startingPricePerToken: priceAmountSchema.refine((value) => {
const numValue = Number(value);
if (numValue === 0) {
return false;
}
return number >= 0 && number <= 100;
},
{
message: "Must be a number between 0 and 100",
},
),
const tick = getInitialTickValue({
startingPricePerToken: Number(value),
});
return isValidTickValue(tick);
}, "Invalid price"),
saleAllocationPercentage: z.string().refine(
(value) => {
const number = Number(value);
if (Number.isNaN(number)) {
return false;
}
return number >= 0 && number <= 100;
},
{
message: "Must be a number between 0 and 100",
},
),
}),
dropERC20Mode: z.object({
pricePerToken: priceAmountSchema,
saleTokenAddress: addressSchema,
saleAllocationPercentage: z.string().refine(
(value) => {
const number = Number(value);
if (Number.isNaN(number)) {
return false;
}
return number >= 0 && number <= 100;
},
{
message: "Must be a number between 0 and 100",
},
),
}),
saleEnabled: z.boolean(),
salePrice: z.string().refine(
(value) => {
const number = Number(value);
return !Number.isNaN(number) && number >= 0;
},
{
message: "Must be number larger than or equal to 0",
},
),
saleTokenAddress: z.string(),
saleMode: z.enum(["erc20-asset:pool", "drop-erc20:token-drop"]),
supply: z.string().min(1, "Supply is required"),
});
@@ -8,34 +8,37 @@ import {
sendAndConfirmTransaction,
type ThirdwebClient,
toUnits,
toWei,
} from "thirdweb";
import { deployERC20Contract } from "thirdweb/deploys";
import type { ClaimConditionsInput } from "thirdweb/dist/types/utils/extensions/drops/types";
import {
approve,
claimTo,
getActiveClaimCondition,
setClaimConditions as setClaimConditionsExtension,
transferBatch,
} from "thirdweb/extensions/erc20";
import { useActiveAccount } from "thirdweb/react";
import {
createToken,
distributeToken,
getDeployedEntrypointERC20,
} from "thirdweb/tokens";
import type { ClaimConditionsInput } from "thirdweb/utils";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
reportContractDeployed,
} from "@/analytics/report";
import { reportContractDeployed } from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import {
DEFAULT_FEE_BPS_NEW,
DEFAULT_FEE_RECIPIENT,
} from "@/constants/addresses";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useAddContractToProject } from "@/hooks/project-contracts";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
import { parseError } from "@/utils/errorParser";
import { pollWithTimeout } from "@/utils/pollWithTimeout";
import { createTokenOnUniversalBridge } from "../_apis/create-token-on-bridge";
import type { CreateAssetFormValues } from "./_common/form";
import { CreateTokenAssetPageUI } from "./create-token-page.client";
import { getInitialTickValue } from "./utils/calculate-tick";
export function CreateTokenAssetPage(props: {
accountAddress: string;
@@ -47,7 +50,6 @@ export function CreateTokenAssetPage(props: {
teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();
const { idToChain } = useAllChainsData();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
@@ -83,7 +85,149 @@ export function CreateTokenAssetPage(props: {
});
}
async function deployContract(params: {
// ERC20Asset ---
async function Erc20Asset_deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const account = getAccount(params.gasless);
const socialUrls = params.values.socialUrls.reduce(
(acc, url) => {
if (url.url && url.platform) {
acc[url.platform] = url.url;
}
return acc;
},
{} as Record<string, string>,
);
const salePercent = params.values.saleEnabled
? Number(params.values.erc20Asset_poolMode.saleAllocationPercentage)
: 0;
const saleAmount = Math.floor(
Number(params.values.supply) * (salePercent / 100),
);
const contractAddress = await createToken({
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(params.values.chain)),
client: props.client,
launchConfig:
params.values.saleEnabled && saleAmount !== 0
? {
kind: "pool",
config: {
amount: BigInt(saleAmount),
initialTick: getInitialTickValue({
startingPricePerToken: Number(
params.values.erc20Asset_poolMode.startingPricePerToken,
),
}),
developerRewardBps: 1250, // 12.5%
},
}
: undefined,
params: {
description: params.values.description,
image: params.values.image,
maxSupply: BigInt(params.values.supply),
name: params.values.name,
social_urls: socialUrls,
symbol: params.values.symbol,
},
developerAddress: "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94",
});
// add contract to project in background
addContractToProject.mutateAsync({
chainId: params.values.chain,
contractAddress: contractAddress,
contractType: "ERC20Asset",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
reportContractDeployed({
address: contractAddress,
chainId: Number(params.values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: account.address,
});
contractAddressRef.current = contractAddress;
return {
contractAddress: contractAddress,
};
}
async function ERC20Asset_airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const contract = getDeployedContract({ chain: values.chain });
const airdropTx = await distributeToken({
chain: contract.chain,
client: props.client,
contents: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
recipient: recipient.address,
})),
tokenAddress: contract.address,
});
await sendAndConfirmTransaction({
account,
transaction: airdropTx,
});
}
async function ERC20Asset_approveAirdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const contract = getDeployedContract({ chain: values.chain });
const totalAmountToAirdrop = values.airdropAddresses.reduce(
(acc, recipient) => acc + Number(recipient.quantity),
0,
);
const entrypoint = await getDeployedEntrypointERC20({
chain: contract.chain,
client: props.client,
});
if (!entrypoint) {
throw new Error("Entrypoint not found");
}
const approvalTx = approve({
amountWei: toWei(totalAmountToAirdrop.toString()),
contract: contract,
spender: entrypoint.address,
});
await sendAndConfirmTransaction({
account,
transaction: approvalTx,
});
}
// DropERC20 ----
async function DropERC20_deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -101,73 +245,53 @@ export function CreateTokenAssetPage(props: {
{} as Record<string, string>,
);
try {
const contractAddress = await deployERC20Contract({
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineDashboardChain(
Number(values.chain),
idToChain.get(Number(values.chain)),
),
client: props.client,
params: {
description: values.description,
image: values.image,
// metadata
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: values.symbol,
},
type: "DropERC20",
});
const contractAddress = await deployERC20Contract({
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(values.chain)),
client: props.client,
params: {
description: values.description,
image: values.image,
// metadata
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: values.symbol,
},
type: "DropERC20",
});
contractAddressRef.current = contractAddress;
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
// add contract to project in background
addContractToProject.mutateAsync({
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
reportContractDeployed({
address: contractAddress,
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
reportContractDeployed({
address: contractAddress,
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
return {
contractAddress: contractAddress,
};
} catch (e) {
console.error(e);
const parsedError = parseError(e);
const errorMessage =
typeof parsedError === "string" ? parsedError : "Unknown error";
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "deploy-contract",
});
console.error(errorMessage);
throw e;
}
return {
contractAddress: contractAddress,
};
}
async function airdropTokens(params: {
async function DropERC20_airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -183,34 +307,21 @@ export function CreateTokenAssetPage(props: {
throw new Error("No connected account");
}
try {
const airdropTx = transferBatch({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
contract,
});
const airdropTx = transferBatch({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
contract,
});
await sendAndConfirmTransaction({
account,
transaction: airdropTx,
});
} catch (e) {
console.error(e);
const errorMessage = parseError(e);
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "airdrop-tokens",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: airdropTx,
});
}
async function mintTokens(params: {
async function DropERC20_mintTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -235,39 +346,25 @@ export function CreateTokenAssetPage(props: {
const totalSupply = Number(values.supply);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
? Number(values.dropERC20Mode.saleAllocationPercentage)
: 0;
const ownerAndAirdropPercent = 100 - salePercent;
const ownerSupplyTokens = (totalSupply * ownerAndAirdropPercent) / 100;
try {
const claimTx = claimTo({
contract,
quantity: ownerSupplyTokens.toString(),
to: account.address,
});
const claimTx = claimTo({
contract,
quantity: ownerSupplyTokens.toString(),
to: account.address,
});
await sendAndConfirmTransaction({
account,
transaction: claimTx,
});
} catch (e) {
const errorMessage = parseError(e);
console.error(e);
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "mint-tokens",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: claimTx,
});
}
async function setClaimConditions(params: {
async function DropERC20_setClaimConditions(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -279,7 +376,7 @@ export function CreateTokenAssetPage(props: {
const account = getAccount(gasless);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
? Number(values.dropERC20Mode.saleAllocationPercentage)
: 0;
const totalSupply = Number(values.supply);
@@ -288,10 +385,10 @@ export function CreateTokenAssetPage(props: {
const phases: ClaimConditionsInput[] = [
{
currencyAddress:
getAddress(values.saleTokenAddress) ===
getAddress(values.dropERC20Mode.saleTokenAddress) ===
getAddress(NATIVE_TOKEN_ADDRESS)
? undefined
: values.saleTokenAddress,
: values.dropERC20Mode.saleTokenAddress,
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
maxClaimableSupply: totalSupplyWei,
metadata: {
@@ -307,7 +404,10 @@ export function CreateTokenAssetPage(props: {
price: "0",
},
],
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
price:
values.saleEnabled && salePercent > 0
? values.dropERC20Mode.pricePerToken
: "0",
startTime: new Date(),
},
];
@@ -317,23 +417,10 @@ export function CreateTokenAssetPage(props: {
phases,
});
try {
await sendAndConfirmTransaction({
account,
transaction: preparedTx,
});
} catch (e) {
const errorMessage = parseError(e);
console.error(e);
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "set-claim-conditions",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: preparedTx,
});
}
return (
@@ -341,12 +428,24 @@ export function CreateTokenAssetPage(props: {
accountAddress={props.accountAddress}
client={props.client}
createTokenFunctions={{
airdropTokens,
deployContract,
mintTokens,
setClaimConditions,
ERC20Asset: {
airdropTokens: ERC20Asset_airdropTokens,
deployContract: Erc20Asset_deployContract,
approveAirdropTokens: ERC20Asset_approveAirdropTokens,
},
DropERC20: {
deployContract: DropERC20_deployContract,
airdropTokens: DropERC20_airdropTokens,
mintTokens: DropERC20_mintTokens,
setClaimConditions: DropERC20_setClaimConditions,
},
}}
onLaunchSuccess={() => {
onLaunchSuccess={(params) => {
createTokenOnUniversalBridge({
chainId: params.chainId,
client: props.client,
tokenAddress: params.contractAddress,
});
revalidatePathAction(
`/team/${props.teamSlug}/project/${props.projectId}/tokens`,
"page",
@@ -1,16 +1,24 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
defineChain,
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import { useActiveWalletChain } from "thirdweb/react";
import {
getDeployedContractFactory,
isPoolRouterEnabled,
} from "thirdweb/tokens";
import { reportAssetCreationStepConfigured } from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { StepCard } from "../_common/step-card";
import {
type CreateAssetFormValues,
type TokenDistributionFormValues,
@@ -28,21 +36,33 @@ type CreateTokenFunctionsParams = {
};
export type CreateTokenFunctions = {
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
ERC20Asset: {
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
airdropTokens: (values: CreateTokenFunctionsParams) => Promise<void>;
approveAirdropTokens: (values: CreateTokenFunctionsParams) => Promise<void>;
};
DropERC20: {
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
};
};
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
export function CreateTokenAssetPageUI(props: {
accountAddress: string;
client: ThirdwebClient;
createTokenFunctions: CreateTokenFunctions;
onLaunchSuccess: () => void;
onLaunchSuccess: (params: {
chainId: number;
contractAddress: string;
}) => void;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
@@ -50,11 +70,11 @@ export function CreateTokenAssetPageUI(props: {
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
"token-info",
);
const activeWalletChain = useActiveWalletChain();
const activeChain = useActiveWalletChain();
const tokenInfoForm = useForm<TokenInfoFormValues>({
defaultValues: {
chain: activeWalletChain?.id.toString() || "1",
chain: activeChain?.id.toString() || "8453",
description: "",
image: undefined,
name: "",
@@ -74,22 +94,70 @@ export function CreateTokenAssetPageUI(props: {
reValidateMode: "onChange",
});
const isERC20AssetSupportedQuery = useQuery({
queryKey: ["is-erc20-asset-supported", tokenInfoForm.watch("chain")],
queryFn: async () => {
try {
const res = await getDeployedContractFactory({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(tokenInfoForm.watch("chain"))),
client: props.client,
});
return !!res;
} catch {
return false;
}
},
});
const isAssetRouterEnabledQuery = useQuery({
queryFn: async () => {
try {
return await isPoolRouterEnabled({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(tokenInfoForm.watch("chain"))),
client: props.client,
});
} catch {
return false;
}
},
queryKey: ["is-asset-router-enabled", tokenInfoForm.watch("chain")],
});
const defaultSaleMode = isERC20AssetSupportedQuery.data
? "erc20-asset:pool"
: "drop-erc20:token-drop";
const tokenDistributionForm = useForm<TokenDistributionFormValues>({
resolver: zodResolver(tokenDistributionFormSchema),
reValidateMode: "onChange",
values: {
airdropAddresses: [],
// airdrop
airdropEnabled: false,
// sale fieldset
saleAllocationPercentage: "0",
saleEnabled: false,
salePrice: "0.1",
saleTokenAddress: checksummedNativeTokenAddress,
supply: "1000000",
erc20Asset_poolMode: {
startingPricePerToken: "0.000000001", // 1gwei per token
saleAllocationPercentage: "100",
},
dropERC20Mode: {
pricePerToken: "0.1",
saleAllocationPercentage: "100",
saleTokenAddress: nativeTokenAddress,
},
supply: "1000000000", // 1 billion
saleEnabled: !(
defaultSaleMode === "erc20-asset:pool" &&
!isAssetRouterEnabledQuery.data
),
saleMode: defaultSaleMode,
},
mode: "onChange",
resolver: zodResolver(tokenDistributionFormSchema),
reValidateMode: "onChange",
});
const distributionFieldIsPending =
isERC20AssetSupportedQuery.isPending || isAssetRouterEnabledQuery.isPending;
return (
<div>
{step === "token-info" && (
@@ -97,10 +165,10 @@ export function CreateTokenAssetPageUI(props: {
client={props.client}
form={tokenInfoForm}
onChainUpdated={() => {
// if the chain is updated, set the sale token address to the native token address
// reset the token address to the native token address on chain change
tokenDistributionForm.setValue(
"saleTokenAddress",
checksummedNativeTokenAddress,
"dropERC20Mode.saleTokenAddress",
nativeTokenAddress,
);
}}
onNext={() => {
@@ -113,25 +181,44 @@ export function CreateTokenAssetPageUI(props: {
/>
)}
{step === "distribution" && (
<TokenDistributionFieldset
accountAddress={props.accountAddress}
chainId={tokenInfoForm.watch("chain")}
client={props.client}
form={tokenDistributionForm}
onNext={() => {
reportAssetCreationStepConfigured({
assetType: "coin",
step: "token-distribution",
});
setStep("launch");
}}
onPrevious={() => {
setStep("token-info");
}}
tokenSymbol={tokenInfoForm.watch("symbol")}
/>
)}
{step === "distribution" &&
(distributionFieldIsPending ? (
<StepCard
nextButton={{
disabled: true,
type: "submit",
}}
prevButton={{
onClick: () => {
setStep("token-info");
},
}}
title="Coin Distribution"
>
<div className="h-[400px] flex items-center justify-center">
<Spinner className="size-10" />
</div>
</StepCard>
) : (
<TokenDistributionFieldset
form={tokenDistributionForm}
accountAddress={props.accountAddress}
chainId={tokenInfoForm.watch("chain")}
isRouterEnabled={isAssetRouterEnabledQuery.data === true}
client={props.client}
onNext={() => {
reportAssetCreationStepConfigured({
assetType: "coin",
step: "token-distribution",
});
setStep("launch");
}}
onPrevious={() => {
setStep("token-info");
}}
tokenSymbol={tokenInfoForm.watch("symbol")}
/>
))}
{step === "launch" && (
<LaunchTokenStatus
@@ -1,7 +1,10 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
import { storybookThirdwebClient } from "@/storybook/utils";
import { CreateTokenAssetPageUI } from "./create-token-page.client";
import {
CreateTokenAssetPageUI,
type CreateTokenFunctions,
} from "./create-token-page.client";
const meta = {
component: CreateTokenAssetPageUI,
@@ -9,9 +12,9 @@ const meta = {
(Story) => (
<ThirdwebProvider>
<div className="container max-w-5xl py-10">
<ConnectButton client={storybookThirdwebClient} />
<div className="h-10" />
<Story />
<div className="h-10" />
<ConnectButton client={storybookThirdwebClient} />
</div>
</ThirdwebProvider>
),
@@ -22,19 +25,33 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
const mockCreateTokenFunctions = {
airdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const mockCreateTokenFunctions: CreateTokenFunctions = {
ERC20Asset: {
airdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
approveAirdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { contractAddress: "0x123" };
},
},
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { contractAddress: "0x123" };
},
mintTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
setClaimConditions: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
DropERC20: {
airdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { contractAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" };
},
setClaimConditions: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
mintTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
},
};
@@ -56,9 +73,12 @@ export const ErrorOnDeploy: Story = {
client: storybookThirdwebClient,
createTokenFunctions: {
...mockCreateTokenFunctions,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error("Failed to deploy contract");
ERC20Asset: {
...mockCreateTokenFunctions.ERC20Asset,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error("Failed to deploy contract");
},
},
},
onLaunchSuccess: () => {},
@@ -74,11 +94,14 @@ export const StorageErrorOnDeploy: Story = {
client: storybookThirdwebClient,
createTokenFunctions: {
...mockCreateTokenFunctions,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error(
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
);
ERC20Asset: {
...mockCreateTokenFunctions.ERC20Asset,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error(
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
);
},
},
},
onLaunchSuccess: () => {},
@@ -0,0 +1,177 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { TokenSelector } from "@/components/blocks/TokenSelector";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { DecimalInput } from "@/components/ui/decimal-input";
import { Switch } from "@/components/ui/switch";
import type { TokenDistributionForm } from "../_common/form";
export function DropERC20_TokenSaleSection(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
}) {
const totalSupply = Number(props.form.watch("supply"));
const sellSupply = Math.floor(
(totalSupply *
Number(props.form.watch("dropERC20Mode.saleAllocationPercentage"))) /
100,
);
const isEnabled = props.form.watch("saleEnabled");
const protocolFee = 2;
const convenienceFee = 0.5;
const recipientFee = 100 - protocolFee - convenienceFee;
return (
<DynamicHeight>
<div className="relative border-t border-dashed p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-xl tracking-tight">Sale</h2>
<p className="text-muted-foreground text-sm">
Make your coin available for purchase by setting a price
</p>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => {
props.form.setValue("saleEnabled", checked);
if (!checked) {
props.form.setValue(
"dropERC20Mode.saleAllocationPercentage",
"0",
);
props.form.setValue("dropERC20Mode.pricePerToken", "0");
} else if (!props.form.getValues("airdropEnabled")) {
props.form.setValue(
"dropERC20Mode.saleAllocationPercentage",
"100",
{
shouldValidate: true,
},
);
}
}}
/>
</div>
{isEnabled && (
<div>
<div className="mt-4 flex flex-col lg:flex-row gap-5 lg:max-w-2xl fade-in-0 duration-300 animate-in">
<FormFieldSetup
errorMessage={
props.form.formState.errors.dropERC20Mode
?.saleAllocationPercentage?.message
}
helperText={`${sellSupply} tokens`}
isRequired
label="Sell % of Total Supply"
>
<div className="relative lg:w-64">
<DecimalInput
maxValue={100}
onChange={(value) => {
props.form.setValue(
"dropERC20Mode.saleAllocationPercentage",
value,
);
}}
value={props.form.watch(
"dropERC20Mode.saleAllocationPercentage",
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
%
</span>
</div>
</FormFieldSetup>
<FormFieldSetup
className="grow"
errorMessage={
props.form.formState.errors.dropERC20Mode?.pricePerToken
?.message ||
props.form.formState.errors.dropERC20Mode?.saleTokenAddress
?.message
}
isRequired
label="Price per Token"
>
<div className="relative flex items-center">
<DecimalInput
onChange={(value) => {
props.form.setValue("dropERC20Mode.pricePerToken", value);
}}
value={props.form.watch("dropERC20Mode.pricePerToken")}
className="rounded-r-none w-42"
/>
<TokenSelector
addNativeTokenIfMissing={true}
chainId={Number(props.chainId)}
className="bg-background border-l-0 rounded-l-none"
client={props.client}
disableAddress={true}
popoverContentClassName="!w-[320px]"
onChange={(value) => {
props.form.setValue(
"dropERC20Mode.saleTokenAddress",
value.address,
);
}}
selectedToken={{
address: props.form.watch(
"dropERC20Mode.saleTokenAddress",
),
chainId: Number(props.chainId),
}}
showCheck={true}
/>
</div>
</FormFieldSetup>
</div>
<div className="mt-5 relative border-t border-dashed pt-5">
<p className="text-muted-foreground text-sm mb-3">
All primary sales are subjected to{" "}
<em className="text-foreground font-medium not-italic">2.5%</em>{" "}
fee
</p>
<div className="relative">
<DistributionBarChart
segments={[
{
label: "Your Wallet",
percent: recipientFee,
color: "hsl(var(--chart-1))",
value: `${recipientFee}%`,
},
{
label: "Protocol",
percent: protocolFee,
color: "hsl(var(--chart-2))",
value: `${protocolFee}%`,
},
{
label: "Convenience Fee",
percent: convenienceFee,
color: "hsl(var(--chart-3))",
value: `${convenienceFee}%`,
},
]}
/>
</div>
</div>
</div>
)}
</div>
</DynamicHeight>
);
}
@@ -66,10 +66,10 @@ export function TokenAirdropSection(props: {
return (
<DynamicHeight>
<div className="relative border-t border-dashed">
<div className="flex items-center justify-between gap-3 px-6 py-5">
<div className="relative border-t border-dashed px-4 py-8 md:px-6">
<div className="flex items-center justify-between gap-3 ">
<div>
<h2 className="font-semibold text-lg">Airdrop</h2>
<h2 className="font-semibold text-xl tracking-tight">Airdrop</h2>
<p className="text-muted-foreground text-sm">
Airdrop tokens to a list of addresses with each address receiving
a specific quantity
@@ -88,7 +88,7 @@ export function TokenAirdropSection(props: {
</div>
{isEnabled && (
<div className="flex justify-start px-6 pb-6">
<div className="flex justify-start mt-4 fade-in-0 duration-300 animate-in">
{airdropAddresses.length > 0 ? (
<div className="flex w-full flex-col gap-4 rounded-lg border bg-background p-4 md:flex-row lg:items-center lg:justify-between">
{/* left */}
@@ -262,8 +262,13 @@ const AirdropUpload: React.FC<AirdropUploadProps> = ({
if (csvUpload.normalizeQuery.isPending) {
return (
<div className="flex h-[300px] w-full grow items-center justify-center rounded-lg border border-border">
<div className="flex min-h-[400px] w-full flex-col grow items-center justify-center">
<Spinner className="size-10" />
<p className="text-base text-foreground mt-5">Resolving ENS</p>
<p className="text-sm text-muted-foreground mt-2">
{csvUpload.normalizeProgress.current} /{" "}
{csvUpload.normalizeProgress.total}
</p>
</div>
);
}
@@ -14,21 +14,28 @@ import type {
TokenDistributionForm,
TokenDistributionFormValues,
} from "../_common/form";
import { DropERC20_TokenSaleSection } from "./drop-erc20-token-sale";
import { TokenAirdropSection } from "./token-airdrop";
import { TokenSaleSection } from "./token-sale";
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 10,
notation: "compact",
});
export function TokenDistributionFieldset(props: {
accountAddress: string;
onNext: () => void;
onPrevious: () => void;
form: TokenDistributionForm;
chainId: string;
form: TokenDistributionForm;
client: ThirdwebClient;
tokenSymbol: string | undefined;
isRouterEnabled: boolean;
}) {
const { form } = props;
const distributionError = getDistributionError(form);
const distributionError = getDistributionError(form);
const supplyId = useId();
return (
@@ -45,40 +52,51 @@ export function TokenDistributionFieldset(props: {
title="Coin Distribution"
>
<div>
<div className="space-y-6 p-4 md:px-6 md:py-6">
<div className="p-4 md:px-6 md:py-6">
<FormFieldSetup
errorMessage={form.formState.errors.supply?.message}
htmlFor={supplyId}
helperText={`${compactNumberFormatter.format(Number(form.watch("supply")))} tokens`}
isRequired
label="Total Supply"
>
<div className="relative">
<div className="relative max-w-96">
<Input id={supplyId} {...form.register("supply")} />
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
{props.tokenSymbol || "Tokens"}
</span>
</div>
</FormFieldSetup>
<div className="flex flex-col gap-3">
<TokenDistributionBarChart
distributionFormValues={form.watch()}
/>
{distributionError && (
<div className="text-destructive-text text-sm">
{distributionError}
</div>
)}
</div>
</div>
{form.watch("saleMode") === "drop-erc20:token-drop" ? (
<DropERC20_TokenSaleSection
chainId={props.chainId}
client={props.client}
form={form}
/>
) : (
<TokenSaleSection
chainId={props.chainId}
isRouterEnabled={props.isRouterEnabled}
client={props.client}
form={form}
/>
)}
<TokenAirdropSection client={props.client} form={form} />
<TokenSaleSection
chainId={props.chainId}
client={props.client}
form={form}
/>
<div className="flex flex-col gap-3 p-4 py-8 md:px-6 border-t border-dashed">
<TokenDistributionBarChart
distributionFormValues={form.watch()}
/>
{distributionError && (
<div className="text-destructive-text text-sm">
{distributionError}
</div>
)}
</div>
</div>
</StepCard>
</form>
@@ -97,10 +115,12 @@ function getDistributionError(form: TokenDistributionForm) {
}
const saleSupplyPercentage = SafeNumber(
form.watch("saleAllocationPercentage"),
form.watch("saleMode") === "erc20-asset:pool"
? form.watch("erc20Asset_poolMode.saleAllocationPercentage")
: form.watch("dropERC20Mode.saleAllocationPercentage"),
);
const saleSupply = (saleSupplyPercentage / 100) * supply;
const saleSupply = Math.round((saleSupplyPercentage / 100) * supply);
const ownerSupply = Math.max(supply - totalAirdrop - saleSupply, 0);
const totalSumOfSupply = totalAirdrop + saleSupply + ownerSupply;
@@ -127,7 +147,10 @@ export function TokenDistributionBarChart(props: {
);
const airdropPercentage = (totalAirdropSupply / totalSupply) * 100;
const salePercentage = Number(
props.distributionFormValues.saleAllocationPercentage,
props.distributionFormValues.saleMode === "erc20-asset:pool"
? props.distributionFormValues.erc20Asset_poolMode
.saleAllocationPercentage
: props.distributionFormValues.dropERC20Mode.saleAllocationPercentage,
);
const ownerPercentage = Math.max(100 - airdropPercentage - salePercentage, 0);
@@ -137,16 +160,19 @@ export function TokenDistributionBarChart(props: {
color: "hsl(var(--chart-1))",
label: "Owner",
percent: ownerPercentage,
value: `${ownerPercentage}%`,
},
{
color: "hsl(var(--chart-3))",
label: "Airdrop",
percent: airdropPercentage,
value: `${airdropPercentage}%`,
},
{
color: "hsl(var(--chart-4))",
label: "Sale",
percent: salePercentage,
value: `${salePercentage}%`,
},
];
@@ -1,110 +1,139 @@
"use client";
import { DollarSignIcon, XIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { TokenSelector } from "@/components/blocks/TokenSelector";
import { Badge } from "@/components/ui/badge";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { DecimalInput } from "@/components/ui/decimal-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useAllChainsData } from "@/hooks/chains/allChains";
import type { TokenDistributionForm } from "../_common/form";
export function TokenSaleSection(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
isRouterEnabled: boolean;
}) {
const totalSupply = Number(props.form.watch("supply"));
const sellSupply = Math.floor(
(totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100,
);
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(Number(props.chainId));
const isSaleEnabled = props.form.watch("saleEnabled");
const protocolFee = 20;
const leftOverFee = 100 - protocolFee;
const convenienceFee = (12.5 * leftOverFee) / 100;
const deployerFee = leftOverFee - convenienceFee;
const isEnabled = props.form.watch("saleEnabled");
return (
<DynamicHeight>
<div className="relative border-t border-dashed p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg">Sale</h2>
<p className="text-muted-foreground text-sm">
Make your coin available for purchase by setting a price
</p>
<div className="relative border-t border-dashed px-4 md:px-6 py-8">
<div>
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-xl tracking-tight">Sale</h2>
<p className="text-muted-foreground text-sm">
List your coin on a decentralized exchange and earn rewards on
every trade
</p>
</div>
<Switch
checked={isSaleEnabled}
disabled={!props.isRouterEnabled}
onCheckedChange={(checked) => {
if (!props.isRouterEnabled) {
return;
}
props.form.setValue("saleEnabled", checked);
if (checked && !props.form.getValues("airdropEnabled")) {
props.form.setValue(
"erc20Asset_poolMode.saleAllocationPercentage",
"100",
{
shouldValidate: true,
},
);
} else {
props.form.setValue(
"erc20Asset_poolMode.saleAllocationPercentage",
"0",
{
shouldValidate: true,
},
);
}
}}
/>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => {
props.form.setValue("saleEnabled", checked);
if (!checked) {
props.form.setValue("saleAllocationPercentage", "0");
props.form.setValue("salePrice", "0");
}
}}
/>
{props.isRouterEnabled === false && (
<div className="mt-4 flex items-center gap-1.5 fade-in-0 duration-300 animate-in">
<div className="rounded-full border bg-background p-1">
<XIcon className="size-3 text-muted-foreground" />
</div>
<h2 className="text-muted-foreground text-sm">
Not Available on {chainMeta?.name || props.chainId}
</h2>
</div>
)}
</div>
{isEnabled && (
<div className="mt-4 flex flex-col gap-6">
<FormFieldSetup
errorMessage={
props.form.formState.errors.saleAllocationPercentage?.message
}
helperText={`${sellSupply} tokens`}
isRequired
label="Sell % of Total Supply"
>
<div className="relative">
<DecimalInput
maxValue={100}
onChange={(value) => {
props.form.setValue("saleAllocationPercentage", value);
}}
value={props.form.watch("saleAllocationPercentage")}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
%
</span>
</div>
</FormFieldSetup>
{isSaleEnabled && props.isRouterEnabled === true && (
<div className="pt-4 fade-in-0 duration-300 animate-in">
<PoolConfig
chainId={props.chainId}
client={props.client}
form={props.form}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<FormFieldSetup
errorMessage={props.form.formState.errors.salePrice?.message}
isRequired
label="Price per Token"
>
<div className="relative">
<DecimalInput
onChange={(value) => {
props.form.setValue("salePrice", value);
}}
value={props.form.watch("salePrice")}
/>
<div className="mt-5 border p-4 bg-background rounded-lg relative">
<div className="flex mb-4">
<div className="p-2 rounded-full border bg-card">
<DollarSignIcon className="size-5 text-muted-foreground" />
</div>
</FormFieldSetup>
</div>
<h3 className="font-medium text-base mt-2"> Sale Rewards </h3>
<p className="text-muted-foreground text-sm mb-4">
All trades on the market are subjected to{" "}
<em className="text-foreground font-medium not-italic"> 1% </em>{" "}
fee distributed as:
</p>
<FormFieldSetup
errorMessage={
props.form.formState.errors.saleTokenAddress?.message
}
isRequired
label="Currency"
>
<TokenSelector
addNativeTokenIfMissing={true}
chainId={Number(props.chainId)}
className="bg-background"
client={props.client}
disableAddress={true}
onChange={(value) => {
props.form.setValue("saleTokenAddress", value.address);
}}
selectedToken={{
address: props.form.watch("saleTokenAddress"),
chainId: Number(props.chainId),
}}
showCheck={true}
<div className="relative">
<div className="absolute -top-6 right-0 text-sm">Total: 1%</div>
<DistributionBarChart
segments={[
{
label: "Your Wallet",
percent: deployerFee,
color: "hsl(var(--chart-1))",
value: `${deployerFee / 100}%`,
},
{
label: "Protocol",
percent: protocolFee,
color: "hsl(var(--chart-2))",
value: `${protocolFee / 100}%`,
},
{
label: "Convenience fee",
percent: convenienceFee,
color: "hsl(var(--chart-3))",
value: `${convenienceFee / 100}%`,
},
]}
/>
</FormFieldSetup>
</div>
</div>
</div>
)}
@@ -112,3 +141,123 @@ export function TokenSaleSection(props: {
</DynamicHeight>
);
}
function PoolConfig(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
}) {
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(Number(props.chainId));
const totalSupply = Number(props.form.watch("supply"));
const sellSupply = Math.floor(
(totalSupply *
Number(
props.form.watch("erc20Asset_poolMode.saleAllocationPercentage"),
)) /
100,
);
return (
<div className="space-y-6">
{/* Pricing Strategy */}
<FormFieldSetup
isRequired
label="Pricing Strategy"
errorMessage={undefined}
helperText="Bonding Curve pricing is a static formula for pricing tokens based on supply and demand"
>
<Select value="bonding-curve">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bonding-curve">Bonding Curve</SelectItem>
<SelectItem value="fixed-price" disabled>
<span className="flex items-center gap-2">
Dynamic Bonding Curve{" "}
<Badge variant="secondary">Coming Soon</Badge>
</span>
</SelectItem>
<SelectItem value="dutch-auction" disabled>
<span className="flex items-center gap-2">
Fixed Price <Badge variant="secondary">Coming Soon</Badge>
</span>
</SelectItem>
</SelectContent>
</Select>
</FormFieldSetup>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:max-w-[536px]">
{/* supply % */}
<FormFieldSetup
errorMessage={
props.form.formState.errors.erc20Asset_poolMode
?.saleAllocationPercentage?.message
}
helperText={`${compactNumberFormatter.format(sellSupply)} tokens`}
isRequired
label="Sell % of Total Supply"
>
<div className="relative">
<DecimalInput
maxValue={100}
onChange={(value) => {
props.form.setValue(
"erc20Asset_poolMode.saleAllocationPercentage",
value,
{
shouldValidate: true,
},
);
}}
value={props.form.watch(
"erc20Asset_poolMode.saleAllocationPercentage",
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-sm text-muted-foreground">
%
</span>
</div>
</FormFieldSetup>
{/* starting price */}
<FormFieldSetup
errorMessage={
props.form.formState.errors.erc20Asset_poolMode
?.startingPricePerToken?.message
}
isRequired
label="Starting price per token"
>
<div className="relative">
<DecimalInput
className="pr-10"
onChange={(value) => {
props.form.setValue(
"erc20Asset_poolMode.startingPricePerToken",
value,
{
shouldValidate: true,
},
);
}}
value={props.form.watch(
"erc20Asset_poolMode.startingPricePerToken",
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-sm text-muted-foreground">
{chainMeta?.nativeCurrency.symbol || "ETH"}
</span>
</div>
</FormFieldSetup>
</div>
</div>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 10,
notation: "compact",
});
@@ -38,10 +38,15 @@ import type { CreateTokenFunctions } from "../create-token-page.client";
import { TokenDistributionBarChart } from "../distribution/token-distribution";
const stepIds = {
"airdrop-tokens": "airdrop-tokens",
"deploy-contract": "deploy-contract",
"mint-tokens": "mint-tokens",
"set-claim-conditions": "set-claim-conditions",
// asset ---
"erc20-asset:airdrop-tokens": "erc20-asset:airdrop-tokens",
"erc20-asset:approve-airdrop-tokens": "erc20-asset:approve-airdrop-tokens",
"erc20-asset:deploy-contract": "erc20-asset:deploy-contract",
// fallback ---
"drop-erc20:deploy-contract": "drop-erc20:deploy-contract",
"drop-erc20:set-claim-conditions": "drop-erc20:set-claim-conditions",
"drop-erc20:mint-tokens": "drop-erc20:mint-tokens",
"drop-erc20:airdrop-tokens": "drop-erc20:airdrop-tokens",
} as const;
type StepId = keyof typeof stepIds;
@@ -51,7 +56,10 @@ export function LaunchTokenStatus(props: {
values: CreateAssetFormValues;
onPrevious: () => void;
client: ThirdwebClient;
onLaunchSuccess: () => void;
onLaunchSuccess: (params: {
chainId: number;
contractAddress: string;
}) => void;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
@@ -60,7 +68,7 @@ export function LaunchTokenStatus(props: {
const { createTokenFunctions } = props;
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [contractLink, setContractLink] = useState<string | null>(null);
const [contractAddress, setContractAddress] = useState<string | null>(null);
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
@@ -85,35 +93,67 @@ export function LaunchTokenStatus(props: {
}
async function handleSubmitClick() {
const initialSteps: MultiStepState<StepId>[] = [
{
id: stepIds["deploy-contract"],
label: "Deploy contract",
status: { type: "idle" },
},
{
id: stepIds["set-claim-conditions"],
label: "Set claim conditions",
status: { type: "idle" },
},
{
id: stepIds["mint-tokens"],
label: "Mint tokens",
status: { type: "idle" },
},
];
if (formValues.saleMode === "erc20-asset:pool") {
const initialSteps: MultiStepState<StepId>[] = [
{
id: stepIds["erc20-asset:deploy-contract"],
label: "Deploy contract",
status: { type: "idle" },
},
];
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
id: stepIds["airdrop-tokens"],
label: "Airdrop tokens",
status: { type: "idle" },
});
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
id: stepIds["erc20-asset:approve-airdrop-tokens"],
label: "Approve spending tokens for airdrop",
status: { type: "idle" },
});
initialSteps.push({
id: stepIds["erc20-asset:airdrop-tokens"],
label: "Airdrop tokens",
status: { type: "idle" },
});
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0, isGasless);
} else {
const initialSteps: MultiStepState<StepId>[] = [
{
id: stepIds["drop-erc20:deploy-contract"],
label: "Deploy contract",
status: { type: "idle" },
},
{
id: stepIds["drop-erc20:set-claim-conditions"],
label: "Set claim conditions",
status: { type: "idle" },
},
];
// if user is selling 100% of the tokens, owner share is 0% - so skip minting
if (Number(formValues.dropERC20Mode.saleAllocationPercentage) !== 100) {
initialSteps.push({
id: stepIds["drop-erc20:mint-tokens"],
label: "Mint tokens",
status: { type: "idle" },
});
}
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
id: stepIds["drop-erc20:airdrop-tokens"],
label: "Airdrop tokens",
status: { type: "idle" },
});
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0, isGasless);
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0, isGasless);
}
const isComplete = steps.every((step) => step.status.type === "completed");
@@ -125,17 +165,28 @@ export function LaunchTokenStatus(props: {
values: formValues,
};
if (stepId === "deploy-contract") {
const result = await createTokenFunctions.deployContract(params);
setContractLink(
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
);
} else if (stepId === "set-claim-conditions") {
await createTokenFunctions.setClaimConditions(params);
} else if (stepId === "mint-tokens") {
await createTokenFunctions.mintTokens(params);
} else if (stepId === "airdrop-tokens") {
await createTokenFunctions.airdropTokens(params);
// erc20-asset
if (stepId === "erc20-asset:deploy-contract") {
const result =
await createTokenFunctions.ERC20Asset.deployContract(params);
setContractAddress(result.contractAddress);
} else if (stepId === "erc20-asset:airdrop-tokens") {
await createTokenFunctions.ERC20Asset.airdropTokens(params);
} else if (stepId === "erc20-asset:approve-airdrop-tokens") {
await createTokenFunctions.ERC20Asset.approveAirdropTokens(params);
}
// drop-erc20
else if (stepId === "drop-erc20:deploy-contract") {
const result =
await createTokenFunctions.DropERC20.deployContract(params);
setContractAddress(result.contractAddress);
} else if (stepId === "drop-erc20:set-claim-conditions") {
await createTokenFunctions.DropERC20.setClaimConditions(params);
} else if (stepId === "drop-erc20:mint-tokens") {
await createTokenFunctions.DropERC20.mintTokens(params);
} else if (stepId === "drop-erc20:airdrop-tokens") {
await createTokenFunctions.DropERC20.airdropTokens(params);
}
}
@@ -165,7 +216,10 @@ export function LaunchTokenStatus(props: {
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
contractType:
formValues.saleMode === "drop-erc20:token-drop"
? "DropERC20"
: "ERC20Asset",
error: errorMessage,
step: currentStep.id,
});
@@ -181,10 +235,18 @@ export function LaunchTokenStatus(props: {
reportAssetCreationSuccessful({
assetType: "coin",
contractType: "DropERC20",
contractType:
formValues.saleMode === "drop-erc20:token-drop"
? "DropERC20"
: "ERC20Asset",
});
props.onLaunchSuccess();
if (contractAddress) {
props.onLaunchSuccess({
chainId: Number(formValues.chain),
contractAddress,
});
}
}
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
@@ -196,6 +258,10 @@ export function LaunchTokenStatus(props: {
await executeSteps(steps, startIndex, gasless);
}
const contractLink = contractAddress
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${contractAddress}`
: null;
return (
<StepCard
nextButton={{
@@ -302,7 +368,7 @@ export function LaunchTokenStatus(props: {
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
dialogCloseClassName="hidden"
>
<div className="flex flex-col gap-6 p-6">
<div className="flex flex-col gap-6 p-6 overflow-hidden">
<DialogHeader className="space-y-0.5">
<DialogTitle className="font-semibold text-xl tracking-tight">
Status
@@ -8,6 +8,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
import { StepCard } from "../../_common/step-card";
@@ -47,7 +48,7 @@ export function TokenInfoFieldset(props: {
client={props.client}
setValue={(file) =>
form.setValue("image", file, {
shouldTouch: true,
shouldValidate: true,
})
}
value={form.watch("image")}
@@ -59,7 +60,7 @@ export function TokenInfoFieldset(props: {
{/* name + symbol */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-4">
<FormFieldSetup
className="grow"
className="lg:max-w-[300px] grow"
errorMessage={form.formState.errors.name?.message}
htmlFor={nameId}
isRequired
@@ -73,7 +74,7 @@ export function TokenInfoFieldset(props: {
</FormFieldSetup>
<FormFieldSetup
className="lg:max-w-[200px]"
className="lg:max-w-[150px]"
errorMessage={form.formState.errors.symbol?.message}
htmlFor={symbolId}
isRequired
@@ -94,14 +95,28 @@ export function TokenInfoFieldset(props: {
isRequired
label="Chain"
>
<ClientOnly ssr={null}>
<ClientOnly ssr={<Skeleton className="h-10" />}>
<SingleNetworkSelector
priorityChains={[
8453, // base
84532, // base sepolia
1, // ethereum
56, // bnb smart chain mainnet (bsc)
42161, // arbitrum one mainnet
43114, // avalanche mainnet
146, // sonic
137, // polygon
80094, // berachain mainnet
10, // optimism
]}
chainId={Number(form.watch("chain"))}
className="bg-background"
client={props.client}
disableChainId
onChange={(chain) => {
form.setValue("chain", chain.toString());
form.setValue("chain", chain.toString(), {
shouldValidate: true,
});
props.onChainUpdated();
}}
/>
@@ -0,0 +1,17 @@
const MIN_TICK = -887200;
const MAX_TICK = 887200;
const TICK_SPACING = 200;
export function getInitialTickValue(params: { startingPricePerToken: number }) {
const calculatedTick =
Math.log(params.startingPricePerToken) / Math.log(1.0001);
// Round to nearest tick spacing
const tick = Math.round(calculatedTick / TICK_SPACING) * TICK_SPACING;
return tick;
}
export function isValidTickValue(tick: number) {
return tick >= MIN_TICK && tick <= MAX_TICK;
}
@@ -86,6 +86,7 @@ const authMethodsToPickFrom: InAppWalletAuth[] = [
"discord",
"line",
"x",
"tiktok",
"coinbase",
"farcaster",
"telegram",
@@ -3,7 +3,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
@@ -240,11 +239,7 @@ export const InAppWalletSettingsUI: React.FC<
return (
<Form {...form}>
<form
autoComplete="off"
className="flex flex-col gap-6"
onSubmit={handleSubmit}
>
<form autoComplete="off" className="space-y-6" onSubmit={handleSubmit}>
{/* Branding */}
<BrandingFieldset
client={props.client}
@@ -252,12 +247,28 @@ export const InAppWalletSettingsUI: React.FC<
requiredPlan={brandingRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
/>
<NativeAppsFieldset form={form} />
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
{/* Authentication */}
<Fieldset legend="Authentication">
<Fieldset
legend="Authentication"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button
className="gap-2"
type="submit"
size="sm"
variant="outline"
>
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<JSONWebTokenFields
form={form}
requiredPlan={authRequiredPlan}
@@ -265,7 +276,7 @@ export const InAppWalletSettingsUI: React.FC<
teamSlug={props.teamSlug}
/>
<div className="h-5" />
<div className="my-5 border-t border-dashed" />
<AuthEndpointFields
form={form}
@@ -274,7 +285,7 @@ export const InAppWalletSettingsUI: React.FC<
teamSlug={props.teamSlug}
/>
<div className="h-5" />
<div className="my-5 border-t border-dashed" />
<SMSCountryFields
form={form}
@@ -284,13 +295,6 @@ export const InAppWalletSettingsUI: React.FC<
teamSlug={props.teamSlug}
/>
</Fieldset>
<div className="flex justify-end">
<Button className="gap-2" type="submit" variant="primary">
{props.isUpdating && <Spinner className="size-4" />}
Save changes
</Button>
</div>
</form>
</Form>
);
@@ -302,14 +306,22 @@ function BrandingFieldset(props: {
teamSlug: string;
requiredPlan: Team["billingPlan"];
client: ThirdwebClient;
isUpdating: boolean;
}) {
return (
<Fieldset legend="Branding">
<SwitchContainer
description="Pass a custom logo and app name to be used in the emails sent to users."
switchId="branding-switch"
title="Custom email logo and name"
>
<FieldsetWithDescription
legend="Branding"
description="Set an app name and logo to be used in the emails sent to users"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button className="gap-2" type="submit" size="sm" variant="outline">
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<div className="absolute top-8 right-6">
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
@@ -330,64 +342,69 @@ function BrandingFieldset(props: {
teamSlug={props.teamSlug}
trackingLabel="customEmailLogoAndName"
/>
</SwitchContainer>
</div>
<GatedCollapsibleContainer
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
className="border-t border-dashed pt-5"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("branding")}
requiredPlan={props.requiredPlan}
>
{/* Application Image */}
<FormField
control={props.form.control}
name="branding.applicationImageUrl"
render={() => (
<FormItem className="space-y-1">
<FormLabel>Application Image URL</FormLabel>
<FormDescription className="!mb-4">
Logo that will display in the emails sent to users.{" "}
<br className="max-sm:hidden" /> The image must be squared with
recommended size of 72x72 px.
</FormDescription>
<FormControl>
<AppImageFormControl
client={props.client}
setUri={(uri) => {
props.form.setValue("branding.applicationImageUrl", uri, {
shouldDirty: true,
shouldTouch: true,
});
}}
uri={props.form.watch("branding.applicationImageUrl")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-6 lg:flex-row">
<FormField
control={props.form.control}
name="branding.applicationImageUrl"
render={() => (
<FormItem className="lg:border-r lg:border-dashed lg:pr-6">
<div className="space-y-1">
<FormLabel>Application Image URL</FormLabel>
<FormDescription className="!mb-4">
Logo that will display in the emails sent to users.{" "}
<br className="max-sm:hidden" /> The image must be squared
with recommended size of 72x72 px.
</FormDescription>
{/* Application Name */}
<FormField
control={props.form.control}
name="branding.applicationName"
render={({ field }) => (
<FormItem>
<FormLabel>Application Name</FormLabel>
<FormDescription className="!mb-2">
Name that will be displayed in the emails sent to users.{" "}
<br className="max-sm:hidden" /> Defaults to your API Key's
name.
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</div>
<FormControl>
<AppImageFormControl
client={props.client}
setUri={(uri) => {
props.form.setValue("branding.applicationImageUrl", uri, {
shouldDirty: true,
shouldTouch: true,
});
}}
uri={props.form.watch("branding.applicationImageUrl")}
/>
</FormControl>
</FormItem>
)}
/>
{/* Application Name */}
<FormField
control={props.form.control}
name="branding.applicationName"
render={({ field }) => (
<FormItem>
<FormLabel>Application Name</FormLabel>
<FormDescription>
Name that will be displayed in the emails sent to users.{" "}
<br className="max-sm:hidden" /> Defaults to your API Key's
name.
</FormDescription>
<FormControl>
<Input {...field} className="max-w-md" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</GatedCollapsibleContainer>
</Fieldset>
</FieldsetWithDescription>
);
}
@@ -418,7 +435,7 @@ function AppImageFormControl(props: {
<div className="relative">
<FileInput
accept={{ "image/*": [] }}
className="w-24 rounded-full bg-background lg:w-28"
className="w-24 rounded-full bg-background"
client={props.client}
disableHelperText
setValue={async (v) => {
@@ -514,14 +531,13 @@ function JSONWebTokenFields(props: {
description={
<>
Optionally allow users to authenticate with a custom JWT.{" "}
<Link
className="text-link-foreground hover:text-foreground"
<UnderlineLink
href="https://portal.thirdweb.com/wallets/custom-auth"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</Link>
</UnderlineLink>
</>
}
switchId="authentication-switch"
@@ -606,14 +622,13 @@ function AuthEndpointFields(props: {
<>
Optionally allow users to authenticate with any arbitrary payload
that you provide.{" "}
<Link
className="text-link-foreground hover:text-foreground"
<UnderlineLink
href="https://portal.thirdweb.com/wallets/custom-auth"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</Link>
</UnderlineLink>
</>
}
switchId="auth-endpoint-switch"
@@ -743,10 +758,21 @@ function AuthEndpointFieldsContent(props: {
function NativeAppsFieldset(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
isUpdating: boolean;
}) {
const { form } = props;
return (
<Fieldset legend="Native Apps">
<Fieldset
legend="Native Apps"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button className="gap-2" type="submit" size="sm" variant="outline">
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<FormField
control={form.control}
name="redirectUrls"
@@ -789,21 +815,60 @@ function GatedCollapsibleContainer(props: {
return null;
}
return <div className={cn("mt-6", props.className)}>{props.children}</div>;
return (
<div
className={cn("mt-5 fade-in-0 duration-300 animate-in", props.className)}
>
{props.children}
</div>
);
}
function Fieldset(props: { legend: string; children: React.ReactNode }) {
function Fieldset(props: {
legend: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<DynamicHeight>
<fieldset className="rounded-lg border border-border bg-card p-4 md:p-6">
{/* put inside div to remove default styles on legend */}
<div className="mb-4 font-semibold text-xl tracking-tight">
<legend> {props.legend}</legend>
</div>
<div className="rounded-lg border bg-card relative">
<DynamicHeight>
<fieldset className="p-4 md:px-6 md:py-5">
{/* put inside div to remove default styles on legend */}
<div className="border-b pb-4 mb-5 border-dashed font-semibold text-xl tracking-tight">
<legend> {props.legend}</legend>
</div>
{props.children}
</fieldset>
</DynamicHeight>
{props.children}
</fieldset>
</DynamicHeight>
{props.footer}
</div>
);
}
function FieldsetWithDescription(props: {
legend: string;
children: React.ReactNode;
footer?: React.ReactNode;
description: React.ReactNode;
}) {
return (
<div className="rounded-lg border bg-card relative">
<DynamicHeight>
<fieldset className="p-4 md:p-6">
{/* put inside div to remove default styles on legend */}
<div className="pr-20">
<legend className="font-semibold text-xl tracking-tight">
{props.legend}
</legend>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
{props.children}
</fieldset>
</DynamicHeight>
{props.footer}
</div>
);
}
@@ -1,6 +1,8 @@
/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
import { CheckIcon, MinusIcon } from "lucide-react";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { SMSCountryTiers } from "../../api/sms";
@@ -31,17 +33,6 @@ export default function CountrySelector({
return tierCountries.every((country) => isCountrySelected(country));
};
// Check if some countries in a tier are selected
const isTierIndeterminate = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
const selectedInTier = tierCountries.filter((country) =>
isCountrySelected(country),
);
return (
selectedInTier.length > 0 && selectedInTier.length < tierCountries.length
);
};
// Toggle a tier selection
const toggleTier = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
@@ -81,101 +72,144 @@ export default function CountrySelector({
onChange(newSelected);
};
// Get selected countries count for a tier
const getSelectedCountInTier = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
return tierCountries.filter((country) => isCountrySelected(country)).length;
};
return (
<div className="space-y-3">
{Object.entries(countryTiers).map(([tier, countries], index) => (
<div className="overflow-hidden rounded-md border" key={tier}>
<div className="flex items-center bg-muted/30 p-2">
<div className="relative mr-2 flex items-center">
<Checkbox
checked={isTierSelected(tier)}
className="h-4 w-4"
id={`tier-${index + 1}`}
onCheckedChange={() => toggleTier(tier)}
/>
{isTierIndeterminate(tier) && (
<MinusIcon className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-1/2 h-2.5 w-2.5 transform text-primary" />
)}
</div>
<label
className="flex-1 cursor-pointer font-medium text-sm"
htmlFor={`tier-${index + 1}`}
>
Tier {index + 1}
{isTierIndeterminate(tier) && (
<span className="ml-1 font-normal text-muted-foreground text-xs">
({getSelectedCountInTier(tier)}/{countries.length})
</span>
)}
</label>
<span
className={cn(
"font-medium text-xs",
tier === "tier1"
? "text-green-600 dark:text-green-500"
: "text-muted-foreground",
)}
>
{tierPricing[tier as keyof typeof tierPricing]}
</span>
</div>
<div className="space-y-5">
{Object.entries(countryTiers).map(([tier, tierCountries], index) => {
const selectedTierCountries = tierCountries.filter((country) =>
isCountrySelected(country),
);
<div
className={cn(
"grid grid-cols-2 gap-1.5 p-2",
countries.length === 2
? "md:grid-cols-2"
: countries.length === 3
? "md:grid-cols-3"
: "md:grid-cols-4",
)}
>
{countries.map((country) => (
<div
className={cn(
"relative flex cursor-pointer items-center justify-between rounded-md border p-1.5 transition-colors ",
isCountrySelected(country)
? "border-primary bg-primary/10"
: "border-transparent hover:bg-muted/50",
)}
key={country}
onClick={() => toggleCountry(country)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleCountry(country);
}
}}
role="button"
tabIndex={0}
title={countryNames[country] || country}
>
<span className="flex items-center space-x-2">
<span aria-hidden="true" className="text-lg">
{getCountryFlag(country)}
</span>
<span className="font-medium text-xs">
{countryNames[country] || country}
{countryPrefixes[country]
? ` (${countryPrefixes[country]})`
: ""}
</span>
</span>
<div className="flex h-3 w-3 items-center justify-center">
{isCountrySelected(country) && (
<CheckIcon className="h-3 w-3 text-primary" />
)}
</div>
</div>
))}
</div>
</div>
))}
return (
<TierCard
key={tier}
tierIndex={index}
tier={tier}
onToggleTier={() => toggleTier(tier)}
tierCountries={tierCountries}
selectedTierCountries={selectedTierCountries}
onToggleCountry={toggleCountry}
/>
);
})}
</div>
);
}
function TierCard(props: {
tier: string;
tierIndex: number;
onToggleTier: () => void;
tierCountries: string[];
selectedTierCountries: string[];
onToggleCountry: (country: string) => void;
}) {
const {
tier,
tierIndex,
onToggleTier,
tierCountries: countries,
selectedTierCountries: selectedCountries,
onToggleCountry,
} = props;
const [isExpanded, setIsExpanded] = useState(true);
const isPartiallySelected =
selectedCountries.length > 0 && selectedCountries.length < countries.length;
const isTierFullySelected = selectedCountries.length === countries.length;
return (
<div className="overflow-hidden rounded-xl border bg-background">
{/* header */}
<div className="flex items-center px-4 py-3 gap-3 justify-between">
{/* left */}
<div className="flex items-center gap-2">
<Checkbox
checked={
isPartiallySelected ? "indeterminate" : isTierFullySelected
}
id={`tier-${tier}`}
onCheckedChange={onToggleTier}
/>
<label
className="flex-1 cursor-pointer font-medium text-sm"
htmlFor={`tier-${tier}`}
>
Tier {tierIndex + 1}
</label>
{isPartiallySelected && (
<span className="text-muted-foreground text-sm">
({selectedCountries.length}/{countries.length})
</span>
)}
</div>
{/* right */}
<div className="flex items-center gap-3">
<span
className={cn(
"text-sm",
tier === "tier1"
? "text-green-600 dark:text-green-500"
: "text-muted-foreground",
)}
>
{tierPricing[tier as keyof typeof tierPricing]}
</span>
<Button
variant="ghost"
className="p-0 size-7"
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isExpanded && "rotate-180",
)}
/>
</Button>
</div>
</div>
{/* body */}
{isExpanded && (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 translate-y-[1px] translate-x-[1px] -ml-[1px] border-t",
)}
>
{countries.map((country) => {
const isSelected = selectedCountries.includes(country);
return (
<Button
variant="outline"
className={cn(
"justify-between gap-3 text-start h-auto rounded-none border-l-0 border-t-0 border-border py-2.5 bg-background",
)}
key={country}
onClick={() => onToggleCountry(country)}
title={countryNames[country] || country}
>
<div className="flex items-center gap-3">
<div className="text-3xl">{getCountryFlag(country)}</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">
{countryNames[country] || country}
</div>
<div className="text-muted-foreground text-xs tabular-nums">
{countryPrefixes[country]
? `${countryPrefixes[country]}`
: ""}
</div>
</div>
</div>
{isSelected && <CheckIcon className="size-5 text-foreground" />}
</Button>
);
})}
</div>
)}
</div>
);
}
@@ -117,14 +117,14 @@ export const countryNames: Record<string, string> = {
KG: "Kyrgyzstan",
KH: "Cambodia",
KI: "Kiribati",
KN: "St Kitts and Nevis",
KR: "Korea Republic of",
KN: "Saint Kitts and Nevis",
KR: "South Korea",
KW: "Kuwait",
KY: "Cayman Islands",
KZ: "Kazakhstan",
LA: "Laos PDR",
LB: "Lebanon",
LC: "St Lucia",
LC: "Saint Lucia",
LI: "Liechtenstein",
LK: "Sri Lanka",
LR: "Liberia",
@@ -139,7 +139,7 @@ export const countryNames: Record<string, string> = {
ME: "Montenegro",
MG: "Madagascar",
MH: "Marshall Islands",
MK: "Macedonia",
MK: "North Macedonia",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
@@ -172,14 +172,14 @@ export const countryNames: Record<string, string> = {
PH: "Philippines",
PK: "Pakistan",
PL: "Poland",
PM: "St Pierre and Miquelon",
PM: "Saint Pierre and Miquelon",
PR: "Puerto Rico",
PS: "Palestinian Territory",
PT: "Portugal",
PW: "Palau",
PY: "Paraguay",
QA: "Qatar",
RE: "Reunion/Mayotte",
RE: "Réunion",
RO: "Romania",
RS: "Serbia",
RW: "Rwanda",
@@ -200,7 +200,7 @@ export const countryNames: Record<string, string> = {
ST: "Sao Tome and Principe",
SV: "El Salvador",
SY: "Syria",
SZ: "Swaziland",
SZ: "Eswatini",
TC: "Turks and Caicos Islands",
TD: "Chad",
TG: "Togo",
@@ -217,7 +217,7 @@ export const countryNames: Record<string, string> = {
UG: "Uganda",
US: "United States",
UY: "Uruguay",
VC: "St Vincent Grenadines",
VC: "Saint Vincent and the Grenadines",
VE: "Venezuela",
VG: "Virgin Islands, British",
VI: "Virgin Islands, U.S.",
@@ -65,7 +65,7 @@ export function CreateContractWebhookButton({
inputAbi: [],
name: "",
secret: "",
sigHash: "",
sigHash: [],
sigHashAbi: "",
toAddresses: "",
webhookUrl: "",
@@ -74,13 +74,16 @@ export function FilterDetailsStep({
const knownFunctionSignatures = functionSignatures.map(
(sig) => sig.signature,
);
// Handle both single and multiple signatures for custom signature detection
const sigHashes = Array.isArray(sigHash) ? sigHash : sigHash ? [sigHash] : [];
const isCustomSignature =
(watchFilterType === "event" &&
sigHash &&
!knownEventSignatures.includes(sigHash)) ||
sigHashes.some((hash) => hash && !knownEventSignatures.includes(hash))) ||
(watchFilterType === "transaction" &&
sigHash &&
!knownFunctionSignatures.includes(sigHash));
sigHashes.some(
(hash) => hash && !knownFunctionSignatures.includes(hash),
));
return (
<>
@@ -140,7 +143,7 @@ export function FilterDetailsStep({
</div>
<FormControl>
<div className="space-y-2">
<Input placeholder="0x1234..." {...field} />
<Input placeholder="0x1234...,0xabcd..." {...field} />
{/* ABI fetch status */}
<div className="mt-2 flex items-center justify-between">
@@ -300,13 +303,13 @@ export function FilterDetailsStep({
<div className="flex items-center justify-between text-xs">
<FormLabel>
{watchFilterType === "event"
? "Event Signature (optional)"
: "Function Signature (optional)"}
? "Event Signatures (optional)"
: "Function Signatures (optional)"}
</FormLabel>
<p className="text-muted-foreground">
{watchFilterType === "event"
? "Select an event to monitor"
: "Select a function to monitor"}
? "Select events to monitor"
: "Select functions to monitor"}
</p>
</div>
<FormControl>
@@ -315,11 +318,16 @@ export function FilterDetailsStep({
eventSignatures.length > 0 ? (
<SignatureSelector
className="block w-full max-w-90 overflow-hidden text-ellipsis"
multiSelect={true}
onChange={(val) => {
field.onChange(val);
// If custom signature, clear ABI field
const known = eventSignatures.map((sig) => sig.signature);
if (val && !known.includes(val)) {
const values = Array.isArray(val) ? val : [val];
const hasCustomSignature = values.some(
(v) => v && !known.includes(v),
);
if (hasCustomSignature) {
form.setValue("abi", "");
}
}}
@@ -328,21 +336,26 @@ export function FilterDetailsStep({
label: truncateMiddle(sig.name, 30, 15),
value: sig.signature,
}))}
placeholder="Select or enter an event signature"
placeholder="Select or enter event signatures"
setAbi={(abi) => form.setValue("sigHashAbi", abi)}
value={field.value || ""}
value={field.value || []}
/>
) : watchFilterType === "transaction" &&
Object.keys(fetchedTxAbis).length > 0 &&
functionSignatures.length > 0 ? (
<SignatureSelector
multiSelect={true}
onChange={(val) => {
field.onChange(val);
// If custom signature, clear ABI field
const known = functionSignatures.map(
(sig) => sig.signature,
);
if (val && !known.includes(val)) {
const values = Array.isArray(val) ? val : [val];
const hasCustomSignature = values.some(
(v) => v && !known.includes(v),
);
if (hasCustomSignature) {
form.setValue("abi", "");
}
}}
@@ -351,9 +364,9 @@ export function FilterDetailsStep({
label: truncateMiddle(sig.name, 30, 15),
value: sig.signature,
}))}
placeholder="Select or enter a function signature"
placeholder="Select or enter function signatures"
setAbi={(abi) => form.setValue("sigHashAbi", abi)}
value={field.value || ""}
value={field.value || []}
/>
) : (
<Input
@@ -367,7 +380,11 @@ export function FilterDetailsStep({
? "Fetching event signatures..."
: "Fetching function signatures..."
}
value={field.value}
value={
Array.isArray(field.value)
? field.value.join(", ")
: field.value || ""
}
/>
)}
</FormControl>
@@ -194,12 +194,27 @@ export default function ReviewStep({
<li className="flex justify-between">
<span className="text-muted-foreground text-sm">
Signature Hash:
Signature Hash
{Array.isArray(form.watch("sigHash")) &&
(form.watch("sigHash")?.length || 0) > 1
? "es"
: ""}
:
</span>
<span className="font-medium text-sm">
{(() => {
const sigHash = form.watch("sigHash");
return sigHash ? truncateMiddle(sigHash, 10, 6) : "None";
if (!sigHash) return "None";
if (Array.isArray(sigHash)) {
if (sigHash.length === 0) return "None";
if (sigHash.length === 1) {
return truncateMiddle(sigHash[0] || "", 10, 6);
}
return `${sigHash.length} signature${sigHash.length > 1 ? "s" : ""} selected`;
} else {
return truncateMiddle(sigHash, 10, 6);
}
})()}
</span>
</li>
@@ -1,4 +1,3 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { AlertTriangleIcon } from "lucide-react";
import type { WebhookConfig } from "@/api/project/webhook-configs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -6,6 +5,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -224,15 +224,18 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
};
if (formData.sigHash) {
filters.signatures = [
{
sig_hash: formData.sigHash.startsWith("0x")
? formData.sigHash
: keccak256(new TextEncoder().encode(formData.sigHash)),
const sigHashes = Array.isArray(formData.sigHash)
? formData.sigHash
: [formData.sigHash];
filters.signatures = sigHashes
.filter((hash) => hash)
.map((hash) => ({
sig_hash: hash.startsWith("0x")
? hash
: keccak256(new TextEncoder().encode(hash)),
abi: formData.sigHashAbi || formData.abi,
params: {},
},
];
}));
}
return { ...topic, filters };
@@ -271,15 +274,18 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
}
if (formData.sigHash) {
filters.signatures = [
{
sig_hash: formData.sigHash.startsWith("0x")
? formData.sigHash
: toFunctionSelector(formData.sigHash),
const sigHashes = Array.isArray(formData.sigHash)
? formData.sigHash
: [formData.sigHash];
filters.signatures = sigHashes
.filter((hash) => hash)
.map((hash) => ({
sig_hash: hash.startsWith("0x")
? hash
: toFunctionSelector(hash),
abi: formData.sigHashAbi || formData.abi,
params: {},
},
];
}));
}
return { ...topic, filters };
@@ -475,8 +481,8 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
<FormLabel>
{topic.id ===
"contracts.event.confirmed"
? "Event Signature"
: "Function Signature"}
? "Event Signatures"
: "Function Signatures"}
</FormLabel>
</div>
<FormControl>
@@ -489,6 +495,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
0 ? (
<SignatureSelector
className="block w-full max-w-90 overflow-hidden text-ellipsis"
multiSelect={true}
onChange={(val) => {
field.onChange(val);
// If custom signature, clear ABI field
@@ -497,9 +504,20 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
(sig) =>
sig.signature,
);
const values =
Array.isArray(val)
? val
: [val];
const hasCustomSignature =
values.some(
(v) =>
v &&
!known.includes(
v,
),
);
if (
val &&
!known.includes(val)
hasCustomSignature
) {
eventFilterForm.setValue(
"abi",
@@ -518,14 +536,14 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
value: sig.signature,
}),
)}
placeholder="Select or enter an event signature"
placeholder="Select or enter event signatures"
setAbi={(abi) =>
eventFilterForm.setValue(
"sigHashAbi",
abi,
)
}
value={field.value || ""}
value={field.value || []}
/>
) : topic.id ===
"contracts.transaction.confirmed" &&
@@ -535,6 +553,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
txAbi.signatures.length >
0 ? (
<SignatureSelector
multiSelect={true}
onChange={(val) => {
field.onChange(val);
// If custom signature, clear ABI field
@@ -543,9 +562,20 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
(sig) =>
sig.signature,
);
const values =
Array.isArray(val)
? val
: [val];
const hasCustomSignature =
values.some(
(v) =>
v &&
!known.includes(
v,
),
);
if (
val &&
!known.includes(val)
hasCustomSignature
) {
transactionFilterForm.setValue(
"abi",
@@ -564,14 +594,14 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
value: sig.signature,
}),
)}
placeholder="Select or enter a function signature"
placeholder="Select or enter function signatures"
setAbi={(abi) =>
transactionFilterForm.setValue(
"sigHashAbi",
abi,
)
}
value={field.value || ""}
value={field.value || []}
/>
) : (
<Input
@@ -714,7 +744,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
<FormItem className="flex flex-col">
<div className="flex items-center justify-between text-xs">
<FormLabel>
Function Signature
Function Signatures
</FormLabel>
</div>
<FormControl>
@@ -724,6 +754,7 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
txAbi.signatures.length >
0 ? (
<SignatureSelector
multiSelect={true}
onChange={(val) => {
field.onChange(val);
// If custom signature, clear ABI field
@@ -732,9 +763,20 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
(sig) =>
sig.signature,
);
const values =
Array.isArray(val)
? val
: [val];
const hasCustomSignature =
values.some(
(v) =>
v &&
!known.includes(
v,
),
);
if (
val &&
!known.includes(val)
hasCustomSignature
) {
transactionFilterForm.setValue(
"abi",
@@ -753,14 +795,14 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
value: sig.signature,
}),
)}
placeholder="Select or enter a function signature"
placeholder="Select or enter function signatures"
setAbi={(abi) =>
transactionFilterForm.setValue(
"sigHashAbi",
abi,
)
}
value={field.value || ""}
value={field.value || []}
/>
) : (
<Input
@@ -769,7 +811,15 @@ export function TopicSelectorModal(props: TopicSelectorModalProps) {
}
onChange={field.onChange}
placeholder="Fetching function signatures..."
value={field.value}
value={
Array.isArray(
field.value,
)
? field.value.join(
", ",
)
: field.value || ""
}
/>
)}
</FormControl>
@@ -33,13 +33,20 @@ export function buildEventWebhookPayload(
): WebhookPayload {
const eventSignatures = [];
if (data.sigHash) {
eventSignatures.push({
abi: data.sigHashAbi || data.abi,
params: {},
sig_hash: data.sigHash.startsWith("0x")
? data.sigHash
: keccak256(new TextEncoder().encode(data.sigHash)),
});
const sigHashes = Array.isArray(data.sigHash)
? data.sigHash
: [data.sigHash];
for (const sigHash of sigHashes) {
if (sigHash) {
eventSignatures.push({
abi: data.sigHashAbi || data.abi,
params: {},
sig_hash: sigHash.startsWith("0x")
? sigHash
: keccak256(new TextEncoder().encode(sigHash)),
});
}
}
}
return {
filters: {
@@ -59,13 +66,20 @@ export function buildTransactionWebhookPayload(
): WebhookPayload {
const txSignatures = [];
if (data.sigHash) {
txSignatures.push({
abi: data.sigHashAbi || data.abi,
params: {},
sig_hash: data.sigHash.startsWith("0x")
? data.sigHash
: toFunctionSelector(data.sigHash),
});
const sigHashes = Array.isArray(data.sigHash)
? data.sigHash
: [data.sigHash];
for (const sigHash of sigHashes) {
if (sigHash) {
txSignatures.push({
abi: data.sigHashAbi || data.abi,
params: {},
sig_hash: sigHash.startsWith("0x")
? sigHash
: toFunctionSelector(sigHash),
});
}
}
}
return {
filters: {
@@ -73,7 +73,7 @@ export const webhookFormSchema = z
.max(100, { message: "Name must be at most 100 characters long" }),
params: z.record(z.unknown()).optional(),
secret: z.string().optional(),
sigHash: z.string().optional(),
sigHash: z.union([z.string(), z.array(z.string())]).optional(),
sigHashAbi: z.string().optional(),
toAddresses: z
.string()
@@ -0,0 +1,96 @@
import type { Meta } from "@storybook/nextjs";
import { toUnits } from "thirdweb";
import { base } from "thirdweb/chains";
import { ThirdwebProvider } from "thirdweb/react";
import { storybookThirdwebClient } from "@/storybook/utils";
import { ClaimRewardsPageUI } from "./claim-rewards-page";
const meta = {
component: ClaimRewardsPageUI,
title: "contracts/extensions/claim-rewards",
decorators: [
(Story) => (
<ThirdwebProvider>
<div className="container max-w-[1154px] py-10">
<Story />
</div>
</ThirdwebProvider>
),
],
} satisfies Meta<typeof ClaimRewardsPageUI>;
export default meta;
const recipient = "0x8C00f3F231c88CcAc2382AaC6e09A78D4F42129d";
const developer = "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94";
function unclaimedFeesStub(token0Amount: bigint, token1Amount: bigint) {
return {
token0: {
address: "0x1234567890123456789012345678901234567890",
amount: token0Amount,
symbol: "FOO",
},
token1: {
address: "0x0987654321098765432109876543210987654321",
amount: token1Amount,
symbol: "BAR",
},
};
}
export function LargeAmounts() {
return (
<Variant
token0Amount={toUnits("100000", 18)}
token1Amount={toUnits("500000", 18)}
includeChainExplorer
/>
);
}
export function SmallAmounts() {
return (
<Variant
token0Amount={toUnits("0.001", 18)}
token1Amount={toUnits("0.0005", 18)}
includeChainExplorer
/>
);
}
export function ZeroAmount() {
return (
<Variant
token0Amount={toUnits("0", 18)}
token1Amount={toUnits("0", 18)}
includeChainExplorer
/>
);
}
export function ZeroAmountNoChainExplorer() {
return (
<Variant token0Amount={toUnits("0", 18)} token1Amount={toUnits("0", 18)} />
);
}
function Variant(props: {
token0Amount: bigint;
token1Amount: bigint;
includeChainExplorer?: boolean;
}) {
return (
<ClaimRewardsPageUI
developerBps={5000}
chainSlug="base"
recipient={recipient}
developer={developer}
client={storybookThirdwebClient}
handleClaim={() => {}}
isClaimPending={false}
chain={base}
unclaimedFees={unclaimedFeesStub(props.token0Amount, props.token1Amount)}
/>
);
}
@@ -0,0 +1,322 @@
"use client";
import { DollarSignIcon, ExternalLinkIcon, SplitIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import {
type Chain,
type ThirdwebClient,
type ThirdwebContract,
toTokens,
} from "thirdweb";
import { TokenIcon, TokenProvider, useSendTransaction } from "thirdweb/react";
import { claimRewards } from "thirdweb/tokens";
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { TransactionButton } from "@/components/tx-button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { parseError } from "@/utils/errorParser";
import { tryCatch } from "@/utils/try-catch";
import type { getValidReward } from "../utils/rewards";
export function ClaimRewardsPage(props: {
assetContractClient: ThirdwebContract;
entrypointContractClient: ThirdwebContract;
reward: NonNullable<Awaited<ReturnType<typeof getValidReward>>>;
unclaimedFees: {
token0: {
address: string;
amount: bigint;
symbol: string;
};
token1: {
address: string;
amount: bigint;
symbol: string;
};
};
chainSlug: string;
}) {
const sendTx = useSendTransaction();
const router = useDashboardRouter();
async function handleClaim() {
const claimRewardsTx = claimRewards({
asset: props.assetContractClient.address,
contract: props.entrypointContractClient,
});
const claimRewardsResult = await tryCatch(
sendTx.mutateAsync(claimRewardsTx),
);
if (claimRewardsResult.error) {
toast.error("Failed to distribute rewards", {
description: parseError(claimRewardsResult.error),
});
} else {
toast.success("Rewards distributed successfully");
router.refresh();
}
}
return (
<ClaimRewardsPageUI
unclaimedFees={props.unclaimedFees}
recipient={props.reward.recipient}
developer={props.reward.developer}
handleClaim={handleClaim}
isClaimPending={sendTx.isPending}
client={props.assetContractClient.client}
chain={props.assetContractClient.chain}
chainSlug={props.chainSlug}
developerBps={props.reward.developerBps}
/>
);
}
function calculateFees(developerBps: number) {
// 20% of is protocol fees
// remaining is split between developer and recipient
const protocolFees = 20;
const remaining = 100 - protocolFees;
const developerPercentageFinal = (remaining * developerBps) / 10000;
return {
protocolFees,
developerPercentage: developerPercentageFinal,
recipientPercentage: 100 - protocolFees - developerPercentageFinal,
};
}
export function ClaimRewardsPageUI(props: {
unclaimedFees: {
token0: {
address: string;
amount: bigint;
symbol: string;
};
token1: {
address: string;
amount: bigint;
symbol: string;
};
};
recipient: string;
developer: string;
developerBps: number;
handleClaim: () => void;
isClaimPending: boolean;
client: ThirdwebClient;
chain: Chain;
chainSlug: string;
}) {
const fees = calculateFees(props.developerBps);
const recipientColor = `hsl(var(--chart-1))`;
const developerColor = `hsl(var(--chart-2))`;
const protocolFeesColor = `hsl(var(--chart-3))`;
const hasUnclaimedRewards =
props.unclaimedFees.token0.amount > 0 ||
props.unclaimedFees.token1.amount > 0;
return (
<div>
<div className="bg-card rounded-lg border">
<div className="p-4 lg:p-6 border-b border-dashed">
<div className="flex mb-3">
<div className="rounded-full border bg-background p-2">
<DollarSignIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
Rewards
</h2>
<p className="text-muted-foreground text-sm">
Earnings received by Liquidity Providers (LPs) in exchange for
depositing tokens into {props.unclaimedFees.token0.symbol} /{" "}
{props.unclaimedFees.token1.symbol} Uniswap liquidity pool
</p>
</div>
<div className="p-4 lg:px-6 py-8 border-b border-dashed">
<div className="mb-4">
<h3 className="font-medium text-base mb-1">Unclaimed Rewards</h3>
<p className="text-muted-foreground text-sm">
The rewards that are earned but haven't been distributed yet
</p>
</div>
<div className="flex flex-col gap-4 lg:flex-row">
<TokenReward
token={props.unclaimedFees.token0}
client={props.client}
chain={props.chain}
chainSlug={props.chainSlug}
/>
<TokenReward
token={props.unclaimedFees.token1}
client={props.client}
chain={props.chain}
chainSlug={props.chainSlug}
/>
</div>
</div>
<div className="p-4 lg:px-6 py-8">
<h3 className="font-medium text-base mb-1">Reward Distribution</h3>
<p className="text-muted-foreground text-sm mb-4">
The unclaimed rewards will be distributed as:
</p>
<DistributionBarChart
segments={[
{
label: "Recipient",
color: recipientColor,
percent: fees.recipientPercentage,
value: `${fees.recipientPercentage}%`,
},
{
label: "Protocol",
color: protocolFeesColor,
percent: fees.protocolFees,
value: `${fees.protocolFees}%`,
},
{
label: "Developer",
color: developerColor,
percent: fees.developerPercentage,
value: `${fees.developerPercentage}%`,
},
]}
/>
<div className="h-6" />
<div className="flex gap-5">
<div>
<p className="font-medium text-sm">Recipient</p>
<WalletAddress
address={props.recipient}
client={props.client}
iconClassName="size-3"
className="h-auto py-1 text-xs"
fallbackIcon={
<div
className="size-3 rounded-full"
style={{
backgroundColor: recipientColor,
}}
/>
}
/>
</div>
<div className="border-l pl-5">
<p className="font-medium text-sm">Developer</p>
<WalletAddress
address={props.developer}
client={props.client}
iconClassName="size-3"
className="h-auto py-1 text-xs"
fallbackIcon={
<div
className="size-3 rounded-full"
style={{
backgroundColor: developerColor,
}}
/>
}
/>
</div>
</div>
</div>
<div className="p-4 lg:p-6 border-t border-dashed flex-col lg:flex-row flex lg:justify-between lg:items-center gap-4">
{hasUnclaimedRewards && (
<p className="text-muted-foreground text-sm">
Click on "Distribute Rewards" to distribute unclaimed rewards
</p>
)}
{!hasUnclaimedRewards && (
<p className="text-muted-foreground text-sm">
There are no unclaimed rewards available for distribution
</p>
)}
<TransactionButton
onClick={props.handleClaim}
className="bg-background rounded-full"
transactionCount={undefined}
isPending={props.isClaimPending}
txChainID={props.chain.id}
isLoggedIn={true}
client={props.client}
disabled={!hasUnclaimedRewards}
size="sm"
variant="outline"
>
<SplitIcon className="size-3.5 text-muted-foreground" />
Distribute Rewards
</TransactionButton>
</div>
</div>
</div>
);
}
function TokenReward(props: {
token: {
address: string;
amount: bigint;
symbol: string;
};
client: ThirdwebClient;
chain: Chain;
chainSlug: string;
}) {
const fallbackIcon = (
<div className="size-9 rounded-full from-muted to-card bg-gradient-to-br flex items-center justify-center text-muted-foreground font-bold uppercase">
{props.token.symbol[0]}
</div>
);
return (
<div className="border p-3 rounded-xl flex items-center gap-3 min-w-[300px] bg-background relative hover:border-active-border">
<div className="rounded-full border shrink-0">
<TokenProvider
address={props.token.address}
client={props.client}
chain={props.chain}
>
<TokenIcon
className="size-9 rounded-full"
loadingComponent={fallbackIcon}
fallbackComponent={fallbackIcon}
/>
</TokenProvider>
</div>
<div className="space-y-0.5">
<p className="font-bold text-sm">
{toTokens(props.token.amount, 18)} {props.token.symbol}
</p>
<Link
target="_blank"
rel="noopener noreferrer"
href={`/${props.chainSlug}/${props.token.address}`}
className="flex items-center gap-1.5 text-muted-foreground before:absolute before:inset-0"
>
<span className="text-xs font-mono">
{props.token.address.slice(0, 6)}...
{props.token.address.slice(-4)}
</span>
<ExternalLinkIcon className="size-3" />
</Link>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import { notFound, redirect } from "next/navigation";
import { getContract } from "thirdweb";
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
import { getProject } from "@/api/project/projects";
import { getContractPageParamsInfo } from "../../../../../../../(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams";
import type { ProjectContractPageParams } from "../types";
import { ClaimRewardsPage } from "./components/claim-rewards-page";
import { getValidReward } from "./utils/rewards";
import { getUnclaimedFees } from "./utils/unclaimed-fees";
export default async function Page(props: {
params: Promise<ProjectContractPageParams>;
}) {
const params = await props.params;
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
notFound();
}
const info = await getContractPageParamsInfo({
chainIdOrSlug: params.chainIdOrSlug,
contractAddress: params.contractAddress,
teamId: project.teamId,
});
if (!info) {
notFound();
}
const chain = info.clientContract.chain;
const assetContractServer = info.serverContract;
const serverClient = assetContractServer.client;
const { address: entrypointContractAddress } =
await getDeployedEntrypointERC20({
chain,
client: serverClient,
});
// Note: must use server contract/client here
const reward = await getValidReward({
assetContract: assetContractServer,
entrypointContract: getContract({
address: entrypointContractAddress,
chain,
client: serverClient,
}),
});
if (!reward) {
redirect(
`/team/${params.team_slug}/${params.project_slug}/contract/${params.chainIdOrSlug}/${params.contractAddress}`,
);
}
// Note: must use server contract/client here
const unclaimedFees = await getUnclaimedFees({
positionManager: getContract({
address: reward.positionManager,
chain,
client: serverClient,
}),
reward: {
tokenId: reward.positionId,
recipient: reward.recipient,
},
});
return (
<ClaimRewardsPage
assetContractClient={info.clientContract}
entrypointContractClient={getContract({
address: entrypointContractAddress,
chain: chain,
client: info.clientContract.client,
})}
reward={reward}
unclaimedFees={unclaimedFees}
chainSlug={info.chainMetadata.slug}
/>
);
}
@@ -0,0 +1,23 @@
import type { ThirdwebContract } from "thirdweb";
import { getRewards } from "thirdweb/tokens";
export async function getValidReward(params: {
assetContract: ThirdwebContract;
entrypointContract: ThirdwebContract;
}) {
try {
const rewards = await getRewards({
contract: params.entrypointContract,
asset: params.assetContract.address,
});
if (rewards.length === 0) {
return null;
}
// It's potentially possible to have multiple rewards locked up, but it's not the default use case.
return rewards[0];
} catch {
return null;
}
}
@@ -0,0 +1,88 @@
import type { ThirdwebContract } from "thirdweb";
import { getContract, readContract } from "thirdweb";
import { symbol } from "thirdweb/extensions/common";
const maxUint128 = 2n ** 128n - 1n;
export async function getUnclaimedFees(params: {
positionManager: ThirdwebContract;
reward: {
tokenId: bigint;
recipient: string;
};
}) {
const collectResultPromise = readContract({
contract: params.positionManager,
method:
"function collect((uint256 tokenId,address recipient,uint128 amount0Max,uint128 amount1Max)) returns (uint256,uint256)",
params: [
{
tokenId: params.reward.tokenId,
recipient: params.reward.recipient,
amount0Max: maxUint128,
amount1Max: maxUint128,
},
],
});
const positionsResultPromise = readContract({
contract: params.positionManager,
method:
"function positions(uint256 tokenId) view returns (uint96,address,address,address,uint24,int24,int24,uint128,uint256,uint256,uint128,uint128)",
params: [params.reward.tokenId],
});
const [collectResult, positionsResult] = await Promise.all([
collectResultPromise,
positionsResultPromise,
]);
// 0- nonce
// 1- owner
// 2- token0
// 3- token1
// 4 - fee
// 5 - tickLower
// 6 - tickUpper
// 7 - liquidity
// 8 - feeGrowthInside0LastX128
// 9 - feeGrowthInside1LastX128
// 10 - tokensOwed0
// 11 - tokensOwed1
const client = params.positionManager.client;
const chain = params.positionManager.chain;
const token0Address = positionsResult[2];
const token1Address = positionsResult[3];
const [token0Symbol, token1Symbol] = await Promise.all([
symbol({
contract: getContract({
address: token0Address,
chain,
client,
}),
}),
symbol({
contract: getContract({
address: token1Address,
chain,
client,
}),
}),
]);
return {
token0: {
address: token0Address,
amount: collectResult[0],
symbol: token0Symbol,
},
token1: {
address: token1Address,
amount: collectResult[1],
symbol: token1Symbol,
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
import "../../global.css";
import "@workspace/ui/global.css";
import { BridgeProviders } from "./components/client/Providers.client";
const fontSans = Inter({
+1 -1
View File
@@ -1,4 +1,4 @@
import "../../global.css";
import "@workspace/ui/global.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import NextTopLoader from "nextjs-toploader";
+192 -36
View File
@@ -1,10 +1,20 @@
import { ShieldCheckIcon } from "lucide-react";
import type { Metadata } from "next";
import { defineChain, getContract } from "thirdweb";
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
import { checksumAddress } from "thirdweb/utils";
import { Bridge, defineChain, toTokens } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { shortenAddress } from "thirdweb/utils";
import { getPaymentLink } from "@/api/universal-bridge/links";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { Badge } from "@/components/ui/badge";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import {
API_SERVER_SECRET,
DASHBOARD_THIRDWEB_SECRET_KEY,
} from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { resolveEns } from "@/lib/ens";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { PayPageWidget } from "../components/client/PayPageWidget.client";
import { payAppThirdwebClient } from "../constants";
const title = "thirdweb Pay";
const description = "Fast, secure, and simple payments.";
@@ -32,40 +42,186 @@ export default async function PayPage({
paymentId: id,
});
const tokenContract = getContract({
address: paymentLink.destinationToken.address, // for this RPC call, use the dashboard client
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(paymentLink.destinationToken.chainId)),
client: getClientThirdwebClient(undefined),
});
const {
symbol,
decimals,
name: tokenName,
} = await getCurrencyMetadata({
contract: tokenContract,
});
const token = {
address: checksumAddress(paymentLink.destinationToken.address),
const projectMetadataPromise = getProjectMetadata(paymentLink.clientId);
const tokensPromise = Bridge.tokens({
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
chainId: Number(paymentLink.destinationToken.chainId),
decimals,
name: tokenName,
symbol,
};
tokenAddress: paymentLink.destinationToken.address,
});
const chainPromise = getChainMetadata(
// eslint-disable-next-line no-restricted-syntax
defineChain(Number(paymentLink.destinationToken.chainId)),
);
const recipientPromise = resolveEns(
paymentLink.receiver,
getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
);
const [tokens, projectMetadata, chain, recipientEnsOrAddress] =
await Promise.all([
tokensPromise,
projectMetadataPromise,
chainPromise,
recipientPromise,
]);
const token = tokens[0];
if (!token) {
throw new Error("Token not found");
}
return (
<PayPageWidget
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
chainId={Number(paymentLink.destinationToken.chainId)}
clientId={undefined} // Payment links don't need to use the same client ID to be executed
image={paymentLink.imageUrl}
name={paymentLink.title}
paymentLinkId={id}
purchaseData={paymentLink.purchaseData}
recipientAddress={paymentLink.receiver}
redirectUri={redirectUri}
theme={theme}
token={token}
/>
<div className="flex z-10 flex-col lg:flex-row h-full w-full">
<header className="min-w-full lg:min-w-[500px] border-b lg:border-r lg:h-full bg-card flex flex-col gap-4 items-start p-4 lg:p-8">
<div>
<div className="flex flex-row items-center justify-start gap-4">
{projectMetadata.image && (
<img
src={
resolveSchemeWithErrorHandler({
uri: projectMetadata.image,
client: payAppThirdwebClient,
}) || ""
}
alt={projectMetadata.name}
width={25}
height={25}
className="rounded-full overflow-hidden"
/>
)}
<h2 className="text-xl font-bold">{projectMetadata.name}</h2>
</div>
{projectMetadata.description && (
<p className="mt-2 text-sm text-muted-foreground">
{projectMetadata.description}
</p>
)}
</div>
<div className="hidden lg:block my-4 w-full">
{paymentLink.amount && (
<div className="flex flex-col gap-1 w-full my-4">
<span className="text-muted-foreground text-xs">Details</span>
<div className="font-medium flex-row flex justify-between items-center w-full">
<div className="flex flex-row items-center gap-2">
{token.iconUri && (
<img
src={resolveSchemeWithErrorHandler({
uri: token.iconUri,
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
})}
alt={token.name}
width={25}
height={25}
className="size-5 rounded-full overflow-hidden"
/>
)}
{toTokens(BigInt(paymentLink.amount), token.decimals)}{" "}
{token.symbol}
</div>
{token.prices.USD && (
<span>
$
{(
Number(token.prices.USD) *
Number(
toTokens(BigInt(paymentLink.amount), token.decimals),
)
).toFixed(2)}
</span>
)}
</div>
</div>
)}
{chain && (
<div className="flex flex-col gap-1 w-full my-4">
<span className="text-muted-foreground text-xs">Network</span>
<div className="font-medium flex-row flex justify-between items-center w-full">
<div className="flex flex-row items-center gap-2">
{chain.icon?.url && (
<img
src={resolveSchemeWithErrorHandler({
uri: chain.icon.url,
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
})}
alt={chain.name}
width={chain.icon.width}
height={chain.icon.height}
className="size-5 rounded-full overflow-hidden"
/>
)}
{chain.name}
</div>
</div>
</div>
)}
{recipientEnsOrAddress.ensName ||
(recipientEnsOrAddress.address && (
<div className="flex flex-col gap-1 w-full my-4">
<span className="text-muted-foreground text-xs">Seller</span>
<div className="font-medium flex-row flex justify-between items-center w-full">
{recipientEnsOrAddress.ensName ??
shortenAddress(recipientEnsOrAddress.address)}
</div>
</div>
))}
</div>
<div className="mt-auto hidden lg:block">
<Badge className="flex items-center gap-1.5 bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800">
<ShieldCheckIcon className="size-3" />
Secured by thirdweb
</Badge>
</div>
</header>
<main className="flex justify-center p-12 w-full items-center">
<PayPageWidget
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
chainId={Number(paymentLink.destinationToken.chainId)}
clientId={undefined} // Payment links don't need to use the same client ID to be executed
image={paymentLink.imageUrl}
name={paymentLink.title}
paymentLinkId={id}
purchaseData={paymentLink.purchaseData}
recipientAddress={paymentLink.receiver}
redirectUri={redirectUri}
theme={theme}
token={token}
/>
</main>
</div>
);
}
async function getProjectMetadata(clientId: string) {
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v2/keys/lookup`);
url.searchParams.append("clientId", clientId);
const response = await fetch(url.toString(), {
headers: {
"x-service-api-key": API_SERVER_SECRET,
},
});
if (!response.ok) {
throw new Error("Failed to fetch project");
}
const { data } = (await response.json()) as {
data: { name: string; image: string | null; description: string | null };
};
return data;
}
@@ -116,7 +116,7 @@ export function PaymentLinkForm() {
const url = `${window.location.origin}/pay?${params.toString()}`;
setPaymentUrl(url);
toast.success("Payment link created!");
toast.success("Payment created!");
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
@@ -188,7 +188,7 @@ export function PaymentLinkForm() {
<CreditCardIcon className="size-5 sm:size-6" />
</div>
<CardTitle className="text-center sm:text-left">
Create a Payment Link
Create a Payment
</CardTitle>
</div>
</CardHeader>
+3 -12
View File
@@ -1,4 +1,4 @@
import "../../global.css";
import "@workspace/ui/global.css";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { cn } from "@/lib/utils";
@@ -30,17 +30,8 @@ export default async function PayLayout({
disableTransitionOnChange
enableSystem={false}
>
<div className="relative mx-auto flex h-full w-full flex-col items-center justify-center overflow-x-hidden overflow-y-scroll py-10">
<main className="container z-10 flex justify-center">
{children}
</main>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
src="/assets/login/background.svg"
/>
<div className="relative mx-auto flex h-full w-full items-center justify-center overflow-x-hidden overflow-y-scroll">
{children}
</div>
</ThemeProvider>
</PayProviders>

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