Compare commits

...
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
120f965941 Version Packages (#5117)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-25 09:03:57 +13:00
kien-ngo cf36a33c3f [Dashboard] Update erc1155 airdrop form (#5127)
- [x] Remove tw-component's Drawer
- [x] Consolidate some shared code
- [x] Remove an useEffect

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the airdrop functionality in a dashboard application, improving the handling of token and NFT airdrops by consolidating interfaces and components.

### Detailed summary
- Renamed `ERC20AirdropAddressInput` to `AirdropAddressInput`.
- Updated `AirdropUploadERC20` to `AirdropUpload`.
- Adjusted state management for airdrop data.
- Simplified CSV data processing.
- Enhanced error handling during transaction processes.
- Replaced modal with `Sheet` component for airdrop interface.

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

<!-- end pr-codex -->
2024-10-24 19:39:52 +00:00
joaquim-verges 2fdb69dd44 optimize: improve mintAdditionalSupplyTo efficiency (#5148)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on optimizing the `mintAdditionalSupplyTo` function in the `erc1155` extension by modifying how the token URI is handled during the minting process.

### Detailed summary
- Removed the retrieval of the existing token URI from the contract.
- Set the `uri` field to an empty string, indicating the contract will maintain the existing token URI.
- Kept the `to`, `tokenId`, and `amount` fields unchanged.

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

<!-- end pr-codex -->
2024-10-24 08:41:20 +00:00
Joaquim VergesandGitHub 06c0cf3898 chore: update @mobile-wallet-protocol/client to 0.1.1 (#5151) 2024-10-24 21:07:55 +13:00
Joaquim VergesandGitHub 47b1bbbd7c feat: enable sign in with wallet for ecosystems (#5153) 2024-10-24 21:06:47 +13:00
kien-ngo 53e9d8bc1d [SDK] Consolidate ERC1155 Drop tests (#5137)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring tests for the `erc1155` extension, updating account references, and adding new test cases for metadata updates and claim conditions.

### Detailed summary
- Deleted `claimTo.test.ts` and `updateMetadata.test.ts`.
- Updated account references from `TEST_ACCOUNT_A` to `TEST_ACCOUNT_C`.
- Added a test case for updating metadata.
- Added a test case to verify claiming without payment for the primary fee recipient.
- Ensured `getActiveClaimCondition` works correctly with new test cases.

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

<!-- end pr-codex -->
2024-10-24 06:01:54 +00:00
jnsdls 9fadbcc17a update dependencies (#5150)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on updating dependencies across multiple packages, including `@thirdweb-dev/react-native-adapter`, `@thirdweb-dev/service-utils`, and `thirdweb`, along with making type exports consistent in various modules.

### Detailed summary
- Updated `@cloudflare/workers-types` from `4.20241011.0` to `4.20241022.0`.
- Updated `@biomejs/biome` from `1.9.3` to `1.9.4`.
- Updated `turbo` from `2.1.3` to `2.2.3`.
- Updated `@tanstack/react-query` from `5.59.13` to `5.59.16` across multiple packages.
- Updated `lucide-react` from `0.452.0` to `0.453.0` across multiple packages.
- Updated `tailwindcss` from `3.4.13` to `3.4.14` across multiple packages.
- Updated `react-native` from `0.75.3` to `0.76.0`.
- Changed exports from `export { type ... }` to `export type { ... }` in several files for consistency.
- Updated multiple AWS SDK dependencies to `3.678.0`.
- Updated `viem` from `2.21.25` to `2.21.34`.

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

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

<!-- end pr-codex -->
2024-10-24 01:15:46 +00:00
gregfromstl c04b93e705 [Dashboard] Feature: AA analytics tab (#5149)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the analytics dashboard for account abstraction by updating components, improving data handling, and adding new features for user operation statistics and wallet usage.

### Detailed summary
- Updated `clientId` handling in `ConnectAnalyticsDashboard` and `AccountAbstractionPage`.
- Introduced `connectLayoutSlug` prop in `ConnectAnalyticsDashboard`.
- Added `IntervalSelector` and `DateRangeSelector` components for better date management.
- Refactored `ConnectAnalyticsDashboardUI` to include `AccountAbstractionSummary`.
- Enhanced `TotalSponsoredChartCard` and `SponsoredTransactionsChartCard` with new chart states.
- Moved `createUserOpStatsStub` to a new location and cleaned up unused code.
- Improved data aggregation for user operation statistics in `useUserOpUsageAggregate`.

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

<!-- end pr-codex -->
2024-10-24 00:25:00 +00:00
Jonas DanielsandGitHub e6e994479f move landing page to framer (#5146) 2024-10-23 17:00:11 -07:00
arcoraven cf61115064 chore: add knowledge base link (#5147)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the `supportLinks` array in the `Header.tsx` file to improve the naming of existing links and to add a new link for the Knowledge Base.

### Detailed summary
- Changed the name of the first link from `"Support Site"` to `"Get thirdweb support"`.
- Added a new link for the Knowledge Base with the name `"Knowledge Base"` and the URL `https://support.thirdweb.com`.

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

<!-- end pr-codex -->
2024-10-23 23:31:43 +00:00
Joaquim VergesandGitHub 60d2190e95 feat: smart account options for ecosystems (#5136) 2024-10-24 12:16:52 +13:00
MananTank 3cf0039c58 Move support ticket creation to /support/create-ticket page (#5144)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on restructuring the support ticket creation process in the dashboard application. It removes the old `CreateTicket` component and replaces it with a new implementation, enhancing user navigation and support form functionality.

### Detailed summary
- Deleted `create-ticket.client.tsx`.
- Updated `SupportPage` to replace `<CreateTicket />` with a button linking to `/support/create-ticket`.
- Created a new `Page` component for the support ticket creation, including authentication check and breadcrumb navigation.
- Introduced a new `CreateTicket` component with dynamic support form imports and form handling.
- Added form reset and submission handling in `CreateTicket`.

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

<!-- end pr-codex -->
2024-10-23 20:01:35 +00:00
kien-ngo 4841ff382a [Dashboard] Add some informative badges to the Explorer (#5138)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/VtULqGKSp33n0VHOzNJK/1670002e-3123-4770-b29c-fdc84dce88dd.png)
2024-10-23 18:51:06 +00:00
MananTank d580fe05e3 Customize faucet claim amount for various chains (#5143)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on updating the `FaucetButton` and related components to use dynamic claim amounts based on the `chainId`, replacing hardcoded values with a function that retrieves the appropriate amount.

### Detailed summary
- Changed `amount` type from `string` to `number` in `FaucetButton.tsx`.
- Introduced `customClaimAmounts` and `defaultClaimAmount` in `claim-amount.ts`.
- Updated `FaucetSection` and `GetFundsFromFaucet` to use `getFaucetClaimAmount`.
- Modified API response in `route.ts` to return dynamic claim amounts.

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

<!-- end pr-codex -->
2024-10-23 17:33:34 +00:00
MananTank 7a1099b601 Make rows in contract table actual links to allow opening in new tab with cmd click (#5142)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `DeployedContractsPageHeader` and related components by improving the link handling for contract names and refining the UI elements in the contract table.

### Detailed summary
- Updated `DeployedContractsPageHeader` to use a new `Button` import.
- Added `linkOverlay` prop to `AsyncContractNameCell`.
- Enhanced link styling in `AsyncContractNameCell` with conditional classes.
- Passed `linkOverlay` prop when rendering `AsyncContractNameCell` in the contract table.
- Simplified event handling in `ContractTableRow`.

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

<!-- end pr-codex -->
2024-10-23 17:09:16 +00:00
MananTank 4e5fdcf415 Fix claim condition loading state + UI tweaks (#5141)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `ClaimConditionsForm` component in the `claim-conditions` files to improve layout and loading state handling.

### Detailed summary
- Updated `Flex` component properties in `claim-conditions.tsx` for layout consistency.
- Added a loading spinner in `claim-conditions-form/index.tsx` to handle fetching state.
- Removed the previous spinner implementation for a cleaner loading experience.
- Adjusted `flexDir` property for better responsiveness.

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

<!-- end pr-codex -->
2024-10-23 16:57:37 +00:00
MananTank eaeb53218c Show 404 on invalid contract address in contract page (#5140)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `Layout` component by adding a validation check to ensure that the `contractAddress` parameter is a valid address using `isAddress`. If the address is invalid, it returns a "not found" response.

### Detailed summary
- Imported `isAddress` from `thirdweb/utils`.
- Added a check to validate `props.params.contractAddress` using `isAddress`.
- If the address is invalid, the function returns `notFound()`.

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

<!-- end pr-codex -->
2024-10-23 16:52:11 +00:00
MananTank 09b7404985 Show 404 on deprecated chain contract page (#5139)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of contract metadata and checking for deprecated chains in the `layout.tsx` file. It ensures that the correct contract information is used and adds a condition to handle deprecated chain statuses.

### Detailed summary
- Added a check for `chainMetadata.status` to call `notFound()` if the status is "deprecated".
- Updated the `isValidContract` check to use `contract` from `info` instead of the previous variable.
- Rearranged the destructuring of `contract` and `chainMetadata` for clarity.

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

<!-- end pr-codex -->
2024-10-23 16:52:10 +00:00
Joaquim VergesandGitHub 3c77c6b2c8 fix: correct indentation in useSendTransaction example (#5133) 2024-10-23 19:46:54 +13:00
kien-ngo 71aa2578c3 [SDK] Consolidate tests to avoid excessive deployERC1155Contract (#5135)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring and enhancing the test coverage for the `erc1155` extension of the `thirdweb` package. It removes outdated tests, introduces new tests, and updates existing ones to improve functionality verification.

### Detailed summary
- Removed multiple test files related to `erc1155` functions.
- Updated `lazyMint.test.ts` to use `DROP1155_CONTRACT`.
- Added a test for `isGetNFTsSupported` in `drop1155.test.ts`.
- Simplified and enhanced tests in `mintAdditionalSupplyToBatch.test.ts`.
- Introduced new tests for `updateTokenURI`, minting, and `getOwnedTokenIds` in `token1155.test.ts`.

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

<!-- end pr-codex -->
2024-10-23 06:37:09 +00:00
kien-ngo c32576fb47 [SDK] ERC721 claimToBatch extension (#5124)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the `claimToBatch` functionality for the `ERC721` extension, allowing multiple claims in a single transaction. It includes tests to ensure the optimization of claim content and the correct processing of batch claims.

### Detailed summary
- Added `claimToBatch` function in `claimToBatch.ts` for batch claiming NFTs.
- Introduced `ClaimToBatchParams` type to define batch claim structure.
- Implemented `optimizeClaimContent` function to consolidate identical recipient claims.
- Created tests for `claimToBatch` and `optimizeClaimContent` in `claimToBatch.test.ts`.
- Verified the functionality of `isClaimToSupported` in the test suite.

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

<!-- end pr-codex -->
2024-10-23 03:39:12 +00:00
kien-ngo d522343e04 [SDK] Optimize mintAdditionalSupplyToBatch (#5125)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on optimizing the `mintAdditionalSupplyToBatch` extension by introducing a new function to batch minting requests that share the same recipient and token ID, reducing the number of transactions required.

### Detailed summary
- Added `WithOverrides` to `MintAdditionalSupplyToBatchParams`.
- Implemented `optimizeMintBatchContent` function to consolidate minting requests.
- Updated `mintAdditionalSupplyToBatch` to use optimized NFT data.
- Added a test for `optimizeMintBatchContent` to verify its functionality.

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

<!-- end pr-codex -->
2024-10-23 03:15:05 +00:00
gregfromstl 4f22e2b190 [SDK] Feature: Track transactions and SIWE (#5086)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the analytics tracking for wallet connections and transactions within the `thirdweb` library. It refines the tracking methods by introducing specific tracking functions for connections and payments, enhancing the data sent for analytics.

### Detailed summary
- Deleted `track.ts` file.
- Updated import paths for `trackConnect` and `trackPayEvent` in several files.
- Introduced `trackConnect` and `trackPayEvent` functions with detailed parameters.
- Added tracking functionality to wallet connection methods in `in-app` and `coinbase` wallets.
- Enhanced error handling and data structure in tracking functions.
- Added tests for `trackConnect`, `trackPayEvent`, and `trackLogin` functionalities.

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

<!-- end pr-codex -->
2024-10-23 00:37:33 +00:00
gregfromstl 319a203a4e [SDK] Fix: Prevent Duplicated RDNS Values from WalletConnect (#5129)
Fixes #5116

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating wallet metadata and image assets for various wallets in the `thirdweb` project. It includes changes to wallet IDs, homepages, image IDs, and mobile app links, ensuring accurate information for wallet integration.

### Detailed summary
- Removed the file `lovely-items-live.md`.
- Updated `native` and `universal` links for `finance.plena` and `finance.openwallet`.
- Changed `image_id` for `Crypto.com`, `Status`, `Coin98`, and others.
- Added new wallet entries for `com.daffione`, `com.beexo`, `id.plumaa`, `io.owallet`, and `com.ipmb`.
- Updated `getWalletInfo` method to include new wallets and remove `org.nitrogen` and `com.brave.wallet`.
- Added new images for several wallets as base64 encoded data.
- Updated the count of supported wallets in `wallet-ids.ts` and `wallet-infos.ts`.

> The following files were skipped due to too many changes: `packages/thirdweb/src/wallets/__generated__/wallet/com.coin98/image.ts`, `packages/thirdweb/src/wallets/__generated__/wallet/app.status/image.ts`

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

<!-- end pr-codex -->
2024-10-23 00:25:29 +00:00
MananTank cdb907f175 Remove Gas estimator link in footer (#5134)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the `app-footer.tsx` component by removing the `Gas Estimator` link from the footer.

### Detailed summary
- Removed the `Link` component for `Gas Estimator`.
- The remaining `Link` component for `chainlist` retains its class names and attributes.

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

<!-- end pr-codex -->
2024-10-22 23:50:57 +00:00
joaquim-verges 364d97e97e feat: auto-resolve entrypoint address from factory (#5123)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `smartWallet` integration to automatically resolve the entrypoint address from the account factory when available, enhancing usability and simplifying the configuration for users.

### Detailed summary
- Replaced `ENTRYPOINT_ADDRESS_v0_7` with `DEFAULT_ACCOUNT_FACTORY_V0_7` in the `smartWallet` configuration.
- Updated documentation to reflect changes in how to use the `smartWallet` with the factory.
- Added logic to resolve the entrypoint address from the factory if not provided.
- Introduced a new function `getEntrypointFromFactory` to fetch the entrypoint address from the account factory contract.

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

<!-- end pr-codex -->
2024-10-22 21:54:20 +00:00
gregfromstl 795e9c13a4 [Service Utils] Feature: add ecosystem headers to extractAuthorizationData (#5083)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new ecosystem headers to the `extractAuthorizationData` function in the `service-utils` package, enhancing the authorization process by including `ecosystemId` and `ecosystemPartnerId`.

### Detailed summary
- Added `ecosystemId` and `ecosystemPartnerId` to the type definitions in `index.ts`.
- Updated `extractAuthorizationData` to retrieve `ecosystemId` and `ecosystemPartnerId` from headers and URL search parameters in `cf-worker/index.ts` and `node/index.ts`.

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

<!-- end pr-codex -->
2024-10-22 21:24:33 +00:00
MananTank 0b510f881b Handle contract page crash if contract is not deployed (#5132)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling in the `ContractOverviewPage` and validating contract existence in the `layout.tsx`. It introduces an `ErrorBoundary` around the `PublishedBy` component and checks if a contract is deployed before rendering the layout.

### Detailed summary
- Wrapped the `PublishedBy` component in an `ErrorBoundary` in `ContractOverviewPage.tsx`.
- Added a check for contract deployment using `isContractDeployed` in `layout.tsx`.
- Implemented a `notFound()` call if the contract is not deployed, with a TODO for future improvements.

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

<!-- end pr-codex -->
2024-10-22 21:09:06 +00:00
kien-ngo 5351d4e407 [SDK] minor fix for test (#5122)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR refactors the test for the `mintToBatch` function, changing its execution context and updating the minting logic to utilize `mintAdditionalSupplyToBatch`. It also modifies the structure of the NFT data being tested.

### Detailed summary
- Changed `describe` to use `describe.runIf(process.env.TW_SECRET_KEY)`.
- Updated minting function from `mintToBatch` to `mintAdditionalSupplyToBatch`.
- Adjusted parameters for minting NFTs.
- Modified the expected NFT details in the final assertion.

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

<!-- end pr-codex -->
2024-10-22 20:26:10 +00:00
gregfromstl 0cafa33a18 [SDK] Feature: GitHub Auth (#5062)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces GitHub authentication support across various components of the application, enhancing the social login options for users.

### Detailed summary
- Added `GitHub` to authentication options in `types.ts`.
- Included `GitHub` in the social login forms and icons.
- Updated the `InAppWebConnector` to handle `GitHub` authentication.
- Defined `GITHUB_ICON` SVG for branding.
- Integrated `githubIconUri` for wallet icons.

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

<!-- end pr-codex -->
2024-10-22 19:44:00 +00:00
gregfromstl dd77c7f57c Analytics UI Empty States (#5121)
<img width="968" alt="Screenshot 2024-10-21 at 6 23 23 PM" src="https://github.com/user-attachments/assets/86000f33-2041-4d3a-b084-b6367e61f032">

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `DocLink` component for better link handling and integrating it into various chart components for improved user guidance on wallet connections and transactions.

### Detailed summary
- Added `DocLink` component in `DocLink.tsx`.
- Refactored `ConnectSDKCard` to use `DocLink` instead of its previous implementation.
- Modified `EmptyChartState` to accept `children` for customizable content.
- Integrated `DocLink` into multiple chart components, providing links for wallet integration guidance.

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

<!-- end pr-codex -->
2024-10-22 19:21:27 +00:00
MananTank 9c3ff94f29 Move ContractCard and PublishedBy components to RSC (#5097) 2024-10-22 18:44:47 +00:00
Joaquim VergesandGitHub 45fcfb1dae feat: support smart account options for ecosystem wallets (#5091) 2024-10-22 22:25:00 +13:00
Joaquim VergesandGitHub 788eefd1d5 refactor: simplify API fetch error handling in aa-chains route (#5119) 2024-10-22 22:24:06 +13:00
kien-ngo f8c981c0ca [SDK] ERC1155 extension: mintAdditionalSupplyToBatch (#5036)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new ERC1155 extension called `mintAdditionalSupplyToBatch`, allowing users to mint additional supplies of NFTs in a single transaction. It also modifies existing minting logic for better handling of asynchronous operations.

### Detailed summary
- Added new extension `mintAdditionalSupplyToBatch` in `mintAdditionalSupplyToBatch.ts`.
- Updated `mintToBatch.ts` for improved async handling and data processing.
- Created tests for `mintAdditionalSupplyToBatch` in `mintAdditionalSupplyToBatch.test.ts`.
- Updated export statements in `erc1155.ts` to include the new function.

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

<!-- end pr-codex -->
2024-10-22 06:30:29 +00:00
kien-ngo 1628fa2a79 [SDK] Improve general test cases (#5054)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new tests for resolving Unstoppable Domains and updates the `mintToBatch` test for `ERC1155` contracts, including the handling of NFT retrieval and ensuring proper error handling.

### Detailed summary
- Added a test case in `resolveName.test.ts` to check error handling for addresses without a domain.
- Updated the `mintToBatch.test.ts` to use `describe.runIf` for conditional execution.
- Refactored the `mintToBatch` test to include NFT retrieval and validation after minting.

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

<!-- end pr-codex -->
2024-10-22 06:18:22 +00:00
joaquim-verges 0e26a88e7f fix: resolve ecosystem image URL using thirdweb storage (#5120)
## Problem solved

Fixes CNCT-2087

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `Layout` component in the `layout.tsx` file by improving the way the ecosystem image URL is resolved using `resolveScheme`, ensuring proper handling of the image source.

### Detailed summary
- Added import for `resolveScheme` from `thirdweb/storage`.
- Added import for `client` from `../../../lib/client`.
- Updated the `src` attribute of the `<img>` tag to use `resolveScheme({ uri: ecosystem.imageUrl, client })` instead of directly using `ecosystem.imageUrl`.

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

<!-- end pr-codex -->
2024-10-22 00:52:15 +00:00
joaquim-verges 50f98d7486 portal: show all supported chains in AA docs (#5090)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `Document` component by adding the `AAChainList` feature, which fetches and displays account abstraction chains. It also improves the API route for fetching chain data.

### Detailed summary
- Added `AAChainList` component in `apps/portal/src/components/Document/AAChainList.tsx`.
- Implemented `getChains` function to fetch account abstraction chains.
- Updated API route in `apps/portal/src/app/api/aa-chains/route.ts` to fetch services and chains.
- Integrated `AAChainList` in the `page.mdx` for displaying available chains.

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

<!-- end pr-codex -->
2024-10-21 23:14:11 +00:00
joaquim-verges 40aeb529ac test: add environment check for ERC1155 mintToBatch tests (#5115)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR modifies the test for the `mintToBatch` function in the `ERC1155 Edition` to include conditional execution based on the presence of a `TW_SECRET_KEY`. It also refactors the test structure, improving readability and organization.

### Detailed summary
- Changed `describe` to conditionally run based on `process.env.TW_SECRET_KEY`.
- Refactored the test structure for `mintToBatch`.
- Moved the `sendAndConfirmTransaction` call inside the test.
- Added detailed NFT validation after minting.
- Removed redundant code and improved clarity.

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

<!-- end pr-codex -->
2024-10-21 23:00:13 +00:00
kumaryash90 e7bbc321c2 Fix auto verification of contracts (#5099)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the export of the `verifyContract` function from `ContractSourcesPage.tsx` and integrates it into the `CustomContractForm` component to send a verification request after deploying a contract.

### Detailed summary
- Changed `verifyContract` from a local function to an exported function in `ContractSourcesPage.tsx`.
- Imported `verifyContract` into `custom-contract.tsx`.
- Added a call to `verifyContract` in the `CustomContractForm` after contract deployment.

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

<!-- end pr-codex -->
2024-10-21 22:09:39 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8f157aa43f chore(deps-dev): bump @vitejs/plugin-react from 4.3.2 to 4.3.3 (#5114)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:54:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a437baa57a chore(deps): bump @shazow/whatsabi from 0.15.3 to 0.15.4 (#5113)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:27:29 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4331ed1f04 chore(deps): bump framer-motion from 11.11.8 to 11.11.9 (#5112)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:12:37 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5c896b0fd5 chore(deps-dev): bump msw from 2.4.11 to 2.4.12 (#5107)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:06:36 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
16aa7b7918 chore(deps-dev): bump typedoc from 0.26.9 to 0.26.10 (#5106)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:01:41 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
341d312679 chore(deps-dev): bump @storybook/test from 8.3.5 to 8.3.6 (#5100)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:00:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3aff548a10 chore(deps-dev): bump @types/papaparse from 5.3.14 to 5.3.15 (#5103)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 14:00:13 -07:00
MananTank 002c545d0e Handle published contract page server side errors (#5098)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling and null checks in the `published-contract` related components of the dashboard application. It ensures that appropriate actions are taken when data is missing, enhancing the application's robustness.

### Detailed summary
- Added null checks for `publishedContracts` in multiple files.
- Implemented `notFound()` calls when `publishedContractVersions` or `publishedContracts` are missing.
- Enhanced error handling in `getPublishedContractsWithPublisherMapping.ts` with try-catch.
- Updated `publisherAddress` resolution logic for better clarity.

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

<!-- end pr-codex -->
2024-10-21 20:29:44 +00:00
MananTank bc5b543269 Fix all resolveScheme unhandled errors (#5096)
Fixes: DASH-333
2024-10-21 19:36:51 +00:00
MananTank 1a45f21abb Handle page crash when rendering NFTs with unexpected title, description values (#5095)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces error handling components to display messages for unexpected values in the NFT-related pages, enhancing user experience by providing feedback when data does not meet expectations.

### Detailed summary
- Added `react-error-boundary` to handle errors.
- Created `UnexpectedValueErrorMessage` component for displaying error messages.
- Integrated error handling in `TokenIdPage`, `NFTName`, and `NFTDescription` components.
- Updated `NFTGetAllTable` to use `UnexpectedValueErrorMessage` for invalid data.
- Wrapped NFT cell rendering in `ErrorBoundary` to catch errors.

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

<!-- end pr-codex -->
2024-10-21 16:42:20 +00:00
kien-ngo d59ab13ce8 [Dashboard] Add Media URI to token page (#5088)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `TokenIdPage` component by adding a section to display the media URI of an NFT, including a button to copy the URI and a link to view it externally.

### Detailed summary
- Added a conditional rendering block to display the media URI if `nft.metadata.image` exists.
- Included a `GridItem` for the "Media URI" heading.
- Added a `CopyTextButton` to copy the media URI.
- Added a button with a link to the media URI, featuring an `ExternalLinkIcon`.

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

<!-- end pr-codex -->
2024-10-21 13:44:06 +00:00
270 changed files with 9468 additions and 12297 deletions
+2
View File
@@ -29,3 +29,5 @@ packages/*/typedoc/*
*storybook.log
storybook-static
.aider*
tsconfig.tsbuildinfo
+5
View File
@@ -34,6 +34,11 @@ module.exports = {
message:
"Use useV5DashboardChain instead if you are using it inside a component",
},
{
selector: "CallExpression[callee.name='resolveScheme']",
message:
"resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead",
},
],
"no-restricted-imports": [
"error",
+7
View File
@@ -0,0 +1,7 @@
// add framer paths here
module.exports = [
"/",
"/connect/sign-in",
"/contracts/modular-contracts",
"/unlimited-wallets",
];
+9
View File
@@ -1,4 +1,5 @@
// @ts-check
/**
*
*/
@@ -77,8 +78,16 @@ module.exports = {
};
},
additionalPaths: async (config) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const FRAMER_PATHS = require("./framer-rewrites");
const allChains = await fetchChainsFromApi();
return [
...FRAMER_PATHS.map((path) => ({
loc: path,
changefreq: config.changefreq,
priority: config.priority,
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
})),
...allChains.map((chain) => {
return {
loc: `/${chain.slug}`,
+8 -9
View File
@@ -6,7 +6,7 @@ const ContentSecurityPolicy = `
media-src * data: blob:;
object-src 'none';
style-src 'self' 'unsafe-inline' vercel.live;
font-src 'self' vercel.live assets.vercel.com framerusercontent.com;
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
frame-src * data:;
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
connect-src * data: blob:;
@@ -38,13 +38,7 @@ const securityHeaders = [
];
const redirects = require("./redirects");
// add framer paths here
const FRAMER_PATHS = [
"/connect/sign-in",
"/contracts/modular-contracts",
"/unlimited-wallets",
];
const FRAMER_PATHS = require("./framer-rewrites");
/**
* @returns {import('next').RemotePattern[]}
@@ -135,6 +129,11 @@ const moduleExports = {
source: "/thirdweb.eth/:path*",
destination: "/deployer.thirdweb.eth/:path*",
},
// re-write /home to / (this is so that logged in users will be able to go to /home and NOT be redirected to the logged in app)
{
source: "/home",
destination: "/",
},
...FRAMER_PATHS.map((path) => ({
source: path,
destination: `https://landing.thirdweb.com${path}`,
@@ -191,7 +190,7 @@ module.exports = withBundleAnalyzer(
// An auth token is required for uploading source maps.
authToken: process.env.SENTRY_AUTH_TOKEN,
// Suppresses source map uploading logs during build
silent: false,
silent: true,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
+18 -18
View File
@@ -46,11 +46,11 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "1.1.3",
"@sentry/nextjs": "8.34.0",
"@shazow/whatsabi": "^0.15.3",
"@sentry/nextjs": "8.35.0",
"@shazow/whatsabi": "^0.15.4",
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^3.5.0",
"@tanstack/react-query": "5.59.13",
"@tanstack/react-query": "5.59.16",
"@tanstack/react-table": "^8.17.3",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/og": "^0.6.2",
@@ -62,13 +62,13 @@
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"flat": "^6.0.1",
"framer-motion": "11.11.8",
"framer-motion": "11.11.9",
"fuse.js": "7.0.0",
"input-otp": "^1.2.4",
"ioredis": "^5.4.1",
"ipaddr.js": "^2.2.0",
"lottie-react": "^2.4.0",
"lucide-react": "0.452.0",
"lucide-react": "0.453.0",
"next": "14.2.15",
"next-plausible": "^3.12.0",
"next-seo": "^6.5.0",
@@ -85,8 +85,8 @@
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-dropzone": "^14.2.9",
"react-error-boundary": "^4.1.2",
"react-hook-form": "7.52.0",
"react-intersection-observer": "^9.10.3",
"react-markdown": "^9.0.1",
"react-responsive-carousel": "^3.2.23",
"react-table": "^7.8.0",
@@ -109,18 +109,18 @@
"@next/bundle-analyzer": "14.2.15",
"@next/eslint-plugin-next": "14.2.15",
"@playwright/test": "1.47.2",
"@storybook/addon-essentials": "8.3.5",
"@storybook/addon-interactions": "8.3.5",
"@storybook/addon-links": "8.3.5",
"@storybook/addon-onboarding": "8.3.5",
"@storybook/addon-viewport": "8.3.5",
"@storybook/blocks": "8.3.5",
"@storybook/nextjs": "8.3.5",
"@storybook/react": "8.3.5",
"@storybook/test": "8.3.5",
"@storybook/addon-essentials": "8.3.6",
"@storybook/addon-interactions": "8.3.6",
"@storybook/addon-links": "8.3.6",
"@storybook/addon-onboarding": "8.3.6",
"@storybook/addon-viewport": "8.3.6",
"@storybook/blocks": "8.3.6",
"@storybook/nextjs": "8.3.6",
"@storybook/react": "8.3.6",
"@storybook/test": "8.3.6",
"@types/color": "^3.0.6",
"@types/node": "20.14.9",
"@types/papaparse": "^5.3.14",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.11",
@@ -139,8 +139,8 @@
"knip": "^5.33.3",
"next-sitemap": "^4.2.3",
"postcss": "8.4.47",
"storybook": "8.3.5",
"tailwindcss": "3.4.13",
"storybook": "8.3.6",
"tailwindcss": "3.4.14",
"typescript": "5.6.3"
}
}
@@ -119,13 +119,7 @@ export function AppFooter(props: AppFooterProps) {
>
Terms of Service
</Link>
<Link
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
href="https://thirdweb.com/gas"
target="_blank"
>
Gas Estimator
</Link>
<Link
className="px-[10px] py-[6px] text-muted-foreground text-sm hover:underline"
href="https://thirdweb.com/chainlist"
@@ -0,0 +1,44 @@
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { useMemo } from "react";
export function UnexpectedValueErrorMessage(props: {
value: unknown;
title: string;
description: string;
className?: string;
}) {
const stringifiedValue = useMemo(() => {
try {
return JSON.stringify(props.value, null, 2);
} catch {
return undefined;
}
}, [props.value]);
return (
<div className={props.className}>
<p className="text-base text-destructive-text"> {props.title} </p>
<p className="text-foreground text-sm">{props.description}</p>
{stringifiedValue && (
<div className="mt-3">
<p className="mb-0.5 text-muted-foreground text-sm">Value Received</p>
<ScrollShadow className="rounded-lg bg-muted/50">
<code className="block whitespace-pre p-4 font-mono text-xs">
{stringifiedValue}
</code>
</ScrollShadow>
<CopyTextButton
copyIconPosition="left"
textToShow="Copy value"
textToCopy={stringifiedValue}
tooltip="Copy value"
className="-translate-x-2 mt-1 text-muted-foreground"
variant="ghost"
/>
</div>
)}
</div>
);
}
@@ -1,5 +1,4 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
HoverCard,
@@ -7,6 +6,7 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { Check, Copy, ExternalLinkIcon } from "lucide-react";
import { useMemo } from "react";
@@ -18,7 +18,6 @@ import {
type SocialProfile,
useSocialProfiles,
} from "thirdweb/react";
import { resolveScheme } from "thirdweb/storage";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
@@ -113,22 +112,20 @@ export function WalletAddress(props: {
) : !profiles.data?.length ? (
<p className="text-muted-foreground text-sm">No profiles found</p>
) : (
profiles.data?.map((profile) => (
<div
className="flex flex-row items-center gap-2"
key={profile.type + profile.name}
>
{profile.avatar &&
(profile.avatar.startsWith("http") ||
profile.avatar?.startsWith("ipfs")) && (
profiles.data?.map((profile) => {
const walletAvatarLink = resolveSchemeWithErrorHandler({
client: thirdwebClient,
uri: profile.avatar,
});
return (
<div
className="flex flex-row items-center gap-2"
key={profile.type + profile.name}
>
{walletAvatarLink && (
<Avatar>
<AvatarImage
src={resolveScheme({
client: thirdwebClient,
uri: profile.avatar,
})}
alt={profile.name}
/>
<AvatarImage src={walletAvatarLink} alt={profile.name} />
{profile.name && (
<AvatarFallback>
{profile.name.slice(0, 2)}
@@ -136,19 +133,20 @@ export function WalletAddress(props: {
)}
</Avatar>
)}
<div className="flex w-full flex-col gap-1">
<div className="flex w-full flex-row items-center justify-between gap-4">
<h4 className="font-semibold text-md">{profile.name}</h4>
<Badge variant="outline">{profile.type}</Badge>
<div className="flex w-full flex-col gap-1">
<div className="flex w-full flex-row items-center justify-between gap-4">
<h4 className="font-semibold text-md">{profile.name}</h4>
<Badge variant="outline">{profile.type}</Badge>
</div>
{profile.bio && (
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
{profile.bio}
</p>
)}
</div>
{profile.bio && (
<p className="line-clamp-1 whitespace-normal text-muted-foreground text-sm">
{profile.bio}
</p>
)}
</div>
</div>
))
);
})
)}
<Button
asChild
@@ -0,0 +1,36 @@
"use client";
import { useClipboard } from "hooks/useClipboard";
import { CheckIcon, CopyIcon } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
export function PlainTextCodeBlock(props: {
code: string;
copyButtonClassName?: string;
}) {
const { hasCopied, onCopy } = useClipboard(props.code);
return (
<div className="relative">
<code className="block whitespace-pre rounded-lg border border-border bg-muted/50 p-4">
{props.code}
</code>
<Button
size="sm"
variant="outline"
onClick={onCopy}
className={cn(
"absolute top-3.5 right-3.5 h-auto p-2",
props.copyButtonClassName,
)}
>
{hasCopied ? (
<CheckIcon className="size-3 text-green-500" />
) : (
<CopyIcon className="size-3 text-muted-foreground" />
)}
</Button>
</div>
);
}
@@ -51,12 +51,12 @@ const RadioGroupItemButton = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"group flex min-w-32 cursor-pointer items-center space-x-3 space-y-0 rounded-md border-2 px-3 py-4 font-medium transition-all hover:border-foreground/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 peer-hover:border-nonce data-[state=checked]:border-inverted",
"group flex min-w-32 cursor-pointer items-center space-x-3 space-y-0 rounded-lg border px-4 py-3 font-medium transition-all hover:border-foreground/25 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 peer-hover:border-nonce data-[state=checked]:border-muted-foreground",
className,
)}
{...props}
>
<div className="flex aspect-square h-4 w-4 items-center justify-center rounded-full border-2 text-inverted ring-offset-background transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 group-hover:border-foreground/25 group-data-[state=checked]:border-inverted">
<div className="flex aspect-square h-4 w-4 items-center justify-center rounded-full border text-inverted ring-offset-background transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 group-hover:border-foreground/25 group-data-[state=checked]:border-inverted">
{/* Show on checked */}
<RadioGroupPrimitive.Indicator className="flex items-center justify-center rounded-full transition-all">
<Circle className="h-2 w-2 fill-current text-current" />
-1
View File
@@ -5,7 +5,6 @@ const LOGGED_IN_ONLY_PATHS = [
"/team",
// anything that _starts_ with /cli is logged in only
"/cli",
"/support",
// publish page
"/contracts/publish",
];
@@ -0,0 +1,21 @@
import type { ThirdwebClient } from "thirdweb";
import { resolveScheme } from "thirdweb/storage";
export function resolveSchemeWithErrorHandler(options: {
uri: string | undefined;
client: ThirdwebClient;
}) {
if (!options.uri) {
return undefined;
}
try {
// eslint-disable-next-line no-restricted-syntax
return resolveScheme({
uri: options.uri,
client: options.client,
});
} catch (err) {
console.error("error resolving ipfs url", options.uri, err);
return undefined;
}
}
@@ -435,7 +435,7 @@ export function useUserOpUsageAggregate(args: {
const { clientId, from, to } = args;
const { user, isLoggedIn } = useLoggedInUser();
return useQuery({
return useQuery<UserOpStats>({
queryKey: accountKeys.userOpStats(
user?.address as string,
clientId as string,
@@ -444,12 +444,28 @@ export function useUserOpUsageAggregate(args: {
"all",
),
queryFn: async () => {
return getUserOpUsage({
const userOpStats: UserOpStats[] = await getUserOpUsage({
clientId,
from,
to,
period: "all",
});
// Aggregate stats across wallet types
return userOpStats.reduce(
(acc, curr) => {
acc.successful += curr.successful;
acc.failed += curr.failed;
acc.sponsoredUsd += curr.sponsoredUsd;
return acc;
},
{
date: (from || new Date()).toISOString(),
successful: 0,
failed: 0,
sponsoredUsd: 0,
},
);
},
enabled: !!clientId && !!user?.address && isLoggedIn,
});
@@ -47,7 +47,7 @@ export function FaucetButton({
amount,
}: {
chain: ChainMetadata;
amount: string;
amount: number;
}) {
const client = useThirdwebClient();
const address = useActiveAccount()?.address;
@@ -1,16 +1,16 @@
import type { ChainMetadata } from "thirdweb/chains";
import { getFaucetClaimAmount } from "../../../../../../api/testnet-faucet/claim/claim-amount";
import { ChainIcon } from "../../../../components/server/chain-icon";
import { FaucetButton } from "../client/FaucetButton";
import { GiftIcon } from "../icons/GiftIcon";
import { SectionTitle } from "./SectionTitle";
const amountToGive = "0.01";
export async function FaucetSection(props: { chain: ChainMetadata }) {
const { chain } = props;
// Check eligibilty.
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
const amountToGive = getFaucetClaimAmount(props.chain.chainId);
return (
<section>
@@ -1,5 +1,6 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
import { useIsAdmin } from "@3rdweb-sdk/react/hooks/useContractRoles";
@@ -13,7 +14,6 @@ import {
Menu,
MenuButton,
MenuList,
Spinner,
} from "@chakra-ui/react";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTrack } from "hooks/analytics/useTrack";
@@ -445,19 +445,16 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
};
}, [claimConditionsQuery.data, controlledFields, isMultiPhase]);
if (isFetchingData) {
return (
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
<Spinner className="size-10" />
</div>
);
}
return (
<>
{/* spinner */}
{isFetchingData && (
<Spinner
color="primary"
size="xs"
position="absolute"
top={2}
right={4}
/>
)}
<Flex onSubmit={handleFormSubmit} direction="column" as="form" gap={10}>
<Flex direction="column" gap={6}>
{/* Show the reason why the form is disabled */}
@@ -553,7 +550,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
<Flex
justifyContent="space-between"
flexDir={{ base: "column", md: isColumn ? "column" : "row" }}
flexDir={{ base: "column", md: "row" }}
gap={2}
>
<div className="flex flex-row gap-2">
@@ -19,12 +19,7 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
return (
<div className="flex w-full flex-col gap-8">
<Flex p={0} position="relative">
<Flex
pt={{ base: isColumn ? 0 : 6, md: 6 }}
direction="column"
gap={8}
w="full"
>
<Flex direction="column" gap={6} w="full">
{/* Info */}
<section>
<h2 className="mb-1 font-semibold text-xl tracking-tight">
@@ -3,7 +3,9 @@ import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { ContractMetadata } from "components/custom-contract/contract-header/contract-metadata";
import { DeprecatedAlert } from "components/shared/DeprecatedAlert";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getContractMetadata } from "thirdweb/extensions/common";
import { isAddress, isContractDeployed } from "thirdweb/utils";
import { resolveFunctionSelectors } from "../../../../../lib/selectors";
import { shortenIfAddress } from "../../../../../utils/usedapp-external";
import { ConfigureCustomChain } from "./ConfigureCustomChain";
@@ -20,6 +22,10 @@ export default async function Layout(props: {
};
children: React.ReactNode;
}) {
if (!isAddress(props.params.contractAddress)) {
return notFound();
}
const info = await getContractPageParamsInfo(props.params);
if (!info) {
@@ -27,6 +33,18 @@ export default async function Layout(props: {
}
const { contract, chainMetadata } = info;
if (chainMetadata.status === "deprecated") {
notFound();
}
// check if the contract exists
const isValidContract = await isContractDeployed(contract);
if (!isValidContract) {
// TODO - replace 404 with a better page to upsale deploy or other thirdweb products
notFound();
}
const contractPageMetadata = await getContractPageMetadata(contract);
const sidebarLinks = getContractPageSidebarLinks({
chainSlug: chainMetadata.slug,
@@ -1,17 +1,28 @@
"use client";
import { Flex, useDisclosure } from "@chakra-ui/react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Flex } from "@chakra-ui/react";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { UploadIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebContract } from "thirdweb";
import { multicall } from "thirdweb/extensions/common";
import { balanceOf, encodeSafeTransferFrom } from "thirdweb/extensions/erc1155";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { Button, Text } from "tw-components";
import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload";
import {
type AirdropAddressInput,
AirdropUpload,
} from "../../../tokens/components/airdrop-upload";
interface AirdropTabProps {
contract: ThirdwebContract;
@@ -22,8 +33,6 @@ interface AirdropTabProps {
* This component must only take in ERC1155 contracts
*/
const AirdropTab: React.FC<AirdropTabProps> = ({ contract, tokenId }) => {
const account = useActiveAccount();
const address = useActiveAccount()?.address;
const { handleSubmit, setValue, watch, reset, formState } = useForm<{
addresses: AirdropAddressInput[];
@@ -31,100 +40,106 @@ const AirdropTab: React.FC<AirdropTabProps> = ({ contract, tokenId }) => {
defaultValues: { addresses: [] },
});
const trackEvent = useTrack();
const { isOpen, onOpen, onClose } = useDisclosure();
const { mutate, isPending } = useSendAndConfirmTransaction();
const { onSuccess, onError } = useTxNotifications(
"Airdrop successful",
"Error transferring",
contract,
);
const sendAndConfirmTx = useSendAndConfirmTransaction();
const addresses = watch("addresses");
const [open, setOpen] = useState(false);
return (
<div className="flex w-full flex-col gap-2">
<form
onSubmit={handleSubmit(async (_data) => {
trackEvent({
category: "nft",
action: "airdrop",
label: "attempt",
contract_address: contract.address,
token_id: tokenId,
});
const totalOwned = await balanceOf({
contract,
tokenId: BigInt(tokenId),
owner: account?.address ?? "",
});
// todo: make a batch-transfer extension for erc1155?
const totalToAirdrop = _data.addresses.reduce((prev, curr) => {
return BigInt(prev) + BigInt(curr?.quantity || 1);
}, 0n);
if (totalOwned < totalToAirdrop) {
return onError(
new Error(
`The caller owns ${totalOwned.toString()} NFTs, but wants to airdrop ${totalToAirdrop.toString()} NFTs.`,
),
);
}
const data = _data.addresses.map(({ address: to, quantity }) =>
encodeSafeTransferFrom({
from: account?.address ?? "",
to,
value: BigInt(quantity),
data: "0x",
try {
trackEvent({
category: "nft",
action: "airdrop",
label: "attempt",
contract_address: contract.address,
token_id: tokenId,
});
const totalOwned = await balanceOf({
contract,
tokenId: BigInt(tokenId),
}),
);
const transaction = multicall({ contract, data });
mutate(transaction, {
onSuccess: () => {
trackEvent({
category: "nft",
action: "airdrop",
label: "success",
contract_address: contract.address,
token_id: tokenId,
});
onSuccess();
reset();
},
onError: (error) => {
trackEvent({
category: "nft",
action: "airdrop",
label: "success",
contract_address: contract.address,
token_id: tokenId,
error,
});
onError(error);
},
});
owner: address ?? "",
});
// todo: make a batch-transfer extension for erc1155?
const totalToAirdrop = _data.addresses.reduce((prev, curr) => {
return BigInt(prev) + BigInt(curr?.quantity || 1);
}, 0n);
if (totalOwned < totalToAirdrop) {
return toast.error(
`The caller owns ${totalOwned.toString()} NFTs, but wants to airdrop ${totalToAirdrop.toString()} NFTs.`,
);
}
const data = _data.addresses.map(({ address: to, quantity }) =>
encodeSafeTransferFrom({
from: address ?? "",
to,
value: BigInt(quantity),
data: "0x",
tokenId: BigInt(tokenId),
}),
);
const transaction = multicall({ contract, data });
const promise = sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => {
trackEvent({
category: "nft",
action: "airdrop",
label: "success",
contract_address: contract.address,
token_id: tokenId,
});
reset();
},
onError: (error) => {
trackEvent({
category: "nft",
action: "airdrop",
label: "success",
contract_address: contract.address,
token_id: tokenId,
error,
});
},
});
toast.promise(promise, {
loading: "Airdropping NFTs",
success: "Airdropped successfully",
error: "Failed to airdrop",
});
} catch (err) {
console.error(err);
toast.error("Failed to airdrop NFTs");
}
})}
>
<div className="flex flex-col gap-2">
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
<AirdropUpload
isOpen={isOpen}
onClose={onClose}
setAirdrop={(value) =>
setValue("addresses", value, { shouldDirty: true })
}
/>
<Flex direction={{ base: "column", md: "row" }} gap={4}>
<Button
colorScheme="primary"
borderRadius="md"
onClick={onOpen}
rightIcon={<UploadIcon className="size-5" />}
>
Upload addresses
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
colorScheme="primary"
borderRadius="md"
rightIcon={<UploadIcon className="size-5" />}
>
Upload addresses
</Button>
</SheetTrigger>
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
<SheetHeader>
<SheetTitle className="mb-5 text-left">
Aidrop NFTs
</SheetTitle>
</SheetHeader>
<AirdropUpload
onClose={() => setOpen(false)}
setAirdrop={(value) =>
setValue("addresses", value, { shouldDirty: true })
}
/>
</SheetContent>
</Sheet>
<Flex
gap={2}
@@ -149,7 +164,7 @@ const AirdropTab: React.FC<AirdropTabProps> = ({ contract, tokenId }) => {
<TransactionButton
txChainID={contract.chain.id}
transactionCount={1}
isLoading={isPending}
isLoading={sendAndConfirmTx.isPending}
type="submit"
colorScheme="primary"
disabled={!!address && addresses.length === 0}
@@ -1,494 +0,0 @@
import { UnorderedList } from "@/components/ui/List/List";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { cn } from "@/lib/utils";
import {
Box,
Container,
Flex,
IconButton,
Link,
Portal,
Select,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tooltip,
Tr,
} from "@chakra-ui/react";
import { Logo } from "components/logo";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleAlertIcon,
UploadIcon,
} from "lucide-react";
import Papa from "papaparse";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type DropzoneOptions, useDropzone } from "react-dropzone";
import { type Column, usePagination, useTable } from "react-table";
import { resolveAddress } from "thirdweb/extensions/ens";
import { isAddress } from "thirdweb/utils";
import { Button, Drawer, Heading, Text } from "tw-components";
import { csvMimeTypes } from "utils/batch";
export interface AirdropAddressInput {
address: string;
quantity: string;
isValid?: boolean;
}
interface AirdropUploadProps {
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
isOpen: boolean;
onClose: () => void;
}
export const AirdropUpload: React.FC<AirdropUploadProps> = ({
setAirdrop,
isOpen,
onClose,
}) => {
const client = useThirdwebClient();
const [validAirdrop, setValidAirdrop] = useState<AirdropAddressInput[]>([]);
const [airdropData, setAirdropData] = useState<AirdropAddressInput[]>([]);
const [noCsv, setNoCsv] = useState(false);
const [invalidFound, setInvalidFound] = useState(false);
const reset = useCallback(() => {
setValidAirdrop([]);
setNoCsv(false);
}, []);
const _onClose = useCallback(() => {
onClose();
}, [onClose]);
const onDrop = useCallback<Required<DropzoneOptions>["onDrop"]>(
(acceptedFiles) => {
setNoCsv(false);
const csv = acceptedFiles.find(
(f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"),
);
if (!csv) {
console.error(
"No valid CSV file found, make sure you have an address column.",
);
setNoCsv(true);
return;
}
Papa.parse(csv, {
header: true,
complete: (results) => {
const data: AirdropAddressInput[] = (
results.data as AirdropAddressInput[]
)
.map(({ address, quantity }) => ({
address: (address || "").trim(),
quantity: (quantity || "1").trim(),
}))
.filter(({ address }) => address !== "");
if (!data[0]?.address) {
setNoCsv(true);
return;
}
setValidAirdrop(data);
},
});
},
[],
);
// FIXME: this can be a mutation or query instead!
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (validAirdrop.length === 0) {
return setAirdropData([]);
}
const normalizeAddresses = async (snapshot: AirdropAddressInput[]) => {
const normalized = await Promise.all(
snapshot.map(async ({ address, ...rest }) => {
let isValid = true;
let resolvedAddress = address;
try {
resolvedAddress = isAddress(address)
? address
: await resolveAddress({ name: address, client });
isValid = !!resolvedAddress;
} catch {
isValid = false;
}
return {
address,
resolvedAddress,
isValid,
...rest,
};
}),
);
const seen = new Set();
const filteredData = normalized.filter((el) => {
const duplicate = seen.has(el.resolvedAddress);
seen.add(el.resolvedAddress);
return !duplicate;
});
const valid = filteredData.filter(({ isValid }) => isValid);
const invalid = filteredData.filter(({ isValid }) => !isValid);
if (invalid?.length > 0) {
setInvalidFound(true);
}
const ordered = [...invalid, ...valid];
setAirdropData(ordered);
};
normalizeAddresses(validAirdrop);
}, [validAirdrop, client]);
const removeInvalid = useCallback(() => {
const filteredData = airdropData.filter(({ isValid }) => isValid);
setValidAirdrop(filteredData);
setInvalidFound(false);
}, [airdropData]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
});
const paginationPortalRef = useRef<HTMLDivElement>(null);
const onSave = () => {
setAirdrop(airdropData);
onClose();
};
return (
<Drawer
allowPinchZoom
preserveScrollBarGap
size="xl"
onClose={_onClose}
isOpen={isOpen}
>
<Flex direction="column" gap={6} h="100%">
<Flex shadow="sm">
<Container maxW="container.page">
<Flex align="center" justify="space-between" p={4}>
<div className="flex flex-row gap-2">
<Logo hideWordmark />
<Heading size="title.md">
{validAirdrop.length ? "Edit" : "Upload"} Airdrop
</Heading>
</div>
</Flex>
</Container>
</Flex>
{validAirdrop.length > 0 ? (
<AirdropTable portalRef={paginationPortalRef} data={airdropData} />
) : (
<Flex flexGrow={1} align="center" overflow="auto">
<Container maxW="container.page">
<Flex gap={8} flexDir="column">
<div className="relative aspect-[21/9] w-full">
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
noCsv ? "bg-red-200" : "bg-card",
)}
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="m-auto flex flex-col p-6">
<UploadIcon
size={16}
className={cn("mx-auto mb-2 text-gray-500", {
"text-red-500": noCsv,
})}
/>
{isDragActive ? (
<Heading
as={Text}
size="label.md"
className="text-center"
>
Drop the files here
</Heading>
) : (
<Heading
as={Text}
size="label.md"
color={noCsv ? "red.500" : "gray.600"}
className="text-center"
>
{noCsv
? `No valid CSV file found, make sure your CSV includes the "address" column.`
: "Drag & Drop a CSV file here"}
</Heading>
)}
</div>
</div>
</div>
<div className="flex flex-row gap-2">
<Heading size="subtitle.sm">Requirements</Heading>
<UnorderedList>
<li>
Files <em>must</em> contain one .csv file with an address
and quantity column, if the quantity column is not
provided, it will default to 1 NFT per wallet. -{" "}
<Link download color="primary.500" href="/airdrop.csv">
Download an example CSV
</Link>
</li>
<li>
Repeated addresses will be removed and only the first
found will be kept.
</li>
</UnorderedList>
</div>
</Flex>
</Container>
</Flex>
)}
<Flex borderTop="1px solid" borderTopColor="borderColor">
<Container maxW="container.page">
<Flex
align="center"
justify="space-between"
p={{ base: 0, md: 4 }}
flexDir={{ base: "column", md: "row" }}
mt={{ base: 4, md: 0 }}
>
<Box ref={paginationPortalRef} />
<Flex
gap={2}
align="center"
mt={{ base: 4, md: 0 }}
w={{ base: "100%", md: "auto" }}
>
<Button
borderRadius="md"
disabled={validAirdrop.length === 0}
onClick={() => {
reset();
}}
w={{ base: "100%", md: "auto" }}
>
Reset
</Button>
{invalidFound ? (
<Button
borderRadius="md"
colorScheme="primary"
disabled={validAirdrop.length === 0}
onClick={() => {
removeInvalid();
}}
w={{ base: "100%", md: "auto" }}
>
Remove invalid
</Button>
) : (
<Button
borderRadius="md"
colorScheme="primary"
onClick={onSave}
w={{ base: "100%", md: "auto" }}
isDisabled={validAirdrop.length === 0}
>
Next
</Button>
)}
</Flex>
</Flex>
</Container>
</Flex>
</Flex>
</Drawer>
);
};
interface AirdropTableProps {
data: AirdropAddressInput[];
portalRef: React.RefObject<HTMLDivElement>;
}
const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
const columns = useMemo(() => {
return [
{
Header: "Address",
accessor: ({ address, isValid }) => {
if (isValid) {
return address;
}
return (
<div className="flex flex-row items-center gap-2">
<Tooltip
label={
address.startsWith("0x")
? "Address is not valid"
: "Address couldn't be resolved"
}
>
<div className="flex flex-row items-center gap-2">
<CircleAlertIcon className="size-5 text-red-500" />
<Text fontWeight="bold" color="red.500" cursor="default">
{address}
</Text>
</div>
</Tooltip>
</div>
);
},
},
{
Header: "Quantity",
accessor: ({ quantity }: { quantity: string }) => {
return quantity || "1";
},
},
] as Column<AirdropAddressInput>[];
}, []);
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
// Instead of using 'rows', we'll use page,
page,
// which has only the rows for the active page
// The rest of these things are super handy, too ;)
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable(
{
columns,
data,
initialState: {
pageSize: 50,
pageIndex: 0,
},
},
// old package: this will be removed
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
);
// Render the UI for your table
return (
<Flex flexGrow={1}>
<TableContainer>
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup, headerGroupIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
{headerGroup.headers.map((column, columnIndex) => (
<Th
{...column.getHeaderProps()}
border="none"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={columnIndex}
>
<Text as="label" size="label.sm" color="faded">
{column.render("Header")}
</Text>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody {...getTableBodyProps()}>
{page.map((row, rowIndex) => {
prepareRow(row);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...row.getRowProps()} key={rowIndex}>
{row.cells.map((cell, cellIndex) => (
<Td
{...cell.getCellProps()}
borderColor="borderColor"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
))}
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
<Portal containerRef={portalRef}>
<div className="flex w-full items-center justify-center">
<div className="flex flex-row gap-1">
<IconButton
isDisabled={!canPreviousPage}
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
onClick={() => gotoPage(0)}
/>
<IconButton
isDisabled={!canPreviousPage}
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
onClick={() => previousPage()}
/>
<p className="my-auto whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
isDisabled={!canNextPage}
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
onClick={() => nextPage()}
/>
<IconButton
isDisabled={!canNextPage}
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
onClick={() => gotoPage(pageCount - 1)}
/>
<Select
onChange={(e) => {
setPageSize(Number.parseInt(e.target.value as string, 10));
}}
value={pageSize}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</Select>
</div>
</div>
</Portal>
</Flex>
);
};
@@ -1,10 +1,12 @@
"use client";
import { UnexpectedValueErrorMessage } from "@/components/blocks/error-fallbacks/unexpect-value-error-message";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import {
Box,
ButtonGroup,
@@ -24,7 +26,6 @@ import type { ThirdwebContract } from "thirdweb";
import { getNFT as getErc721NFT } from "thirdweb/extensions/erc721";
import { getNFT as getErc1155NFT } from "thirdweb/extensions/erc1155";
import { useReadContract } from "thirdweb/react";
import { resolveScheme } from "thirdweb/storage";
import { Badge, Button, Card, CodeBlock, Heading, Text } from "tw-components";
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
import { shortenString } from "utils/usedapp-external";
@@ -53,6 +54,8 @@ interface TokenIdPageProps {
isErc721: boolean;
}
// TODO: verify the entire nft object with zod schema and display an error message
export const TokenIdPage: React.FC<TokenIdPageProps> = ({
contract,
tokenId,
@@ -80,6 +83,15 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
},
);
const tokenURIHttpLink = resolveSchemeWithErrorHandler({
client,
uri: nft?.tokenURI,
});
const nftImageLink = resolveSchemeWithErrorHandler({
client,
uri: nft?.metadata.image,
});
if (isPending) {
return (
<div className="flex h-[400px] items-center justify-center">
@@ -132,15 +144,15 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
height={isMobile ? "100%" : "300px"}
/>
</Card>
<Flex flexDir="column" gap={6} w="full" px={2}>
<Flex flexDir="column" gap={2}>
<Heading size="title.lg">{nft.metadata.name}</Heading>
<Flex flexDir="column" gap={1.5}>
<NFTName value={nft.metadata.name} />
{nft.metadata?.description && (
<Text size="label.md" noOfLines={50} whiteSpace="pre-wrap">
{nft.metadata.description}
</Text>
<NFTDescription value={nft.metadata.description} />
)}
</Flex>
<Flex flexDir="column" gap={{ base: 0, md: 4 }}>
<Box
w="full"
@@ -260,15 +272,39 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
tooltip="The URI of this NFT"
copyIconPosition="right"
/>
<Button variant="ghost">
<Link
href={resolveScheme({ client, uri: nft.tokenURI })}
target="_blank"
>
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
{tokenURIHttpLink && (
<Button variant="ghost" size="sm">
<Link href={tokenURIHttpLink} target="_blank">
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
)}
</GridItem>
{nft.metadata.image && (
<>
<GridItem colSpan={4}>
<Heading size="label.md">Media URI</Heading>
</GridItem>
<GridItem
colSpan={8}
className="flex flex-row items-center gap-1"
>
<CopyTextButton
textToCopy={nft.metadata.image}
textToShow={shortenString(nft.metadata.image)}
tooltip="The media URI of this NFT"
copyIconPosition="right"
/>
{nftImageLink && (
<Button variant="ghost" size="sm">
<Link href={nftImageLink} target="_blank">
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
)}
</GridItem>
</>
)}
</SimpleGrid>
</Card>
{properties ? (
@@ -309,3 +345,39 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
</Flex>
);
};
function NFTName(props: {
value: unknown;
}) {
if (typeof props.value === "string") {
return (
<h2 className="font-semibold text-lg tracking-tight"> {props.value}</h2>
);
}
return (
<UnexpectedValueErrorMessage
title="Invalid Name"
description="Name is not a string"
value={props.value}
className="mb-3 rounded-lg border border-border p-4"
/>
);
}
function NFTDescription(props: {
value: unknown;
}) {
if (typeof props.value === "string") {
return <p className="text-muted-foreground"> {props.value}</p>;
}
return (
<UnexpectedValueErrorMessage
title="Invalid Description"
description="Description is not a string"
value={props.value}
className="mb-3 rounded-lg border border-border p-4"
/>
);
}
@@ -1,7 +1,10 @@
"use client";
import { UnexpectedValueErrorMessage } from "@/components/blocks/error-fallbacks/unexpect-value-error-message";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import {
Flex,
IconButton,
@@ -16,6 +19,7 @@ import {
Thead,
Tr,
} from "@chakra-ui/react";
import * as Sentry from "@sentry/nextjs";
import { MediaCell } from "components/contract-pages/table/table-columns/cells/media-cell";
import { useChainSlug } from "hooks/chains/chainSlug";
import {
@@ -26,6 +30,7 @@ import {
ChevronRightIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
import {
type CellProps,
type Column,
@@ -72,24 +77,51 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
{
Header: "Name",
accessor: (row) => row.metadata.name,
Cell: (cell: CellProps<NFT, string>) => (
<Text noOfLines={1} size="label.md">
{cell.value}
</Text>
),
Cell: (cell: CellProps<NFT, string>) => {
if (typeof cell.value !== "string") {
return (
<UnexpectedValueErrorMessage
title="Invalid Name"
description="Name is not a string"
value={cell.value}
className="w-[300px] py-3"
/>
);
}
return (
<p className="line-clamp-1 text-muted-foreground text-sm">
{cell.value}
</p>
);
},
},
{
Header: "Description",
accessor: (row) => row.metadata.description,
Cell: (cell: CellProps<NFT, string>) => (
<Text
noOfLines={4}
size="body.md"
fontStyle={!cell.value ? "italic" : "normal"}
>
{cell.value || "No description"}
</Text>
),
Cell: (cell: CellProps<NFT, string>) => {
if (typeof cell.value !== "string") {
return (
<UnexpectedValueErrorMessage
title="Invalid description"
description="Description is not a string"
value={cell.value}
className="w-[300px] py-3"
/>
);
}
return (
<p
className={cn(
"line-clamp-4 max-w-[200px] whitespace-normal text-muted-foreground text-sm",
{ italic: !cell.value },
)}
>
{cell.value || "No description"}
</p>
);
},
},
];
if (isErc721) {
@@ -280,18 +312,22 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={rowIndex}
>
{row.cells.map((cell, cellIndex) => (
<Td
{...cell.getCellProps()}
borderBottomWidth="inherit"
borderColor="borderColor"
maxW="sm"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
))}
{row.cells.map((cell, cellIndex) => {
return (
<Td
{...cell.getCellProps()}
borderBottomWidth="inherit"
borderColor="borderColor"
maxW="sm"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
<ErrorBoundary FallbackComponent={NFTCellErrorBoundary}>
{cell.render("Cell")}
</ErrorBoundary>
</Td>
);
})}
<Td borderBottomWidth="inherit" borderColor="borderColor">
{!failedToLoad && <ArrowRightIcon className="size-4" />}
</Td>
@@ -374,3 +410,25 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
</Flex>
);
};
function NFTCellErrorBoundary(errorProps: FallbackProps) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
Sentry.withScope((scope) => {
scope.setTag("component-crashed", "true");
scope.setTag("component-crashed-boundary", "NFTCellErrorBoundary");
scope.setLevel("fatal");
Sentry.captureException(errorProps.error);
});
}, [errorProps.error]);
return (
<CopyTextButton
textToShow={errorProps.error.message}
textToCopy={errorProps.error.message}
copyIconPosition="left"
tooltip={errorProps.error.message}
className="max-w-[250px] truncate text-destructive-text"
/>
);
}
@@ -1,6 +1,5 @@
"use client";
import { PublishedBy } from "components/contract-components/shared/published-by";
import { ErrorBoundary } from "react-error-boundary";
import type { ThirdwebContract } from "thirdweb";
import { AnalyticsOverview } from "./components/Analytics";
import { BuildYourApp } from "./components/BuildYourApp";
@@ -21,6 +20,7 @@ interface ContractOverviewPageProps {
isPermissionsEnumerable: boolean;
chainSlug: string;
isAnalyticsSupported: boolean;
functionSelectors: string[];
}
const TRACKING_CATEGORY = "contract_overview";
@@ -35,6 +35,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
isPermissionsEnumerable,
chainSlug,
isAnalyticsSupported,
functionSelectors,
}) => {
return (
<div className="flex flex-col gap-8 lg:flex-row">
@@ -45,6 +46,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
isErc20={isErc20}
contract={contract}
chainSlug={chainSlug}
functionSelectors={functionSelectors}
/>
{isAnalyticsSupported && (
@@ -98,7 +100,9 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
/>
</div>
<div className="shrink-0 lg:w-[300px]">
<PublishedBy contract={contract} />
<ErrorBoundary fallback={null}>
<PublishedBy contract={contract} />
</ErrorBoundary>
</div>
</div>
);
@@ -1,3 +1,5 @@
"use client";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { Button } from "@/components/ui/button";
import {
@@ -1,3 +1,5 @@
"use client";
import {
Flex,
GridItem,
@@ -7,7 +9,9 @@ import {
} from "@chakra-ui/react";
import { ChakraNextImage as Image } from "components/Image";
import { PRODUCTS } from "components/product-pages/common/nav/data";
import { Card, Text, TrackedLink, type TrackedLinkProps } from "tw-components";
import { Card } from "tw-components/card";
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
import { Text } from "tw-components/text";
const RENDERED_PRODUCTS = ["sdk", "storage", "ui-components", "auth"];
@@ -1,7 +1,8 @@
"use client";
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
import { useIsMinter } from "@3rdweb-sdk/react/hooks/useContractRoles";
import { StepsCard } from "components/dashboard/StepsCard";
import { useContractFunctionSelectors } from "contract-ui/hooks/useContractFunctionSelectors";
import Link from "next/link";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
@@ -18,6 +19,7 @@ interface ContractChecklistProps {
isErc1155: boolean;
isErc20: boolean;
chainSlug: string;
functionSelectors: string[];
}
type Step = {
@@ -27,13 +29,10 @@ type Step = {
};
export const ContractChecklist: React.FC<ContractChecklistProps> = (props) => {
const functionSelectorQuery = useContractFunctionSelectors(props.contract);
return (
// if no permissions, simply return null (do not fail open)
<AdminOnly contract={props.contract} failOpen={false}>
{!!functionSelectorQuery.data?.length && (
<Inner functionSelectors={functionSelectorQuery.data} {...props} />
)}
<Inner {...props} />
</AdminOnly>
);
};
@@ -1,3 +1,5 @@
"use client";
import {
type InternalTransaction,
useActivity,
@@ -1,3 +1,5 @@
"use client";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { SkeletonContainer } from "@/components/ui/skeleton";
@@ -1,9 +1,11 @@
"use client";
import { Flex, useBreakpointValue } from "@chakra-ui/react";
import type { ThirdwebContract } from "thirdweb";
import * as ERC721 from "thirdweb/extensions/erc721";
import * as ERC1155 from "thirdweb/extensions/erc1155";
import { useReadContract } from "thirdweb/react";
import { TrackedLink, type TrackedLinkProps } from "tw-components";
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
import { NFTCards } from "../../_components/NFTCards";
interface NFTDetailsProps {
@@ -1,3 +1,5 @@
"use client";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { Box, Flex, List, SimpleGrid, Tag } from "@chakra-ui/react";
import { getAllRoleMembers } from "contract-ui/hooks/permissions";
@@ -8,14 +10,11 @@ import { useMemo } from "react";
import { toast } from "sonner";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
import { useReadContract } from "thirdweb/react";
import {
Button,
Card,
Heading,
Text,
TrackedLink,
type TrackedLinkProps,
} from "tw-components";
import { Button } from "tw-components/button";
import { Card } from "tw-components/card";
import { Heading } from "tw-components/heading";
import { TrackedLink, type TrackedLinkProps } from "tw-components/link";
import { Text } from "tw-components/text";
import { shortenIfAddress } from "utils/usedapp-external";
interface PermissionsTableProps {
@@ -31,6 +31,7 @@ export default async function Page(props: {
}
chainSlug={chainMetadata.slug}
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
functionSelectors={contractPageMetadata.functionSelectors}
/>
);
}
@@ -89,7 +89,8 @@ export const SettingsMetadata = ({
let image: string | undefined = metadata.data?.image;
try {
image = image
? resolveScheme({
? // eslint-disable-next-line no-restricted-syntax
resolveScheme({
client: contract.client,
uri: image,
})
@@ -34,7 +34,7 @@ interface VerificationResult {
error?: string;
}
async function verifyContract(contract: ThirdwebContract) {
export async function verifyContract(contract: ThirdwebContract) {
try {
const response = await fetch(
"https://contract.thirdweb.com/verify/contract",
@@ -41,7 +41,7 @@ export const TokenAirdropButton: React.FC<TokenAirdropButtonProps> = ({
Airdrop
</Button>
</SheetTrigger>
<SheetContent className="z-[10000] overflow-y-auto sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
<SheetHeader>
<SheetTitle>Aidrop tokens</SheetTitle>
</SheetHeader>
@@ -10,10 +10,7 @@ import type { ThirdwebContract } from "thirdweb";
import { transferBatch } from "thirdweb/extensions/erc20";
import { useSendAndConfirmTransaction } from "thirdweb/react";
import { Button, Text } from "tw-components";
import {
AirdropUploadERC20,
type ERC20AirdropAddressInput,
} from "./airdrop-upload-erc20";
import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload";
interface TokenAirdropFormProps {
contract: ThirdwebContract;
toggle?: Dispatch<SetStateAction<boolean>>;
@@ -25,7 +22,7 @@ export const TokenAirdropForm: React.FC<TokenAirdropFormProps> = ({
toggle,
}) => {
const { handleSubmit, setValue, watch } = useForm<{
addresses: ERC20AirdropAddressInput[];
addresses: AirdropAddressInput[];
}>({
defaultValues: { addresses: [] },
});
@@ -41,55 +38,60 @@ export const TokenAirdropForm: React.FC<TokenAirdropFormProps> = ({
<div className="pt-3">
<form
onSubmit={handleSubmit((data) => {
trackEvent({
category: "token",
action: "airdrop",
label: "attempt",
contractAddress: contract.address,
});
const tx = transferBatch({
contract,
batch: data.addresses
.filter((address) => address.quantity !== undefined)
.map((address) => ({
to: address.address,
amount: address.quantity,
})),
});
const promise = sendTransaction.mutateAsync(tx, {
onSuccess: () => {
trackEvent({
category: "token",
action: "airdrop",
label: "success",
contract_address: contract.address,
});
// Close the sheet/modal on success
if (toggle) {
toggle(false);
}
},
onError: (error) => {
trackEvent({
category: "token",
action: "airdrop",
label: "success",
contract_address: contract.address,
error,
});
console.error(error);
},
});
toast.promise(promise, {
loading: "Airdropping tokens",
success: "Tokens airdropped successfully",
error: "Failed to airdrop tokens",
});
try {
trackEvent({
category: "token",
action: "airdrop",
label: "attempt",
contractAddress: contract.address,
});
const tx = transferBatch({
contract,
batch: data.addresses
.filter((address) => address.quantity !== undefined)
.map((address) => ({
to: address.address,
amount: address.quantity,
})),
});
const promise = sendTransaction.mutateAsync(tx, {
onSuccess: () => {
trackEvent({
category: "token",
action: "airdrop",
label: "success",
contract_address: contract.address,
});
// Close the sheet/modal on success
if (toggle) {
toggle(false);
}
},
onError: (error) => {
trackEvent({
category: "token",
action: "airdrop",
label: "success",
contract_address: contract.address,
error,
});
console.error(error);
},
});
toast.promise(promise, {
loading: "Airdropping tokens",
success: "Tokens airdropped successfully",
error: "Failed to airdrop tokens",
});
} catch (err) {
console.error(err);
toast.error("Failed to airdrop tokens");
}
})}
>
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
{airdropFormOpen ? (
<AirdropUploadERC20
<AirdropUpload
onClose={() => setAirdropFormOpen(false)}
setAirdrop={(value) =>
setValue("addresses", value, { shouldDirty: true })
@@ -33,17 +33,17 @@ import { isAddress } from "thirdweb/utils";
import { Button, Heading, Text } from "tw-components";
import { csvMimeTypes } from "utils/batch";
export interface ERC20AirdropAddressInput {
export interface AirdropAddressInput {
address: string;
quantity: string;
isValid?: boolean;
}
interface AirdropUploadProps {
setAirdrop: (airdrop: ERC20AirdropAddressInput[]) => void;
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
onClose: () => void;
}
async function checkIsAddress(
item: ERC20AirdropAddressInput,
item: AirdropAddressInput,
thirdwebClient: ThirdwebClient,
) {
const { address, ...rest } = item;
@@ -75,10 +75,7 @@ async function checkIsAddress(
};
}
function processAirdropData(
data: (
| (ERC20AirdropAddressInput & { resolvedAddress: string })
| undefined
)[],
data: ((AirdropAddressInput & { resolvedAddress: string }) | undefined)[],
) {
const seen = new Set();
const filteredData = data
@@ -95,14 +92,12 @@ function processAirdropData(
return ordered;
}
export const AirdropUploadERC20: React.FC<AirdropUploadProps> = ({
export const AirdropUpload: React.FC<AirdropUploadProps> = ({
setAirdrop,
onClose,
}) => {
const thirdwebClient = useThirdwebClient();
const [validAirdrop, setValidAirdrop] = useState<ERC20AirdropAddressInput[]>(
[],
);
const [validAirdrop, setValidAirdrop] = useState<AirdropAddressInput[]>([]);
const [noCsv, setNoCsv] = useState(false);
const reset = useCallback(() => {
setValidAirdrop([]);
@@ -124,8 +119,8 @@ export const AirdropUploadERC20: React.FC<AirdropUploadProps> = ({
Papa.parse(csv, {
header: true,
complete: (results) => {
const data: ERC20AirdropAddressInput[] = (
results.data as ERC20AirdropAddressInput[]
const data: AirdropAddressInput[] = (
results.data as AirdropAddressInput[]
)
.map(({ address, quantity }) => ({
address: (address || "").trim(),
@@ -278,7 +273,7 @@ export const AirdropUploadERC20: React.FC<AirdropUploadProps> = ({
};
interface AirdropTableProps {
data: ERC20AirdropAddressInput[];
data: AirdropAddressInput[];
portalRef: React.RefObject<HTMLDivElement>;
}
const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
@@ -316,7 +311,7 @@ const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
return quantity || "1";
},
},
] as Column<ERC20AirdropAddressInput>[];
] as Column<AirdropAddressInput>[];
}, []);
const {
getTableProps,
@@ -2,8 +2,8 @@
import "server-only";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { cn } from "@/lib/utils";
import { resolveScheme } from "thirdweb/storage";
const fallbackChainIcon =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHZpZXdCb3g9IjAgMCA5NiA5NiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTY4LjE1MTkgNzUuNzM3MkM2Mi4yOTQzIDc5Ljk5MyA1NS4yMzk3IDgyLjI4NTIgNDcuOTk5MyA4Mi4yODUyQzQwLjc1ODkgODIuMjg1MiAzMy43MDQzIDc5Ljk5MyAyNy44NDY2IDc1LjczNzJNNjMuMDI5MSAxNy4xODM3QzY5LjUzNjggMjAuMzU3NyA3NC44NzI2IDI1LjUxMDQgNzguMjcxOCAzMS45MDMzQzgxLjY3MDkgMzguMjk2MiA4Mi45NTkgNDUuNjAxMiA4MS45NTEzIDUyLjc3MTFNMTQuMDQ3NiA1Mi43NzA4QzEzLjAzOTkgNDUuNjAwOCAxNC4zMjggMzguMjk1OSAxNy43MjcxIDMxLjkwM0MyMS4xMjYzIDI1LjUxMDEgMjYuNDYyMSAyMC4zNTczIDMyLjk2OTggMTcuMTgzM000Ni4wNTk4IDI5LjM2NzVMMjkuMzY3MyA0Ni4wNkMyOC42ODg1IDQ2LjczODkgMjguMzQ5IDQ3LjA3ODMgMjguMjIxOCA0Ny40Njk3QzI4LjExIDQ3LjgxNCAyOC4xMSA0OC4xODQ5IDI4LjIyMTggNDguNTI5MkMyOC4zNDkgNDguOTIwNiAyOC42ODg1IDQ5LjI2MDEgMjkuMzY3MyA0OS45MzlMNDYuMDU5OCA2Ni42MzE0QzQ2LjczODcgNjcuMzEwMyA0Ny4wNzgxIDY3LjY0OTcgNDcuNDY5NSA2Ny43NzY5QzQ3LjgxMzggNjcuODg4OCA0OC4xODQ3IDY3Ljg4ODggNDguNTI5IDY3Ljc3NjlDNDguOTIwNCA2Ny42NDk3IDQ5LjI1OTkgNjcuMzEwMyA0OS45Mzg4IDY2LjYzMTRMNjYuNjMxMiA0OS45MzlDNjcuMzEwMSA0OS4yNjAxIDY3LjY0OTUgNDguOTIwNiA2Ny43NzY3IDQ4LjUyOTJDNjcuODg4NiA0OC4xODQ5IDY3Ljg4ODYgNDcuODE0IDY3Ljc3NjcgNDcuNDY5N0M2Ny42NDk1IDQ3LjA3ODMgNjcuMzEwMSA0Ni43Mzg5IDY2LjYzMTIgNDYuMDZMNDkuOTM4OCAyOS4zNjc1QzQ5LjI1OTkgMjguNjg4NyA0OC45MjA0IDI4LjM0OTIgNDguNTI5IDI4LjIyMkM0OC4xODQ3IDI4LjExMDIgNDcuODEzOCAyOC4xMTAyIDQ3LjQ2OTUgMjguMjIyQzQ3LjA3ODEgMjguMzQ5MiA0Ni43Mzg3IDI4LjY4ODcgNDYuMDU5OCAyOS4zNjc1WiIgc3Ryb2tlPSIjNDA0MDQwIiBzdHJva2Utd2lkdGg9IjYuODU3MTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K";
@@ -13,24 +13,35 @@ export async function ChainIcon(props: {
className?: string;
}) {
if (props.iconUrl) {
const resolved = resolveScheme({
let imageLink = fallbackChainIcon;
const resolved = resolveSchemeWithErrorHandler({
client: getThirdwebClient(),
uri: props.iconUrl,
});
const res = await fetch(resolved, {
// revalidate every hour
next: { revalidate: 60 * 60 },
method: "HEAD",
headers: DASHBOARD_THIRDWEB_SECRET_KEY
? {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
}
: {},
}).catch(() => null);
if (resolved) {
// check if it loads or not
const res = await fetch(resolved, {
// revalidate every hour
next: { revalidate: 60 * 60 },
method: "HEAD",
headers: DASHBOARD_THIRDWEB_SECRET_KEY
? {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
}
: {},
}).catch(() => null);
if (res?.status === 200) {
imageLink = resolved;
}
}
return (
<img
alt=""
src={res?.status === 200 ? resolved : fallbackChainIcon}
src={imageLink}
className={cn("object-contain", props.className)}
/>
);
@@ -3,7 +3,7 @@ import { getActiveAccountCookie, getJWTCookie } from "@/constants/cookie";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { ContractPublishForm } from "components/contract-components/contract-publish-form";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { fetchDeployMetadata } from "thirdweb/contract";
import { getPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
@@ -47,6 +47,10 @@ export default async function PublishContractPage(
contract_id: publishMetadataFromUri.name,
});
if (!publishedContractVersions) {
notFound();
}
const publishedContract = publishedContractVersions[0];
if (publishedContract) {
@@ -44,7 +44,7 @@ export default async function Page(props: {
);
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-6">
<div className="flex flex-col content-start justify-between gap-4 lg:flex-row">
<PageHeader />
@@ -68,7 +68,9 @@ export default async function Page(props: {
<SmartWallets
apiKeyServices={apiKey.services || []}
trackingCategory="smart-wallet"
defaultTab={props.searchParams.tab === "1" ? 1 : 0}
tab={props.searchParams.tab}
smartWalletsLayoutSlug={`/dashboard/connect/account-abstraction/${clientId}`}
clientId={apiKey.key}
/>
<ConnectSDKCard
@@ -51,7 +51,10 @@ export default async function Page(props: {
<div className="h-4 lg:h-8" />
<ConnectAnalyticsDashboard clientId={apiKey.key} />
<ConnectAnalyticsDashboard
clientId={apiKey.key}
connectLayoutSlug="/dashboard/connect"
/>
<div className="h-4 lg:h-8" />
<ConnectSDKCard description="Add the Connect SDK to your app to start collecting analytics." />
@@ -10,7 +10,7 @@ export function EcosystemPermissionsPage({
const { ecosystem } = useEcosystem({ slug: params.slug });
return (
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-8">
<IntegrationPermissionsSection ecosystem={ecosystem} />
<AuthOptionsSection ecosystem={ecosystem} />
{ecosystem?.permission === "PARTNER_WHITELIST" && (
@@ -20,7 +20,7 @@ export function AddPartnerDialogButton(props: {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 max-sm:w-full">
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
@@ -1,8 +1,8 @@
"use client";
import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog";
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@@ -13,111 +13,114 @@ import {
} from "@/components/ui/form";
import { FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { getSocialIcon } from "thirdweb/wallets/in-app";
import {
DEFAULT_ACCOUNT_FACTORY_V0_6,
DEFAULT_ACCOUNT_FACTORY_V0_7,
} from "thirdweb/wallets/smart";
import invariant from "tiny-invariant";
import { z } from "zod";
import { type Ecosystem, authOptions } from "../../../../types";
import { useUpdateEcosystem } from "../../hooks/use-update-ecosystem";
type AuthOptionsFormData = {
authOptions: string[];
useCustomAuth: boolean;
customAuthEndpoint: string;
customHeaders: { key: string; value: string }[];
useSmartAccount: boolean;
chainIds: number[];
sponsorGas: boolean;
accountFactoryType: "v0.6" | "v0.7" | "custom";
customAccountFactoryAddress: string;
};
export function AuthOptionsForm({ ecosystem }: { ecosystem: Ecosystem }) {
const [messageToConfirm, setMessageToConfirm] = useState<
| {
title: string;
description: string;
authOptions: typeof ecosystem.authOptions;
}
| undefined
>();
const {
mutateAsync: updateEcosystem,
variables,
isPending,
} = useUpdateEcosystem({
onError: (error) => {
const message =
error instanceof Error ? error.message : "Failed to update ecosystem";
toast.error(message);
},
});
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-5 md:gap-2">
{authOptions.map((option) => (
<CheckboxWithLabel
key={option}
className={cn(
isPending &&
variables?.authOptions?.includes(option) &&
"animate-pulse",
"hover:cursor-pointer hover:text-foreground",
)}
>
<Checkbox
checked={ecosystem.authOptions?.includes(option)}
onClick={() => {
if (ecosystem.authOptions?.includes(option)) {
setMessageToConfirm({
title: `Are you sure you want to remove ${option.slice(0, 1).toUpperCase() + option.slice(1)} as an authentication option for this ecosystem?`,
description:
"Users will no longer be able to log into your ecosystem using this option. Any users that previously used this option will be unable to log in.",
authOptions: ecosystem.authOptions?.filter(
(o) => o !== option,
),
});
} else {
setMessageToConfirm({
title: `Are you sure you want to add ${option.slice(0, 1).toUpperCase() + option.slice(1)} as an authentication option for this ecosystem?`,
description:
"Users will be able to log into your ecosystem using this option. If you later remove this option users that used it will no longer be able to log in.",
authOptions: [...ecosystem.authOptions, option],
});
}
}}
/>
{option.slice(0, 1).toUpperCase() + option.slice(1)}
</CheckboxWithLabel>
))}
<ConfirmationDialog
open={!!messageToConfirm}
onOpenChange={(open) => {
if (!open) {
setMessageToConfirm(undefined);
}
}}
title={messageToConfirm?.title}
description={messageToConfirm?.description}
onSubmit={() => {
invariant(
messageToConfirm,
"Must have message for modal to be open",
);
updateEcosystem({
...ecosystem,
authOptions: messageToConfirm.authOptions,
});
}}
/>
</div>
<CustomAuthOptionsForm ecosystem={ecosystem} />
</div>
);
}
function CustomAuthOptionsForm({ ecosystem }: { ecosystem: Ecosystem }) {
const form = useForm({
const form = useForm<AuthOptionsFormData>({
defaultValues: {
customAuthEndpoint: ecosystem.customAuthOptions?.authEndpoint?.url,
customHeaders: ecosystem.customAuthOptions?.authEndpoint?.headers,
authOptions: ecosystem.authOptions || [],
useCustomAuth: !!ecosystem.customAuthOptions,
customAuthEndpoint: ecosystem.customAuthOptions?.authEndpoint?.url || "",
customHeaders: ecosystem.customAuthOptions?.authEndpoint?.headers || [],
useSmartAccount: !!ecosystem.smartAccountOptions,
chainIds: ecosystem.smartAccountOptions?.chainIds || [],
sponsorGas: ecosystem.smartAccountOptions?.sponsorGas || false,
accountFactoryType:
ecosystem.smartAccountOptions?.accountFactoryAddress ===
DEFAULT_ACCOUNT_FACTORY_V0_7
? "v0.7"
: ecosystem.smartAccountOptions?.accountFactoryAddress ===
DEFAULT_ACCOUNT_FACTORY_V0_6
? "v0.6"
: "custom",
customAccountFactoryAddress:
ecosystem.smartAccountOptions?.accountFactoryAddress || "",
},
resolver: zodResolver(
z
.object({
authOptions: z.array(z.string()),
useCustomAuth: z.boolean(),
customAuthEndpoint: z.string().optional(),
customHeaders: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
useSmartAccount: z.boolean(),
chainIds: z.array(z.number()),
sponsorGas: z.boolean(),
accountFactoryType: z.enum(["v0.6", "v0.7", "custom"]),
customAccountFactoryAddress: z.string().optional(),
})
.refine(
(data) => {
if (data.useSmartAccount && data.chainIds.length === 0) {
return false;
}
return true;
},
{
message: "Please select at least one chain for smart accounts",
path: ["chainIds"],
},
)
.refine(
(data) => {
if (data.useCustomAuth && !data.customAuthEndpoint) {
return false;
}
return true;
},
{
message: "Please enter a valid custom auth endpoint",
path: ["customAuthEndpoint"],
},
),
),
});
const { fields, remove, append } = useFieldArray({
control: form.control,
name: "customHeaders",
});
const { mutateAsync: updateEcosystem, isPending } = useUpdateEcosystem({
onError: (error) => {
const message =
@@ -125,126 +128,378 @@ function CustomAuthOptionsForm({ ecosystem }: { ecosystem: Ecosystem }) {
toast.error(message);
},
onSuccess: () => {
toast.success("Custom Auth Options updated");
toast.success("Ecosystem options updated");
},
});
return (
<div className="flex flex-col gap-4">
<h4 className="font-semibold text-2xl text-foreground">
Custom Auth Options
</h4>
<Card className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-4">
<Form {...form}>
<FormField
control={form.control}
name="customAuthEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Endpoint</FormLabel>
<FormDescription>
Enter the URL for your own authentication endpoint.{" "}
<a
className="underline"
href="https://portal.thirdweb.com/connect/in-app-wallet/custom-auth/configuration#generic-auth"
>
Learn more.
</a>
</FormDescription>
<FormControl>
<Input
{...field}
type="url"
placeholder="https://your-custom-auth-endpoint.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customHeaders"
render={() => (
<FormItem>
<FormLabel>Headers</FormLabel>
<FormDescription>
Optional: Add headers for your authentication endpoint
</FormDescription>
<FormControl>
<div className="space-y-2">
{fields.map((item, index) => (
<div key={item.id} className="flex gap-2">
<Input
placeholder="Header Key"
{...form.register(`customHeaders.${index}.key`)}
/>
<Input
placeholder="Header Value"
{...form.register(`customHeaders.${index}.value`)}
/>
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="secondary"
onClick={() => append({ key: "", value: "" })}
>
Add Header
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
disabled={isPending}
type="submit"
onClick={() => {
const customAuthEndpoint =
form.getValues("customAuthEndpoint");
let customAuthOptions:
| Ecosystem["customAuthOptions"]
| undefined = undefined;
if (customAuthEndpoint) {
try {
const url = new URL(customAuthEndpoint);
invariant(url.hostname, "Invalid URL");
} catch {
toast.error("Invalid URL");
return;
}
const customHeaders = form.getValues("customHeaders");
customAuthOptions = {
authEndpoint: {
url: customAuthEndpoint,
headers: customHeaders,
},
};
}
updateEcosystem({
...ecosystem,
customAuthOptions,
});
const onSubmit = (data: AuthOptionsFormData) => {
let customAuthOptions: Ecosystem["customAuthOptions"] | null = null;
if (data.useCustomAuth && data.customAuthEndpoint) {
try {
const url = new URL(data.customAuthEndpoint);
invariant(url.hostname, "Invalid URL");
customAuthOptions = {
authEndpoint: {
url: data.customAuthEndpoint,
headers: data.customHeaders,
},
};
} catch {
toast.error("Invalid Custom Auth URL");
return;
}
}
let smartAccountOptions: Ecosystem["smartAccountOptions"] | null = null;
if (data.useSmartAccount) {
let accountFactoryAddress: string;
switch (data.accountFactoryType) {
case "v0.6":
accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_6;
break;
case "v0.7":
accountFactoryAddress = DEFAULT_ACCOUNT_FACTORY_V0_7;
break;
case "custom":
accountFactoryAddress = data.customAccountFactoryAddress;
break;
}
smartAccountOptions = {
chainIds: data.chainIds,
sponsorGas: data.sponsorGas,
accountFactoryAddress,
};
}
updateEcosystem({
...ecosystem,
authOptions: data.authOptions as (typeof authOptions)[number][],
customAuthOptions,
smartAccountOptions,
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit, (errors) => {
console.log(errors);
})}
className="flex flex-col gap-8"
>
<SettingsCard
bottomText=""
errorText=""
noPermissionText=""
header={{
title: "Auth Options",
description:
"Configure the authentication options your ecosystem supports",
}}
saveButton={{
onClick: form.handleSubmit(onSubmit),
disabled: !form.formState.isValid,
isPending: isPending,
}}
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{authOptions.map((option) => (
<FormField
key={option}
control={form.control}
name="authOptions"
render={({ field }) => {
const isChecked = field.value?.includes(option);
return (
<FormItem>
<FormLabel>
<div
className={cn(
"flex cursor-pointer flex-row items-center justify-center gap-3 rounded-lg border border-border p-3 hover:bg-muted",
isChecked && "bg-muted hover:bg-muted/50",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getSocialIcon(option)}
alt={option}
className="h-6 w-6"
/>
<p className="text-center font-normal">
{option === "siwe"
? "Wallet"
: option.slice(0, 1).toUpperCase() +
option.slice(1)}
</p>
<div className="flex-1" />
<FormControl>
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, option])
: field.onChange(
field.value?.filter(
(value) => value !== option,
),
);
}}
/>
</FormControl>
</div>
</FormLabel>
</FormItem>
);
}}
>
{isPending ? "Saving..." : "Save"}
</Button>
/>
))}
</div>
</SettingsCard>
<SettingsCard
header={{
title: "Custom Auth",
description: "Authenticate with a custom endpoint",
}}
bottomText=""
errorText=""
noPermissionText=""
saveButton={{
onClick: form.handleSubmit(onSubmit),
disabled: !form.formState.isValid,
isPending: isPending,
}}
>
<FormField
control={form.control}
name="useCustomAuth"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className="absolute top-6 right-6"
aria-label={
field.value
? "Custom Auth Enabled"
: "Custom Auth Disabled"
}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("useCustomAuth") && (
<div className="mt-1 flex flex-col gap-4">
<FormField
control={form.control}
name="customAuthEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Endpoint</FormLabel>
<FormDescription>
Enter the URL for your own authentication endpoint.{" "}
<a
className="underline"
href="https://portal.thirdweb.com/connect/in-app-wallet/custom-auth/configuration#generic-auth"
>
Learn more.
</a>
</FormDescription>
<FormControl>
<Input
{...field}
type="url"
placeholder="https://your-custom-auth-endpoint.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customHeaders"
render={() => (
<FormItem>
<FormLabel>Headers</FormLabel>
<FormDescription>
Optional: Add headers for your authentication endpoint
</FormDescription>
<FormControl>
<div className="flex flex-col gap-3">
{fields.map((item, index) => (
<div key={item.id} className="flex gap-3">
<Input
placeholder="Header Key"
{...form.register(`customHeaders.${index}.key`)}
/>
<Input
placeholder="Header Value"
{...form.register(`customHeaders.${index}.value`)}
/>
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => append({ key: "", value: "" })}
className="gap-2 self-start"
>
<PlusIcon className="size-4" />
Add Header
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</Form>
</div>
</Card>
</div>
)}
</SettingsCard>
<SettingsCard
bottomText=""
errorText=""
noPermissionText=""
header={{
title: "Account Abstraction",
description: "Enable smart accounts for your ecosystem",
}}
saveButton={{
onClick: form.handleSubmit(onSubmit),
disabled: !form.formState.isValid,
isPending: isPending,
}}
>
<FormField
control={form.control}
name="useSmartAccount"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className="absolute top-6 right-6"
aria-label={
field.value ? "Smart Accounts Enabled" : "Smart Accounts"
}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("useSmartAccount") && (
<div className="mt-1 flex flex-col gap-4">
<FormField
control={form.control}
name="chainIds"
render={({ field }) => (
<FormItem>
<FormLabel>Supported Chains</FormLabel>
<FormDescription>
Select the chains you want to support for smart accounts
</FormDescription>
<FormControl>
<div className="w-full bg-background">
<MultiNetworkSelector
selectedChainIds={field.value}
onChange={field.onChange}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sponsorGas"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Sponsor Gas</FormLabel>
<FormDescription>
Enable gas sponsorship for smart accounts
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountFactoryType"
render={({ field }) => (
<FormItem>
<FormLabel>Account Factory</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account factory type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="v0.6">
Default Account Factory (v0.6)
</SelectItem>
<SelectItem value="v0.7">
Default Account Factory (v0.7)
</SelectItem>
<SelectItem value="custom">Custom factory</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose a default account factory or select custom to enter
your own address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("accountFactoryType") === "custom" && (
<FormField
control={form.control}
name="customAccountFactoryAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Account Factory Address</FormLabel>
<FormControl>
<Input {...field} placeholder="0x..." />
</FormControl>
<FormDescription>
Enter your own smart account factory contract address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
</SettingsCard>
</form>
</Form>
);
}
@@ -14,6 +14,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { TabLinks } from "@/components/ui/tabs";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import {
AlertTriangleIcon,
CheckIcon,
@@ -23,7 +24,6 @@ import {
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { resolveScheme } from "thirdweb/storage";
import { useEcosystemList } from "../../../../hooks/use-ecosystem-list";
import type { Ecosystem } from "../../../../types";
import { useEcosystem } from "../../hooks/use-ecosystem";
@@ -127,6 +127,11 @@ export function EcosystemHeader(props: {
const ecosystem = fetchedEcosystem ?? props.ecosystem;
const ecosystemImageLink = resolveSchemeWithErrorHandler({
uri: ecosystem.imageUrl,
client,
});
return (
<div className="flex flex-col gap-8">
<EcosystemAlertBanner ecosystem={ecosystem} />
@@ -136,13 +141,10 @@ export function EcosystemHeader(props: {
{!ecosystem.imageUrl ? (
<Skeleton className="size-24" />
) : (
ecosystem.imageUrl && (
ecosystemImageLink && (
<div className="relative size-24 overflow-hidden rounded-md">
<Image
src={resolveScheme({
uri: ecosystem.imageUrl,
client,
})}
src={ecosystemImageLink}
sizes="100px"
alt={ecosystem.name}
fill
@@ -1,4 +1,3 @@
import { Skeleton } from "@/components/ui/skeleton";
import type { Ecosystem } from "../../../../types";
import {
AuthOptionsForm,
@@ -8,18 +7,6 @@ import {
export function AuthOptionsSection({ ecosystem }: { ecosystem?: Ecosystem }) {
return (
<section className="flex flex-col gap-4 md:gap-8">
<div className="flex flex-col gap-1">
<h4 className="font-semibold text-2xl text-foreground">
Authentication Options
</h4>
{ecosystem ? (
<p className="text-muted-foreground text-sm">
Configure the authentication options your ecosystem supports.
</p>
) : (
<Skeleton className="h-6 w-[300px]" />
)}
</div>
{ecosystem ? (
<AuthOptionsForm ecosystem={ecosystem} />
) : (
@@ -6,24 +6,24 @@ export function EcosystemPartnersSection({
ecosystem,
}: { ecosystem: Ecosystem }) {
return (
<section className="flex flex-col gap-8 lg:gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-col gap-1">
<h4 className="font-semibold text-2xl text-foreground">
<div className="rounded-lg border border-border bg-muted/50 px-4 py-6 lg:px-6">
<div className="flex flex-col items-start justify-between max-sm:mb-5 lg:flex-row">
<div>
<h3 className="font-semibold text-xl tracking-tight">
Ecosystem partners
</h4>
<p className="text-muted-foreground text-sm leading-normal">
</h3>
<p className="mt-1.5 mb-5 text-foreground text-sm">
Configure apps that can use your wallet. Creating a partner will
generate a unique partner ID that can access your ecosystem. <br />{" "}
You will need to generate at least one partner ID for use in your
own app.
</p>
</div>
<AddPartnerDialogButton ecosystem={ecosystem} />
</div>
<PartnersTable ecosystem={ecosystem} />
</section>
</div>
);
}
@@ -1,4 +1,3 @@
import { Skeleton } from "@/components/ui/skeleton";
import type { Ecosystem } from "../../../../types";
import {
IntegrationPermissionsToggle,
@@ -9,26 +8,26 @@ export function IntegrationPermissionsSection({
ecosystem,
}: { ecosystem?: Ecosystem }) {
return (
<section className="flex flex-col gap-4 md:gap-8">
<div className="flex flex-col gap-1">
<h4 className="font-semibold text-2xl text-foreground">
Integration permissions
</h4>
{ecosystem ? (
<p className="text-muted-foreground text-sm">
{ecosystem.permission === "PARTNER_WHITELIST"
? "Your ecosystem has an allowlist. Only preset partners can add your wallet to their app."
: "Your ecosystem is public. Anyone can add your wallet to their app."}
</p>
) : (
<Skeleton className="h-6 w-[300px]" />
)}
</div>
{ecosystem ? (
<IntegrationPermissionsToggle ecosystem={ecosystem} />
) : (
<IntegrationPermissionsToggleSkeleton />
<div className="relative rounded-lg border border-border bg-muted/50 px-4 py-6 lg:px-6">
<h3 className="font-semibold text-xl tracking-tight">
Integration Permissions
</h3>
{ecosystem && (
<p className="mt-1.5 mb-4 text-foreground text-sm">
{ecosystem.permission === "PARTNER_WHITELIST"
? "Your ecosystem has an allowlist. Only preset partners can add your wallet to their app."
: "Your ecosystem is public. Anyone can add your wallet to their app."}
</p>
)}
</section>
<section className="flex flex-col gap-4 md:gap-8">
{ecosystem ? (
<IntegrationPermissionsToggle ecosystem={ecosystem} />
) : (
<IntegrationPermissionsToggleSkeleton />
)}
</section>
</div>
);
}
@@ -83,32 +83,32 @@ function PartnerRow(props: {
isDeleting && "animate-pulse",
)}
>
<TableCell className="max-w-32 truncate align-top">
<TableCell className="max-w-32 truncate align-center">
{props.partner.name}
</TableCell>
<TableCell className="hidden max-w-32 text-wrap align-top md:table-cell">
<TableCell className="hidden max-w-32 text-wrap align-center md:table-cell">
{props.partner.allowlistedDomains.map((domain) => (
<div key={domain}>{domain}</div>
))}
</TableCell>
<TableCell className="hidden max-w-32 align-top md:table-cell">
<TableCell className="hidden max-w-32 align-center md:table-cell">
{props.partner.allowlistedBundleIds.map((domain) => (
<div key={domain} className="truncate">
{domain}
</div>
))}
</TableCell>
<TableCell className="hidden max-w-32 align-top sm:table-cell">
<TableCell className="hidden max-w-32 align-center sm:table-cell">
<ToolTipLabel label={props.partner.id}>
<div className="truncate">
<div className="flex items-center ">
<CopyButton text={props.partner.id} className="mr-1" />
{props.partner.id}
<span className="truncate">{props.partner.id}</span>
</div>
</ToolTipLabel>
</TableCell>
<td className="table-cell py-1 align-middle">
<div className="flex justify-end gap-1.5 pr-1.5">
<td className="table-cell py-3 align-middle">
<div className="flex justify-end gap-3 pr-3">
<UpdatePartnerModal
partner={props.partner}
ecosystem={props.ecosystem}
@@ -1,14 +1,17 @@
export const authOptions = [
"email",
"phone",
"passkey",
"siwe",
"guest",
"google",
"facebook",
"x",
"discord",
"farcaster",
"telegram",
"github",
"twitch",
"phone",
"email",
"guest",
"apple",
"coinbase",
"line",
@@ -30,12 +33,12 @@ export type Ecosystem = {
jwksUri: string;
aud: string;
};
};
} | null;
smartAccountOptions?: {
chainIds: number[];
sponsorGas: boolean;
accountFactoryAddress: string;
};
} | null;
url: string;
status: "active" | "requested" | "paymentFailed";
createdAt: string;
@@ -1,4 +1,3 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import {
Breadcrumb,
BreadcrumbItem,
@@ -7,12 +6,16 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ContractCard } from "components/explore/contract-card";
import {
ContractCard,
ContractCardSkeleton,
} from "components/explore/contract-card";
import { DeployUpsellCard } from "components/explore/upsells/deploy-your-own";
import { ALL_CATEGORIES, getCategory } from "data/explore";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
type ExploreCategoryPageProps = {
params: {
@@ -101,35 +104,36 @@ export default async function ExploreCategoryPage(
}
return (
<ContractCard
<Suspense
fallback={<ContractCardSkeleton />}
key={publisher + contractId + overrides?.title}
publisher={publisher}
contractId={contractId}
titleOverride={overrides?.title}
descriptionOverride={overrides?.description}
tracking={{
source: category.id,
itemIndex: `${idx}`,
}}
isBeta={category.isBeta}
modules={
modules?.length
? modules.map((m) => ({
publisher: m.split("/")[0] || "",
moduleId: m.split("/")[1] || "",
}))
: undefined
}
/>
>
<ContractCard
publisher={publisher}
contractId={contractId}
titleOverride={overrides?.title}
descriptionOverride={overrides?.description}
tracking={{
source: category.id,
itemIndex: `${idx}`,
}}
isBeta={category.isBeta}
modules={
modules?.length
? modules.map((m) => ({
publisher: m.split("/")[0] || "",
moduleId: m.split("/")[1] || "",
}))
: undefined
}
/>
</Suspense>
);
})}
</div>
<div className="h-16" />
{/* TODO: remove this once we update the deploy upsell card */}
<ChakraProviderSetup>
<DeployUpsellCard />
</ChakraProviderSetup>
<DeployUpsellCard />
</div>
</div>
);
@@ -140,3 +144,6 @@ export async function generateStaticParams() {
params: { category },
}));
}
// TODO - figure out why this page is not building if we let it be static
export const dynamic = "force-dynamic";
@@ -1,4 +1,3 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import {
Breadcrumb,
BreadcrumbItem,
@@ -53,11 +52,11 @@ export default async function ExplorePage() {
</div>
<div className="h-16" />
{/* TODO: remove this once we update the deploy upsell card */}
<ChakraProviderSetup>
<DeployUpsellCard />
</ChakraProviderSetup>
<DeployUpsellCard />
</div>
</div>
);
}
// TODO - figure out why this page is not building if we let it be static
export const dynamic = "force-dynamic";
@@ -28,6 +28,10 @@ export default async function Image(props: {
fetchPublisherProfile(publisher),
]);
if (!publishedContracts) {
return null;
}
const publishedContract =
publishedContracts.find((p) => p.version === props.params.version) ||
publishedContracts[0];
@@ -29,12 +29,24 @@ export default async function PublishedContractPage(
props: PublishedContractDeployPageProps,
) {
// resolve ENS if required
const publisherAddress = isAddress(props.params.publisher)
? props.params.publisher
: await resolveAddress({
let publisherAddress: string | undefined = undefined;
if (isAddress(props.params.publisher)) {
publisherAddress = props.params.publisher;
} else {
try {
publisherAddress = await resolveAddress({
client: getThirdwebClient(),
name: mapThirdwebPublisher(props.params.publisher),
});
} catch {
// ignore
}
}
if (!publisherAddress) {
notFound();
}
// get all the published versions of the contract
const publishedContractVersions = await fetchPublishedContractVersions(
@@ -1,3 +1,4 @@
import { notFound } from "next/navigation";
import { PublishedContractBreadcrumbs } from "./components/breadcrumbs.client";
import { getPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
@@ -23,6 +24,10 @@ export async function generateMetadata({ params }: { params: Params }) {
contract_id: contract_id,
});
if (!publishedContracts) {
notFound();
}
const publishedContract = publishedContracts[0];
if (!publishedContract) {
@@ -27,6 +27,10 @@ export default async function Image(props: {
fetchPublisherProfile(publisher),
]);
if (!publishedContracts) {
return null;
}
const publishedContract = publishedContracts[0];
if (!publishedContract) {
@@ -22,6 +22,10 @@ export default async function PublishedContractPage(
contract_id: props.params.contract_id,
});
if (!publishedContractVersions) {
notFound();
}
const publishedContract = publishedContractVersions[0];
if (!publishedContract) {
@@ -16,19 +16,23 @@ export async function getPublishedContractsWithPublisherMapping(options: {
}) {
const { publisher, contract_id } = options;
// resolve ENS
const publisherAddress = isAddress(publisher)
? publisher
: await resolveAddress({
client: getThirdwebClient(),
name: mapThirdwebPublisher(publisher),
});
try {
// resolve ENS
const publisherAddress = isAddress(publisher)
? publisher
: await resolveAddress({
client: getThirdwebClient(),
name: mapThirdwebPublisher(publisher),
});
// get all the published versions of the contract
const publishedContractVersions = await fetchPublishedContractVersions(
publisherAddress,
contract_id,
);
// get all the published versions of the contract
const publishedContractVersions = await fetchPublishedContractVersions(
publisherAddress,
contract_id,
);
return publishedContractVersions;
return publishedContractVersions;
} catch {
return undefined;
}
}
@@ -1,5 +1,5 @@
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveScheme } from "thirdweb/storage";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
export function DeployContractInfo(props: {
name: string;
@@ -9,20 +9,18 @@ export function DeployContractInfo(props: {
}) {
const contractNameDisplay = props.displayName || props.name;
const contractImageLink = resolveSchemeWithErrorHandler({
client: getThirdwebClient(),
uri: props.logo,
});
return (
<div className="flex flex-col gap-2">
<div className="flex flex-1 items-center gap-4">
{props.logo && (
{contractImageLink && (
<div className="hidden shrink-0 items-center justify-center rounded-xl border border-border p-2 md:flex">
{/*eslint-disable-next-line @next/next/no-img-element*/}
<img
className="size-12"
alt={props.name}
src={resolveScheme({
client: getThirdwebClient(),
uri: props.logo,
})}
/>
<img className="size-12" alt={props.name} src={contractImageLink} />
</div>
)}
@@ -1,187 +0,0 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
import dynamic from "next/dynamic";
import Link from "next/link";
import { type ReactElement, useEffect, useRef, useState } from "react";
import { useFormState, useFormStatus } from "react-dom";
import { toast } from "sonner";
import { createTicketAction } from "./create-ticket.action";
const ConnectSupportForm = dynamic(
() => import("../../../../components/help/contact-forms/connect"),
{
ssr: false,
},
);
const EngineSupportForm = dynamic(
() => import("../../../../components/help/contact-forms/engine"),
{
ssr: false,
},
);
const ContractSupportForm = dynamic(
() => import("../../../../components/help/contact-forms/contracts"),
{
ssr: false,
},
);
const AccountSupportForm = dynamic(
() => import("../../../../components/help/contact-forms/account"),
{
ssr: false,
},
);
const OtherSupportForm = dynamic(
() => import("../../../../components/help/contact-forms/other"),
{
ssr: false,
},
);
const productOptions: { label: string; component: ReactElement }[] = [
{
label: "Connect",
component: <ConnectSupportForm />,
},
{
label: "Engine",
component: <EngineSupportForm />,
},
{
label: "Contracts",
component: <ContractSupportForm />,
},
{
label: "Account",
component: <AccountSupportForm />,
},
{
label: "Other",
component: <OtherSupportForm />,
},
];
export function CreateTicket() {
const formRef = useRef<HTMLFormElement>(null);
const [productLabel, setProductLabel] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [state, formAction] = useFormState(createTicketAction, {
message: "",
success: false,
});
const { user } = useLoggedInUser();
// needed here
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!state.message) {
return;
}
if (state.success) {
toast.success(state.message);
setIsOpen(false);
} else {
toast.error(state.message);
}
}, [state.success, state.message]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
formRef.current?.reset();
setProductLabel("");
}
}}
>
<DialogTrigger asChild>
<Button variant="default" className="mt-6" size="lg">
Get Support
</Button>
</DialogTrigger>
{/* Check this */}
<DialogContent className="max-h-full overflow-x-auto">
<form ref={formRef} action={formAction}>
<DialogHeader>
<DialogTitle>Get in touch</DialogTitle>
<DialogDescription>
We are here to help. Ask product questions, report problems, or
leave feedback.
</DialogDescription>
</DialogHeader>
{/* render the form */}
{user?.jwt ? (
<>
<div className="mt-3 flex flex-col gap-4 py-4">
<SupportForm_SelectInput
formLabel="What do you need help with?"
name="product"
options={productOptions.map((o) => o.label)}
promptText="Select a product"
onValueChange={setProductLabel}
value={productLabel}
required={true}
/>
{
productOptions.find((o) => o.label === productLabel)
?.component
}
</div>
<DialogFooter>
<Button
onClick={() => {
formRef.current?.reset();
setProductLabel("");
}}
variant="outline"
>
Reset
</Button>
<SubmitButton />
</DialogFooter>
</>
) : (
<div className="mt-4 flex flex-col gap-2 py-4">
<Button variant="default" asChild size="lg">
<Link href={`/login?next=${encodeURIComponent("/support")}`}>
Sign In
</Link>
</Button>
<p className="text-center text-warning-foreground text-xs">
Please sign in to create a ticket
</p>
</div>
)}
</form>
</DialogContent>
</Dialog>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} className="flex flex-row gap-2">
{pending && <Spinner className="size-4" />}
{pending ? "Submitting" : "Submit"}
</Button>
);
}
@@ -0,0 +1,152 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
import dynamic from "next/dynamic";
import { type ReactElement, useEffect, useRef, useState } from "react";
import { useFormState, useFormStatus } from "react-dom";
import { toast } from "sonner";
import { createTicketAction } from "./create-ticket.action";
const ConnectSupportForm = dynamic(
() => import("../../../../../components/help/contact-forms/connect"),
{
ssr: false,
loading: () => <Skeleton className="h-12" />,
},
);
const EngineSupportForm = dynamic(
() => import("../../../../../components/help/contact-forms/engine"),
{
ssr: false,
loading: () => <Skeleton className="h-12" />,
},
);
const ContractSupportForm = dynamic(
() => import("../../../../../components/help/contact-forms/contracts"),
{
ssr: false,
loading: () => <Skeleton className="h-12" />,
},
);
const AccountSupportForm = dynamic(
() => import("../../../../../components/help/contact-forms/account"),
{
ssr: false,
loading: () => <Skeleton className="h-12" />,
},
);
const OtherSupportForm = dynamic(
() => import("../../../../../components/help/contact-forms/other"),
{
ssr: false,
loading: () => <Skeleton className="h-12" />,
},
);
const productOptions: { label: string; component: ReactElement }[] = [
{
label: "Connect",
component: <ConnectSupportForm />,
},
{
label: "Engine",
component: <EngineSupportForm />,
},
{
label: "Contracts",
component: <ContractSupportForm />,
},
{
label: "Account",
component: <AccountSupportForm />,
},
{
label: "Other",
component: <OtherSupportForm />,
},
];
export function CreateTicket() {
const formRef = useRef<HTMLFormElement>(null);
const [productLabel, setProductLabel] = useState("");
const [state, formAction] = useFormState(createTicketAction, {
message: "",
success: false,
});
// needed here
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!state.message) {
return;
}
if (state.success) {
toast.success(state.message);
} else {
toast.error(state.message);
}
}, [state.success, state.message]);
return (
<form
ref={formRef}
action={formAction}
className="rounded-lg border bg-muted/50"
>
<div className="px-4 py-6 lg:px-6">
<h2 className="font-semibold text-2xl tracking-tight">Get Support</h2>
<p className="text-muted-foreground">
We are here to help. Ask product questions, report problems, or leave
feedback.
</p>
<div className="h-7" />
<div className="flex flex-col gap-6">
<SupportForm_SelectInput
formLabel="What do you need help with?"
name="product"
options={productOptions.map((o) => o.label)}
promptText="Select a product"
onValueChange={setProductLabel}
value={productLabel}
required={true}
/>
{productOptions.find((o) => o.label === productLabel)?.component}
</div>
</div>
<div className="h-7" />
<div className="flex justify-end gap-3 border-t px-4 py-6 lg:px-6">
<Button
onClick={() => {
formRef.current?.reset();
setProductLabel("");
}}
variant="outline"
>
Reset
</Button>
<SubmitButton />
</div>
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button
type="submit"
disabled={pending}
className="flex min-w-24 flex-row gap-2"
>
{pending && <Spinner className="size-4" />}
{pending ? "Submitting" : "Submit"}
</Button>
);
}
@@ -0,0 +1,43 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { CreateTicket } from "./components/create-ticket.client";
export default function Page() {
const authToken = getAuthToken();
if (!authToken) {
return redirect(
`/login?next=${encodeURIComponent("/support/create-ticket")}`,
);
}
return (
<div>
<Breadcrumb className="border-border border-b px-6 py-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/support">Support</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Get Support</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="container max-w-[750px] py-10">
<CreateTicket />
</div>
</div>
);
}
@@ -17,7 +17,6 @@ import discordIllustration from "../../../../public/assets/support/discord-illus
import engineIcon from "../../../../public/assets/support/engine.png";
import miscIcon from "../../../../public/assets/support/misc.svg";
import connectIcon from "../../../../public/assets/support/wallets.png";
import { CreateTicket } from "./components/create-ticket.client";
export const metadata: Metadata = {
title: "thirdweb Support",
@@ -139,7 +138,9 @@ export default function SupportPage() {
Our dedicated support team is here to help you with any questions
or issues you may have. Contact us today and let us assist you.
</p>
<CreateTicket />
<Button variant="default" className="mt-6" size="lg" asChild>
<Link href="/support/create-ticket">Get Support</Link>
</Button>
</div>
</header>
</div>
@@ -0,0 +1,12 @@
const customClaimAmounts: Record<number, number> = {
// Aavegotchi Polter
631571: 0.1,
// Aleph Zero
2039: 0.1,
};
const defaultClaimAmount = 0.01;
export function getFaucetClaimAmount(chainId: number) {
return customClaimAmounts[chainId] || defaultClaimAmount;
}
@@ -2,6 +2,7 @@ import { startOfToday } from "date-fns";
import { cacheGet, cacheSet } from "lib/redis";
import { type NextRequest, NextResponse } from "next/server";
import { ZERO_ADDRESS } from "thirdweb";
import { getFaucetClaimAmount } from "./claim-amount";
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
@@ -109,6 +110,7 @@ export const POST = async (req: NextRequest) => {
);
const todayUTCSeconds = Math.floor(todayUTC.getTime() / 1000);
const idempotencyKey = `${ipCacheKey}:${todayUTCSeconds}`;
const amountToClaim = getFaucetClaimAmount(chainId).toString();
try {
// Store the claim request for 24 hours.
@@ -129,7 +131,7 @@ export const POST = async (req: NextRequest) => {
body: JSON.stringify({
to: toAddress,
currencyAddress: ZERO_ADDRESS,
amount: "0.01",
amount: amountToClaim,
}),
});
@@ -144,5 +146,5 @@ export const POST = async (req: NextRequest) => {
);
}
return NextResponse.json({ amount: "0.01" }, { status: 200 });
return NextResponse.json({ amount: amountToClaim }, { status: 200 });
};
@@ -7,11 +7,11 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useMutation } from "@tanstack/react-query";
import { FileInput } from "components/shared/FileInput";
import { useState } from "react";
import { toast } from "sonner";
import { resolveScheme } from "thirdweb/storage";
type UpdateTeamField = (team: Partial<Team>) => Promise<void>;
@@ -152,12 +152,10 @@ function TeamAvatarFormControl(props: {
avatar: string | undefined;
}) {
const client = useThirdwebClient();
const teamUrl = props.avatar
? resolveScheme({
client: client,
uri: props.avatar,
})
: undefined;
const teamAvatarUrl = resolveSchemeWithErrorHandler({
client: client,
uri: props.avatar,
});
const [teamAvatar, setTeamAvatar] = useState<File | undefined>();
@@ -200,7 +198,7 @@ function TeamAvatarFormControl(props: {
setValue={setTeamAvatar}
className="w-20 rounded-full lg:w-28"
disableHelperText
fileUrl={teamUrl}
fileUrl={teamAvatarUrl}
/>
</div>
</SettingsCard>
@@ -10,10 +10,10 @@ import {
} from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { SmartWallets } from "components/smart-wallets";
import { CircleAlertIcon } from "lucide-react";
import { useMemo } from "react";
import { useActiveWalletChain } from "thirdweb/react";
import { AccountFactories } from "../../../../../../components/smart-wallets/AccountFactories";
import { AAFooterSection } from "./AAFooterSection";
import { isOpChainId } from "./isOpChain";
@@ -22,7 +22,11 @@ const TRACKING_CATEGORY = "smart-wallet";
// TODO - the factories shown on this page is not project specific, need to revamp this page
export function AccountAbstractionPage(props: {
projectSlug: string;
teamSlug: string;
projectKey: string;
apiKeyServices: ApiKeyService[];
tab?: string;
}) {
const { apiKeyServices } = props;
const looggedInUserQuery = useLoggedInUser();
@@ -48,7 +52,6 @@ export function AccountAbstractionPage(props: {
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Account Abstraction
</h1>
<p className="text-muted-foreground text-sm">
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
into your apps.{" "}
@@ -62,9 +65,7 @@ export function AccountAbstractionPage(props: {
View Documentation
</TrackedLinkTW>
</p>
<div className="h-6" />
{looggedInUserQuery.isPending ? (
<div className="flex h-[400px] items-center justify-center rounded-lg border border-border">
<Spinner className="size-14" />
@@ -89,10 +90,15 @@ export function AccountAbstractionPage(props: {
)
)}
<AccountFactories trackingCategory={TRACKING_CATEGORY} />
<SmartWallets
smartWalletsLayoutSlug={`/team/${props.teamSlug}/${props.projectSlug}/connect/account-abstraction`}
apiKeyServices={apiKeyServices}
trackingCategory={TRACKING_CATEGORY}
clientId={props.projectKey}
tab={props.tab}
/>
</div>
)}
<div className="h-14" />
<AAFooterSection trackingCategory={TRACKING_CATEGORY} />
</div>
@@ -6,6 +6,7 @@ import { AccountAbstractionPage } from "./AccountAbstractionPage";
export default async function Page(props: {
params: { team_slug: string; project_slug: string };
searchParams: { tab?: string };
}) {
const { team_slug, project_slug } = props.params;
const project = await getProject(team_slug, project_slug);
@@ -22,7 +23,13 @@ export default async function Page(props: {
return (
<ChakraProviderSetup>
<AccountAbstractionPage apiKeyServices={apiKey.services || []} />
<AccountAbstractionPage
projectSlug={project.slug}
teamSlug={team_slug}
projectKey={project.publishableKey}
apiKeyServices={apiKey.services || []}
tab={props.searchParams.tab}
/>
</ChakraProviderSetup>
);
}
@@ -1,24 +1,22 @@
"use client";
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useUserOpUsageAggregate,
useUserOpUsagePeriod,
useWalletUsageAggregate,
useWalletUsagePeriod,
} from "@3rdweb-sdk/react/hooks/useApi";
import { differenceInDays, format, subDays } from "date-fns";
import {
DateRangeSelector,
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { IntervalSelector } from "components/analytics/interval-selector";
import { differenceInDays } from "date-fns";
import { useState } from "react";
import { ConnectAnalyticsDashboardUI } from "./ConnectAnalyticsDashboardUI";
export function ConnectAnalyticsDashboard(props: {
clientId: string;
connectLayoutSlug: string;
}) {
const [range, setRange] = useState<Range>(() =>
getLastNDaysRange("last-120"),
@@ -26,7 +24,6 @@ export function ConnectAnalyticsDashboard(props: {
// use date-fns to calculate the number of days in the range
const daysInRange = differenceInDays(range.to, range.from);
const [intervalType, setIntervalType] = useState<"day" | "week">(
daysInRange > 30 ? "week" : "day",
);
@@ -44,17 +41,8 @@ export function ConnectAnalyticsDashboard(props: {
to: range.to,
});
const userOpUsageQuery = useUserOpUsagePeriod({
const userOpAggregateQuery = useUserOpUsageAggregate({
clientId: props.clientId,
from: range.from,
to: range.to,
period: intervalType,
});
const userOpUsageAggregateQuery = useUserOpUsageAggregate({
clientId: props.clientId,
from: range.from,
to: range.to,
});
return (
@@ -77,8 +65,8 @@ export function ConnectAnalyticsDashboard(props: {
<ConnectAnalyticsDashboardUI
walletUsage={walletUsageQuery.data || []}
aggregateWalletUsage={walletUsageAggregateQuery.data || []}
userOpUsage={userOpUsageQuery.data || []}
aggregateUserOpUsage={userOpUsageAggregateQuery.data || []}
aggregateUserOpUsageQuery={userOpAggregateQuery.data}
connectLayoutSlug={props.connectLayoutSlug}
isPending={
walletUsageQuery.isPending || walletUsageAggregateQuery.isPending
}
@@ -86,133 +74,3 @@ export function ConnectAnalyticsDashboard(props: {
</div>
);
}
const durationPresets = [
{
name: "Last 7 Days",
id: "last-7",
days: 7,
},
{
name: "Last 30 Days",
id: "last-30",
days: 30,
},
{
name: "Last 60 Days",
id: "last-60",
days: 60,
},
{
name: "Last 120 Days",
id: "last-120",
days: 120,
},
] as const;
type DurationId = (typeof durationPresets)[number]["id"];
type Range = {
type: DurationId | "custom";
label?: string;
from: Date;
to: Date;
};
function getLastNDaysRange(id: DurationId) {
const durationInfo = durationPresets.find((preset) => preset.id === id);
if (!durationInfo) {
throw new Error("Invalid duration id");
}
const todayDate = new Date();
const value: Range = {
type: id,
from: subDays(todayDate, durationInfo.days),
to: todayDate,
label: durationInfo.name,
};
return value;
}
function DateRangeSelector(props: {
range: Range;
setRange: (range: Range) => void;
}) {
const { range, setRange } = props;
return (
<DatePickerWithRange
from={range.from}
to={range.to}
setFrom={(from) =>
setRange({
from,
to: range.to,
type: "custom",
})
}
setTo={(to) =>
setRange({
from: range.from,
to,
type: "custom",
})
}
header={
<div className="mb-2 border-border border-b p-4">
<Select
value={range.type}
onValueChange={(id: DurationId) => {
setRange(getLastNDaysRange(id));
}}
>
<SelectTrigger className="flex bg-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
{durationPresets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
{range.type === "custom" && (
<SelectItem value="custom">
{format(range.from, "LLL dd, y")} -{" "}
{format(range.to, "LLL dd, y")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
}
labelOverride={range.label}
className="w-auto"
/>
);
}
function IntervalSelector(props: {
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
return (
<Select
value={props.intervalType}
onValueChange={(value: "day" | "week") => {
props.setIntervalType(value);
}}
>
<SelectTrigger className="w-auto hover:bg-muted">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="week"> Weekly </SelectItem>
<SelectItem value="day"> Daily</SelectItem>
</SelectContent>
</Select>
);
}
@@ -1,23 +1,19 @@
import { Button } from "@/components/ui/button";
import type { UserOpStats, WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import {
ActivityIcon,
CableIcon,
CoinsIcon,
WalletCardsIcon,
} from "lucide-react";
import type React from "react";
import { Stat } from "components/analytics/stat";
import { AccountAbstractionSummary } from "components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { CableIcon, WalletCardsIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { DailyConnectionsChartCard } from "./_components/DailyConnectionsChartCard";
import { SponsoredTransactionsChartCard } from "./_components/SponsoredTransactionsChartCard";
import { TotalSponsoredChartCard } from "./_components/TotalSponsoredChartCard";
import { WalletConnectorsChartCard } from "./_components/WalletConnectorsChartCard";
import { WalletDistributionChartCard } from "./_components/WalletDistributionChartCard";
export function ConnectAnalyticsDashboardUI(props: {
walletUsage: WalletStats[];
aggregateWalletUsage: WalletStats[];
userOpUsage: UserOpStats[];
aggregateUserOpUsage: UserOpStats[];
aggregateUserOpUsageQuery?: UserOpStats;
connectLayoutSlug: string;
isPending: boolean;
}) {
const { totalWallets, uniqueWallets } = useMemo(() => {
@@ -31,17 +27,6 @@ export function ConnectAnalyticsDashboardUI(props: {
);
}, [props.aggregateWalletUsage]);
const { totalSponsoredTransactions, totalSponsoredUsd } = useMemo(() => {
return props.aggregateUserOpUsage.reduce(
(acc, curr) => {
acc.totalSponsoredTransactions += curr.successful;
acc.totalSponsoredUsd += curr.sponsoredUsd;
return acc;
},
{ totalSponsoredTransactions: 0, totalSponsoredUsd: 0 },
);
}, [props.aggregateUserOpUsage]);
return (
<div className="flex flex-col gap-4 lg:gap-6">
{/* Connections */}
@@ -54,6 +39,38 @@ export function ConnectAnalyticsDashboardUI(props: {
/>
</div>
<div className="h-4">
<div className="w-full border-muted border-b" />
</div>
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-medium text-xl tracking-tight">
Account Abstraction
</h1>
<p className="text-muted-foreground">
Sponsor and batch transactions for your users.
</p>
</div>
<div>
<Button asChild variant="primary" size="sm" className="w-full">
<Link href={`${props.connectLayoutSlug}/account-abstraction`}>
View More Stats
</Link>
</Button>
</div>
</div>
<AccountAbstractionSummary
aggregateUserOpUsageQuery={props.aggregateUserOpUsageQuery}
/>
</div>
<div className="h-4">
<div className="w-full border-muted border-b" />
</div>
<DailyConnectionsChartCard
walletStats={props.walletUsage}
isPending={props.isPending}
@@ -68,57 +85,6 @@ export function ConnectAnalyticsDashboardUI(props: {
walletStats={props.walletUsage}
isPending={props.isPending}
/>
{/* Connections */}
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
label="Sponsored Transactions"
value={totalSponsoredTransactions}
icon={ActivityIcon}
/>
<Stat
label="Total Sponsored"
value={totalSponsoredUsd}
formatter={(value) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value)
}
icon={CoinsIcon}
/>
</div>
<TotalSponsoredChartCard
userOpStats={props.userOpUsage}
isPending={props.isPending}
/>
<SponsoredTransactionsChartCard
userOpStats={props.userOpUsage}
isPending={props.isPending}
/>
</div>
);
}
const Stat: React.FC<{
label: string;
value?: number;
icon: React.FC<{ className?: string }>;
formatter?: (value: number) => string;
}> = ({ label, value, formatter, icon: Icon }) => {
return (
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/50 p-4 lg:p-6">
<div>
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
{value && formatter ? formatter(value) : value?.toLocaleString()}
</dd>
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
{label}
</dt>
</div>
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
</dl>
);
};
@@ -1,7 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createUserOpStatsStub } from "components/smart-wallets/AccountAbstractionAnalytics/storyUtils";
import { mobileViewport } from "../../../../../../../stories/utils";
import { ConnectAnalyticsDashboardUI } from "../ConnectAnalyticsDashboardUI";
import { createUserOpStatsStub, createWalletStatsStub } from "./storyUtils";
import { createWalletStatsStub } from "./storyUtils";
const meta = {
title: "Charts/Connect/Analytics Dashboard",
@@ -31,8 +32,8 @@ function Component() {
<ConnectAnalyticsDashboardUI
walletUsage={createWalletStatsStub(30)}
aggregateWalletUsage={createWalletStatsStub(30)}
userOpUsage={createUserOpStatsStub(30)}
aggregateUserOpUsage={createUserOpStatsStub(1)}
aggregateUserOpUsageQuery={createUserOpStatsStub(1)?.[0]}
connectLayoutSlug="connectLayoutSlug"
isPending={false}
/>
</div>
@@ -15,10 +15,19 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis } from "recharts";
import { EmptyChartState, LoadingChartState } from "./EmptyChartState";
type ChartToShow = "uniqueWallets" | "totalWallets";
@@ -129,7 +138,45 @@ export function DailyConnectionsChartCard(props: {
{props.isPending ? (
<LoadingChartState />
) : barChartData.length === 0 ? (
<EmptyChartState />
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Send your first connect event
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/getting-started"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react-native/v5"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/dotnet/getting-started"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/getting-started"
label="Unreal Engine"
icon={UnrealIcon}
/>
<DocLink
link="https://portal.thirdweb.com/dotnet/getting-started"
label=".NET"
icon={DotNetIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
@@ -17,10 +17,16 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { EmptyChartState, LoadingChartState } from "./EmptyChartState";
type ChartToShow = "uniqueWalletsConnected" | "totalConnections";
@@ -166,7 +172,30 @@ export function WalletConnectorsChartCard(props: {
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ? (
<EmptyChartState />
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Connect any wallet to your app
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React Native"
icon={ReactIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
@@ -17,9 +17,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { DocLink } from "components/shared/DocLink";
import { useMemo, useState } from "react";
import { Pie, PieChart } from "recharts";
import { EmptyChartState, LoadingChartState } from "./EmptyChartState";
type ChartToShow = "totalConnections" | "uniqueWalletsConnected";
@@ -192,7 +198,30 @@ export function WalletDistributionChartCard(props: {
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ? (
<EmptyChartState />
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Connect any wallet to your app
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React Native"
icon={ReactIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<PieChart>
<ChartTooltip
@@ -1,4 +1,4 @@
import type { UserOpStats, WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import type { WalletId } from "thirdweb/wallets";
const walletsToPickFrom: WalletId[] = [
@@ -50,26 +50,3 @@ export function createWalletStatsStub(days: number): WalletStats[] {
return stubbedData;
}
export function createUserOpStatsStub(days: number): UserOpStats[] {
const stubbedData: UserOpStats[] = [];
let d = days;
while (d !== 0) {
const successful = Math.floor(Math.random() * 100);
const failed = Math.floor(Math.random() * 100);
const sponsoredUsd = Math.floor(Math.random() * 100);
stubbedData.push({
date: new Date(2024, 1, d).toLocaleString(),
successful,
failed,
sponsoredUsd,
});
if (Math.random() > 0.7) {
d--;
}
}
return stubbedData;
}
@@ -30,7 +30,10 @@ export default async function Page(props: {
</div>
<div className="h-6 lg:h-8" />
<ConnectAnalyticsDashboard clientId={project.publishableKey} />
<ConnectAnalyticsDashboard
clientId={project.publishableKey}
connectLayoutSlug={`/team/${props.params.team_slug}/${props.params.project_slug}/connect`}
/>
<div className="h-4 lg:h-8" />
<ConnectSDKCard description="Add the Connect SDK to your app to get started collecting analytics." />
</div>
@@ -1,9 +1,9 @@
"use client";
import { Button } from "@/components/ui/button";
import { DownloadIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "../../../../../@/components/ui/button";
import { ImportModal } from "../../../../../components/contract-components/import-contract/modal";
export function DeployedContractsPageHeader() {
@@ -0,0 +1,117 @@
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { format, subDays } from "date-fns";
export function DateRangeSelector(props: {
range: Range;
setRange: (range: Range) => void;
}) {
const { range, setRange } = props;
return (
<DatePickerWithRange
from={range.from}
to={range.to}
setFrom={(from) =>
setRange({
from,
to: range.to,
type: "custom",
})
}
setTo={(to) =>
setRange({
from: range.from,
to,
type: "custom",
})
}
header={
<div className="mb-2 border-border border-b p-4">
<Select
value={range.type}
onValueChange={(id: DurationId) => {
setRange(getLastNDaysRange(id));
}}
>
<SelectTrigger className="flex bg-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
{durationPresets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
{range.type === "custom" && (
<SelectItem value="custom">
{format(range.from, "LLL dd, y")} -{" "}
{format(range.to, "LLL dd, y")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
}
labelOverride={range.label}
className="w-auto"
/>
);
}
export function getLastNDaysRange(id: DurationId) {
const durationInfo = durationPresets.find((preset) => preset.id === id);
if (!durationInfo) {
throw new Error("Invalid duration id");
}
const todayDate = new Date();
const value: Range = {
type: id,
from: subDays(todayDate, durationInfo.days),
to: todayDate,
label: durationInfo.name,
};
return value;
}
const durationPresets = [
{
name: "Last 7 Days",
id: "last-7",
days: 7,
},
{
name: "Last 30 Days",
id: "last-30",
days: 30,
},
{
name: "Last 60 Days",
id: "last-60",
days: 60,
},
{
name: "Last 120 Days",
id: "last-120",
days: 120,
},
] as const;
type DurationId = (typeof durationPresets)[number]["id"];
export type Range = {
type: DurationId | "custom";
label?: string;
from: Date;
to: Date;
};
@@ -22,14 +22,14 @@ const skeletonChartConfig = {
},
} satisfies ChartConfig;
export function EmptyChartState() {
export function EmptyChartState({ children }: { children?: React.ReactNode }) {
const barChartData = useMemo(() => generateRandomData(), []);
return (
<div className="relative z-0 h-full w-full">
<span className="absolute inset-0 z-[1] flex items-center justify-center font-semibold text-base text-muted-foreground">
No data available
</span>
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center font-semibold text-base text-muted-foreground">
{children ?? "No data available"}
</div>
<SkeletonBarChart data={barChartData} />
</div>
);
@@ -0,0 +1,29 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function IntervalSelector(props: {
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
return (
<Select
value={props.intervalType}
onValueChange={(value: "day" | "week") => {
props.setIntervalType(value);
}}
>
<SelectTrigger className="w-auto hover:bg-muted">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="week"> Weekly </SelectItem>
<SelectItem value="day"> Daily</SelectItem>
</SelectContent>
</Select>
);
}
@@ -0,0 +1,20 @@
export const Stat: React.FC<{
label: string;
value?: number;
icon: React.FC<{ className?: string }>;
formatter?: (value: number) => string;
}> = ({ label, value, formatter, icon: Icon }) => {
return (
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/50 p-4 lg:p-6">
<div>
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
{value && formatter ? formatter(value) : value?.toLocaleString()}
</dd>
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
{label}
</dt>
</div>
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
</dl>
);
};
@@ -51,6 +51,7 @@ import {
} from "thirdweb/react";
import { privateKeyToAccount } from "thirdweb/wallets";
import { Button, type ButtonProps, Card, Heading, Text } from "tw-components";
import { getFaucetClaimAmount } from "../../app/api/testnet-faucet/claim/claim-amount";
import { THIRDWEB_API_HOST } from "../../constants/urls";
import { useAllChainsData } from "../../hooks/chains/allChains";
import { useV5DashboardChain } from "../../lib/v5-adapter";
@@ -354,8 +355,7 @@ function NoFundsDialogContent(props: {
function GetFundsFromFaucet(props: {
chain: ChainMetadata;
}) {
// TODO - improvement for later -> estimate gas required for transaction, and use that as the amount to give
const amountToGive = "0.01";
const amountToGive = getFaucetClaimAmount(props.chain.chainId);
return (
<div className="flex justify-center rounded-lg border border-border px-4 py-6">
@@ -18,6 +18,7 @@ import {
FormControl,
} from "@chakra-ui/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { verifyContract } from "app/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
import { SolidityInput } from "contract-ui/components/solidity-inputs";
import { useTrack } from "hooks/analytics/useTrack";
@@ -537,6 +538,13 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
// do the actual deployment
const contractAddr = await deployMutation.mutateAsync(formData);
// send verification request - no need to await
verifyContract({
address: contractAddr,
chain: walletChain,
client: thirdwebClient,
});
trackEvent({
category: "custom-contract",
action: "deploy",
@@ -1,7 +1,7 @@
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { fetchDeployMetadata as sdkFetchDeployMetadata } from "thirdweb/contract";
import { removeUndefinedFromObjectDeep } from "../../utils/object";
import { toContractIdIpfsHash } from "./hooks";
import type { ContractId } from "./types";
// metadata PRE publish, only has the compiler output info (from CLI)
@@ -15,3 +15,10 @@ export async function fetchDeployMetadata(contractId: string) {
}),
);
}
function toContractIdIpfsHash(contractId: ContractId) {
if (contractId?.startsWith("ipfs://")) {
return contractId;
}
return `ipfs://${contractId}`;
}
@@ -131,13 +131,6 @@ export function useFunctionParamsFromABI(abi?: Abi, functionName?: string) {
}, [abi, functionName]);
}
export function toContractIdIpfsHash(contractId: ContractId) {
if (contractId?.startsWith("ipfs://")) {
return contractId;
}
return `ipfs://${contractId}`;
}
export type PublishedContractDetails = Awaited<
ReturnType<typeof fetchPublishedContracts>
>[number];
@@ -1,7 +1,7 @@
"use client";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveScheme } from "thirdweb/storage";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import {
MaskedAvatar,
type MaskedAvatarProps,
@@ -22,17 +22,16 @@ export const PublisherAvatar: React.FC<PublisherAvatarProps> = ({
const publisherProfile = usePublisherProfile(
ensQuery.data?.address || undefined,
);
const publisherImageUrl = resolveSchemeWithErrorHandler({
uri: publisherProfile.data?.avatar,
client,
});
return (
<MaskedAvatar
isPending={isPending || ensQuery.isPending || publisherProfile.isPending}
src={
publisherProfile.data?.avatar
? resolveScheme({
uri: publisherProfile.data.avatar,
client,
})
: ""
}
src={publisherImageUrl || ""}
{...restProps}
/>
);
@@ -1,139 +1,147 @@
import { useQuery } from "@tanstack/react-query";
import {
useEns,
usePublishedContractsFromDeploy,
} from "components/contract-components/hooks";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { ContractCard } from "components/explore/contract-card";
import { THIRDWEB_DEPLOYER_ADDRESS } from "constants/addresses";
import { useMemo } from "react";
import type { ThirdwebContract } from "thirdweb";
import { polygon } from "thirdweb/chains";
import { getBytecode, getContract } from "thirdweb/contract";
import { getPublishedUriFromCompilerUri } from "thirdweb/extensions/thirdweb";
import { getInstalledModules } from "thirdweb/modules";
import { useActiveAccount, useReadContract } from "thirdweb/react";
import { download } from "thirdweb/storage";
import { extractIPFSUri } from "thirdweb/utils";
import { getAuthTokenWalletAddress } from "../../../app/api/lib/getAuthToken";
import { isEnsName, resolveEns } from "../../../lib/ens";
import { fetchPublishedContractsFromDeploy } from "../fetchPublishedContractsFromDeploy";
interface PublishedByProps {
contract: ThirdwebContract;
}
export const PublishedBy: React.FC<PublishedByProps> = ({ contract }) => {
const publishedContractsFromDeploy =
usePublishedContractsFromDeploy(contract);
type ModuleMetadataPickedKeys = {
publisher: string;
moduleId: string;
name: string;
version: string;
};
const address = useActiveAccount()?.address;
const publishedContractToShow = useMemo(() => {
const reversedPublishedContractsFromDeploy = [
...(publishedContractsFromDeploy.data || []),
].reverse();
return (
reversedPublishedContractsFromDeploy.find(
(publishedContract) => publishedContract.publisher === address,
) ||
reversedPublishedContractsFromDeploy.find(
(publishedContract) =>
publishedContract.publisher === THIRDWEB_DEPLOYER_ADDRESS,
) ||
reversedPublishedContractsFromDeploy[
reversedPublishedContractsFromDeploy.length - 1
] ||
undefined
);
}, [publishedContractsFromDeploy.data, address]);
const publisherEnsQuery = useEns(publishedContractToShow?.publisher);
const publisherAddress =
publisherEnsQuery.data?.ensName || publisherEnsQuery.data?.address;
const installedModules = useReadContract(getInstalledModules, {
export const PublishedBy: React.FC<PublishedByProps> = async ({ contract }) => {
const client = getThirdwebClient();
const publishedContractsFromDeploy = await fetchPublishedContractsFromDeploy({
contract,
queryOptions: {
enabled: publishedContractToShow?.routerType === "modular" && !!contract,
},
client,
});
// this handles all the logic for modules in the published contract card
const installedModulesQuery = useQuery({
queryKey: [
"published-by-modules",
contract,
installedModules.data?.map((m) => m.implementation),
],
queryFn: async () => {
if (!installedModules.data?.length) {
return [];
}
const address = getAuthTokenWalletAddress();
const moduleContracts = installedModules.data.map((module) => {
return getContract({ ...contract, address: module.implementation });
});
const metadataUris = await Promise.allSettled(
moduleContracts.map(async (c) => {
const byteCode = await getBytecode(c);
const ipfsUri = extractIPFSUri(byteCode);
const reversedPublishedContractsFromDeploy = [
...(publishedContractsFromDeploy || []),
].reverse();
if (!ipfsUri) {
throw new Error("No IPFS URI found in bytecode");
}
let uris = await getPublishedUriFromCompilerUri({
contract: {
chain: polygon,
client: contract.client,
address: "0xf5b896Ddb5146D5dA77efF4efBb3Eae36E300808",
},
compilerMetadataUri: ipfsUri,
}).catch((e) => {
console.error("Error fetching published URI", e);
return [];
});
const publishedContractToShow =
reversedPublishedContractsFromDeploy.find(
(publishedContract) => publishedContract.publisher === address,
) ||
reversedPublishedContractsFromDeploy.find(
(publishedContract) =>
publishedContract.publisher === THIRDWEB_DEPLOYER_ADDRESS,
) ||
reversedPublishedContractsFromDeploy[
reversedPublishedContractsFromDeploy.length - 1
] ||
undefined;
uris = uris.filter((uri) => uri.length > 0);
if (uris.length === 0) {
throw new Error("No published URI found");
}
const results = await Promise.allSettled(
uris.map(async (uri) => {
const content = await download({
uri,
client: contract.client,
});
return JSON.parse(await content.text());
}),
);
return results
.filter((r) => r.status === "fulfilled")
.map((r) => r.value);
}),
);
const filtered = metadataUris
.filter((m) => m.status === "fulfilled")
.map((m) => m.value);
return filtered.map((m) => m[0]);
},
enabled: !!installedModules.data?.length,
});
if (!publishedContractToShow || !publisherAddress) {
if (!publishedContractToShow || !publishedContractToShow.publisher) {
return null;
}
// get publisher address/ens
let publisherAddressOrEns = publishedContractToShow.publisher;
if (!isEnsName(publishedContractToShow.publisher)) {
try {
const res = await resolveEns(publishedContractToShow.publisher);
if (res.ensName) {
publisherAddressOrEns = res.ensName;
}
} catch {
// no op
}
}
// Get Modules metadata for modular contract
let modules: ModuleMetadataPickedKeys[] = [];
if (publishedContractToShow?.routerType === "modular") {
try {
const installedModules = await getInstalledModules({
contract,
});
if (installedModules.length !== 0) {
const moduleContracts = installedModules.map((module) => {
return getContract({ ...contract, address: module.implementation });
});
const metadataUris = await Promise.allSettled(
moduleContracts.map(async (c) => {
const byteCode = await getBytecode(c);
const ipfsUri = extractIPFSUri(byteCode);
if (!ipfsUri) {
throw new Error("No IPFS URI found in bytecode");
}
let uris = await getPublishedUriFromCompilerUri({
contract: {
chain: polygon,
client: contract.client,
address: "0xf5b896Ddb5146D5dA77efF4efBb3Eae36E300808",
},
compilerMetadataUri: ipfsUri,
}).catch((e) => {
console.error("Error fetching published URI", e);
return [];
});
uris = uris.filter((uri) => uri.length > 0);
if (uris.length === 0) {
throw new Error("No published URI found");
}
const results = await Promise.allSettled(
uris.map(async (uri) => {
const content = await download({
uri,
client: contract.client,
});
return JSON.parse(
await content.text(),
) as ModuleMetadataPickedKeys;
}),
);
return results
.filter((r) => r.status === "fulfilled")
.map((r) => r.value);
}),
);
const filtered = metadataUris
.filter((m) => m.status === "fulfilled")
.map((m) => m.value);
modules = filtered.map((m) => m[0]).filter((m) => m !== undefined);
}
} catch {
// no op
}
}
return (
<ContractCard
contractId={publishedContractToShow.name}
publisher={publisherAddress}
publisher={publisherAddressOrEns}
version={publishedContractToShow.version}
isBeta={(publishedContractToShow.displayName || "")
.toLowerCase()
.includes("beta")}
modules={installedModulesQuery.data?.map((m) => ({
modules={modules.map((m) => ({
publisher: m.publisher,
moduleId: m.name,
version: m.version,
@@ -3,6 +3,7 @@
import { Badge } from "@/components/ui/badge";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { cn } from "@/lib/utils";
import { useDashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
import type { BasicContract } from "contract-ui/types/types";
import { useChainSlug } from "hooks/chains/chainSlug";
@@ -16,13 +17,14 @@ import { usePublishedContractsFromDeploy } from "../hooks";
interface AsyncContractNameCellProps {
cell: BasicContract;
linkOverlay?: boolean;
}
// The row components for the contract table, in the <Your contracts> page
// url: /dashboard/contracts/deploy
export const AsyncContractNameCell = memo(
({ cell }: AsyncContractNameCellProps) => {
({ cell, linkOverlay }: AsyncContractNameCellProps) => {
const chainSlug = useChainSlug(cell.chainId);
const chain = useV5DashboardChain(cell.chainId);
const client = useThirdwebClient();
@@ -46,7 +48,10 @@ export const AsyncContractNameCell = memo(
<Link
href={`/${chainSlug}/${cell.address}`}
passHref
className="text-foreground"
className={cn(
"text-foreground",
linkOverlay && "before:absolute before:inset-0",
)}
>
{v || shortenIfAddress(cell.address)}
</Link>
@@ -19,7 +19,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import {
type useAllContractList,
useRemoveContractMutation,
@@ -38,7 +37,6 @@ import {
useTable,
} from "react-table";
import { useAllChainsData } from "../../../hooks/chains/allChains";
import { useChainSlug } from "../../../hooks/chains/chainSlug";
import { AsyncContractNameCell, AsyncContractTypeCell } from "./cells";
import { ShowMoreButton } from "./show-more-button";
@@ -174,7 +172,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
accessor: (row) => row.address,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
Cell: (cell: any) => {
return <AsyncContractNameCell cell={cell.row.original} />;
return <AsyncContractNameCell cell={cell.row.original} linkOverlay />;
},
},
{
@@ -231,6 +229,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
copyIconPosition="left"
address={cell.row.original.address}
variant="ghost"
className="relative z-10"
/>
);
},
@@ -245,6 +244,7 @@ const ContractTable: React.FC<ContractTableProps> = ({
<Button
size="icon"
variant="ghost"
className="relative z-10"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon className="size-4" />
@@ -367,8 +367,6 @@ const ContractTable: React.FC<ContractTableProps> = ({
};
const ContractTableRow = memo(({ row }: { row: Row<BasicContract> }) => {
const chainSlug = useChainSlug(row.original.chainId);
const router = useDashboardRouter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...rowProps } = row.getRowProps();
@@ -376,11 +374,7 @@ const ContractTableRow = memo(({ row }: { row: Row<BasicContract> }) => {
<TableRow
{...rowProps}
role="group"
className="cursor-pointer hover:bg-muted/50"
// TODO - replace this with before:absolute thing
onClick={() => {
router.push(`/${chainSlug}/${row.original.address}`);
}}
className="relative cursor-pointer hover:bg-muted/50"
>
{row.cells.map((cell, cellIndex) => {
return (
@@ -1,5 +1,7 @@
"use client";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
@@ -34,7 +36,7 @@ import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { useReadContract } from "thirdweb/react";
import { toFunctionSelector } from "thirdweb/utils";
import { Badge, Button, Card, Heading, Text } from "tw-components";
import { Button, Card, Heading, Text } from "tw-components";
import { useDebounce } from "use-debounce";
import { useContractFunctionSelectors } from "../../contract-ui/hooks/useContractFunctionSelectors";
import {
@@ -85,6 +87,11 @@ function ContractFunctionInner({ contract, fn }: ContractFunctionProps) {
return undefined;
}, [isERC20, isERC721Query.data, isERC1155Query.data]);
const functionSelector = useMemo(
() => (fn?.type === "function" ? toFunctionSelector(fn) : undefined),
[fn],
);
if (!fn) {
return null;
}
@@ -111,21 +118,22 @@ function ContractFunctionInner({ contract, fn }: ContractFunctionProps) {
return (
<Flex direction="column" gap={1.5}>
<Flex
alignItems={{ base: "start", md: "center" }}
gap={2}
direction={{ base: "column", md: "row" }}
>
<Flex alignItems="center" gap={2} direction="row" flexWrap="wrap">
<Flex alignItems="baseline" gap={1} flexWrap="wrap">
<Heading size="subtitle.md">{camelToTitle(fn.name)}</Heading>
<Heading size="subtitle.sm" className="text-muted-foreground">
({fn.name})
</Heading>
</Flex>
{isFunction && (
<Badge size="label.sm" variant="subtle" colorScheme="green">
{fn.stateMutability}
</Badge>
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
{functionSelector && (
<CopyTextButton
textToCopy={functionSelector}
textToShow={functionSelector}
copyIconPosition="right"
tooltip="The selector of this function"
className="ml-auto text-xs"
/>
)}
</Flex>
@@ -173,11 +181,7 @@ function ContractFunctionInputs(props: {
({fn.name})
</Heading>
</Flex>
{isFunction && (
<Badge size="label.sm" variant="subtle" colorScheme="green">
{fn.stateMutability}
</Badge>
)}
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
</Flex>
{fn.inputs?.length ? (
@@ -1,5 +1,6 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { InlineCode } from "@/components/ui/inline-code";
import { ToolTipLabel } from "@/components/ui/tooltip";
import {
@@ -416,7 +417,15 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = ({
) : formattedResponseData ? (
<>
<Divider />
<Heading size="label.sm">Output</Heading>
<div className="flex flex-row items-center gap-2">
<Heading size="label.sm">Output</Heading>
{/* Show the Solidity type of the function's output */}
{abiFunction.outputs.length > 0 && (
<Badge variant="default">
{abiFunction.outputs[0]?.type}
</Badge>
)}
</div>
<CodeBlock
w="full"
position="relative"
@@ -1,17 +1,13 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton, SkeletonContainer } from "@/components/ui/skeleton";
import { Skeleton } from "@/components/ui/skeleton";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { cn } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { moduleToBase64 } from "app/(dashboard)/published-contract/utils/module-base-64";
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
import Link from "next/link";
import { resolveScheme } from "thirdweb/storage";
import invariant from "tiny-invariant";
import { fetchPublishedContractVersion } from "../../contract-components/fetch-contracts-with-versions";
import { ContractPublisher, replaceDeployerAddress } from "../publisher";
@@ -80,7 +76,7 @@ function getContractUrl(
return replaceDeployerAddress(pathName);
}
export const ContractCard: React.FC<ContractCardProps> = ({
export async function ContractCard({
publisher,
contractId,
titleOverride,
@@ -89,20 +85,28 @@ export const ContractCard: React.FC<ContractCardProps> = ({
tracking,
modules = [],
isBeta,
}) => {
const client = useThirdwebClient();
const publishedContractResult = usePublishedContract(
`${publisher}/${contractId}/${version}`,
);
}: ContractCardProps) {
const client = getThirdwebClient();
const publishedContractResult = await fetchPublishedContractVersion(
publisher,
contractId,
version,
).catch(() => null);
const showSkeleton = publishedContractResult.isPending;
if (!publishedContractResult) {
return null;
}
const auditLink = resolveSchemeWithErrorHandler({
uri: publishedContractResult.audit,
client,
});
return (
<article
className={cn(
"relative flex min-h-[220px] flex-col rounded-lg border border-border p-4",
!showSkeleton ? "bg-muted/50 hover:bg-muted" : "pointer-events-none",
)}
className={
"relative flex min-h-[220px] flex-col rounded-lg border border-border bg-muted/50 p-4 hover:bg-muted"
}
>
<TrackedLinkTW
className="absolute inset-0 z-0 cursor-pointer"
@@ -127,15 +131,12 @@ export const ContractCard: React.FC<ContractCardProps> = ({
<div className="flex justify-between">
<div className="flex items-center gap-1.5">
{/* Audited */}
{publishedContractResult.data?.audit && (
{auditLink && (
<>
<Link
target="_blank"
className="relative z-1 flex items-center gap-1 font-medium text-sm text-success-text hover:underline"
href={resolveScheme({
uri: publishedContractResult.data.audit,
client,
})}
href={auditLink}
>
<ShieldCheckIcon className="size-4" />
Audited
@@ -145,17 +146,12 @@ export const ContractCard: React.FC<ContractCardProps> = ({
)}
{/* Version */}
<SkeletonContainer
skeletonData="0.0.0"
loadedData={publishedContractResult.data?.version}
render={(v) => {
return (
<p className="font-medium text-muted-foreground text-sm">
v{v}
</p>
);
}}
/>
{publishedContractResult.version && (
<p className="font-medium text-muted-foreground text-sm">
v{publishedContractResult.version}
</p>
)}
</div>
{/* Tags */}
@@ -168,34 +164,20 @@ export const ContractCard: React.FC<ContractCardProps> = ({
<div className="h-3.5" />
<SkeletonContainer
className="inline-block"
skeletonData="Edition Drop"
loadedData={
{/* Title */}
<h3 className="font-semibold text-lg tracking-tight">
{(
titleOverride ||
publishedContractResult.data?.displayName ||
publishedContractResult.data?.name
}
render={(v) => {
return (
<h3 className="font-semibold text-lg tracking-tight">
{v.replace("[Beta]", "")}
</h3>
);
}}
/>
publishedContractResult.displayName ||
publishedContractResult.name
).replace("[Beta]", "")}
</h3>
{/* Desc */}
<p className="mt-1 text-muted-foreground text-sm leading-5">
{descriptionOverride || publishedContractResult.description}
</p>
{publishedContractResult.data ? (
<p className="mt-1 text-muted-foreground text-sm leading-5">
{descriptionOverride || publishedContractResult.data?.description}
</p>
) : (
<div className="mt-1">
<Skeleton className="h-4 w-[80%]" />
<div className="h-1" />
<Skeleton className="h-4 w-[60%]" />
</div>
)}
{modules.length ? (
<div className="mt-auto flex flex-row flex-wrap gap-1 pt-3">
{modules.slice(0, 2).map((m) => (
@@ -214,10 +196,9 @@ export const ContractCard: React.FC<ContractCardProps> = ({
!modules?.length && "mt-auto",
)}
>
<ContractPublisher
addressOrEns={publishedContractResult.data?.publisher}
showSkeleton={showSkeleton}
/>
{publishedContractResult.publisher && (
<ContractPublisher addressOrEns={publishedContractResult.publisher} />
)}
<div className="flex items-center justify-between">
<Button
@@ -245,22 +226,8 @@ export const ContractCard: React.FC<ContractCardProps> = ({
</div>
</article>
);
};
// data fetching
type PublishedContractId =
| `${string}/${string}`
| `${string}/${string}/${string}`;
function usePublishedContract(publishedContractId: PublishedContractId) {
const [publisher, contractId, version] = publishedContractId.split("/");
return useQuery({
queryKey: ["published-contract", { publishedContractId }],
queryFn: () => {
invariant(publisher, "publisher is required");
invariant(contractId, "contractId is required");
return fetchPublishedContractVersion(publisher, contractId, version);
},
enabled: !!publisher || !!contractId,
});
}
export function ContractCardSkeleton() {
return <Skeleton className="h-[218px] border" />;
}
@@ -1,7 +1,8 @@
import type { ExploreCategory } from "data/explore";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { ContractCard } from "../contract-card";
import { Suspense } from "react";
import { ContractCard, ContractCardSkeleton } from "../contract-card";
interface ContractRowProps {
category: ExploreCategory;
@@ -69,26 +70,30 @@ export function ContractRow({ category }: ContractRowProps) {
return null;
}
return (
<ContractCard
<Suspense
key={publisher + contractId + overrides?.title}
publisher={publisher}
contractId={contractId}
titleOverride={overrides?.title}
descriptionOverride={overrides?.description}
tracking={{
source: category.id,
itemIndex: `${idx}`,
}}
isBeta={category.isBeta}
modules={
modules?.length
? modules.map((m) => ({
publisher: m.split("/")[0] || "",
moduleId: m.split("/")[1] || "",
}))
: undefined
}
/>
fallback={<ContractCardSkeleton />}
>
<ContractCard
publisher={publisher}
contractId={contractId}
titleOverride={overrides?.title}
descriptionOverride={overrides?.description}
tracking={{
source: category.id,
itemIndex: `${idx}`,
}}
isBeta={category.isBeta}
modules={
modules?.length
? modules.map((m) => ({
publisher: m.split("/")[0] || "",
moduleId: m.split("/")[1] || "",
}))
: undefined
}
/>
</Suspense>
);
})}
</div>
@@ -1,41 +1,40 @@
import { SkeletonContainer } from "@/components/ui/skeleton";
import { useEns } from "components/contract-components/hooks";
import { PublisherAvatar } from "components/contract-components/publisher/masked-avatar";
import Link from "next/link";
import type { RequiredParam } from "utils/types";
import { shortenIfAddress } from "utils/usedapp-external";
import { isEnsName, resolveEns } from "../../../lib/ens";
interface ContractPublisherProps {
addressOrEns: RequiredParam<string>;
showSkeleton?: boolean;
addressOrEns: string;
}
export const ContractPublisher: React.FC<ContractPublisherProps> = ({
export const ContractPublisher: React.FC<ContractPublisherProps> = async ({
addressOrEns,
showSkeleton,
}) => {
const ensQuery = useEns(addressOrEns || undefined);
let ensOrAddressToShow = addressOrEns;
if (!isEnsName(addressOrEns)) {
try {
const res = await resolveEns(addressOrEns);
if (res.ensName) {
ensOrAddressToShow = res.ensName;
}
} catch {
// ignore
}
}
return (
<Link
className="flex shrink-0 items-center gap-1.5 hover:underline"
href={replaceDeployerAddress(
`/${ensQuery.data?.ensName || ensQuery.data?.address || addressOrEns}`,
)}
href={replaceDeployerAddress(`/${ensOrAddressToShow}`)}
>
<PublisherAvatar
isPending={showSkeleton}
isPending={false}
boxSize={5}
address={addressOrEns || ""}
/>
<SkeletonContainer
loadedData={
ensQuery.data?.ensName || ensQuery.data?.address || addressOrEns || ""
}
skeletonData=""
render={(v) => <p className="text-xs"> {treatAddress(v)} </p>}
/>
<p className="text-xs"> {treatAddress(ensOrAddressToShow)} </p>
</Link>
);
};
@@ -1,8 +1,6 @@
"use client";
import { PlainTextCodeBlock } from "@/components/ui/code";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { ExternalLinkIcon } from "lucide-react";
import { CodeBlock } from "tw-components";
export const DeployUpsellCard: React.FC = () => {
return (
@@ -29,11 +27,7 @@ export const DeployUpsellCard: React.FC = () => {
<div className="h-2" />
<CodeBlock
code="npx thirdweb create contract"
language="bash"
prefix="$"
/>
<PlainTextCodeBlock code="npx thirdweb create contract" />
<div className="h-8" />
@@ -53,7 +47,7 @@ export const DeployUpsellCard: React.FC = () => {
<div className="h-2" />
<CodeBlock code="npx thirdweb deploy" language="bash" prefix="$" />
<PlainTextCodeBlock code="npx thirdweb deploy" />
</section>
);
};
@@ -1,5 +1,3 @@
"use client";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import Image from "next/image";
@@ -1,262 +0,0 @@
import {
Box,
Code,
type CodeProps,
Flex,
IconButton,
useColorModeValue,
useTheme,
} from "@chakra-ui/react";
import { useClipboard } from "hooks/useClipboard";
import { CheckIcon, CopyIcon, ZapIcon } from "lucide-react";
import { Highlight, themes } from "prism-react-renderer";
import { useEffect, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { Text } from "tw-components";
const darkThemeDefault = themes.vsDark;
const lightThemeDefault = themes.vsLight;
type PrismTheme = typeof darkThemeDefault;
interface CodeBlockProps extends Omit<CodeProps, "size"> {
code: string;
language: string;
canCopy?: boolean;
wrap?: boolean;
prefix?: string;
darkTheme?: PrismTheme;
lightTheme?: PrismTheme;
autoType?: boolean;
typingSpeed?: number;
title?: string;
titleColor?: string;
// biome-ignore lint/suspicious/noExplicitAny: FIXME
containerHeight?: any;
}
export const HomePageCodeBlock: React.FC<CodeBlockProps> = ({
code,
language,
px = 4,
py = 2,
w = "full",
borderRadius = "md",
borderColor = "borderColor",
borderWidth = "1px",
fontFamily = "mono",
backgroundColor,
prefix,
canCopy = true,
wrap = true,
darkTheme = themes.vsDark,
lightTheme,
autoType = false,
typingSpeed = 50,
title,
titleColor,
...restCodeProps
}) => {
const { ref, inView } = useInView({
threshold: 0.1,
triggerOnce: true,
});
const theme = useColorModeValue(
lightTheme || lightThemeDefault,
darkTheme || darkThemeDefault,
);
const { onCopy, hasCopied } = useClipboard(code);
const [speedUpEnabled, setSpeedUpEnabled] = useState(false);
const [currentCodeIndex, setCurrentCodeIndex] = useState(0);
const [currentTypingSpeed] = useState(typingSpeed);
const containerRef = useRef<HTMLDivElement>(null);
const chakraTheme = useTheme();
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!inView) {
return;
}
const interval = setInterval(() => {
if (currentCodeIndex < code.length) {
setCurrentCodeIndex((prev) => prev + 1);
} else {
// clear the interval when the code is fully typed
clearInterval(interval);
}
}, currentTypingSpeed);
return () => clearInterval(interval);
}, [currentCodeIndex, currentTypingSpeed, code, inView]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, []);
return (
<Box ref={ref}>
<Flex
direction="column"
w="full"
h="full"
border="1px solid"
borderColor="borderColor"
borderRadius="lg"
>
<Flex
justify="space-between"
align="center"
px={2}
py={2}
bg="#161b22"
roundedTop="lg"
w="full"
>
{canCopy && code && autoType && currentCodeIndex < code.length ? (
<IconButton
onClick={() => {
setSpeedUpEnabled((prev) => {
setCurrentCodeIndex(code.length);
return !prev;
});
}}
aria-label="Copy"
borderRadius="md"
variant="ghost"
colorScheme="gray"
size="sm"
icon={<ZapIcon className="size-4" />}
/>
) : (
<Box w="32px" />
)}
{title && (
<Text
fontSize="large"
fontWeight="bold"
position="static"
color={titleColor ? titleColor : "white"}
px={4}
py={2}
h="120%"
mb={-2}
>
{title}
</Text>
)}
{canCopy && code && (
<IconButton
onClick={onCopy}
aria-label="Copy"
borderRadius="md"
variant="ghost"
colorScheme="gray"
size="sm"
icon={
hasCopied ? (
<CheckIcon className="size-4 text-green-500" />
) : (
<CopyIcon className="size-4" />
)
}
/>
)}
</Flex>
<Highlight
code={
prefix
? `${prefix} ${code}`
: autoType
? speedUpEnabled
? code
: code.slice(0, currentCodeIndex)
: code
}
language={language}
theme={{
...theme,
plain: {
backgroundColor:
(backgroundColor as string) ||
"var(--chakra-colors-backgroundHighlight)",
},
}}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<Box
ref={containerRef}
borderRadius={borderRadius}
py={py}
px={px}
w={w}
borderWidth={borderWidth}
borderColor={borderColor}
position="relative"
className={className}
style={style}
fontFamily={fontFamily}
whiteSpace={wrap ? "pre-wrap" : "pre"}
overflowY="auto"
{...restCodeProps}
as={Code}
h="full"
>
<div>
<Box as="span" display="block" my={1} color="heading">
{tokens.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Box key={i} {...getLineProps({ line })}>
<LineNumbers
lineNumber={i + 1}
lineHeight={chakraTheme.sizes["5"]}
totalLines={tokens.length}
/>
{line.map((token, key) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<span key={key} {...getTokenProps({ token })} />
))}
</Box>
))}
</Box>
</div>
</Box>
)}
</Highlight>
</Flex>
</Box>
);
};
interface LineNumbersProps {
lineNumber: number;
lineHeight: string | number;
totalLines: number;
}
const LineNumbers: React.FC<LineNumbersProps> = ({
lineNumber,
lineHeight,
totalLines,
}) => {
const maxLineNumber = totalLines.toString().length;
const filledLineNumber = lineNumber.toString().padStart(maxLineNumber, " ");
return (
<Box
as="span"
display="inline-block"
textAlign="right"
paddingRight="1em"
userSelect="none"
opacity={0.3}
lineHeight={lineHeight}
>
{filledLineNumber}
</Box>
);
};
@@ -1,85 +0,0 @@
import { Box, Flex, Image } from "@chakra-ui/react";
import { HomepageSection } from "components/product-pages/homepage/HomepageSection";
import { ZapIcon } from "lucide-react";
import { Heading, LinkButton, Text } from "tw-components";
export const AnyEVMSection = () => {
return (
<HomepageSection id="any-evm" position="relative" pb={{ base: 36, md: 64 }}>
<Flex
flexDir="column"
pb={24}
pt={{ base: 12, md: 24 }}
align="center"
gap={{ base: 6, md: 8 }}
>
<Heading
bg="linear-gradient(350.21deg, #FFFFFF -13.99%, rgba(255, 255, 255, 0) 136.74%)"
bgClip="text"
as="h2"
size="title.2xl"
textAlign="center"
letterSpacing="-0.04em"
>
Build on 1,000+ EVM chains
</Heading>
<Text
bg="linear-gradient(29.47deg, #FFFFFF -12.61%, rgba(255, 255, 255, 0) 152.49%)"
bgClip="text"
textAlign="center"
as="h3"
maxW={480}
size="label.xl"
lineHeight={1.2}
>
Our tools work with any contract deployed on any EVM-compatible chain.
</Text>
<Flex
flexDir="column"
justifyContent="center"
alignItems="center"
marginTop="16px"
w="100%"
>
<LinkButton
href="/chainlist"
px={4}
py={7}
// h={{ base: "48px", md: "68px" }}
fontSize="20px"
leftIcon={<ZapIcon className="size-5" />}
color="black"
flexShrink={0}
background="rgba(255,255,255,1)"
_hover={{
background: "rgba(255,255,255,0.9)!important",
}}
maxW="320px"
width="100%"
>
See the Chainlist
</LinkButton>
</Flex>
</Flex>
<Box
pointerEvents="none"
zIndex={-1}
position="absolute"
bottom={0}
h="0"
w="full"
>
<Image
transform="translate(-50%, -60%)"
ml="50%"
alt=""
w="1500px"
maxW="250vw"
src="/assets/landingpage/any-evm.png"
justifySelf="flex-end"
/>
</Box>
</HomepageSection>
);
};
@@ -1,175 +0,0 @@
import { Flex, SimpleGrid } from "@chakra-ui/react";
import { ChakraNextImage } from "components/Image";
import { HomepageSection } from "components/product-pages/homepage/HomepageSection";
import { ZapIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Heading, Text, TrackedLink, TrackedLinkButton } from "tw-components";
import { getCookie } from "../../../stores/SyncStoreToCookies";
import { Aurora } from "../Aurora";
import styles from "../category/categories.module.css";
import { OpenSource } from "../open-source/OpenSource";
interface HeroSectionProps {
TRACKING_CATEGORY: string;
}
export const HeroSection = ({ TRACKING_CATEGORY }: HeroSectionProps) => {
return (
<HomepageSection id="home">
{/* top */}
<Aurora
pos={{ left: "50%", top: "0%" }}
size={{ width: "2400px", height: "1400px" }}
color="hsl(260deg 78% 35% / 40%)"
/>
<SimpleGrid
columns={{ base: 1, md: 2 }}
gap={{ base: 12, md: 8 }}
mt={{ base: 4, md: 20 }}
placeItems="center"
>
<Flex flexDir="column" gap={{ base: 6, md: 8 }}>
<Flex flexDir="column" gap={4}>
<Heading
as="h1"
size="title.2xl"
fontWeight={800}
px={{ base: 2, md: 0 }}
fontSize={{ base: "36px", sm: "45px" }}
>
Full-stack, <OpenSource TRACKING_CATEGORY={TRACKING_CATEGORY} />{" "}
web3 development platform
</Heading>
</Flex>
<Text size="body.xl" mr={6}>
Frontend, backend, and onchain tools to build complete web3 apps —
on every EVM chain.
</Text>
<Flex
flexDirection={{ base: "column", sm: "row" }}
gap={{ base: 4, md: 6 }}
>
<GetStartedButtonLink trackingCategory={TRACKING_CATEGORY} />
<TrackedLinkButton
variant="outline"
w="full"
py={6}
px={8}
href="https://portal.thirdweb.com"
category={TRACKING_CATEGORY}
label="see-docs"
maxW={{ base: "full", sm: "fit-content" }}
isExternal
>
See the docs
</TrackedLinkButton>
</Flex>
</Flex>
<Flex
minW={{ base: "auto", md: "420px" }}
maxW={{ base: "280px", sm: "400px", md: "500px" }}
position="relative"
className={styles.heroContainer}
>
<TrackedLink
href="/connect"
category={TRACKING_CATEGORY}
label="connect-icon"
>
<ChakraNextImage
src={require("../../../../public/assets/landingpage/desktop/icon-frontend.png")}
alt="icon-frontend"
maxW={{ base: "45px", sm: "60px", md: "75px" }}
zIndex={2}
cursor="pointer"
className={styles.connect}
/>
</TrackedLink>
<TrackedLink
href="/engine"
category={TRACKING_CATEGORY}
label="backend-icon"
>
<ChakraNextImage
src={require("../../../../public/assets/landingpage/desktop/icon-backend.png")}
alt="icon-backend"
maxW={{ base: "45px", sm: "60px", md: "75px" }}
position="absolute"
zIndex={2}
cursor="pointer"
className={styles.backend}
/>
</TrackedLink>
<TrackedLink
href="/contracts"
category={TRACKING_CATEGORY}
label="onchain-icon"
>
<ChakraNextImage
src={require("../../../../public/assets/landingpage/desktop/icon-onchain.png")}
alt="icon-onchain"
maxW={{ base: "45px", sm: "60px", md: "75px" }}
position="absolute"
zIndex={2}
cursor="pointer"
className={styles.onchain}
/>
</TrackedLink>
<ChakraNextImage
src={require("../../../../public/assets/landingpage/desktop/hero-homepage.png")}
alt="hero-image"
className={styles.heroImageV1}
/>
<ChakraNextImage
src={require("../../../../public/assets/landingpage/desktop/hero-homepage-v2.png")}
alt="hero-image"
className={styles.heroImageV2}
/>
</Flex>
</SimpleGrid>
</HomepageSection>
);
};
function GetStartedButtonLink(props: {
trackingCategory: string;
showTeamLayout?: boolean;
}) {
// using state+effect here to avoid hydration errors
const [showTeamLayout, setShowTeamLayout] = useState(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (getCookie("x-dashboard-type") === "team") {
setShowTeamLayout(true);
}
}, []);
return (
<TrackedLinkButton
leftIcon={<ZapIcon className="size-4 fill-black" />}
py={6}
px={8}
w="full"
bgColor="white"
_hover={{
bgColor: "white",
opacity: 0.8,
}}
color="black"
href={showTeamLayout ? "/team" : "/dashboard"}
category={props.trackingCategory}
label="get-started"
fontWeight="bold"
maxW={{ base: "full", sm: "fit-content" }}
>
Get started
</TrackedLinkButton>
);
}

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