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
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
221f95b4f8 Version Packages (#5093)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-21 23:17:57 +13:00
Joaquim VergesandGitHub 223c497f49 fix: ensure smart accounts are deployed before validating signatures (#5092) 2024-10-21 23:08:43 +13:00
Joaquim VergesandGitHub 541bee5b34 fix: update useProfiles when connecting to different account (#5089) 2024-10-21 22:40:31 +13:00
MananTank df128bfc17 Open Share Modal after applying freewallets coupon (#5085)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new modal component, `ShareFreeWalletsModal`, to facilitate sharing free wallet offers via social media or email. It also updates the `CouponCard` to handle coupon applications and trigger the modal.

### Detailed summary
- Added `ShareFreeWalletsModal` component for sharing options.
- Created Storybook stories for `ShareFreeWalletsModal` in `share-free-wallets-modal.stories.tsx`.
- Updated `ApplyCouponCard` to include an additional parameter for coupon application callback.
- Integrated lazy loading for `ShareFreeWalletsModal` in `CouponCard`.
- Implemented state management for modal visibility in `CouponSection`.

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

<!-- end pr-codex -->
2024-10-19 18:31:07 +00:00
kien-ngo f2d10853f1 [Dashboard] Remove AddressCopyButton (#5077)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on removing the `AddressCopyButton` component from various files and replacing it with `CopyTextButton` and `CopyAddressButton` components to optimize bundle size and improve code consistency.

### Detailed summary
- Deleted `AddressCopyButton.tsx` file.
- Removed all imports of `AddressCopyButton` from multiple files.
- Replaced instances of `AddressCopyButton` with `CopyTextButton` in `webhooks-table.tsx` and `token-id.tsx`.
- Replaced `AddressCopyButton` with `CopyAddressButton` in `transaction-timeline.tsx` and `contract-subscriptions-table.tsx`.

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

<!-- end pr-codex -->
2024-10-19 08:52:15 +00:00
kien-ngo a68830ca67 [Dashboard] Edition's Mint tab to have custom recipient (#4981)
<img width="1211" alt="image" src="https://github.com/user-attachments/assets/35a4a06f-6170-4757-a901-73eb43e0af89">

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `mint-supply-tab` component by integrating a new form validation schema using `zod`, improving the user experience with better form handling, and updating the imports for consistency.

### Detailed summary
- Added `zod` for form validation.
- Created `mintAdditionalSupplyFormSchema` to validate recipient address and mint amount.
- Refactored form handling to use the new validation schema.
- Updated the form structure with `Form`, `FormField`, and related components.
- Replaced old form submission logic with a new `onSubmit` function.
- Improved user feedback with toast notifications for transaction status.

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

<!-- end pr-codex -->
2024-10-19 08:40:16 +00:00
MananTank 5da500edca Add noUncheckedIndexedAccess: true in dashboard tsconfig and fix all issues (#5084)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces various enhancements and bug fixes across multiple files, focusing on improving type safety, handling undefined values, and refining conditions to prevent errors. It also updates the handling of cookies, contract metadata, and native token definitions.

### Detailed summary
- Added `"noUncheckedIndexedAccess": true` in `tsconfig.json`.
- Updated cookie retrieval to check for `_val` in `SyncStoreToCookies.tsx`.
- Modified ecosystem redirection condition in `EcosystemLandingPage.tsx`.
- Added checks for undefined chains in `allChains.ts`.
- Improved handling of optional values in various components.
- Enhanced error handling for uploaded files in `IconUpload.tsx`.
- Refactored native token definitions for better clarity and maintainability.
- Checked for existence of items before accessing properties in several components.
- Ensured consistent handling of optional chaining to avoid runtime errors.
- Updated metadata handling in various contract-related components.

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

<!-- end pr-codex -->
2024-10-19 02:46:44 +00:00
kien-ngo 4542a48933 [Dashboard] Add tokenURI section to the NFT page (#5035)
<img width="1172" alt="image" src="https://github.com/user-attachments/assets/bef8ec5b-b453-43e1-bc90-f7156b3dd578">

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `TokenIdPage` component by adding functionality for displaying and copying the token URI of an NFT. It introduces a `CopyTextButton` and a link that opens the token URI in a new tab, improving user interaction and accessibility.

### Detailed summary
- Added import for `CopyTextButton`.
- Added import for `ExternalLinkIcon`.
- Introduced `Link` from `next/link`.
- Added `token URI` section in the UI.
- Implemented `CopyTextButton` to copy the `nft.tokenURI`.
- Added a button with a link that opens the `nft.tokenURI` in a new tab.

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

<!-- end pr-codex -->
2024-10-18 23:58:59 +00:00
Vasiliy GualotoandGitHub 17127d7763 Docs(update): Correcting the Modular contract getting started tutoria… (#5074) 2024-10-18 16:14:38 -07:00
MananTank 0dbb174e50 Mention missing Unreal Engine in various copy (#5082)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the descriptions in two files to include `Unreal Engine` as a supported platform alongside `Unity` and `React Native` for social logins and smart contract integrations.

### Detailed summary
- Updated the text in `apps/dashboard/src/components/embedded-wallets/Configure/index.tsx` to include `Unreal Engine` in the supported platforms for social logins.
- Modified the `description` in `apps/dashboard/src/pages/interact.tsx` to add `Unreal Engine` as a supported platform for smart contract integrations.

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

<!-- end pr-codex -->
2024-10-18 17:18:50 +00:00
333 changed files with 10957 additions and 13207 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>
);
}
@@ -119,13 +119,18 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
if (filteredOptions.length >= itemsToShow) {
break;
}
const option = options[i];
if (!option) {
continue;
}
if (overrideSearchFn) {
if (overrideSearchFn(options[i], searchValLowercase)) {
filteredOptions.push(options[i]);
if (overrideSearchFn(option, searchValLowercase)) {
filteredOptions.push(option);
}
} else {
if (options[i].label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(options[i]);
if (option.label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(option);
}
}
}
@@ -63,14 +63,18 @@ export const SelectWithSearch = React.forwardRef<
if (filteredOptions.length >= itemsToShow) {
break;
}
const option = options[i];
if (!option) {
continue;
}
if (overrideSearchFn) {
if (overrideSearchFn(options[i], searchValLowercase)) {
filteredOptions.push(options[i]);
if (overrideSearchFn(option, searchValLowercase)) {
filteredOptions.push(option);
}
} else {
if (options[i].label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(options[i]);
if (option.label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(option);
}
}
}
@@ -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
+1 -1
View File
@@ -143,7 +143,7 @@ const ChartTooltipContent = React.forwardRef<
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
@@ -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>
);
}
@@ -16,7 +16,9 @@ const ImageUpload = React.forwardRef<HTMLInputElement, ImageUploadProps>(
const [activeFile, setActiveFile] = React.useState<File | null>(null);
const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
setActiveFile(acceptedFiles[0]);
if (acceptedFiles[0]) {
setActiveFile(acceptedFiles[0]);
}
onUpload?.(acceptedFiles);
},
[onUpload],
@@ -35,7 +35,8 @@ const InputOTPSlot = React.forwardRef<
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
// biome-ignore lint/style/noNonNullAssertion: pure shadcn component - it works
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
return (
<div
@@ -62,7 +63,7 @@ const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
// biome-ignore lint/a11y/useFocusableInteractive: <explanation>
// biome-ignore lint/a11y/useFocusableInteractive: pure shadcn component - it works
<div ref={ref} role="separator" {...props}>
<Dot />
</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;
}
}
@@ -44,13 +44,12 @@ export function useActivity(contract: ThirdwebContract, autoUpdate?: boolean) {
}
const obj = eventsQuery.data.slice(0, 100).reduce(
(acc, curr) => {
if (acc[curr.transactionHash]) {
acc[curr.transactionHash].events.push(curr);
acc[curr.transactionHash].events.sort(
(a, b) => b.logIndex - a.logIndex,
);
if (acc[curr.transactionHash].blockNumber > curr.blockNumber) {
acc[curr.transactionHash].blockNumber = curr.blockNumber;
const internalTx = acc[curr.transactionHash];
if (internalTx) {
internalTx.events.push(curr);
internalTx.events.sort((a, b) => b.logIndex - a.logIndex);
if (internalTx.blockNumber > curr.blockNumber) {
internalTx.blockNumber = curr.blockNumber;
}
} else {
acc[curr.transactionHash] = {
@@ -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,
});
@@ -48,7 +48,7 @@ export async function fetchDashboardContractMetadata(
if (compilerMetadata?.name.includes(":")) {
const _name = compilerMetadata.name.split(":")[1];
if (_name) {
compilerMetadata.name = compilerMetadata.name.split(":")[1];
compilerMetadata.name = _name;
}
}
@@ -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">
@@ -171,7 +171,7 @@ function minimizeChain(
return {
name: chain.name,
chain: chain.chain,
rpc: [firstRpc],
rpc: [firstRpc || ""],
nativeCurrency: chain.nativeCurrency,
shortName: chain.shortName,
chainId: chain.chainId,
@@ -509,7 +509,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
)}
{colorOptions.map((color) => (
<option key={color} value={color}>
{color[0].toUpperCase() + color.substring(1)}
{color[0]?.toUpperCase() + color.substring(1)}
</option>
))}
</Select>
@@ -524,7 +524,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
<Select {...register("secondaryColor")}>
{colorOptions.map((color) => (
<option key={color} value={color}>
{color[0].toUpperCase() + color.substring(1)}
{color[0]?.toUpperCase() + color.substring(1)}
</option>
))}
</Select>
@@ -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,101 +1,137 @@
"use client";
import { FormControl, Input } from "@chakra-ui/react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { TransactionButton } from "components/buttons/TransactionButton";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { useForm } from "react-hook-form";
import type { ThirdwebContract } from "thirdweb";
import { toast } from "sonner";
import { type ThirdwebContract, isAddress } from "thirdweb";
import { mintAdditionalSupplyTo } from "thirdweb/extensions/erc1155";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
import { z } from "zod";
interface MintSupplyTabProps {
contract: ThirdwebContract;
tokenId: string;
}
const mintAdditionalSupplyFormSchema = z.object({
to: z.string().refine((value) => isAddress(value), {
message: "Invalid Ethereum address",
}),
amount: z.number().int().min(1, "Amount must be at least 1"),
});
const MintSupplyTab: React.FC<MintSupplyTabProps> = ({ contract, tokenId }) => {
const trackEvent = useTrack();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<{ to: string; amount: string }>({
defaultValues: { amount: "1" },
const address = useActiveAccount()?.address;
const form = useForm<z.input<typeof mintAdditionalSupplyFormSchema>>({
resolver: zodResolver(mintAdditionalSupplyFormSchema),
defaultValues: {
amount: 1,
to: address || "",
},
});
const address = useActiveAccount()?.address;
const { mutate, isPending } = useSendAndConfirmTransaction();
const { onSuccess, onError } = useTxNotifications(
"Mint successful",
"Error minting additional supply",
contract,
);
const sendAndConfirmTx = useSendAndConfirmTransaction();
function onSubmit(values: z.input<typeof mintAdditionalSupplyFormSchema>) {
trackEvent({
category: "nft",
action: "mint-supply",
label: "attempt",
});
const transaction = mintAdditionalSupplyTo({
contract,
to: values.to,
tokenId: BigInt(tokenId),
supply: BigInt(values.amount),
});
const promise = sendAndConfirmTx.mutateAsync(transaction, {
onSuccess: () => {
trackEvent({
category: "nft",
action: "mint-supply",
label: "success",
});
form.reset();
},
onError: (error) => {
trackEvent({
category: "nft",
action: "mint-supply",
label: "error",
error,
});
},
});
toast.promise(promise, {
loading: "Minting NFT",
success: "Minted successfully",
error: "Failed to mint",
});
}
return (
<div className="flex w-full flex-col gap-2">
<Form {...form}>
<form
onSubmit={handleSubmit((data) => {
if (address) {
trackEvent({
category: "nft",
action: "mint-supply",
label: "attempt",
});
const transaction = mintAdditionalSupplyTo({
contract,
to: address,
tokenId: BigInt(tokenId),
supply: BigInt(data.amount),
});
mutate(transaction, {
onSuccess: () => {
trackEvent({
category: "nft",
action: "mint-supply",
label: "success",
});
onSuccess();
reset();
},
onError: (error) => {
trackEvent({
category: "nft",
action: "mint-supply",
label: "error",
error,
});
onError(error);
},
});
}
})}
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-2"
>
<div className="flex flex-col gap-3">
<div className="flex w-full flex-col gap-6 md:flex-row">
<FormControl isRequired isInvalid={!!errors.to}>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<Input placeholder="1" {...register("amount")} />
<FormHelperText>How many would you like to mint?</FormHelperText>
<FormErrorMessage>{errors.to?.message}</FormErrorMessage>
</FormControl>
</div>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
How many would you like to mint?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<TransactionButton
txChainID={contract.chain.id}
transactionCount={1}
isLoading={isPending}
type="submit"
colorScheme="primary"
alignSelf="flex-end"
>
Mint
</TransactionButton>
</div>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>Recipient</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TransactionButton
txChainID={contract.chain.id}
transactionCount={1}
isLoading={sendAndConfirmTx.isPending}
type="submit"
colorScheme="primary"
alignSelf="flex-end"
>
Mint
</TransactionButton>
</form>
</div>
</Form>
);
};
@@ -1,8 +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,
@@ -15,15 +19,16 @@ import {
useBreakpointValue,
} from "@chakra-ui/react";
import { useChainSlug } from "hooks/chains/chainSlug";
import { ChevronLeftIcon } from "lucide-react";
import { ChevronLeftIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
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 { Badge, Button, Card, CodeBlock, Heading, Text } from "tw-components";
import { AddressCopyButton } from "tw-components/AddressCopyButton";
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
import { shortenString } from "utils/usedapp-external";
import { NftProperty } from "../components/nft-property";
import { useNFTDrawerTabs } from "./useNftDrawerTabs";
@@ -49,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,
@@ -65,6 +72,8 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
tokenId,
});
const client = useThirdwebClient();
const { data: nft, isPending } = useReadContract(
isErc721 ? getErc721NFT : getErc1155NFT,
{
@@ -74,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">
@@ -126,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"
@@ -203,10 +221,11 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
<Heading size="label.md">Token ID</Heading>
</GridItem>
<GridItem colSpan={8}>
<AddressCopyButton
size="xs"
address={nft.id?.toString()}
title="Token ID"
<CopyTextButton
textToCopy={nft.id?.toString()}
textToShow={nft.id?.toString()}
tooltip="Token ID"
copyIconPosition="right"
/>
</GridItem>
@@ -240,6 +259,52 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
</GridItem>
</>
)}
<GridItem colSpan={4}>
<Heading size="label.md">Token URI</Heading>
</GridItem>
<GridItem
colSpan={8}
className="flex flex-row items-center gap-1"
>
<CopyTextButton
textToCopy={nft.tokenURI}
textToShow={shortenString(nft.tokenURI)}
tooltip="The URI of this NFT"
copyIconPosition="right"
/>
{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 ? (
@@ -280,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 {
@@ -41,7 +40,9 @@ export const PermissionsTable: React.FC<PermissionsTableProps> = ({
roleMembers.forEach((member) => {
return !acc.find((m) => m.member === member)
? acc.push({ member, roles: [role] })
: acc[acc.findIndex((m) => m.member === member)].roles.push(role);
: acc[acc.findIndex((m) => m.member === member)]?.roles.push(
role,
);
});
return acc;
@@ -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",
@@ -31,7 +31,7 @@ export const DistributeButton: React.FC<DistributeButtonProps> = ({
const numTransactions = useMemo(() => {
if (
validBalances.length === 1 &&
validBalances[0].name === "Native Token"
validBalances[0]?.name === "Native Token"
) {
return 1;
}
@@ -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." />
@@ -25,7 +25,7 @@ export async function EcosystemLandingPage(props: {
console.error("failed to fetch ecosystems", err);
return [];
});
if (ecosystems.length > 0) {
if (ecosystems[0]) {
redirect(`${props.ecosystemLayoutPath}/${ecosystems[0].slug}`);
}
}
@@ -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}
@@ -105,8 +105,10 @@ export function CreateEcosystemForm(props: {
<ImageUpload
accept="image/png, image/jpeg"
onUpload={(files) => {
form.setValue("logo", files[0]);
form.clearErrors("logo");
if (files[0]) {
form.setValue("logo", files[0]);
form.clearErrors("logo");
}
}}
/>
</FormControl>
@@ -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: {
@@ -80,10 +83,14 @@ export default async function ExploreCategoryPage(
<div className="h-10" />
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{category.contracts.map((publishedContractId, idx) => {
const publisher: string = Array.isArray(publishedContractId)
const publisher: string | undefined = Array.isArray(
publishedContractId,
)
? publishedContractId[0].split("/")[0]
: publishedContractId.split("/")[0];
const contractId: string = Array.isArray(publishedContractId)
const contractId: string | undefined = Array.isArray(
publishedContractId,
)
? publishedContractId[0].split("/")[1]
: publishedContractId.split("/")[1];
const modules = Array.isArray(publishedContractId)
@@ -92,36 +99,41 @@ export default async function ExploreCategoryPage(
const overrides = Array.isArray(publishedContractId)
? publishedContractId[2]
: undefined;
if (!publisher || !contractId) {
return null;
}
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>
);
@@ -132,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,10 +28,18 @@ export default async function Image(props: {
fetchPublisherProfile(publisher),
]);
if (!publishedContracts) {
return null;
}
const publishedContract =
publishedContracts.find((p) => p.version === props.params.version) ||
publishedContracts[0];
if (!publishedContract) {
return null;
}
const publishedContractName =
publishedContract?.displayName || publishedContract?.name;
@@ -4,6 +4,7 @@ import { getThirdwebClient } from "@/constants/thirdweb.server";
import { SimpleGrid } from "@chakra-ui/react";
import { fetchPublishedContractVersions } from "components/contract-components/fetch-contracts-with-versions";
import { PublishedContract } from "components/contract-components/published-contract";
import { notFound } from "next/navigation";
import { isAddress } from "thirdweb";
import { resolveAddress } from "thirdweb/extensions/ens";
import { PublishedActions } from "../../../components/contract-actions-published.client";
@@ -28,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(
@@ -46,6 +59,10 @@ export default async function PublishedContractPage(
publishedContractVersions.find((v) => v.version === props.params.version) ||
publishedContractVersions[0];
if (!publishedContract) {
return notFound();
}
return (
<>
<DeployContractHeader
@@ -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,8 +27,16 @@ export default async function Image(props: {
fetchPublisherProfile(publisher),
]);
if (!publishedContracts) {
return null;
}
const publishedContract = publishedContracts[0];
if (!publishedContract) {
return null;
}
const publishedContractName =
publishedContract?.displayName || publishedContract?.name;
@@ -1,5 +1,6 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { Separator } from "@/components/ui/separator";
import { notFound } from "next/navigation";
import { PublishedContract } from "../../../../../components/contract-components/published-contract";
import { PublishedActions } from "../../components/contract-actions-published.client";
import { DeployContractHeader } from "../../components/contract-header";
@@ -21,8 +22,16 @@ export default async function PublishedContractPage(
contract_id: props.params.contract_id,
});
if (!publishedContractVersions) {
notFound();
}
const publishedContract = publishedContractVersions[0];
if (!publishedContract) {
notFound();
}
return (
<>
<DeployContractHeader
@@ -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;
}
}
@@ -118,10 +118,11 @@ function descriptionShortener(description: string) {
words.push(word);
currentLength += word.length + 1;
}
if (words[words.length - 1].length < 4) {
const lastWord = words[words.length - 1];
if (lastWord && lastWord.length < 4) {
words = words.slice(0, -1);
}
if (words[words.length - 1].endsWith(".")) {
if (words[words.length - 1]?.endsWith(".")) {
return words.join(" ");
}
if (!shortened) {
@@ -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>
)}
@@ -48,8 +48,12 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
publishedContractVersions.find((v) => v.version === props.version) ||
publishedContractVersions[0];
if (!publishedContract) {
return null;
}
const moduleUris = modules
.filter((m) => m !== null)
.filter((m) => m !== null && m !== undefined)
.map((m) => m.publishMetadataUri);
const [contractMetadata, ...fetchedModules] = await Promise.all([
fetchDeployMetadata({
@@ -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>
);
}
@@ -116,7 +116,7 @@ export async function createTicketAction(
const markdown = prepareEmailBody({
product,
markdownInput: keyVal.markdown,
markdownInput: keyVal.markdown || "",
email: account.data.email,
name: account.data.name,
extraInfoInput: keyVal,
@@ -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 });
};
@@ -15,7 +15,7 @@ export function TeamSettingsMobileNav(props: {
return (
<div className="flex items-center gap-2 border-border border-b px-4 py-4 text-muted-foreground">
<Link
href={teamLinks[0].href}
href={teamLinks[0]?.href || "#"}
className="inline-flex items-center gap-1"
onClick={() => {
setShowFull(true);
@@ -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
@@ -191,7 +220,7 @@ export function WalletConnectorsChartCard(props: {
<Bar
key={walletType}
dataKey={walletType}
fill={chartConfig[walletType].color}
fill={chartConfig[walletType]?.color}
radius={4}
stackId="a"
strokeWidth={1.5}
@@ -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";
@@ -112,7 +118,7 @@ export function WalletDistributionChartCard(props: {
return {
walletType,
value: data,
fill: _chartConfig[walletType].color || "transparent",
fill: _chartConfig[walletType]?.color || "transparent",
};
},
);
@@ -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[] = [
@@ -24,9 +24,10 @@ const walletsToPickFrom: WalletId[] = [
];
const pickRandomWallet = () => {
return walletsToPickFrom[
Math.floor(Math.random() * walletsToPickFrom.length)
];
return (
walletsToPickFrom[Math.floor(Math.random() * walletsToPickFrom.length)] ||
"io.metamask"
);
};
export function createWalletStatsStub(days: number): WalletStats[] {
@@ -49,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() {
@@ -37,12 +37,14 @@ type ContentItem = {
onClick?: () => void;
};
type TabId = "explore" | "import" | "build" | "deploy";
const DeployOptions = () => {
const [showImportModal, setShowImportModal] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const content: Record<string, ContentItem> = useMemo(
const content: Record<TabId, ContentItem> = useMemo(
() => ({
explore: {
title: "Ready-to-deploy",
@@ -72,8 +74,7 @@ const DeployOptions = () => {
[],
);
const contentKeys = Object.keys(content);
const [activeTab, setActiveTab] = useState(contentKeys[0]);
const [activeTab, setActiveTab] = useState<TabId>("explore");
const activeTabContent = content[activeTab];
return (
@@ -91,7 +92,7 @@ const DeployOptions = () => {
name: value.title,
isActive: activeTab === key,
isEnabled: true,
onClick: () => setActiveTab(key),
onClick: () => setActiveTab(key as TabId),
}))}
tabClassName="font-medium !text-sm"
/>
@@ -29,7 +29,12 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
const { currentTeam, teamsAndProjects } = props;
const [hoveredTeam, setHoveredTeam] = useState<Team>();
const projectsToShowOfTeam =
hoveredTeam || currentTeam || teamsAndProjects[0].team;
hoveredTeam || currentTeam || teamsAndProjects[0]?.team;
// if we can't find a single team associated with this user - something is really wrong
if (!projectsToShowOfTeam) {
return null;
}
const projectsToShow = teamsAndProjects.find(
(x) => x.team.slug === projectsToShowOfTeam.slug,
@@ -39,13 +39,21 @@ function Variants(props: {
const Comp =
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
const team1 = teamsAndProjectsStub[0]?.team;
const team2 = teamsAndProjectsStub[1]?.team;
const team3 = teamsAndProjectsStub[2]?.team;
const team3Project = teamsAndProjectsStub[2]?.projects[0];
if (!team1 || !team2 || !team3 || !team3Project) {
return <div> failed to get team and project stubs </div>;
}
return (
<ThirdwebProvider>
<div className="flex min-h-screen flex-col gap-6 bg-gray-700 px-4 py-10">
<BadgeContainer label="Team Free, Account Loaded">
<Comp
teamsAndProjects={teamsAndProjectsStub}
currentTeam={teamsAndProjectsStub[0].team}
currentTeam={team1}
currentProject={undefined}
logout={() => {}}
connectButton={<ConnectButtonStub />}
@@ -60,7 +68,7 @@ function Variants(props: {
<BadgeContainer label="Team Growth, Account Loading">
<Comp
teamsAndProjects={teamsAndProjectsStub}
currentTeam={teamsAndProjectsStub[1].team}
currentTeam={team2}
currentProject={undefined}
account={undefined}
logout={() => {}}
@@ -72,7 +80,7 @@ function Variants(props: {
<BadgeContainer label="Team, Pro">
<Comp
teamsAndProjects={teamsAndProjectsStub}
currentTeam={teamsAndProjectsStub[2].team}
currentTeam={team3}
currentProject={undefined}
account={{
email: "[email protected]",
@@ -87,8 +95,8 @@ function Variants(props: {
<BadgeContainer label="Team + Project, Pro">
<Comp
teamsAndProjects={teamsAndProjectsStub}
currentTeam={teamsAndProjectsStub[2].team}
currentProject={teamsAndProjectsStub[2].projects[0]}
currentTeam={team3}
currentProject={team3Project}
account={{
email: "[email protected]",
id: "foo",
+3 -2
View File
@@ -72,7 +72,7 @@ export class ConnectFrame {
action: "post_redirect",
},
],
image: connectFrames["7"].imageUrl,
image: connectFrames[7].imageUrl,
// hardcode to "7"
post_url: `${getAbsoluteUrl()}/api/frame/connect?step=7`,
});
@@ -81,7 +81,8 @@ export class ConnectFrame {
static getFrameHtmlResponse = (step: Step, direction: "next" | "back") => {
const readyStepNum =
direction === "next" ? Number(step) + 1 : Number(step) - 1;
const frameImg = connectFrames[readyStepNum]?.imageUrl;
const frameImg =
connectFrames[readyStepNum as keyof typeof connectFrames]?.imageUrl;
if (!frameImg) {
throw new Error(`Image frame not found for step: ${step}`);
@@ -58,6 +58,10 @@ const AreaChart = <
}
const indexType = index.type || "date";
const firstData = data[0];
const lastData = data[data.length - 1];
const firstDataAtIndexId = firstData?.[index.id];
const lastDataAtIndexId = lastData?.[index.id];
return (
<div className={cn("h-full w-full", className)}>
@@ -149,8 +153,8 @@ const AreaChart = <
type="number"
tick={{ transform: "translate(0, 6)" }}
ticks={
startEndOnly
? [data[0][index.id], data[data.length - 1][index.id]]
startEndOnly && firstDataAtIndexId && lastDataAtIndexId
? [firstDataAtIndexId, lastDataAtIndexId]
: undefined
}
/>
@@ -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>
);

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