Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8af6143d53 Version Packages (#7304)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-16 17:56:01 -07:00
ed81006741 Feature: New UB Components (#7354)
Co-authored-by: Joaquim Verges <[email protected]>
2025-06-16 17:07:28 -07:00
MananTank 3f74760113 [TOOL-4819] Dashboard: Load all engine backend wallets, add pagination in table (#7347)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `useEngine` hook and the `BackendWalletsSection` component by adding pagination functionality for backend wallets retrieval, allowing users to navigate through pages of wallet data.

### Detailed summary
- Added optional `limit` and `page` parameters to the `useEngine` hook.
- Updated the fetch URL in the hook to accommodate pagination.
- Introduced pagination controls in `BackendWalletsSection` using `Button` components.
- Managed pagination state with `useState` for current page tracking.

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

<!-- end pr-codex -->
2025-06-16 20:59:30 +00:00
MananTank 9253c54cf9 [TOOL-4821] Improve Team members search (#7346)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a search feature for members in the `ManageMembersSection` component by integrating the `Fuse.js` library for fuzzy searching. This enhancement allows users to filter members based on their names and emails more effectively.

### Detailed summary
- Added `Fuse` from `fuse.js` to enable fuzzy searching.
- Created a `membersIndex` using `useMemo` to index `props.members`.
- Updated the filtering logic to use `membersIndex.search(searchTerm)` for searching members.
- Included `membersIndex` in the dependency array of the `membersToShow` memoization.

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

<!-- end pr-codex -->

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

- **New Features**
  - Improved member search functionality with fuzzy matching, allowing for more flexible and accurate search results based on names and emails.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-16 20:47:28 +00:00
MananTank 913ea98d23 [TOOL-4802] NFT Asset Page (#7332)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the handling of NFTs and contract components within the application, including updates to types, UI components, and functionality related to NFT collections and claims.

### Detailed summary
- Updated `NFTMetadata` type to include `slug`.
- Added `slug` property in various components.
- Introduced `supplyFormatter` for better NFT supply formatting.
- Changed contract type handling to `NFTCollection`.
- Enhanced `MinimalProject` type to include `slug`.
- Updated UI components for better user interaction.
- Improved pagination and NFT display logic.
- Added new utilities for NFT claim conditions and total counts.
- Refactored claim token UI to utilize new components and structures.
- Implemented responsive layouts for better mobile support.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/nft/overview/nfts-grid.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/nft/overview/buy-nft-drop/buy-nft-drop-ui.client.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/nft/token-viewer/token-viewer.tsx`

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

<!-- end pr-codex -->

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

- **New Features**
  - Introduced public NFT pages for ERC-721 and ERC-1155 contracts with responsive layouts, NFT grids, token viewer dialogs, and buy functionality.
  - Added components for token price display, supply claimed progress, and enhanced pagination controls.
  - Enabled NFT purchases from drops with dedicated UI and transaction handling for ERC-721 and ERC-1155 standards.
  - Added tabbed navigation for public NFT pages and improved media rendering with loading placeholders.
  - Introduced a responsive layout component for device-specific rendering and customizable wallet address focus behavior.
  - Added new components for buying NFTs and editions with form validation, pricing display, and transaction feedback.
  - Added a token price component with loading skeleton and strikethrough styling.
  - Added a supply claimed progress bar with detailed percentage display.
  - Introduced a token viewer dialog with dynamic data fetching and rich metadata display.
  - Added a paginated NFT grid with detailed claim condition info for ERC-1155 tokens.
  - Added buy NFT drop card components with server and client rendering support.
  - Added a custom media renderer component with loading state management.

- **Enhancements**
  - Improved contract and page header components with customizable styling options.
  - Expanded project and team data structures to include project slugs for better navigation.
  - Updated wallet address and pagination components with additional props for accessibility and styling.
  - Refined tracking of click and keyboard events for asset cards.
  - Simplified purchase UI code by delegating price and supply display to shared components.
  - Enhanced pagination to handle two-page scenarios with simplified controls.
  - Updated public page rendering logic to support multiple ERC standards with unified detection.
  - Improved purchase flows with analytics tracking and toast notifications.
  - Added support for special user pricing and dynamic claim condition fetching.

- **Bug Fixes**
  - Fixed conditional rendering and redirection logic for public NFT pages based on contract type and metadata presence.

- **Documentation**
  - Added Storybook stories for new UI components including supply claimed progress and NFT drop purchase flows.

- **Refactor**
  - Delegated price and supply display logic to shared components for consistency.
  - Updated types and props to support new features and improve maintainability.

- **Chores**
  - Broadened NFT metadata field types for improved compatibility with diverse data formats.
  - Added fallback for React experimental API to prevent errors in unsupported environments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-16 17:56:26 +00:00
gregandGitHub 5ce8a5618f Revert "brige embed (#7222)" (#7345) 2025-06-16 10:39:31 -07:00
35a909a897 WIP brige embed (#7222)
Co-authored-by: gregfromstl <[email protected]>
2025-06-15 18:36:11 -07:00
gregandGitHub 9f6c1a4820 Feature: clarify webhook disclaimer (#7339) 2025-06-15 16:21:26 -07:00
Phillip HoandGitHub e43afa1b4e chore: update team/account docs for team management (#7341) 2025-06-14 14:04:32 +08:00
Jonas DanielsandGitHub 94e2cbc468 [Dashboard] Add Google Tag Manager and PostHog to CSP script sources (#7338) 2025-06-13 15:08:47 -07:00
kumaryash90 64ee04c25d Update implementations (#7335)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `MarketplaceV3` addresses in the `ZKSYNC_IMPLEMENTATIONS` and `ZKSYNC_WETH` records, reflecting new contract addresses for the marketplace.

### Detailed summary
- Updated `MarketplaceV3` address for `[324]` from `0x88e7F2bEf0a23073b7Cc2c79D307Dbd9138Fa79c` to `0xFD5663B3Ed316aEB79c9b5c4A242172273cA3395`.
- Updated `MarketplaceV3` address for `[2741]` from `0x4b14569c7B79DBe686Ac3Ba5996131E7EDaB7a93` to `0x9Fdf34f552cdA8E2B0cD64e2EddcB732a5Ff8455`.
- Added new `MarketplaceV3` address for `[531050104]`: `0x292c324920d4120F928dA6f04548442fa10B8B0f`.
- Added new `MarketplaceV3` address for `[50104]`: `0x292c324920d4120F928dA6f04548442fa10B8B0f`.
- Added new `WETH` address for `[531050104]`: `0x9B7EA63EF5062661570afe548e3AD691D5652527`.
- Added new `WETH` address for `[50104]`: `0x2b1A859dE6A55c553520D7780Bc5805712B128F9`.

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

<!-- end pr-codex -->

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

- **New Features**
  - Added support for MarketplaceV3 contract on two additional networks.
  - Updated MarketplaceV3 contract addresses on existing networks for improved compatibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-13 13:50:20 +00:00
Jonas DanielsandGitHub 680b8cdb16 [Dashboard] Rename getMemberById to getMemberByAccountId and add onboarding banner (#7334) 2025-06-12 16:40:00 -07:00
saminacodes 787db9308d [Docs] UB (#7333)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the documentation for the `Universal Bridge` by adding troubleshooting information and FAQs regarding payment issues and token support.

### Detailed summary
- Added a section for handling "Error 500: Internal Server Error" with upgrade instructions for the SDK.
- Included a new FAQ on supporting tokens on the `Universal Bridge` with criteria and request instructions.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- **Documentation**
  - Added a new FAQ explaining how to request support for a token on Universal Bridge, including submission steps and expected processing time.
  - Introduced troubleshooting guidance for resolving "Error 500" or "Something went wrong" messages in Universal Bridge, with instructions to update the SDK and contact support if needed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 23:32:30 +00:00
kilavvyandGitHub c152db3e2f Fix Typographical Errors in Documentation and Comments (#7331)
Signed-off-by: kilavvy <[email protected]>
2025-06-12 14:01:03 -07:00
Corey RiceandGitHub 5f57300cf9 docs: fix typos in doc string (#7301) 2025-06-11 23:44:15 -07:00
Jonas DanielsandGitHub db20bd17b1 update deps 2 (#7328) 2025-06-11 23:41:12 -07:00
Jonas DanielsandGitHub 0a85205e4a update storybook (#7327) 2025-06-11 23:24:45 -07:00
Jonas DanielsandGitHub cda390eefe update dependencies (#7326) 2025-06-11 21:23:36 -07:00
leopardracerandGitHub 305f738ff1 Fix typos in comments and test descriptions (#7323)
Signed-off-by: leopardracer <[email protected]>
2025-06-11 21:08:07 -07:00
Jonas DanielsandGitHub a1c875c596 [Dashboard] Add self-service dedicated engine deployment option (#7325) 2025-06-11 19:32:08 -07:00
Jonas DanielsandGitHub 38f1821eb9 [Dashboard] Add plan selection step to team onboarding flow (#7324) 2025-06-11 17:56:00 -07:00
jnsdls 848984481f [Dashboard] Add 7-day free trial for Growth plan (#7322)
# Add 7-day free trial for Growth plan and include Starter plan in team onboarding

This PR adds a 7-day free trial indicator for teams that have just signed up but haven't subscribed yet when selecting the Growth plan. It also updates the team onboarding flow to properly handle the Starter plan.

Changes include:
- Added logic to detect if a team qualifies for the 7-day trial (new signup with no payment on Growth plan)
- Added a "7 day free trial" text display for qualifying teams
- Updated the polling logic to consider both "free" and "starter" plans as non-paid plans
- Modified the onboarding description text to remove reference to "free Starter plan"

## Summary by CodeRabbit

- **New Features**
  - Added a clear "7 day free trial" indicator for eligible Growth plan users.

- **Enhancements**
  - Updated onboarding modal text to remove "free" from the Starter plan description.
  - Modified billing plan polling logic to exclude both "free" and "starter" plans before proceeding.

---

## PR-Codex overview
This PR focuses on updating the logic for determining billing plans and enhancing the user experience regarding trial periods in the `InviteTeamMembers` and `PricingCard` components.

### Detailed summary
- In `InviteTeamMembers.tsx`, updated the condition for `isNonFreePlan` to exclude both "free" and "starter" plans.
- Modified the text in `InviteModalContent` to clarify the Starter plan.
- In `pricing-card.tsx`, added a condition for a 7-day trial for teams with a "growth" billing plan and "noPayment" status.
- Changed the rendering logic for remaining trial days to include a message for the 7-day trial.

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

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the pricing logic and UI in the dashboard. It modifies how pricing is handled, specifically for trial periods, and updates the display of pricing information to better reflect the available options and trials.

### Detailed summary
- Changed `price` type from `string | number` to `number`.
- Updated `isNonFreePlan` logic to exclude "starter" plans.
- Revised promotional text for the Growth plan.
- Added logic for a 7-day free trial for new teams on the Growth plan.
- Updated UI to display a "7 Day Free Trial" badge and adjusted pricing display accordingly.
- Modified call-to-action button text based on trial eligibility.

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

<!-- end pr-codex -->

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

- **New Features**
  - Added a "7 Day Free Trial" badge and updated price display for eligible users on the Growth plan.
  - Call-to-action button now shows "Start 7 Day Free Trial" when applicable.

- **Bug Fixes**
  - Improved onboarding flow by updating plan detection logic to better handle "Starter" and "Free" plans.

- **Style**
  - Revised onboarding text to emphasize upgrading to the Growth plan and removed mention of the Starter plan.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-11 19:59:56 +00:00
Jonas DanielsandGitHub 950bbaee1c [Dashboard] Update URL rewrites for grants and superchain pages (#7320) 2025-06-10 18:09:35 -07:00
MananTank 99d6b3b151 Dashboard: Add NFT creation wizard in Asset page (#7315)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of file inputs and media uploads within the application. It introduces new functionalities for managing blob URLs, refines error handling, and enhances the user interface for uploading assets.

### Detailed summary
- Added `fileToBlobUrl` function to convert files to blob URLs.
- Enhanced `FileInput` components to accept a `client` prop for better integration.
- Updated error handling in several components to provide more detailed feedback.
- Refined layouts and styles for asset creation pages.
- Introduced new schemas for validating NFT and social URL data.
- Improved tracking functionality for NFT creation steps.
- Added support for handling multiple file types in uploads.
- Enhanced the user interface for attributes and social URLs input fields.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx`, `apps/dashboard/src/core-ui/batch-upload/batch-table.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token-info-fieldset.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx`, `apps/dashboard/src/components/shared/FileInput.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx`

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

<!-- end pr-codex -->

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

- **New Features**
  - Added logic to always display the selected token in token selectors.
  - Introduced a styled drag-and-drop file upload area with error handling and reset options.
  - Added reusable components: file preview for files and URLs, step cards with navigation and tracking, social URLs fieldset, downloadable file button, batch upload instructions, and NFT attribute management.
  - Launched a multi-step NFT creation workflow covering collection info, asset upload (single and batch), sales settings, and launch steps.
  - Added schema validation for NFT metadata including pricing, social URLs, and blockchain addresses.
  - Enabled batch uploading and inline editing of NFTs with price, currency, and supply management.
  - Added advanced sales and royalty settings with real-time validation and analytics tracking.

- **Enhancements**
  - Improved error reporting in toast notifications with detailed parsed messages.
  - Centralized and streamlined media file handling by removing redundant hooks and consolidating upload logic.
  - Enhanced navigation and event tracking across multi-step asset creation flows.
  - Updated UI components to accept client context for consistent file handling.
  - Refined form validation by reusing common schemas.

- **Bug Fixes**
  - Corrected NFT supply column label to "Circulating Supply".
  - Improved loading placeholder text in token selectors.

- **Refactor**
  - Consolidated CSV upload logic and replaced custom drag-and-drop UIs with a unified DropZone component.
  - Replaced multiple media preview components with a single FilePreview component.
  - Updated prop types and component interfaces for better consistency and maintainability.
  - Removed deprecated hooks and unused imports to reduce technical debt.
  - Reorganized and standardized import paths across modules.
  - Simplified step execution logic in launch flows and enhanced retry capabilities.

- **Documentation**
  - Added Storybook stories for DropZone, multi-step status, and NFT upload components to support visual testing and documentation.

- **Chores**
  - Exported utility functions and constants for broader use.
  - Improved styling flexibility by enhancing component className handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-10 21:50:05 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
81c66f4e7b chore(deps): bump github/codeql-action from 3.28.18 to 3.28.19 (#7308)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 12:22:11 -07:00
catalyst17 639356071c docs: add webhooks section to agents and LLMs documentation (#7317)
# [Portal] Feature: Add Webhooks Documentation for AI Agents + Example with multiple ABIs for decoding

## Notes for the reviewer

Example with multiple ABIs for decoding ([related PR](https://app.graphite.dev/github/pr/thirdweb-dev/insight-service/268/fix-improve-ABI-validation-to-support-array-format))

1. Added examples on creating events filters with multiple ABI options

Also, this PR adds comprehensive documentation about using webhooks with AI agents in the Insight platform. The documentation covers:

1. Use cases for webhooks with AI agents
2. How to create and manage webhooks
3. Filtering capabilities, including support for multiple ABIs
4. Information about webhook payloads

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

## Summary by CodeRabbit

- **Documentation**
  - Added a new "Webhooks" section describing how to use webhooks with the Insight API for real-time blockchain event notifications and event-driven AI agents.
  - Provided use cases, filter examples, and links to detailed webhook and payload documentation.
  - Enhanced webhook filter documentation to clarify support for multiple ABIs, including examples for handling ERC-20 and ERC-721 Transfer events.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-10 18:53:39 +00:00
saminacodes 62af256575 [Docs] Update Broken Link (#7319)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the content in the `page.mdx` file related to fees and support for the `Universal Bridge` feature.

### Detailed summary
- Updated the explanation of fees for "Buy With Crypto" to specify fee settings in the project page.
- Revised the link for supported cryptocurrencies to direct to a new URL with a specific service.

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

<!-- end pr-codex -->

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

- **Documentation**
  - Clarified FAQ entry about setting additional fees for swap transactions and provided step-by-step instructions for configuring these fees.
  - Updated the "supported chains" link in the FAQ to use an absolute URL.
  - Removed an unnecessary blank line for improved readability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-10 18:46:55 +00:00
jnsdls eb1c705654 [Dashboard] Improve error handling in billing checkout process (#7318)
# Add "Go back" button to Stripe redirect error page and improve error handling

This PR enhances the Stripe redirect error page by adding a "Go back" button, allowing users to navigate away from error states. It also improves error handling in the billing checkout flow by:

1. Converting `getBillingCheckoutUrl()` to return structured responses with proper error messages
2. Adding specific error messages for different HTTP status codes (402 for outstanding invoices, 429 for rate limiting)
3. Displaying these more descriptive error messages to users when checkout fails

These changes provide a better user experience when errors occur during the checkout process.

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

## Summary by CodeRabbit

- **New Features**
  - Added a "Go back" button to the Stripe redirect error page, allowing users to easily return to the previous screen.

- **Bug Fixes**
  - Improved error handling during the checkout process, providing clearer error messages for issues such as authentication, outstanding invoices, rate limiting, and unknown errors. Error messages are now displayed directly on the error page when checkout fails.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-10 18:30:16 +00:00
arcoraven 9643b8d2c5 feat: create/delete teams (#7293)
# [Dashboard] Feature: Add Team Creation and Deletion

## Notes for the reviewer

This PR adds the ability for users to create and delete teams from the dashboard. It includes:

1. Server actions for creating and deleting teams
2. UI integration in the account header, team selector, and account teams page
3. Team deletion functionality in the team settings page

## How to test

- Try creating a new team from the account header dropdown
- Try creating a new team from the account teams page
- Try deleting a team from the team settings page (only available for team owners)

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

- **New Features**
  - Enabled team creation directly from the account and team selection interfaces, allowing users to create a new team and be redirected to its page immediately.
  - Activated the "Create Team" button in relevant menus and headers, making team creation accessible across both desktop and mobile views.

- **Bug Fixes**
  - Improved error handling and user feedback with toast notifications when team creation fails.

- **Refactor**
  - Updated team deletion to use real permission checks and improved the user interface for deleting teams.
  - Added comprehensive handling of authorization and error messages for team creation and deletion operations.
  - Redirected users to the account page when no teams are available.

- **Documentation**
  - Marked the default team retrieval function as deprecated in the documentation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on enhancing team management functionalities within the application. It introduces the ability to create and delete teams, updates various UI components to accommodate these changes, and ensures proper redirection and error handling during team operations.

### Detailed summary
- Added `createTeam` function in `createTeam.ts` for team creation.
- Introduced `deleteTeam` function in `deleteTeam.ts` for team deletion.
- Updated UI components to include `createTeam` functionality.
- Modified `TeamHeaderLoggedIn`, `AccountHeader`, and other components to handle team creation and deletion.
- Implemented redirection upon successful team creation and deletion.
- Updated `DeleteTeamCard` and `TeamGeneralSettingsPageUI` to manage permissions for team deletion.
- Enhanced error handling and user feedback with toast notifications during team operations.

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

<!-- end pr-codex -->
2025-06-10 01:44:58 +00:00
jnsdls 56d27d4c63 [Dashboard] Sort usage categories by total amount in descending order (#7314)
# Sort usage categories by total amount

This PR sorts the usage categories displayed on the team usage page by their total amount in descending order. Categories with higher total costs will now appear at the top of the list, making it easier for users to identify their most significant expenses at a glance.

The implementation:
- Creates a `sortedCategories` array by sorting the usage preview data
- Sorts based on the sum of `amountUsdCents` for each category's line items
- Uses the sorted array in the render function instead of the original data

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

## Summary by CodeRabbit

- **Enhancements**
  - Usage categories are now displayed in descending order based on their subtotal amounts, making it easier to identify the highest usage categories at a glance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-09 20:52:46 +00:00
jnsdls e742929b2c [Dashboard] Add notifications system with unread tracking (#7302)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new notifications system with a bell icon button displaying unread indicators.
  - Added a notifications panel with Inbox and Archive tabs supporting infinite scrolling and marking notifications as read.
  - Implemented a centralized notification state manager with optimized fetching, caching, and mutation handling.
  - Added a UI drawer component for mobile notification display with smooth open/close interactions.

- **Refactor**
  - Replaced legacy notification button and fetching logic with the new unified Notifications button using only account ID.
  - Removed legacy notification callbacks and components from header and navigation for simplified notification handling.

- **Chores**
  - Removed deprecated notification files and Storybook stories.
  - Updated dependencies by replacing "idb-keyval" with "vaul" for UI drawer functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on removing the `NotificationButton` component and its related files, replacing it with a new `NotificationsButton` component that integrates notifications more effectively within the application.

### Detailed summary
- Deleted `NotificationButton.tsx`, `fetch-notifications.ts`, and `NotificationButton.stories.tsx`.
- Added `NotificationsButton` component with enhanced functionality for displaying notifications.
- Updated various components (`AccountHeaderUI`, `SecondaryNav`, `TeamHeaderUI`) to use `NotificationsButton`.
- Removed references to `getInboxNotifications` and `markNotificationAsRead` from multiple components.
- Introduced new notification management logic and UI components in `notification-list.tsx` and `notification-entry.tsx`.
- Updated dependencies in `package.json` to include the new `vaul` library for UI elements.

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

<!-- end pr-codex -->
2025-06-09 20:09:05 +00:00
MananTank dacfd94af5 Nebula: Improved toasts in move funds page (#7307)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling and user feedback in the `SendFunds` function by utilizing `toast.promise` for asynchronous operations, enhancing the user experience during token transfers.

### Detailed summary
- Replaced `toast.loading`, `toast.success`, and `toast.error` with `toast.promise` for batch and single token transfers.
- Improved error handling by catching errors without logging them.
- Simplified success and error messages in the toast notifications.
- Removed duration settings for toast notifications.

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

<!-- end pr-codex -->

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

- **Refactor**
  - Toast notifications for sending funds are now streamlined using unified loading, success, and error messages, providing clearer and more consistent feedback during transactions. Error messages are also improved for better clarity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-09 15:24:28 +00:00
catalyst17 2d15cd6a47 docs: update webhook parameter filtering types and add example (#7295)
# [Portal] Fix: Update webhook parameter filtering documentation

## Notes for the reviewer

This PR updates the webhook filtering documentation to:
1. Correct the type definition for `params` in both events and transactions filters
2. Add a practical example showing how to filter for `Transfer` events with specific parameters

## How to test

Review the updated documentation to ensure the type definitions are correct and the new example is clear and helpful.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `params` property in the webhooks filtering configuration to allow for stricter type definitions, changing it from `Record<string, any>` to `Record<string, string | number>`. It also provides an example of how to filter `Transfer` events.

### Detailed summary
- Changed `params` type in the `webhooks` filtering from `Record<string, any>` to `Record<string, string | number>`.
- Updated `params` type in the `signatures` object from `string[]` to `Record<string, string | number>`.
- Added an example for filtering `Transfer` events with a specific `from` address.

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

<!-- end pr-codex -->
2025-06-09 12:00:22 +00:00
catalyst17 62a3cd8348 fix: make contract and to addresses optional in webhook form (#7294)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the handling of contract addresses in the `CreateWebhookModal` and `FilterDetailsStep` components, as well as refining validation logic for addresses in `webhookTypes.ts`. It enhances user experience by clarifying requirements and allowing optional inputs.

### Detailed summary
- In `CreateWebhookModal.tsx`, the `addresses` property now defaults to an empty string if not provided.
- In `FilterDetailsStep.tsx`, the `FormLabel` for "Contract Addresses" and "To Address" no longer includes a required indicator.
- Validation in `webhookTypes.ts` for `addresses`, `fromAddresses`, and `toAddresses` is updated to allow empty inputs and provide clearer error messages.

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

<!-- end pr-codex -->

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

- **Bug Fixes**
	- Removed the required field indicator from "Contract Addresses" and "To Address" labels in the form UI.

- **New Features**
	- Updated form validation to allow "Contract Addresses", "From Address", and "To Address" fields to be optional and accept empty values, with improved error messages for clarity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-09 12:00:22 +00:00
Phillip HoandGitHub 2a4195b4ad chore: pass authToken to analytics query (#7306) 2025-06-09 17:51:38 +08:00
Phillip HoandGitHub 6378f371c5 chore: pass allowImpersonation flag to /v2/keys/use (#7303) 2025-06-08 17:59:00 +08:00
Jonas DanielsandGitHub b3e42f7407 [Dashboard] Add audit log feature for Scale plan teams (#7300) 2025-06-07 00:37:21 -07:00
MananTank 044f897763 Nebula: Add Move funds page (#7298)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the user experience in the `Nebula` application by adding new features, improving existing components, and ensuring better handling of user authentication and fund transfers.

### Detailed summary
- Updated redirect logic in `middleware.ts` to preserve search params and prevent redirect from certain paths.
- Added `placeholder` prop to `DecimalInput` in `decimal-input.tsx`.
- Integrated `TWAutoConnect` component in `ChatPageLayout.tsx`.
- Enhanced `TransactionButton` in `TransactionButton.tsx` to manage a new `disableNoFundsPopup` prop.
- Refactored `NebulaProviders` to remove redundant `TWAutoConnect` usage.
- Created a new `RecoverPage` component in `move-funds/page.tsx` for fund recovery.
- Introduced `MoveFundsConnectButton` for connecting wallets in `connect-button.tsx`.
- Implemented a new `MoveFundsPage` for managing fund transfers.
- Updated `MismatchButton` to handle the new `disableNoFundsPopup` prop.
- Enhanced token management and user feedback within the `MoveFundsPage`.
- Added new components and functions for improved token selection and balance rendering.

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

<!-- end pr-codex -->

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

- **New Features**
	- Introduced a Move Funds page for transferring cryptocurrency between wallets, featuring wallet connection, chain and token selection, and transaction handling.
	- Added a customizable connect button supporting multiple authentication methods and theme adaptation.
	- Added the ability to display placeholder text in decimal input fields.
	- Enhanced button components with an option to disable the "Not Enough Funds" popup.
	- Added a dedicated page layout combining header, theme toggle, support link, and Move Funds functionality.
	- Integrated automatic wallet connection within the chat page layout for seamless user experience.

- **Bug Fixes**
	- Updated middleware to allow unauthenticated access to the Move Funds page without redirecting to the login page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-07 00:45:33 +00:00
jnsdls 7fa5be5317 [Dashboard] Add staff mode for viewing teams without membership (#7299)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added a "STAFF MODE" warning banner for users viewing a team they do not belong to, with a button to leave staff mode.
- **User Interface**
  - Adjusted the team header layout for improved accessibility, ensuring the team badge is no longer nested inside the team link.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on updating the `TeamHeaderUI` component and enhancing the `TeamLayout` and `ProjectLayout` components to incorporate a "Staff Mode" feature, which restricts user actions based on team membership.

### Detailed summary
- Modified the structure of the `TeamHeaderUI` component to wrap the `Link` inside a `span`.
- Added a "Staff Mode" warning message in `TeamLayout` if the user is not part of the team.
- Implemented the same "Staff Mode" warning in `ProjectLayout`.
- Updated API calls to include `getTeamBySlug` and `getProject`.

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

<!-- end pr-codex -->
2025-06-06 21:42:16 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2510c4f0c1 Version Packages (#7297)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 12:43:05 -05:00
gregandGitHub 69bf925c01 [SDK] Feature: preferred chan id pay embed (#7296) 2025-06-06 11:54:06 -05:00
Yash094 d4aed43b18 feat: add SIWA feedback functionality to dashboard chat (#7272)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `CustomChatContent` and `CustomChats` components to improve type safety and functionality in handling user messages and feedback. It introduces new types and methods for managing chat messages and feedback submission.

### Detailed summary
- Replaced `NebulaUserMessage` with `UserMessage` and `CustomChatMessage`.
- Added `handleFeedback` function for submitting feedback on messages.
- Updated `CustomChats` component to handle new message types and feedback.
- Modified `sendMessage` logic in `ChatBar` to use the new message structure.
- Introduced `RenderMessage` and `StyledMarkdownRenderer` for rendering chat messages.
- Added feedback buttons for assistant messages in `RenderMessage`.

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

<!-- end pr-codex -->

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

- **New Features**
  - Introduced a new chat interface supporting multiple message types with distinct visual styles.
  - Enabled users to submit feedback (thumbs up/down) on assistant messages.
  - Enhanced chat experience with auto-scrolling that respects user interactions.

- **Bug Fixes**
  - Prevented duplicate feedback submissions on assistant messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-06 16:24:33 +00:00
Jonas DanielsandGitHub f89c15c517 [Dashboard] Add detailed usage breakdown and billing preview (#7290) 2025-06-06 00:11:11 -07:00
MananTank c95c0dc5bd Nebula: Fix AutoConnect not using nebula client in root layout (#7291)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `TWAutoConnect` component to accept a `client` prop, allowing for better integration with different third-party clients in the application.

### Detailed summary
- Updated `TWAutoConnect` in `layout.tsx` to accept a `client` prop.
- Introduced `thirdwebClient` in `providers.tsx` to provide the client to `TWAutoConnect`.
- Modified `TWAutoConnect` component to require a `client` prop.
- Updated usage of `TWAutoConnect` in `nebula-app/providers.tsx` to pass in `nebulaAppThirdwebClient`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- **New Features**
  - Improved integration of client context within the auto-connect wallet feature, enhancing authentication and connectivity for users.

- **Refactor**
  - Updated internal handling of client instances for wallet auto-connect components to streamline authentication processes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-06 00:45:26 +00:00
GiselleNessi c77b4d8f85 updated old support links to portal (#7286)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `SupportPage` component in the `page.tsx` file by adding new features, modifying existing sections, and improving the overall structure and content of the help products.

### Detailed summary
- Added new icons: `HomeIcon`, `WalletIcon`, `EngineIcon`, `InsightIcon`, `PayIcon`, and `NebulaIcon`.
- Introduced a new help product: "Playground".
- Replaced "Connect" product icon and updated its description.
- Changed "Contracts" to "Universal Bridge" with a new icon and description.
- Updated "Engine" product with a new icon and description.
- Replaced "Account" with "Insight" and updated its details.
- Replaced "Misc" with "Nebula" and updated its details.
- Changed section title from "Knowledge base" to "Learning Resources".
- Modified product icon rendering from image to component usage.
- Updated button text from "View All" to "View".

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

<!-- end pr-codex -->

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

- **New Features**
  - Introduced new products "Playground" and "Nebula" to the support page.
- **Improvements**
  - Updated product categories, icons, and descriptions for a clearer overview.
  - Replaced static images with modern icon components for improved visual consistency.
  - Changed page heading to "Learning Resources."
  - Simplified layout by replacing help article lists with concise product descriptions.
  - Shortened the "View All" button text to "View."
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-05 21:13:38 +00:00
PaoloRollo 5c4c49b90a [Dashboard] Fix: Handle ping event (#7287)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

This PR adds a simple `ping` case in the `promptNebula` function to avoid `console.warn` logs in the browser console.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds support for a new event type, `"ping"`, in the `ChatStreamedEvent` type definition and handles the `"ping"` event case in the event processing logic.

### Detailed summary
- Added a new case for `"ping"` in the event handling switch statement.
- Introduced a new type definition for the `"ping"` event in the `ChatStreamedEvent` type, including a `data` field of type `string`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- **New Features**
  - Improved chat event handling with support for a new "ping" event type, enhancing the stability of real-time chat updates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-05 18:55:15 +00:00
MananTank a5cd66439c [TOOL-4687] Dashboard: Show Special Price UI in claim tokens card (#7284)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the `RecentTransfers` and `ClaimTokenCardUI` components in a React application by refining state management, simplifying logic, and enhancing UI elements.

### Detailed summary
- Removed `hasFetchedOnce` state in `RecentTransfers`.
- Changed condition for disabling the button in `RecentTransfersUI`.
- Simplified logic for claim parameters in `ClaimTokenCardUI`.
- Introduced `TokenPrice` component to handle price display.
- Added tooltip for special pricing indication.
- Adjusted UI elements for better clarity and organization.

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

<!-- end pr-codex -->
2025-06-05 18:44:04 +00:00
MananTank 05f65ec150 [TOOL-4686] Dashboard: Add supply remaining progress in asset page claim tokens card (#7283)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `ClaimTokenCardUI` component by adding a new `SupplyRemaining` feature, which displays the supply of tokens claimed and remaining. It also improves the UI with a progress bar and formatting adjustments.

### Detailed summary
- Added `Progress` component for visual representation of supply sold.
- Introduced `SupplyRemaining` function to show claimed and remaining supply.
- Formatted supply numbers using `compactNumberFormatter`.
- Updated headings and styling for better UI consistency.

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

<!-- end pr-codex -->

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

- **New Features**
  - Added a visual display showing claimed versus total claimable token supply, including a progress bar and percentage sold.
- **Style**
  - Improved typography and spacing for headings and token symbol labels for better visual consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-05 18:44:04 +00:00
gregandGitHub 6610633799 [Dashboard] Fix: Async team analytics rendering (#7289) 2025-06-05 12:19:36 -05:00
MananTank 7ab79dc3bb Dashboard: Fix contract deploy page missing last used teamId in client object (#7285)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the authentication mechanism by replacing the usage of `getAuthToken` with `getUserThirdwebClient` in the `page.tsx` and `uri-based-deploy.tsx` files, enhancing the handling of third-party client connections.

### Detailed summary
- Removed `getAuthToken` and its associated logic in `page.tsx`.
- Introduced `getUserThirdwebClient` to fetch the client in `page.tsx`.
- Updated the `isLoggedIn` prop in `PublishedContract` to use `!!account`.
- Adjusted the logic in `uri-based-deploy.tsx` to fetch client and teams without `authToken`.
- Changed the `isLoggedIn` prop to always be `true` in `uri-based-deploy.tsx`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

- **Refactor**
  - Streamlined the process for obtaining the user client, simplifying authentication and client initialization for published contract and deploy flows.
  - Improved logic for login checks and client handling, reducing redundant token fetching and making the login state determination more straightforward.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-05 14:07:42 +00:00
512 changed files with 32710 additions and 9839 deletions
+3 -3
View File
@@ -46,7 +46,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,4 +72,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
+3 -1
View File
@@ -20,6 +20,7 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
- Biome governs formatting and linting; its rules live in biome.json.
- Run pnpm biome check --apply before committing.
- Avoid editor‑specific configs; rely on the shared settings.
- make sure everything builds after each file change by running `pnpm build`
⸻
@@ -39,7 +40,8 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
- Co‑locate tests: foo.ts ↔ foo.test.ts.
- Use real function invocations with stub data; avoid brittle mocks.
- For network interactions, use Mock Service Worker (MSW) to intercept fetch/HTTP calls, mocking only scenarios that are hard to reproduce.
- Keep tests deterministic and side‑effect free; Jest is pre‑configured.
- Keep tests deterministic and side‑effect free; Vitest is pre‑configured.
- to run the tests: `cd packages thirdweb & pnpm test:dev <filename>`
⸻
+1 -2
View File
@@ -13,9 +13,8 @@ const config: StorybookConfig = {
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-docs"),
],
framework: {
name: getAbsolutePath("@storybook/nextjs"),
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Preview } from "@storybook/react";
import type { Preview } from "@storybook/nextjs";
import "../src/global.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MoonIcon, SunIcon } from "lucide-react";
+2 -1
View File
@@ -40,7 +40,8 @@ module.exports = [
"/community/ambassadors",
"/community/startup-program",
// -- grants --
"/grant/superchain",
"/grants",
"/superchain",
// -- templates --
"/templates",
"/templates/:template_slug",
-1
View File
@@ -10,7 +10,6 @@
"project": ["src/**"],
"ignoreBinaries": ["only-allow", "biome"],
"ignoreDependencies": [
"@storybook/blocks",
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"@types/color",
+1 -1
View File
@@ -12,7 +12,7 @@ const ContentSecurityPolicy = `
style-src 'self' 'unsafe-inline' vercel.live;
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;
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 googletagmanager.com us-assets.i.posthog.com edit.framer.com;
connect-src * data: blob:;
worker-src 'self' blob:;
block-all-mixed-content;
+43 -48
View File
@@ -29,30 +29,30 @@
"@emotion/styled": "11.14.0",
"@hookform/resolvers": "^3.9.1",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.7",
"@radix-ui/react-alert-dialog": "^1.1.10",
"@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-checkbox": "^1.2.2",
"@radix-ui/react-dialog": "1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-hover-card": "^1.1.10",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.10",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tooltip": "1.2.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@scalar/api-reference-react": "^0.6.19",
"@sentry/nextjs": "9.13.0",
"@shazow/whatsabi": "0.21.0",
"@tanstack/react-query": "5.74.4",
"@sentry/nextjs": "9.28.1",
"@shazow/whatsabi": "0.22.2",
"@tanstack/react-query": "5.80.7",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.0.0",
"@vercel/functions": "2.1.0",
"@vercel/og": "^0.6.8",
"abitype": "1.0.8",
"chakra-react-select": "^4.7.6",
@@ -61,17 +61,16 @@
"color": "5.0.0",
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.1",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "12.9.2",
"framer-motion": "12.17.0",
"fuse.js": "7.1.0",
"idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.503.0",
"next": "15.3.2",
"lucide-react": "0.514.0",
"next": "15.3.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
@@ -96,54 +95,50 @@
"responsive-rsc": "0.0.7",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"sonner": "2.0.3",
"sonner": "2.0.5",
"spdx-correct": "^3.2.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.21.0",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.4",
"zod": "3.25.24"
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"zod": "3.25.62"
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.6",
"@next/bundle-analyzer": "15.3.2",
"@next/eslint-plugin-next": "15.3.2",
"@playwright/test": "1.52.0",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/addon-onboarding": "8.6.14",
"@storybook/addon-viewport": "8.6.14",
"@storybook/blocks": "8.6.14",
"@storybook/nextjs": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/test": "8.6.14",
"@chromatic-com/storybook": "4.0.0",
"@next/bundle-analyzer": "15.3.3",
"@next/eslint-plugin-next": "15.3.3",
"@playwright/test": "1.53.0",
"@storybook/addon-docs": "9.0.8",
"@storybook/addon-links": "9.0.8",
"@storybook/addon-onboarding": "9.0.8",
"@storybook/nextjs": "9.0.8",
"@types/color": "4.2.0",
"@types/node": "22.14.1",
"@types/papaparse": "^5.3.16",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^5.18.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.21",
"checkly": "5.2.0",
"checkly": "5.6.0",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
"eslint-plugin-storybook": "0.12.0",
"knip": "5.56.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-storybook": "9.0.8",
"knip": "5.60.2",
"next-sitemap": "^4.2.3",
"postcss": "8.5.3",
"storybook": "8.6.14",
"postcss": "8.5.5",
"storybook": "9.0.8",
"tailwindcss": "3.4.17",
"typescript": "5.8.3"
}
+6
View File
@@ -368,6 +368,12 @@ async function redirects() {
destination: "/learn/guides",
permanent: false,
},
// redirect to /grant/superchain to /superchain
{
source: "/grant/superchain",
destination: "/superchain",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
@@ -0,0 +1,75 @@
"use server";
import "server-only";
// biome-ignore lint/style/useNodejsImportProtocol: breaks storybook if it's `node:` prefixed
import { randomBytes } from "crypto";
import type { Team } from "@/api/team";
import { format } from "date-fns";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
export async function createTeam(options?: {
name?: string;
slug?: string;
}) {
const token = await getAuthToken();
if (!token) {
return {
status: "error",
errorMessage: "You are not authorized to perform this action",
} as const;
}
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name:
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
slug: options?.slug ?? randomBytes(20).toString("hex"),
billingEmail: null,
image: null,
}),
});
if (!res.ok) {
const reason = await res.text();
console.error("failed to create team", {
status: res.status,
reason,
});
switch (res.status) {
case 400: {
return {
status: "error",
errorMessage: "Invalid team name or slug.",
} as const;
}
case 401: {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
} as const;
}
default: {
return {
status: "error",
errorMessage: "An unknown error occurred.",
} as const;
}
}
}
const json = (await res.json()) as {
result: Team;
};
return {
status: "success",
data: json.result,
} as const;
}
@@ -0,0 +1,70 @@
"use server";
import "server-only";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
export async function deleteTeam(options: {
teamId: string;
}) {
const token = await getAuthToken();
if (!token) {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
} as const;
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
},
);
// handle errors
if (!res.ok) {
const reason = await res.text();
console.error("failed to delete team", {
status: res.status,
reason,
});
switch (res.status) {
case 400: {
return {
status: "error",
errorMessage: "Invalid team ID.",
} as const;
}
case 401: {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
} as const;
}
case 403: {
return {
status: "error",
errorMessage: "You do not have permission to delete this team.",
} as const;
}
case 404: {
return {
status: "error",
errorMessage: "Team not found.",
} as const;
}
default: {
return {
status: "error",
errorMessage: "An unknown error occurred.",
} as const;
}
}
}
return {
status: "success",
} as const;
}
+1 -30
View File
@@ -22,7 +22,6 @@ async function fetchAnalytics(
init?: RequestInit,
): Promise<Response> {
const token = await getAuthToken();
if (!token) {
throw new Error("You are not authorized to perform this action");
}
@@ -48,22 +47,11 @@ async function fetchAnalytics(
decodeURIComponent(value),
);
}
// client id DEBUG OVERRIDE
// analyticsServiceUrl.searchParams.delete("projectId");
// analyticsServiceUrl.searchParams.delete("teamId");
// analyticsServiceUrl.searchParams.append(
// "teamId",
// "team_cm0lde33r02344w129k5hm2xz",
// );
// analyticsServiceUrl.searchParams.append(
// "projectId",
// "prj_cm4rqwx9b002qrnsnr37wqpo6",
// );
return fetch(analyticsServiceUrl, {
...init,
headers: {
"content-type": "application/json",
Authorization: `Bearer ${token}`,
...init?.headers,
},
});
@@ -99,7 +87,6 @@ export async function getWalletConnections(
`v2/sdk/wallet-connects?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -123,7 +110,6 @@ export async function getInAppWalletUsage(
`v2/wallet/connects?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -147,7 +133,6 @@ export async function getUserOpUsage(
`v2/bundler/usage?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -205,7 +190,6 @@ export async function getClientTransactions(
`v2/sdk/contract-transactions?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -229,7 +213,6 @@ export async function getRpcMethodUsage(
`v2/rpc/evm-methods?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -250,7 +233,6 @@ export async function getWalletUsers(
`v2/sdk/wallet-connects/users?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -287,7 +269,6 @@ export async function isProjectActive(params: {
`v2/active-usage?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
@@ -357,9 +338,6 @@ export async function getEcosystemWalletUsage(args: {
`v2/wallet/connects?${searchParams.toString()}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
@@ -386,9 +364,6 @@ export async function getUniversalBridgeUsage(args: {
const searchParams = buildSearchParams(args);
const res = await fetchAnalytics(`v2/universal?${searchParams.toString()}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (res?.status !== 200) {
@@ -415,9 +390,6 @@ export async function getUniversalBridgeWalletUsage(args: {
`v2/universal/wallets?${searchParams.toString()}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
@@ -441,7 +413,6 @@ export async function getEngineCloudMethodUsage(
`v2/engine-cloud/requests?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
+91
View File
@@ -0,0 +1,91 @@
"use server";
import "server-only";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
export type AuditLogEntry = {
who: {
text: string;
metadata?: {
email?: string;
image?: string;
wallet?: string;
clientId?: string;
};
type: "user" | "apikey" | "system";
path?: string;
};
what: {
text: string;
action: "create" | "update" | "delete";
path?: string;
in?: {
text: string;
path?: string;
};
description?: string;
resourceType:
| "team"
| "project"
| "team-member"
| "team-invite"
| "contract"
| "secret-key";
};
when: string;
};
type AuditLogApiResponse = {
result: AuditLogEntry[];
nextCursor?: string;
hasMore: boolean;
};
export async function getAuditLogs(teamSlug: string, cursor?: string) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const url = new URL(
`/v1/teams/${teamSlug}/audit-log`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
// artifically limit page size to 15 for now
url.searchParams.set("take", "15");
const response = await fetch(url, {
next: {
// revalidate this query once per 10 seconds (does not need to be more granular than that)
revalidate: 10,
},
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!response.ok) {
// if the status is 402, the most likely reason is that the team is on a free plan
if (response.status === 402) {
return {
status: "error",
reason: "higher_plan_required",
} as const;
}
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
const data = (await response.json()) as AuditLogApiResponse;
return {
status: "success",
data,
} as const;
}
+157
View File
@@ -0,0 +1,157 @@
"use server";
import "server-only";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
export type Notification = {
id: string;
createdAt: string;
accountId: string;
teamId: string | null;
description: string;
readAt: string | null;
ctaText: string;
ctaUrl: string;
};
export type NotificationsApiResponse = {
result: Notification[];
nextCursor?: string;
};
export async function getUnreadNotifications(cursor?: string) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const url = new URL(
"/v1/dashboard-notifications/unread",
NEXT_PUBLIC_THIRDWEB_API_HOST,
);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
const data = (await response.json()) as NotificationsApiResponse;
return {
status: "success",
data,
} as const;
}
export async function getArchivedNotifications(cursor?: string) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const url = new URL(
"/v1/dashboard-notifications/archived",
NEXT_PUBLIC_THIRDWEB_API_HOST,
);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
const data = (await response.json()) as NotificationsApiResponse;
return {
status: "success",
data,
} as const;
}
export async function getUnreadNotificationsCount() {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const url = new URL(
"/v1/dashboard-notifications/unread-count",
NEXT_PUBLIC_THIRDWEB_API_HOST,
);
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
const data = (await response.json()) as {
result: {
unreadCount: number;
};
};
return {
status: "success",
data,
} as const;
}
export async function markNotificationAsRead(notificationId?: string) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const url = new URL(
"/v1/dashboard-notifications/mark-as-read",
NEXT_PUBLIC_THIRDWEB_API_HOST,
);
const response = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
// if notificationId is provided, mark it as read, otherwise mark all as read
body: JSON.stringify(notificationId ? { notificationId } : {}),
});
if (!response.ok) {
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
return {
status: "success",
} as const;
}
+9 -3
View File
@@ -33,7 +33,7 @@ export async function getMembers(teamSlug: string) {
}
const teamsRes = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamSlug}/members`,
new URL(`/v1/teams/${teamSlug}/members`, NEXT_PUBLIC_THIRDWEB_API_HOST),
{
headers: {
Authorization: `Bearer ${token}`,
@@ -48,7 +48,10 @@ export async function getMembers(teamSlug: string) {
return undefined;
}
export async function getMemberById(teamSlug: string, memberId: string) {
export async function getMemberByAccountId(
teamSlug: string,
accountId: string,
) {
const token = await getAuthToken();
if (!token) {
@@ -56,7 +59,10 @@ export async function getMemberById(teamSlug: string, memberId: string) {
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamSlug}/members/${memberId}`,
new URL(
`/v1/teams/${teamSlug}/members/${accountId}`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
headers: {
Authorization: `Bearer ${token}`,
+23
View File
@@ -3,8 +3,10 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import type { TeamResponse } from "@thirdweb-dev/service-utils";
import { cookies } from "next/headers";
import { getValidAccount } from "../../app/(app)/account/settings/getAccount";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { LAST_USED_TEAM_ID } from "../../constants/cookies";
import { getMemberByAccountId } from "./team-members";
export type Team = TeamResponse & { stripeCustomerId: string | null };
@@ -67,6 +69,7 @@ export async function getTeams() {
return null;
}
/** @deprecated */
export async function getDefaultTeam() {
const token = await getAuthToken();
if (!token) {
@@ -102,3 +105,23 @@ export async function getLastVisitedTeam() {
return null;
}
export async function hasToCompleteTeamOnboarding(
team: Team,
pagePath: string,
) {
// if the team is already onboarded, we don't need to check anything else here
if (team.isOnboarded) {
return false;
}
const account = await getValidAccount(pagePath);
const teamMember = await getMemberByAccountId(team.slug, account.id);
// if the team member is not an owner (or we cannot find them), they cannot complete onboarding anyways
if (teamMember?.role !== "OWNER") {
return false;
}
// if we get here the team is not onboarded and the team member is an owner, so we need to show the onboarding page
return true;
}
@@ -47,13 +47,7 @@ export async function createWebhook(props: {
secret?: string;
}) {
const authToken = await getAuthToken();
console.log(
"UB_BASE_URL",
UB_BASE_URL,
props.clientId,
props.teamId,
authToken,
);
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
method: "POST",
body: JSON.stringify({
@@ -0,0 +1,66 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "../../../app/(app)/api/lib/getAuthToken";
type LineItem = {
quantity: number;
amountUsdCents: number;
unitAmountUsdCents: string;
description: string;
};
export type UsageCategory = {
category: string;
unitName: string;
lineItems: LineItem[];
};
type UsageApiResponse = {
result: UsageCategory[];
periodStart: string;
periodEnd: string;
planVersion: number;
};
export async function getBilledUsage(teamSlug: string) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const response = await fetch(
new URL(
`/v1/teams/${teamSlug}/billing/billed-usage`,
NEXT_PUBLIC_THIRDWEB_API_HOST,
),
{
next: {
// revalidate this query once per minute (does not need to be more granular than that)
revalidate: 60,
},
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!response.ok) {
// if the status is 404, the most likely reason is that the team is on a free plan
if (response.status === 404) {
return {
status: "error",
reason: "free_plan",
} as const;
}
const body = await response.text();
return {
status: "error",
reason: "unknown",
body,
} as const;
}
const data = (await response.json()) as UsageApiResponse;
return {
status: "success",
data,
} as const;
}
-52
View File
@@ -2,58 +2,6 @@ import "server-only";
import { unstable_cache } from "next/cache";
import { ANALYTICS_SERVICE_URL } from "../../constants/server-envs";
export type RPCUsageDataItem = {
date: string;
usageType: "included" | "overage" | "rate-limit";
count: string;
};
export const fetchRPCUsage = unstable_cache(
async (params: {
teamId: string;
projectId?: string;
authToken: string;
from: string;
to: string;
period: "day" | "week" | "month" | "year" | "all";
}) => {
const analyticsEndpoint = ANALYTICS_SERVICE_URL;
const url = new URL(`${analyticsEndpoint}/v2/rpc/usage-types`);
url.searchParams.set("teamId", params.teamId);
if (params.projectId) {
url.searchParams.set("projectId", params.projectId);
}
url.searchParams.set("from", params.from);
url.searchParams.set("to", params.to);
url.searchParams.set("period", params.period);
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${params.authToken}`,
},
});
if (!res.ok) {
const error = await res.text();
return {
ok: false as const,
error: error,
};
}
const resData = await res.json();
return {
ok: true as const,
data: resData.data as RPCUsageDataItem[],
};
},
["rpc-usage"],
{
revalidate: 60 * 60, // 1 hour
},
);
type Last24HoursRPCUsageApiResponse = {
peakRate: {
date: string;
@@ -0,0 +1,25 @@
"use client";
import { Suspense } from "react";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { useIsMobile } from "../hooks/use-mobile";
export function ResponsiveLayout(props: {
desktop: React.ReactNode;
mobile: React.ReactNode;
fallback: React.ReactNode;
debugMode?: boolean;
}) {
const isMobile = useIsMobile();
if (props.debugMode) {
return props.fallback;
}
return (
<Suspense fallback={props.fallback}>
<ClientOnly ssr={props.fallback}>
{isMobile ? props.mobile : props.desktop}
</ClientOnly>
</Suspense>
);
}
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import {
BadgeContainer,
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import {
BadgeContainer,
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "../../../stories/utils";
import { DangerSettingCard } from "./DangerSettingCard";
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ImageIcon } from "lucide-react";
import { useState } from "react";
import { BadgeContainer } from "../../../stories/utils";
@@ -11,6 +11,7 @@ type imgElementProps = React.DetailedHTMLProps<
skeleton?: React.ReactNode;
fallback?: React.ReactNode;
src: string | undefined;
containerClassName?: string;
};
export function Img(props: imgElementProps) {
@@ -23,7 +24,8 @@ export function Img(props: imgElementProps) {
: props.src === ""
? "fallback"
: _status;
const { className, fallback, skeleton, ...restProps } = props;
const { className, fallback, skeleton, containerClassName, ...restProps } =
props;
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
const defaultFallback = <div className="bg-accent" />;
const imgRef = useRef<HTMLImageElement>(null);
@@ -47,7 +49,7 @@ export function Img(props: imgElementProps) {
}, []);
return (
<div className="relative shrink-0">
<div className={cn("relative shrink-0", containerClassName)}>
<img
{...restProps}
// avoid setting empty src string to prevent request to the entire page
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import {
BadgeContainer,
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "../../../stories/utils";
import { SettingsCard } from "./SettingsCard";
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { SidebarLayout } from "./SidebarLayout";
const meta = {
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import {
BadgeContainer,
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import {
BadgeContainer,
@@ -159,6 +159,17 @@ export function TokenSelector(props: {
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
: undefined;
// if selected value is not in options, add it
if (
selectedValue &&
!options.find((option) => option.value === selectedValue)
) {
options.push({
label: props.selectedToken?.address || "Unknown",
value: selectedValue,
});
}
return (
<SelectWithSearch
searchPlaceholder="Search by name or symbol"
@@ -175,7 +186,7 @@ export function TokenSelector(props: {
showCheck={props.showCheck}
placeholder={
tokensQuery.isPending
? "Loading Tokens..."
? "Loading Tokens"
: props.placeholder || "Select Token"
}
overrideSearchFn={searchFn}
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ArrowRightIcon, RocketIcon, StarIcon } from "lucide-react";
import { BadgeContainer } from "../../../stories/utils";
import { UpsellBannerCard } from "./UpsellBannerCard";
@@ -20,7 +20,7 @@ import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { useMemo } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
@@ -94,7 +94,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
axisLine={false}
tickMargin={20}
tickFormatter={(value) =>
formatDate(
format(
new Date(value),
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
)
@@ -22,7 +22,7 @@ import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { useMemo } from "react";
type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
@@ -86,7 +86,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => formatDate(new Date(value), "MMM d")}
tickFormatter={(value) => format(new Date(value), "MMM d")}
/>
<ChartTooltip
content={
@@ -6,7 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { BadgeContainer } from "stories/utils";
import { type CodeEnvironment, CodeSegment } from "./code-segment.client";
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { DropZone } from "./drop-zone";
const meta = {
title: "blocks/DropZone",
component: DropZone,
decorators: [
(Story) => (
<div className="container max-w-6xl py-10">
<Story />
</div>
),
],
} satisfies Meta<typeof DropZone>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
isError: false,
onDrop: () => {},
title: "This is a title",
description: "This is a description for drop zone",
accept: undefined,
resetButton: undefined,
},
};
export const ErrorState: Story = {
args: {
isError: true,
onDrop: () => {},
title: "this is title",
description: "This is a description",
accept: undefined,
resetButton: undefined,
},
};
export const ErrorStateWithResetButton: Story = {
args: {
isError: true,
onDrop: () => {},
title: "this is title",
description: "This is a description",
accept: undefined,
resetButton: {
label: "Remove Files",
onClick: () => {},
},
},
};
@@ -0,0 +1,81 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { UploadIcon, XIcon } from "lucide-react";
import { useDropzone } from "react-dropzone";
export function DropZone(props: {
isError: boolean;
onDrop: (acceptedFiles: File[]) => void;
title: string;
description: string;
resetButton:
| {
label: string;
onClick: () => void;
}
| undefined;
className?: string;
accept: string | undefined;
}) {
const { getRootProps, getInputProps } = useDropzone({
onDrop: props.onDrop,
});
const { resetButton } = props;
return (
<div
className={cn(
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border",
props.isError &&
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
props.className,
)}
{...getRootProps()}
>
<input {...getInputProps()} accept={props.accept} />
<div className="flex flex-col items-center justify-center gap-3">
{!props.isError && (
<div className="flex flex-col items-center">
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
<UploadIcon className="size-5" />
</div>
<h2 className="mb-0.5 text-center font-medium text-lg">
{props.title}
</h2>
<p className="text-center font-medium text-muted-foreground text-sm">
{props.description}
</p>
</div>
)}
{props.isError && (
<div className="flex flex-col items-center">
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
<XIcon className="size-5" />
</div>
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
{props.title}
</h2>
<p className="text-balance text-center text-sm">
{props.description}
</p>
{resetButton && (
<Button
className="relative z-50 mt-4"
size="sm"
onClick={(e) => {
e.stopPropagation();
resetButton.onClick();
}}
>
{resetButton.label}
</Button>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import { MediaRenderer, type MediaRendererProps } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Skeleton } from "../ui/skeleton";
export function CustomMediaRenderer(props: MediaRendererProps) {
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (loadedSrc && loadedSrc !== props.src) {
setLoadedSrc(undefined);
}
}, [loadedSrc, props.src]);
return (
<div
className="relative"
onLoad={() => {
if (props.src) {
setLoadedSrc(props.src);
}
}}
>
{!loadedSrc && <Skeleton className="absolute inset-0" />}
<MediaRenderer
{...props}
className={cn(
props.className,
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative z-10",
)}
/>
</div>
);
}
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMemo, useState } from "react";
import { BadgeContainer } from "../../../stories/utils";
import { MultiSelect } from "./multi-select";
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { MultiStepStatus } from "./multi-step-status";
const meta = {
@@ -16,45 +16,34 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const AllStates: Story = {
args: {
onRetry: () => {},
steps: [
{
status: { type: "completed" },
label: "Connect Wallet",
execute: async () => {
await sleep(1000);
},
id: "connect-wallet",
},
{
status: { type: "pending" },
label: "Sign Message",
execute: async () => {
await sleep(1000);
},
id: "sign-message",
},
{
status: { type: "error", message: "This is an error message" },
label: "Approve Transaction",
execute: async () => {
await sleep(1000);
},
id: "approve-transaction",
},
{
status: { type: "idle" },
label: "Confirm Transaction",
execute: async () => {
await sleep(1000);
},
id: "confirm-transaction",
},
{
status: { type: "idle" },
label: "Finalize",
execute: async () => {
await sleep(1000);
},
id: "finalize",
},
],
},
@@ -10,21 +10,23 @@ import {
import { DynamicHeight } from "../../ui/DynamicHeight";
import { Spinner } from "../../ui/Spinner/Spinner";
export type MultiStepState = {
export type MultiStepState<T extends string> = {
id: T;
status:
| {
type: "idle" | "pending" | "completed";
}
| {
type: "error";
message: string | React.ReactNode;
message: React.ReactNode;
};
label: string;
execute: () => Promise<void>;
description?: string;
};
export function MultiStepStatus(props: {
steps: MultiStepState[];
export function MultiStepStatus<T extends string>(props: {
steps: MultiStepState<T>[];
onRetry: (step: MultiStepState<T>) => void;
}) {
return (
<DynamicHeight>
@@ -55,6 +57,15 @@ export function MultiStepStatus(props: {
{step.label}
</p>
{/* show description when this step is active */}
{(step.status.type === "pending" ||
step.status.type === "error") &&
step.description && (
<p className="text-muted-foreground text-sm">
{step.description}
</p>
)}
{step.status.type === "error" && (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
@@ -64,7 +75,7 @@ export function MultiStepStatus(props: {
variant="destructive"
size="sm"
className="gap-2"
onClick={() => step.execute()}
onClick={() => props.onRetry(step)}
>
<RefreshCwIcon className="size-4" />
Retry
@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useIsMobile } from "@/hooks/use-mobile";
import { BellIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { NotificationList } from "./notification-list";
import { useNotifications } from "./state/manager";
export function NotificationsButton(props: { accountId: string }) {
const manager = useNotifications(props.accountId);
const [open, setOpen] = useState(false);
const isMobile = useIsMobile();
const trigger = useMemo(
() => (
<Button variant="outline" size="icon" className="relative rounded-full">
<BellIcon className="h-4 w-4" />
{(manager.unreadNotificationsCount || 0) > 0 && (
<span className="absolute top-0 right-0 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
)}
</Button>
),
[manager.unreadNotificationsCount],
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[90vh] min-h-[66vh]">
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
<NotificationList {...manager} />
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="max-h-[90vh] min-h-[500px] w-[400px] max-w-md p-0"
align="end"
>
<NotificationList {...manager} />
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,82 @@
"use client";
import type { Notification } from "@/api/notifications";
import { Button } from "@/components/ui/button";
import {
format,
formatDistanceToNow,
isBefore,
parseISO,
subDays,
} from "date-fns";
import { ArchiveIcon } from "lucide-react";
import { useMemo } from "react";
interface NotificationEntryProps {
notification: Notification;
onMarkAsRead?: (id: string) => void;
}
export function NotificationEntry({
notification,
onMarkAsRead,
}: NotificationEntryProps) {
const timeAgo = useMemo(() => {
try {
const now = new Date();
const date = parseISO(notification.createdAt);
// if the date is older than 1 day, show the date
// otherwise, show the time ago
if (isBefore(date, subDays(now, 1))) {
return format(date, "MMM d, yyyy");
}
return formatDistanceToNow(date, {
addSuffix: true,
});
} catch (error) {
console.error("Failed to parse date", error);
return null;
}
}, [notification.createdAt]);
return (
<div className="flex flex-row py-1.5">
{onMarkAsRead && (
<div className="min-h-full w-1 shrink-0 rounded-r-lg bg-primary" />
)}
<div className="flex w-full flex-row justify-between gap-2 border-b px-4 py-2 transition-colors last:border-b-0">
<div className="flex items-start gap-3">
<div className="flex-1 space-y-1">
<p className="text-sm">{notification.description}</p>
{timeAgo && (
<p className="text-muted-foreground text-xs">{timeAgo}</p>
)}
<div className="flex flex-row justify-between gap-2 pt-1">
<Button asChild variant="link" size="sm" className="px-0">
<a
href={notification.ctaUrl}
target="_blank"
rel="noopener noreferrer"
>
{notification.ctaText}
</a>
</Button>
</div>
</div>
</div>
{onMarkAsRead && (
<Button
variant="ghost"
size="icon"
onClick={() => onMarkAsRead(notification.id)}
className="text-muted-foreground hover:text-foreground"
>
<ArchiveIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
import { TabButtons } from "@/components/ui/tabs";
import { ArchiveIcon, BellIcon, Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Badge } from "../../ui/badge";
import { NotificationEntry } from "./notification-entry";
import type { useNotifications } from "./state/manager";
export function NotificationList(props: ReturnType<typeof useNotifications>) {
// default to inbox if there are unread notifications, otherwise default to archive
const [activeTab, setActiveTab] = useState(
props.unreadNotificationsCount > 0
? "inbox"
: // if we have archived notifications, default to archive
props.archivedNotifications.length > 0
? "archive"
: // otherwise defualt to inbox (if there are no archived notifications either)
"inbox",
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<div className="flex w-full flex-1 flex-col">
<TabButtons
tabContainerClassName="space-x-6 px-4 pt-2"
tabClassName="!text-sm hover:!bg-transparent !px-0 !py-1"
tabs={[
{
name: (
<div className="flex items-center gap-1">
Inbox
{props.unreadNotificationsCount > 0 && (
<Badge variant="secondary">
{props.unreadNotificationsCount}
</Badge>
)}
</div>
),
onClick: () => setActiveTab("inbox"),
isActive: activeTab === "inbox",
},
{
name: "Archive",
onClick: () => setActiveTab("archive"),
isActive: activeTab === "archive",
},
]}
/>
<div
className="h-full w-full flex-1 overflow-y-auto"
ref={scrollContainerRef}
>
{activeTab === "inbox" ? (
<InboxTab
unreadNotifications={props.unreadNotifications}
isLoadingUnread={props.isLoadingUnread}
hasMoreUnread={props.hasMoreUnread}
isFetchingMoreUnread={props.isFetchingMoreUnread}
loadMoreUnread={props.loadMoreUnread}
markAsRead={props.markAsRead}
markAllAsRead={props.markAllAsRead}
scrollContainerRef={scrollContainerRef}
/>
) : (
<ArchiveTab
archivedNotifications={props.archivedNotifications}
isLoadingArchived={props.isLoadingArchived}
hasMoreArchived={props.hasMoreArchived}
isFetchingMoreArchived={props.isFetchingMoreArchived}
loadMoreArchived={props.loadMoreArchived}
scrollContainerRef={scrollContainerRef}
/>
)}
</div>
</div>
);
}
function InboxTab(
props: Pick<
ReturnType<typeof useNotifications>,
| "unreadNotifications"
| "isLoadingUnread"
| "hasMoreUnread"
| "isFetchingMoreUnread"
| "loadMoreUnread"
| "markAsRead"
| "markAllAsRead"
> & {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
},
) {
return props.unreadNotifications.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<BellIcon className="h-4 w-4 text-muted-foreground" />
</div>
<p className="text-muted-foreground text-sm">No new notifications</p>
</div>
) : (
<>
{props.unreadNotifications.map((notification) => (
<NotificationEntry
key={notification.id}
notification={notification}
onMarkAsRead={props.markAsRead}
/>
))}
<AutoLoadMore
hasMore={props.hasMoreUnread}
loadMore={props.loadMoreUnread}
isLoading={props.isFetchingMoreUnread || props.isLoadingUnread}
scrollContainerRef={props.scrollContainerRef}
/>
</>
);
}
function ArchiveTab(
props: Pick<
ReturnType<typeof useNotifications>,
| "archivedNotifications"
| "isLoadingArchived"
| "hasMoreArchived"
| "isFetchingMoreArchived"
| "loadMoreArchived"
> & {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
},
) {
return props.archivedNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<ArchiveIcon className="h-4 w-4 text-muted-foreground" />
</div>
<p className="text-muted-foreground text-sm">No archived notifications</p>
</div>
) : (
<>
{props.archivedNotifications.map((notification) => (
<NotificationEntry key={notification.id} notification={notification} />
))}
<AutoLoadMore
hasMore={props.hasMoreArchived}
loadMore={props.loadMoreArchived}
isLoading={props.isFetchingMoreArchived || props.isLoadingArchived}
scrollContainerRef={props.scrollContainerRef}
/>
</>
);
}
function AutoLoadMore(props: {
hasMore: boolean;
isLoading: boolean;
loadMore: () => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
}) {
const ref = useRef<HTMLDivElement>(null);
// if the element is scrolled into view, load more
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
// only run if the ref and scroll container ref are defined
if (ref.current && props.scrollContainerRef.current) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !props.isLoading && props.hasMore) {
props.loadMore();
observer.unobserve(entry.target); // prevent duplicate fires
}
}
},
{ root: props.scrollContainerRef.current, threshold: 0.1 },
);
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [
props.hasMore,
props.isLoading,
props.loadMore,
props.scrollContainerRef,
]);
if (!props.hasMore) return null;
if (props.isLoading) {
return (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2Icon className="h-4 w-4 animate-spin" />
</div>
);
}
return <div ref={ref} className="h-[1px] w-full opacity-0" aria-hidden />;
}
@@ -0,0 +1,223 @@
"use client";
import {
type Notification,
type NotificationsApiResponse,
getArchivedNotifications,
getUnreadNotifications,
getUnreadNotificationsCount,
markNotificationAsRead,
} from "@/api/notifications";
import {
type InfiniteData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMemo } from "react";
import { toast } from "sonner";
/**
* Internal helper to safely flatten pages coming from useInfiniteQuery.
*/
function flattenPages<T extends { result: Notification[] }>(
data?: InfiniteData<T>,
): Notification[] {
return data?.pages.flatMap((p) => p.result) ?? [];
}
/**
* React hook that provides notifications state with optimistic archiving.
*
* Example:
* ```tsx
* const {
* unreadNotifications,
* archivedNotifications,
* unreadCount,
* markAsRead,
* markAllAsRead,
* loadMoreUnread,
* loadMoreArchived,
* hasMoreUnread,
* hasMoreArchived,
* } = useNotifications();
* ```
*/
export function useNotifications(accountId: string) {
const queryClient = useQueryClient();
// --------------------
// Query definitions
// --------------------
const unreadQueryKey = useMemo(
() => ["notifications", "unread", { accountId }],
[accountId],
);
const archivedQueryKey = useMemo(
() => ["notifications", "archived", { accountId }],
[accountId],
);
const unreadCountKey = useMemo(
() => ["notifications", "unread-count", { accountId }],
[accountId],
);
const unreadQuery = useInfiniteQuery<NotificationsApiResponse>({
queryKey: unreadQueryKey,
queryFn: async ({ pageParam }) => {
const cursor = (pageParam ?? undefined) as string | undefined;
const res = await getUnreadNotifications(cursor);
if (res.status === "error") {
throw new Error(res.reason ?? "unknown");
}
return res.data;
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
enabled: !!accountId,
refetchInterval: 60_000, // 1min
});
const archivedQuery = useInfiniteQuery<NotificationsApiResponse>({
queryKey: archivedQueryKey,
queryFn: async ({ pageParam }) => {
const cursor = (pageParam ?? undefined) as string | undefined;
const res = await getArchivedNotifications(cursor);
if (res.status === "error") {
throw new Error(res.reason ?? "unknown");
}
return res.data;
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
enabled: !!accountId,
refetchInterval: 60_000, // 1min
});
const unreadCountQuery = useQuery({
queryKey: unreadCountKey,
queryFn: async () => {
const res = await getUnreadNotificationsCount();
if (res.status === "error") {
throw new Error(res.reason ?? "unknown");
}
return res.data.result.unreadCount;
},
refetchInterval: 60_000, // 1min
enabled: !!accountId,
});
// --------------------
// Mutation (archive)
// --------------------
const archiveMutation = useMutation({
mutationFn: async (notificationId?: string) => {
const res = await markNotificationAsRead(notificationId);
if (res.status === "error") {
toast.error("Failed to mark notification as read");
throw new Error(res.reason ?? "unknown");
}
return { notificationId } as const;
},
// Optimistic update
onMutate: async (notificationId) => {
await Promise.all([
queryClient.cancelQueries({ queryKey: unreadQueryKey }),
queryClient.cancelQueries({ queryKey: archivedQueryKey }),
]);
const previousUnread =
queryClient.getQueryData<InfiniteData<NotificationsApiResponse>>(
unreadQueryKey,
);
const previousCount = queryClient.getQueryData<number>(unreadCountKey);
let optimisticUnread: InfiniteData<NotificationsApiResponse> | undefined;
let optimisticCount: number | undefined;
if (previousUnread) {
if (notificationId) {
// Remove a single notification
optimisticUnread = {
...previousUnread,
pages: previousUnread.pages.map((page) => ({
...page,
result: page.result.filter((n) => n.id !== notificationId),
})),
};
if (typeof previousCount === "number") {
optimisticCount = Math.max(previousCount - 1, 0);
}
} else {
// Clear all unread notifications
optimisticUnread = {
...previousUnread,
pages: previousUnread.pages.map((page) => ({
...page,
result: [],
})),
};
optimisticCount = 0;
}
queryClient.setQueryData(unreadQueryKey, optimisticUnread);
}
if (typeof optimisticCount === "number") {
queryClient.setQueryData(unreadCountKey, optimisticCount);
}
return { previousUnread, previousCount } as const;
},
// Rollback on error
onError: (_err, _vars, context) => {
if (context?.previousUnread) {
queryClient.setQueryData(unreadQueryKey, context.previousUnread);
}
if (typeof context?.previousCount === "number") {
queryClient.setQueryData(unreadCountKey, context.previousCount);
}
},
// Always refetch to ensure consistency
onSettled: () => {
queryClient.invalidateQueries({ queryKey: unreadQueryKey });
queryClient.invalidateQueries({ queryKey: archivedQueryKey });
queryClient.invalidateQueries({ queryKey: unreadCountKey });
},
});
// --------------------
// Derived helpers
// --------------------
const unreadNotifications = flattenPages(unreadQuery.data);
const archivedNotifications = flattenPages(archivedQuery.data);
const unreadNotificationsCount = unreadCountQuery.data ?? 0; // this is the total unread count
return {
// data
unreadNotifications,
archivedNotifications,
unreadNotificationsCount,
// booleans
isLoadingUnread: unreadQuery.isLoading,
isLoadingArchived: archivedQuery.isLoading,
hasMoreUnread: unreadQuery.hasNextPage ?? false,
hasMoreArchived: archivedQuery.hasNextPage ?? false,
isFetchingMoreUnread: unreadQuery.isFetchingNextPage,
isFetchingMoreArchived: archivedQuery.isFetchingNextPage,
// pagination helpers
loadMoreUnread: () => unreadQuery.fetchNextPage(),
loadMoreArchived: () => archivedQuery.fetchNextPage(),
// mutations
markAsRead: (id: string) => archiveMutation.mutate(id),
markAllAsRead: () => archiveMutation.mutate(undefined),
} as const;
}
@@ -16,7 +16,6 @@ import { CheckoutButton } from "../billing";
type PricingCardCta = {
hint?: string;
onClick?: () => void;
} & (
| {
@@ -58,12 +57,17 @@ export const PricingCard: React.FC<PricingCardProps> = ({
activeTrialEndsAt,
}) => {
const plan = TEAM_PLANS[billingPlan];
const isCustomPrice = typeof plan.price === "string";
const trackEvent = useTrack();
const remainingTrialDays =
(activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0;
// if the team has just signed up and has not subscribed yet, and the billing plan is growth, then they get a 7 day trial
const has7DayTrial =
remainingTrialDays === 0 &&
billingStatus === "noPayment" &&
billingPlan === "growth";
const handleCTAClick = () => {
cta?.onClick?.();
trackEvent({
@@ -97,6 +101,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
{plan.title}
</h3>
{current && <Badge className="capitalize">Current plan</Badge>}
{has7DayTrial && <Badge variant="success">7 Day Free Trial</Badge>}
</div>
<p className="max-w-[320px] text-muted-foreground text-sm">
{plan.description}
@@ -108,15 +113,23 @@ export const PricingCard: React.FC<PricingCardProps> = ({
{plan.isStartingPriceOnly && (
<span className="text-muted-foreground text-xs">Starting at</span>
)}
<div className="flex items-center gap-2">
<span className="font-semibold text-2xl text-foreground tracking-tight">
${plan.price.toLocaleString()}
</span>
{!isCustomPrice && (
{has7DayTrial ? (
<div className="flex flex-col items-start gap-0">
<span className="font-bold text-2xl text-foreground tracking-tight">
7 days free
</span>
<span className="text-muted-foreground text-sm">
Then ${plan.price.toLocaleString()} / month
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-semibold text-2xl text-foreground tracking-tight">
${plan.price.toLocaleString()}
</span>
<span className="text-muted-foreground">/ month</span>
)}
</div>
</div>
)}
{remainingTrialDays > 0 && (
<p className="text-muted-foreground text-sm">
@@ -155,7 +168,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
teamSlug={teamSlug}
sku={billingPlanToSkuMap[billingPlan]}
>
{cta.label}
{has7DayTrial ? "Start 7 Day Free Trial" : cta.label}
</CheckoutButton>
)}
@@ -166,7 +179,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
asChild
>
<Link href={cta.href} target="_blank" onClick={handleCTAClick}>
{cta.label}
{has7DayTrial ? "Start 7 Day Free Trial" : cta.label}
</Link>
</Button>
)}
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMemo, useState } from "react";
import { BadgeContainer } from "../../../stories/utils";
import { SelectWithSearch } from "./select-with-search";
@@ -0,0 +1,142 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { TeamPlanBadge } from "../../../app/(app)/components/TeamPlanBadge";
import type { Team } from "../../api/team";
import { Badge } from "../ui/badge";
interface UpsellWrapperProps {
teamSlug: string;
children: React.ReactNode;
isLocked?: boolean;
requiredPlan: Team["billingPlan"];
currentPlan?: Team["billingPlan"];
featureName: string;
featureDescription: string;
benefits?: {
description: string;
status: "available" | "soon";
}[];
className?: string;
}
export function UpsellWrapper({
teamSlug,
children,
isLocked = true,
requiredPlan,
currentPlan = "free",
featureName,
featureDescription,
benefits = [],
className,
}: UpsellWrapperProps) {
if (!isLocked) {
return <>{children}</>;
}
return (
<div className={cn("relative flex-1", className)}>
{/* Background content - blurred and non-interactive */}
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none select-none opacity-60 blur-[1px]">
{children}
</div>
</div>
{/* Overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-b from-muted/20 via-muted/30 to-background" />
{/* Upsell content */}
<div className="relative z-10 flex items-center justify-center p-16">
<Card className="w-full max-w-2xl border-2 shadow-2xl">
<CardHeader className="space-y-4 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full border-2 bg-muted">
<LockIcon className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-2">
<TeamPlanBadge
plan="scale"
teamSlug={teamSlug}
postfix=" Feature"
/>
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
Unlock {featureName}
</CardTitle>
<CardDescription className="mx-auto max-w-md text-base text-muted-foreground">
{featureDescription}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-6">
{benefits.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
What you'll get:
</h4>
<div className="grid gap-2">
{benefits.map((benefit) => (
<div
key={benefit.description}
className="flex items-center gap-3"
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
<SparklesIcon className="h-3 w-3 text-success-text" />
</div>
<span className="text-sm">{benefit.description}</span>
{benefit.status === "soon" && (
<Badge variant="secondary" className="text-xs">
Coming Soon
</Badge>
)}
</div>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 pt-4 sm:flex-row">
<Button className="flex-1 py-3 font-semibold" size="lg" asChild>
<Link
href={`/team/${teamSlug}/~/settings/billing?showPlans=true&highlight=${requiredPlan}`}
>
<CrownIcon className="mr-2 h-4 w-4" />
Upgrade to{" "}
<span className="ml-1 capitalize">{requiredPlan}</span>
</Link>
</Button>
<Button variant="outline" size="lg" className="md:flex-1" asChild>
<Link
href={`/team/${teamSlug}/~/settings/billing?showPlans=true`}
>
View All Plans
</Link>
</Button>
</div>
<div className="pt-2 text-center">
<p className="text-muted-foreground text-xs">
You are currently on the{" "}
<span className="font-medium capitalize">{currentPlan}</span>{" "}
plan.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -24,6 +24,7 @@ export function WalletAddress(props: {
className?: string;
iconClassName?: string;
client: ThirdwebClient;
preventOpenOnFocus?: boolean;
}) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
@@ -64,7 +65,10 @@ export function WalletAddress(props: {
return (
<HoverCard>
<HoverCardTrigger asChild>
<HoverCardTrigger
asChild
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
>
<Button
onClick={(e) => e.stopPropagation()}
variant="link"
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { BadgeContainer } from "stories/utils";
import { PaginationButtons } from "./pagination-buttons";
@@ -19,6 +19,7 @@ export const PaginationButtons = (props: {
activePage: number;
totalPages: number;
onPageClick: (page: number) => void;
className?: string;
}) => {
const { activePage, totalPages, onPageClick: setPage } = props;
const [inputHasError, setInputHasError] = useState(false);
@@ -40,11 +41,37 @@ export const PaginationButtons = (props: {
}
}
// if only two pages, show "prev" and "next"
if (totalPages === 2) {
return (
<Pagination className={props.className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={activePage === 1}
onClick={() => {
setPage(activePage - 1);
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
disabled={activePage === totalPages}
onClick={() => {
setPage(activePage + 1);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
// just render all the page buttons directly
if (totalPages <= 6) {
const pages = [...Array(totalPages)].map((_, i) => i + 1);
return (
<Pagination>
<Pagination className={props.className}>
<PaginationContent>
{pages.map((page) => (
<PaginationItem key={page}>
@@ -64,7 +91,7 @@ export const PaginationButtons = (props: {
}
return (
<Pagination>
<Pagination className={props.className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { StarIcon } from "lucide-react";
import { BadgeContainer } from "../../../stories/utils";
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "stories/utils";
import { CodeClient } from "./code.client";
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "stories/utils";
import { PlainTextCodeBlock } from "./plaintext-code";
@@ -5,6 +5,8 @@ export function DecimalInput(props: {
maxValue?: number;
id?: string;
className?: string;
placeholder?: string;
disabled?: boolean;
}) {
return (
<Input
@@ -13,6 +15,8 @@ export function DecimalInput(props: {
value={props.value}
className={props.className}
inputMode="decimal"
placeholder={props.placeholder}
disabled={props.disabled}
onChange={(e) => {
const number = Number(e.target.value);
// ignore if string becomes invalid number
@@ -0,0 +1,118 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"font-semibold text-lg leading-none tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -7,7 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { BadgeContainer } from "../../../stories/utils";
const meta = {
@@ -8,7 +8,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import Link from "next/link";
import { BadgeContainer } from "../../../stories/utils";
import { cn } from "../../lib/utils";
@@ -1,7 +1,11 @@
import "server-only";
import { experimental_taintUniqueValue } from "react";
import { experimental_taintUniqueValue as _experimental_taintUniqueValue } from "react";
import { isProd } from "./env-utils";
// on storybook, experimental_taintUniqueValue is not available, because its not available in the react version we are using - its added by next.js
const experimental_taintUniqueValue =
_experimental_taintUniqueValue || (() => {});
// Make sure to taint the server only envs here with experimental_taintUniqueValue ONLY if they contain a UNIQUE sensitive value
// if an env has a generic value that may appear naturally in client components - do not taint it
+4
View File
@@ -0,0 +1,4 @@
export function fileToBlobUrl(file: File) {
const blob = new Blob([file], { type: file.type });
return URL.createObjectURL(blob);
}
@@ -70,15 +70,25 @@ export type BackendWallet = {
export function useEngineBackendWallets(params: {
instanceUrl: string;
authToken: string;
limit?: number;
page?: number;
}) {
const { instanceUrl, authToken } = params;
return useQuery({
queryKey: [...engineKeys.backendWallets(instanceUrl), authToken],
queryKey: [
...engineKeys.backendWallets(instanceUrl),
authToken,
params.limit,
params.page,
],
queryFn: async () => {
const res = await fetch(`${instanceUrl}backend-wallet/get-all?limit=50`, {
method: "GET",
headers: getEngineRequestHeaders(authToken),
});
const res = await fetch(
`${instanceUrl}backend-wallet/get-all?limit=${params.limit ?? 1000}&page=${params.page ?? 1}`,
{
method: "GET",
headers: getEngineRequestHeaders(authToken),
},
);
const json = await res.json();
@@ -1,7 +1,7 @@
import { getAuthToken } from "@app/api/lib/getAuthToken";
import { ArrowUpRightIcon } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { SearchInput } from "./components/client/search";
import { QueryType } from "./components/client/type";
import { RouteListView } from "./components/client/view";
@@ -49,6 +49,7 @@ import {
useSwitchActiveWalletChain,
useWalletBalance,
} from "thirdweb/react";
import { parseError } from "utils/errorParser";
import { z } from "zod";
function formatTime(seconds: number) {
@@ -234,7 +235,12 @@ export function FaucetButton({
const claimPromise = claimMutation.mutateAsync(values.turnstileToken);
toast.promise(claimPromise, {
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
error: (err) => {
return {
message: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
description: parseError(err),
};
},
});
};
@@ -42,7 +42,8 @@ export function PayModalButton(props: {
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
className="!w-auto"
payOptions={{
onPurchaseSuccess(info) {
// biome-ignore lint/suspicious/noExplicitAny: false positive
onPurchaseSuccess(info: any) {
if (
info.type === "crypto" &&
info.status.status !== "NOT_FOUND"
@@ -14,16 +14,16 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "@app/api/lib/getAuthToken";
import { ChevronDownIcon, TicketCheckIcon } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { mapV4ChainToV5Chain } from "../../../../../../contexts/map-chains";
import { NebulaChatButton } from "../../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../../api/lib/getAuthToken";
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
import { useCsvUpload } from "hooks/useCsvUpload";
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
import { useRef } from "react";
import { useDropzone } from "react-dropzone";
import type { Column } from "react-table";
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { CsvDataTable } from "../csv-data-table";
@@ -52,23 +53,18 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
onClose,
client,
}) => {
const {
normalizeQuery,
getInputProps,
getRootProps,
isDragActive,
rawData,
noCsv,
reset,
removeInvalid,
} = useCsvUpload<SnapshotAddressInput>({
const csvUpload = useCsvUpload<SnapshotAddressInput>({
csvParser,
defaultRawData: value,
client,
});
const dropzone = useDropzone({
onDrop: csvUpload.setFiles,
});
const paginationPortalRef = useRef<HTMLDivElement>(null);
const normalizeData = normalizeQuery.data;
const normalizeData = csvUpload.normalizeQuery.data;
if (!normalizeData) {
return (
@@ -152,11 +148,11 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
return (
<div className="flex flex-col gap-6">
{rawData.length > 0 ? (
{csvUpload.rawData.length > 0 ? (
<div>
<CsvDataTable<SnapshotAddressInput>
portalRef={paginationPortalRef}
data={normalizeQuery.data.result}
data={csvUpload.normalizeQuery.data.result}
columns={columns}
/>
</div>
@@ -166,23 +162,27 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
noCsv ? "bg-red-200" : "bg-background",
csvUpload.noCsv ? "bg-red-200" : "bg-background",
)}
{...getRootProps()}
{...dropzone.getRootProps()}
>
<input {...getInputProps()} />
<input {...dropzone.getInputProps()} />
<div className="!m-auto flex flex-col">
<UploadIcon
className={cn(
"mx-auto mb-2 size-8",
noCsv ? "text-red-500" : "text-gray-600",
csvUpload.noCsv ? "text-red-500" : "text-gray-600",
)}
/>
{isDragActive ? (
{dropzone.isDragActive ? (
<p>Drop the files here</p>
) : (
<p className={noCsv ? "text-red-500" : "text-gray-600"}>
{noCsv
<p
className={
csvUpload.noCsv ? "text-red-500" : "text-gray-600"
}
>
{csvUpload.noCsv
? `No valid CSV file found, make sure your CSV includes the "address" column.`
: "Drag & Drop a CSV file here"}
</p>
@@ -294,20 +294,20 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
<Button
className="w-full rounded-md md:w-auto"
disabled={isDisabled || rawData.length === 0}
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
reset();
csvUpload.reset();
}}
>
Reset
</Button>
{normalizeQuery.data?.invalidFound ? (
{csvUpload.normalizeQuery.data?.invalidFound ? (
<Button
className="w-full rounded-md md:w-auto"
variant="primary"
disabled={isDisabled || rawData.length === 0}
disabled={isDisabled || csvUpload.rawData.length === 0}
onClick={() => {
removeInvalid();
csvUpload.removeInvalid();
}}
>
Remove invalid
@@ -317,7 +317,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
className="w-full rounded-md md:w-auto"
variant="primary"
onClick={onSave}
disabled={isDisabled || rawData.length === 0}
disabled={isDisabled || csvUpload.rawData.length === 0}
>
Next
</Button>
@@ -1,8 +1,10 @@
import { resolveFunctionSelectors } from "lib/selectors";
import type { ThirdwebContract } from "thirdweb";
import { isERC20 } from "thirdweb/extensions/erc20";
import { isGetNFTsSupported as ERC721_isGetNFTsSupported } from "thirdweb/extensions/erc721";
import { isGetNFTsSupported as ERC1155_isGetNFTsSupported } from "thirdweb/extensions/erc1155";
import { supportedERCs } from "./detectedFeatures/supportedERCs";
export type NewPublicPageType = "erc20";
export type NewPublicPageType = "erc20" | "erc1155" | "erc721";
export async function shouldRenderNewPublicPage(
contract: ThirdwebContract,
@@ -16,9 +18,29 @@ export async function shouldRenderNewPublicPage(
return false;
}
const isERC20Contract = isERC20(functionSelectors);
const _supportedERCs = supportedERCs(functionSelectors);
if (isERC20Contract) {
// ERC1155
if (
_supportedERCs.isERC1155 &&
ERC1155_isGetNFTsSupported(functionSelectors)
) {
return {
type: "erc1155",
};
}
// ERC721
if (
_supportedERCs.isERC721 &&
ERC721_isGetNFTsSupported(functionSelectors)
) {
return {
type: "erc721",
};
}
if (_supportedERCs.isERC20) {
return {
type: "erc20",
};
@@ -1,7 +1,7 @@
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Flex, SimpleGrid, useBreakpointValue } from "@chakra-ui/react";
import { formatDistance } from "date-fns/formatDistance";
import { formatDistance } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import type { ThirdwebClient } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
@@ -19,7 +19,7 @@ import {
useTotalContractTransactionAnalytics,
useTotalContractUniqueWallets,
} from "data/analytics/hooks";
import { formatDate } from "date-fns";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import type { ThirdwebContract } from "thirdweb";
@@ -125,7 +125,7 @@ type ChartProps = {
function toolTipLabelFormatter(_v: string, item: unknown) {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}
@@ -1,5 +1,5 @@
import { getAuthToken } from "@app/api/lib/getAuthToken";
import type { Metadata } from "next";
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import {
SharedContractLayout,
generateContractLayoutMetadata,
@@ -199,7 +199,11 @@ function UploadMetadataNFTSection(props: {
<div className="flex flex-col gap-6 lg:flex-row">
{/* Left */}
<div className="shrink-0 lg:w-[300px]">
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
<NFTMediaFormGroup
form={form}
previewMaxWidth="300px"
client={props.client}
/>
</div>
{/* Right */}
@@ -397,7 +397,11 @@ function MintNFTSection(props: {
<div className="flex flex-col gap-6 lg:flex-row">
{/* Left */}
<div className="shrink-0 lg:w-[300px]">
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
<NFTMediaFormGroup
form={form}
previewMaxWidth="300px"
client={props.client}
/>
</div>
{/* Right */}
@@ -1,5 +1,5 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import type { TransactionError } from "contexts/error-handler";
import { useState } from "react";
@@ -6,7 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import { subDays } from "date-fns";
import { useState } from "react";
@@ -7,7 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
@@ -1,5 +1,5 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { BadgeContainer, storybookThirdwebClient } from "stories/utils";
@@ -1,89 +1,37 @@
"use client";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { FileInput } from "components/shared/FileInput";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import type { UseFormReturn } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import type { NFTInput } from "thirdweb/utils";
import {
getUploadedNFTMediaMeta,
handleNFTMediaUpload,
} from "./handleNFTMediaUpload";
type NFTMediaFormGroupValues = {
image?: NFTInput["image"];
animation_url?: NFTInput["animation_url"];
external_url?: NFTInput["external_url"];
};
export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
form: UseFormReturn<T>;
previewMaxWidth?: string;
client: ThirdwebClient;
}) {
// T contains all properties of NFTMediaFormGroupValues, so this is a-ok
const form = props.form as unknown as UseFormReturn<NFTMediaFormGroupValues>;
const setFile = (file: File) => {
const external_url = form.watch("external_url");
const animation_url = form.watch("animation_url");
if (file.type.includes("image")) {
form.setValue("image", file);
if (external_url instanceof File) {
form.setValue("external_url", undefined);
}
if (animation_url instanceof File) {
form.setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/*"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
form.setValue("animation_url", file);
if (external_url instanceof File) {
form.setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
form.setValue("external_url", file);
if (animation_url instanceof File) {
form.setValue("animation_url", undefined);
}
}
};
const external_url = form.watch("external_url");
const animation_url = form.watch("animation_url");
const image = form.watch("image");
const errors = form.formState.errors;
const imageUrl = useImageFileOrUrl(image as File | string);
const showCoverImageUpload =
animation_url instanceof File || external_url instanceof File;
const mediaFileUrl =
animation_url instanceof File
? animation_url
: external_url instanceof File
? external_url
: image instanceof File
? imageUrl
: undefined;
const mediaFileError =
animation_url instanceof File
? errors?.animation_url
: external_url instanceof File
? errors?.external_url
: image instanceof File
? errors?.image
: undefined;
const { media, image, mediaFileError, showCoverImageUpload } =
getUploadedNFTMediaMeta(form);
const previewMaxWidth = props.previewMaxWidth ?? "200px";
const setFile = (file: File) => {
handleNFTMediaUpload({ file, form });
};
return (
<div className="flex flex-col gap-6">
<FormFieldSetup
@@ -97,11 +45,12 @@ export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
</p>
<FileInput
previewMaxWidth={previewMaxWidth}
value={mediaFileUrl as File | string}
value={media}
showPreview={true}
setValue={setFile}
className="max-sm:!max-w-full rounded border border-border transition-all duration-200"
selectOrUpload="Upload"
client={props.client}
helperText="Media"
/>
</FormFieldSetup>
@@ -112,12 +61,13 @@ export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
htmlFor="cover-image"
label="Cover Image"
tooltip="You can optionally upload an image as the cover of your NFT."
isRequired
isRequired={false}
>
<FileInput
previewMaxWidth={previewMaxWidth}
client={props.client}
accept={{ "image/*": [] }}
value={imageUrl}
value={image}
showUploadButton
setValue={(file) => {
form.setValue("image", file);
@@ -0,0 +1,85 @@
import type { UseFormReturn } from "react-hook-form";
import type { NFTInput } from "thirdweb/utils";
type MinimalNFTMetadata = {
image?: NFTInput["image"];
animation_url?: NFTInput["animation_url"];
external_url?: NFTInput["external_url"];
};
export function handleNFTMediaUpload<T extends MinimalNFTMetadata>(params: {
file: File;
form: UseFormReturn<T>;
}) {
const { file } = params;
const form = params.form as unknown as UseFormReturn<MinimalNFTMetadata>;
const external_url = form.getValues("external_url");
const animation_url = form.getValues("animation_url");
if (file.type.includes("image")) {
form.setValue("image", file);
if (external_url instanceof File) {
form.setValue("external_url", undefined);
}
if (animation_url instanceof File) {
form.setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
form.setValue("animation_url", file);
if (external_url instanceof File) {
form.setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
form.setValue("external_url", file);
if (animation_url instanceof File) {
form.setValue("animation_url", undefined);
}
}
}
export function getUploadedNFTMediaMeta<T extends MinimalNFTMetadata>(
_form: UseFormReturn<T>,
) {
const form = _form as unknown as UseFormReturn<MinimalNFTMetadata>;
const _animation_url = form.watch("animation_url");
const _external_url = form.watch("external_url");
const _image = form.watch("image");
const _media = _animation_url || _external_url || _image;
const errors = form.formState.errors;
return {
media: stringOrFile(_media),
image: stringOrFile(_image),
mediaFileError:
errors?.animation_url || errors.external_url || errors?.image,
showCoverImageUpload: !!_animation_url || !!_external_url,
animation_url: stringOrFile(_animation_url),
external_url: stringOrFile(_external_url),
};
}
function stringOrFile(value: unknown): string | File | undefined {
if (typeof value === "string" || value instanceof File) {
return value;
}
return undefined;
}
@@ -1,5 +1,5 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
@@ -1,5 +1,5 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
@@ -1,5 +1,5 @@
import { Checkbox } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
@@ -10,6 +10,7 @@ import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
import { claimTo } from "thirdweb/extensions/erc1155";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
import { parseError } from "utils/errorParser";
interface ClaimTabProps {
contract: ThirdwebContract;
@@ -67,7 +68,12 @@ const ClaimTabERC1155: React.FC<ClaimTabProps> = ({
toast.promise(promise, {
loading: "Claiming NFT",
success: "NFT claimed successfully",
error: "Failed to claim NFT",
error: (error) => {
return {
message: "Failed to claim NFT",
description: parseError(error),
};
},
});
trackEvent({
category: "nft",
@@ -15,7 +15,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import { type Dispatch, type SetStateAction, useMemo } from "react";
import { useForm } from "react-hook-form";
@@ -38,9 +37,12 @@ import {
FormLabel,
Heading,
} from "tw-components";
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
import {
getUploadedNFTMediaMeta,
handleNFTMediaUpload,
} from "../../../modules/components/nft/handleNFTMediaUpload";
const UPDATE_METADATA_FORM_ID = "nft-update-metadata-form";
@@ -83,6 +85,11 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
return nftMetadata;
}, [nft]);
const form = useForm<NFTMetadataInputLimited>({
defaultValues: transformedQueryData,
values: transformedQueryData,
});
const {
setValue,
control,
@@ -90,78 +97,20 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
watch,
handleSubmit,
formState: { errors, isDirty },
} = useForm<NFTMetadataInputLimited>({
defaultValues: transformedQueryData,
values: transformedQueryData,
});
} = form;
const setFile = (file: File) => {
if (file.type.includes("image")) {
// image files
setValue("image", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/*"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
setValue("animation_url", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
setValue("external_url", file);
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
}
handleNFTMediaUpload({ file, form });
};
const {
media,
image,
mediaFileError,
showCoverImageUpload,
animation_url,
external_url,
} = getUploadedNFTMediaMeta(form);
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
const animationUrlFormValue = watch("animation_url");
const imageUrlFormValue = watch("image");
const mediaFileUrl =
watch("animation_url") instanceof File
? watch("animation_url")
: watch("external_url") instanceof File
? watch("external_url")
: watch("image") instanceof File
? imageUrl
: undefined;
const mediaFileError =
watch("animation_url") instanceof File
? errors?.animation_url
: watch("external_url") instanceof File
? errors?.external_url
: watch("image") instanceof File
? errors?.image
: undefined;
const externalUrl = watch("external_url");
const externalIsTextFile =
externalUrl instanceof File &&
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
const showCoverImageUpload =
watch("animation_url") instanceof File ||
watch("external_url") instanceof File;
const sendAndConfirmTx = useSendAndConfirmTransaction();
const updateMetadataNotifications = useTxNotifications(
"NFT metadata updated successfully",
@@ -247,37 +196,20 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
<Input autoFocus {...register("name")} />
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!mediaFileError}>
<FormLabel>Media</FormLabel>
<div className="flex flex-row flex-wrap gap-3">
{nft?.metadata && !mediaFileUrl && (
<NFTMediaWithEmptyState
client={contract.client}
metadata={{
name: nft.metadata.name,
animation_url:
typeof animationUrlFormValue === "string"
? animationUrlFormValue
: nft.metadata.animation_url,
image:
typeof imageUrlFormValue === "string"
? imageUrlFormValue
: nft.metadata.image,
}}
width="200px"
height="200px"
/>
)}
<FileInput
previewMaxWidth="200px"
value={mediaFileUrl as File | string}
value={media}
showUploadButton
showPreview={nft?.metadata ? !!mediaFileUrl : true}
showPreview
setValue={setFile}
className="shrink-0 rounded border border-border transition-all duration-200"
selectOrUpload="Upload"
helperText={nft?.metadata ? "New Media" : "Media"}
client={contract.client}
/>
</div>
@@ -289,13 +221,15 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
{mediaFileError?.message as unknown as string}
</FormErrorMessage>
</FormControl>
{showCoverImageUpload && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
previewMaxWidth="200px"
client={contract.client}
accept={{ "image/*": [] }}
value={imageUrl}
value={image}
showUploadButton
setValue={(file) => setValue("image", file)}
className="rounded border border-border transition-all"
@@ -320,6 +254,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
control={control}
register={register}
setValue={setValue}
client={contract.client}
/>
<Accordion
@@ -344,7 +279,8 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
{errors?.background_color?.message}
</FormErrorMessage>
</FormControl>
{!externalIsTextFile && (
{!(external_url instanceof File) && (
<FormControl isInvalid={!!errors.external_url}>
<FormLabel>
External URL <OpenSeaPropertyBadge />
@@ -360,42 +296,43 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
</FormErrorMessage>
</FormControl>
)}
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
value={
typeof imageUrlFormValue === "string" ? imageUrlFormValue : ""
}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded to a URL, you
can specify it here instead of uploading the media file
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
value={
typeof animationUrlFormValue === "string"
? animationUrlFormValue
: ""
}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded to a
URL, you can specify it here instead of uploading the media file
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
value={typeof image === "string" ? image : ""}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded to a URL, you
can specify it here instead of uploading the media file
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
)}
{!(animation_url instanceof File) && (
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
value={typeof animation_url === "string" ? animation_url : ""}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded to a
URL, you can specify it here instead of uploading the media
file
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
@@ -415,7 +352,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
isPending={sendAndConfirmTx.isPending}
form={UPDATE_METADATA_FORM_ID}
type="submit"
disabled={!isDirty && imageUrl === nft?.metadata.image}
disabled={!isDirty}
>
Update NFT
</TransactionButton>
@@ -5,6 +5,8 @@ import { redirectToContractLandingPage } from "../../../../../../team/[team_slug
import { buildContractPagePath } from "../../_utils/contract-page-path";
import { getContractPageParamsInfo } from "../../_utils/getContractFromParams";
import { getContractPageMetadata } from "../../_utils/getContractPageMetadata";
import { shouldRenderNewPublicPage } from "../../_utils/newPublicPage";
import { NFTPublicPage } from "../../public-pages/nft/nft-page";
import { TokenIdPageClient } from "./TokenIdPage.client";
import { TokenIdPage } from "./token-id";
@@ -40,6 +42,22 @@ export async function SharedNFTTokenPage(props: {
);
}
// public /nfts/[tokenId] page
if (!props.projectMeta) {
const meta = await shouldRenderNewPublicPage(info.serverContract);
if (meta && (meta.type === "erc721" || meta.type === "erc1155")) {
return (
<NFTPublicPage
serverContract={info.serverContract}
clientContract={info.clientContract}
chainMetadata={info.chainMetadata}
tokenId={props.tokenId}
type={meta.type}
/>
);
}
}
const { clientContract, serverContract, isLocalhostChain } = info;
if (isLocalhostChain) {
return (
@@ -17,7 +17,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -34,6 +33,10 @@ import {
} from "tw-components";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
import {
getUploadedNFTMediaMeta,
handleNFTMediaUpload,
} from "../../modules/components/nft/handleNFTMediaUpload";
const LAZY_MINT_FORM_ID = "nft-lazy-mint-form";
@@ -54,6 +57,8 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
const address = useActiveAccount()?.address;
const sendAndConfirmTx = useSendAndConfirmTransaction();
const form = useForm<NFTMetadataInputLimited>();
const {
setValue,
control,
@@ -61,75 +66,20 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
watch,
handleSubmit,
formState: { errors, isDirty },
} = useForm<NFTMetadataInputLimited>();
} = form;
const setFile = (file: File) => {
if (file.type.includes("image")) {
// image files
setValue("image", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/*"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
setValue("animation_url", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
setValue("external_url", file);
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
}
handleNFTMediaUpload({ file, form });
};
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
const animationUrlFormValue = watch("animation_url");
const imageUrlFormValue = watch("image");
const mediaFileUrl =
watch("animation_url") instanceof File
? watch("animation_url")
: watch("external_url") instanceof File
? watch("external_url")
: watch("image") instanceof File
? imageUrl
: undefined;
const mediaFileError =
watch("animation_url") instanceof File
? errors?.animation_url
: watch("external_url") instanceof File
? errors?.external_url
: watch("image") instanceof File
? errors?.image
: undefined;
const externalUrl = watch("external_url");
const externalIsTextFile =
externalUrl instanceof File &&
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
const showCoverImageUpload =
watch("animation_url") instanceof File ||
watch("external_url") instanceof File;
const {
media,
image,
mediaFileError,
showCoverImageUpload,
animation_url,
external_url,
} = getUploadedNFTMediaMeta(form);
const lazyMintNotifications = useTxNotifications(
"NFT lazy minted successfully",
@@ -184,7 +134,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
})}
>
<div className="flex flex-col gap-2">
<Heading size="subtitle.md">Metadata</Heading>
<Heading size="subtitle.md">Metadata </Heading>
<Divider />
</div>
<FormControl isRequired isInvalid={!!errors.name}>
@@ -197,13 +147,14 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
<div>
<FileInput
previewMaxWidth="200px"
value={mediaFileUrl as File | string}
value={media}
showUploadButton
showPreview={true}
setValue={setFile}
className="rounded border border-border transition-all duration-200"
selectOrUpload="Upload"
helperText="Media"
client={contract.client}
/>
</div>
<FormHelperText>
@@ -219,8 +170,9 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
<FormLabel>Cover Image</FormLabel>
<FileInput
previewMaxWidth="200px"
client={contract.client}
accept={{ "image/*": [] }}
value={imageUrl}
value={image}
showUploadButton
setValue={(file) => setValue("image", file)}
className="rounded border border-border transition-all"
@@ -245,6 +197,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
control={control}
register={register}
setValue={setValue}
client={contract.client}
/>
<Accordion
allowToggle={!(errors.background_color || errors.external_url)}
@@ -270,7 +223,8 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
{errors?.background_color?.message}
</FormErrorMessage>
</FormControl>
{!externalIsTextFile && (
{!(external_url instanceof File) && (
<FormControl isInvalid={!!errors.external_url}>
<FormLabel>
External URL <OpenSeaPropertyBadge />
@@ -286,47 +240,45 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
</FormErrorMessage>
</FormControl>
)}
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
{...register("image")}
value={
typeof imageUrlFormValue === "string"
? imageUrlFormValue
: ""
}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded to a URL, you
can specify it here instead of uploading the media file
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
{...register("animation_url")}
value={
typeof animationUrlFormValue === "string"
? animationUrlFormValue
: ""
}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded to a
URL, you can specify it here instead of uploading the media
file
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
{...register("image")}
value={image}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded to a URL,
you can specify it here instead of uploading the media file
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
)}
{!(animation_url instanceof File) && (
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
{...register("animation_url")}
value={animation_url}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded to a
URL, you can specify it here instead of uploading the media
file
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
@@ -16,7 +16,6 @@ import { TransactionButton } from "components/buttons/TransactionButton";
import { PropertiesFormControl } from "components/contract-pages/forms/properties.shared";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -34,6 +33,10 @@ import {
} from "tw-components";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
import {
getUploadedNFTMediaMeta,
handleNFTMediaUpload,
} from "../../modules/components/nft/handleNFTMediaUpload";
const MINT_FORM_ID = "nft-mint-form";
@@ -52,6 +55,12 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
}) => {
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
const form = useForm<
NFTMetadataInputLimited & {
supply: number;
}
>();
const {
setValue,
control,
@@ -59,78 +68,20 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
watch,
handleSubmit,
formState: { errors, isDirty },
} = useForm<
NFTMetadataInputLimited & {
supply: number;
}
>();
} = form;
const setFile = (file: File) => {
if (file.type.includes("image")) {
// image files
setValue("image", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/*"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
setValue("animation_url", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
setValue("external_url", file);
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
}
handleNFTMediaUpload({ file, form });
};
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
const animationUrlFormValue = watch("animation_url");
const imageUrlFormValue = watch("image");
const mediaFileUrl =
watch("animation_url") instanceof File
? watch("animation_url")
: watch("external_url") instanceof File
? watch("external_url")
: watch("image") instanceof File
? imageUrl
: undefined;
const mediaFileError =
watch("animation_url") instanceof File
? errors?.animation_url
: watch("external_url") instanceof File
? errors?.external_url
: watch("image") instanceof File
? errors?.image
: undefined;
const externalUrl = watch("external_url");
const externalIsTextFile =
externalUrl instanceof File &&
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
const showCoverImageUpload =
watch("animation_url") instanceof File ||
watch("external_url") instanceof File;
const {
media,
image,
mediaFileError,
showCoverImageUpload,
animation_url,
external_url,
} = getUploadedNFTMediaMeta(form);
const sendAndConfirmTx = useSendAndConfirmTransaction();
const nftMintNotifications = useTxNotifications(
@@ -198,7 +149,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
})}
>
<div className="flex flex-col gap-2">
<Heading size="subtitle.md">Metadata</Heading>
<Heading size="subtitle.md">Metadata xx</Heading>
<Divider />
</div>
<FormControl isRequired isInvalid={!!errors.name}>
@@ -211,7 +162,8 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
<div>
<FileInput
previewMaxWidth="200px"
value={mediaFileUrl as File | string}
client={contract.client}
value={media}
showUploadButton
showPreview={true}
setValue={setFile}
@@ -232,9 +184,10 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
client={contract.client}
previewMaxWidth="200px"
accept={{ "image/*": [] }}
value={imageUrl}
value={image}
showUploadButton
setValue={(file) => setValue("image", file)}
className="shrink-0 rounded border border-border transition-all"
@@ -266,6 +219,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
)}
<PropertiesFormControl
watch={watch}
client={contract.client}
// biome-ignore lint/suspicious/noExplicitAny: FIXME
errors={errors as any}
control={control}
@@ -283,7 +237,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="flex flex-col gap-6 px-0">
<AccordionPanel className="!px-0 flex flex-col gap-6">
<FormControl isInvalid={!!errors.background_color}>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
@@ -296,7 +250,8 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
{errors?.background_color?.message}
</FormErrorMessage>
</FormControl>
{!externalIsTextFile && (
{!(external_url instanceof File) && (
<FormControl isInvalid={!!errors.external_url}>
<FormLabel>
External URL <OpenSeaPropertyBadge />
@@ -312,44 +267,42 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
</FormErrorMessage>
</FormControl>
)}
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
value={
typeof imageUrlFormValue === "string"
? imageUrlFormValue
: ""
}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded to a URL, you
can specify it here instead of uploading the asset
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
value={
typeof animationUrlFormValue === "string"
? animationUrlFormValue
: ""
}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded to a
URL, you can specify it here instead of uploading the asset
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
value={image}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded to a URL,
you can specify it here instead of uploading the asset
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
)}
{!(animation_url instanceof File) && (
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
value={animation_url}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded to a
URL, you can specify it here instead of uploading the asset
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
@@ -14,7 +14,6 @@ import {
import { TransactionButton } from "components/buttons/TransactionButton";
import { FileInput } from "components/shared/FileInput";
import { useTrack } from "hooks/analytics/useTrack";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
@@ -31,6 +30,10 @@ import {
} from "tw-components";
import type { NFTMetadataInputLimited } from "types/modified-types";
import { parseAttributes } from "utils/parseAttributes";
import {
getUploadedNFTMediaMeta,
handleNFTMediaUpload,
} from "../../modules/components/nft/handleNFTMediaUpload";
const SHARED_METADATA_FORM_ID = "shared-metadata-form";
@@ -42,75 +45,20 @@ export const SharedMetadataForm: React.FC<{
const trackEvent = useTrack();
const address = useActiveAccount()?.address;
const sendAndConfirmTx = useSendAndConfirmTransaction();
const form = useForm<NFTMetadataInputLimited>();
const {
setValue,
register,
watch,
handleSubmit,
formState: { errors, isDirty },
} = useForm<NFTMetadataInputLimited>();
} = form;
const setFile = (file: File) => {
if (file.type.includes("image")) {
// image files
setValue("image", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
} else if (
["audio", "video", "text/html", "model/*"].some((type: string) =>
file.type.includes(type),
) ||
file.name?.endsWith(".glb") ||
file.name?.endsWith(".usdz") ||
file.name?.endsWith(".gltf") ||
file.name.endsWith(".obj")
) {
// audio, video, html, and glb (3d) files
setValue("animation_url", file);
if (watch("external_url") instanceof File) {
setValue("external_url", undefined);
}
} else if (
["text", "application/pdf"].some((type: string) =>
file.type?.includes(type),
)
) {
// text and pdf files
setValue("external_url", file);
if (watch("animation_url") instanceof File) {
setValue("animation_url", undefined);
}
}
handleNFTMediaUpload({ file, form });
};
const imageUrl = useImageFileOrUrl(watch("image") as File | string);
const animationUrlFormValue = watch("animation_url");
const imageUrlFormValue = watch("image");
const mediaFileUrl =
watch("animation_url") instanceof File
? watch("animation_url")
: watch("external_url") instanceof File
? watch("external_url")
: watch("image") instanceof File
? imageUrl
: undefined;
const mediaFileError =
watch("animation_url") instanceof File
? errors?.animation_url
: watch("external_url") instanceof File
? errors?.external_url
: watch("image") instanceof File
? errors?.image
: undefined;
const showCoverImageUpload =
watch("animation_url") instanceof File ||
watch("external_url") instanceof File;
const { media, image, mediaFileError, showCoverImageUpload, animation_url } =
getUploadedNFTMediaMeta(form);
const setSharedMetaNotifications = useTxNotifications(
"Shared metadata updated successfully",
@@ -185,7 +133,8 @@ export const SharedMetadataForm: React.FC<{
<div>
<FileInput
previewMaxWidth="200px"
value={mediaFileUrl as File | string}
value={media}
client={contract.client}
showUploadButton
showPreview={true}
setValue={setFile}
@@ -206,9 +155,10 @@ export const SharedMetadataForm: React.FC<{
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
client={contract.client}
previewMaxWidth="200px"
accept={{ "image/*": [] }}
value={imageUrl}
value={image}
showUploadButton
setValue={(file) => setValue("image", file)}
className="shrink-0 rounded border border-border transition-all"
@@ -237,45 +187,42 @@ export const SharedMetadataForm: React.FC<{
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="flex flex-col gap-6 px-0">
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
value={
typeof imageUrlFormValue === "string"
? imageUrlFormValue
: ""
}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded, you can set
the URL or URI here.
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
value={
typeof animationUrlFormValue === "string"
? animationUrlFormValue
: ""
}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded, you
can set the URL or URI here.
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
<AccordionPanel className="!px-0 flex flex-col gap-6">
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
value={image}
onChange={(e) => {
setValue("image", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT image pre-uploaded, you can set
the URL or URI here.
</FormHelperText>
<FormErrorMessage>{errors?.image?.message}</FormErrorMessage>
</FormControl>
)}
{!(animation_url instanceof File) && (
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
value={animation_url}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
/>
<FormHelperText>
If you already have your NFT Animation URL pre-uploaded, you
can set the URL or URI here.
</FormHelperText>
<FormErrorMessage>
{errors?.animation_url?.message}
</FormErrorMessage>
</FormControl>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
@@ -139,7 +139,7 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
}
if (isErc1155) {
cols.push({
Header: "Supply",
Header: "Circulating Supply",
accessor: (row) => row,
Cell: (cell: CellProps<NFT, number>) => {
if (cell.row.original.type === "ERC1155") {
@@ -3,6 +3,7 @@ import type { ProjectMeta } from "../../../../../team/[team_slug]/[project_slug]
import { redirectToContractLandingPage } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
import { shouldRenderNewPublicPage } from "../_utils/newPublicPage";
import { ContractNFTPage } from "./ContractNFTPage";
import { ContractNFTPageClient } from "./ContractNFTPage.client";
@@ -44,6 +45,18 @@ export async function SharedNFTPage(props: {
});
}
// public nft page doesn't have /nfts page
if (!props.projectMeta) {
const shouldHide = await shouldRenderNewPublicPage(info.serverContract);
if (shouldHide) {
redirectToContractLandingPage({
contractAddress: props.contractAddress,
chainIdOrSlug: props.chainIdOrSlug,
projectMeta: props.projectMeta,
});
}
}
return (
<ContractNFTPage
contract={clientContract}
@@ -7,7 +7,7 @@ import {
useContractTransactionAnalytics,
useContractUniqueWalletAnalytics,
} from "data/analytics/hooks";
import { differenceInCalendarDays, formatDate } from "date-fns";
import { differenceInCalendarDays, format } from "date-fns";
import { useTrack } from "hooks/analytics/useTrack";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
@@ -200,7 +200,7 @@ export function toolTipLabelFormatterWithPrecision(precision: "day" | "hour") {
return function toolTipLabelFormatter(_v: string, item: unknown) {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(
return format(
new Date(time),
precision === "day" ? "MMM d, yyyy" : "MMM d, yyyy hh:mm a",
);
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { LatestEventsUI } from "./LatestEvents";
const meta: Meta<typeof LatestEventsUI> = {
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ThirdwebProvider } from "thirdweb/react";
import { PermissionsTableUI } from "./PermissionsTable";
@@ -1,15 +1,23 @@
import { ToggleThemeButton } from "@/components/color-mode-toggle";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { ThirdwebMiniLogo } from "../../../../../../components/ThirdwebMiniLogo";
import { PublicPageConnectButton } from "./PublicPageConnectButton";
export function PageHeader() {
export function PageHeader(props: {
containerClassName?: string;
}) {
return (
<div className="border-b border-dashed">
<header className="container flex max-w-8xl justify-between py-3">
<header
className={cn(
"container flex max-w-8xl justify-between py-3",
props.containerClassName,
)}
>
<div className="flex items-center gap-4">
<Link href="/team" className="flex items-center gap-2">
<ThirdwebMiniLogo className="h-6" />
<ThirdwebMiniLogo className="h-5" />
<span className="hidden font-bold text-2xl tracking-tight lg:block">
thirdweb
</span>
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { maxUint256 } from "thirdweb/utils";
import { BadgeContainer } from "../../../../../../../../stories/utils";
import { SupplyClaimedProgress } from "./supply-claimed-progress";
const meta = {
title: "Assets/NFT/SupplyClaimedProgress",
component: StoryVariants,
} satisfies Meta<typeof StoryVariants>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
args: {},
};
function StoryVariants() {
return (
<div className="container max-w-md space-y-10 py-10">
<BadgeContainer label="10 / Unlimited Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={maxUint256} />
</BadgeContainer>
<BadgeContainer label="500/1000 Supply">
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
</BadgeContainer>
<BadgeContainer label="10/1M Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
</BadgeContainer>
<BadgeContainer label="0/1M Supply">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
</BadgeContainer>
<BadgeContainer label="10/10 Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
</BadgeContainer>
<BadgeContainer label="0 Supply - don't show anything">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
</BadgeContainer>
</div>
);
}
@@ -0,0 +1,50 @@
import { Progress } from "@/components/ui/progress";
import { InfinityIcon } from "lucide-react";
import { maxUint256 } from "thirdweb/utils";
import { supplyFormatter } from "../nft/format";
export function SupplyClaimedProgress(props: {
claimedSupply: bigint;
totalSupply: bigint;
}) {
// if total supply is unlimited
if (props.totalSupply === maxUint256) {
return (
<p className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">Claimed Supply </span>
<span className="flex items-center gap-1 font-bold text-sm">
{supplyFormatter.format(props.claimedSupply)} /{" "}
<InfinityIcon className="size-4" aria-label="Unlimited" />
</span>
</p>
);
}
// if total supply is 0 - don't show anything
if (props.totalSupply === 0n) {
return null;
}
// multiply by 10k to have precision up to 2 decimal places in percentage value
const soldFractionTimes10KBigInt =
(props.claimedSupply * 10000n) / props.totalSupply;
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Supply Claimed</span>
<span className="font-bold text-sm">
{supplyFormatter.format(props.claimedSupply)} /{" "}
{supplyFormatter.format(props.totalSupply)}
</span>
</div>
<Progress value={soldPercentage} className="h-2.5" />
<p className="font-medium text-muted-foreground text-xs">
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
{soldPercentage.toFixed(2)}% Sold
</p>
</div>
);
}
@@ -0,0 +1,39 @@
import { SkeletonContainer } from "@/components/ui/skeleton";
export function TokenPrice(props: {
strikethrough: boolean;
data:
| {
priceInTokens: number;
symbol: string;
}
| undefined;
}) {
return (
<SkeletonContainer
skeletonData={"0.00 ETH"}
loadedData={
props.data
? props.data.priceInTokens === 0
? "FREE"
: `${tokenPriceFormatter.format(props.data.priceInTokens)} ${props.data.symbol}`
: undefined
}
render={(v) => {
if (props.strikethrough) {
return (
<s className="font-medium text-muted-foreground text-sm line-through decoration-muted-foreground/50">
{v}
</s>
);
}
return <span className="font-medium text-foreground text-sm">{v}</span>;
}}
/>
);
}
const tokenPriceFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 10,
});
@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { storybookThirdwebClient } from "stories/utils";
import { getContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
@@ -41,6 +41,7 @@ export function ContractHeaderUI(props: {
chainMetadata: ChainMetadata;
clientContract: ThirdwebContract;
socialUrls: object;
imageClassName?: string;
}) {
const socialUrls = useMemo(() => {
const socialUrlsValue: { name: string; href: string }[] = [];
@@ -67,7 +68,10 @@ export function ContractHeaderUI(props: {
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
{props.image && (
<Img
className="size-20 shrink-0 rounded-full border bg-muted"
className={cn(
"size-20 shrink-0 rounded-full border bg-muted",
props.imageClassName,
)}
src={
props.image
? resolveSchemeWithErrorHandler({
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { differenceInCalendarDays, formatDate } from "date-fns";
import { differenceInCalendarDays, format } from "date-fns";
import { ArrowUpIcon, InfoIcon } from "lucide-react";
import { ArrowDownIcon } from "lucide-react";
import { useMemo, useState } from "react";
@@ -81,7 +81,7 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
return (_v: string, item: unknown) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(
return format(
new Date(time),
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
);
@@ -18,7 +18,7 @@ import {
ChevronRightIcon,
ExternalLinkIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { type ThirdwebClient, type ThirdwebContract, toTokens } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import {
@@ -171,7 +171,7 @@ function RecentTransfersUI(props: {
<Button
variant="outline"
size="sm"
disabled={props.isPending || props.data.length === 0}
disabled={props.isPending || props.data.length < props.rowsPerPage}
className="gap-1.5 bg-background"
onClick={() => props.setPage(props.page + 1)}
>
@@ -213,7 +213,6 @@ export function RecentTransfers(props: {
}) {
const rowsPerPage = 10;
const [page, setPage] = useState(0);
const [hasFetchedOnce, setHasFetchedOnce] = useState(false);
const tokenQuery = useTokenTransfers({
chainId: props.clientContract.chain.id,
@@ -222,18 +221,11 @@ export function RecentTransfers(props: {
limit: rowsPerPage,
});
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!tokenQuery.isPending) {
setHasFetchedOnce(true);
}
}, [tokenQuery.isPending]);
return (
<div>
<RecentTransfersUI
data={tokenQuery.data ?? []}
isPending={tokenQuery.isPending && !hasFetchedOnce}
isPending={tokenQuery.isPending}
rowsPerPage={rowsPerPage}
client={props.clientContract.client}
tokenMetadata={{

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