Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
75bf09126f Version Packages (#7701)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-24 13:40:52 -07:00
Jonas DanielsandGitHub 9d8c7f1f7d Add gateway to possible usage sources (#7700) 2025-07-24 13:39:12 -07:00
MananTank 9dc8f5a637 [TOOL-5111] Playground: Update slugs, add redirects (#7698)
<!--

## 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 renaming and restructuring routes from `/connect` to `/wallets`, alongside adjustments in documentation links and component imports to streamline the application.

### Detailed summary
- Updated redirect paths from `/connect/...` to `/wallets/...`
- Changed documentation links accordingly
- Refactored component imports to match new route structure
- Adjusted breadcrumb links and sidebar navigation items
- Renamed files and folders to reflect the new `/wallets` hierarchy

> ✨ 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**
  * Expanded and reorganized URL redirects for improved navigation, including new paths for wallets, payments, and transactions sections.

* **Refactor**
  * Updated navigation links and internal references to use new URL structures for wallets, payments, and transactions.
  * Simplified import paths and component structures for improved code clarity and maintainability.

* **Documentation**
  * Updated documentation links throughout the app to reflect new URLs and more relevant resources.

* **Style**
  * Minor formatting improvements for code consistency and readability.

* **Chores**
  * Enhanced code comments to clarify the use of array index as React list keys.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-24 20:20:12 +00:00
Jonas DanielsandGitHub 48361f92b7 Add custom amount input to credit balance section (#7697) 2025-07-24 12:46:25 -07:00
MananTank 1659ae3754 Dashboard: Migrate Various forms in contract pages from chakra to tailwind (#7690)
<!--

## 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 form components in the dashboard's NFT management system to improve structure and usability. It replaces Chakra UI components with custom UI components, enhancing consistency and maintainability.

### Detailed summary
- Refactored `PropertiesFormControl` to utilize custom form components.
- Removed unused imports and consolidated imports from `@chakra-ui/react`.
- Updated form handling to use `FormField`, `FormItem`, and `FormDescription`.
- Enhanced error handling by integrating `FormMessage` for displaying validation messages.
- Replaced `Flex`, `FormControl`, and other Chakra UI components with custom equivalents.
- Updated `LazyMintNftForm`, `NFTMintForm`, and `UpdateNftMetadata` forms to use new structure and components.
- Improved user experience by refining button and input styles and behavior.

> ✨ 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**
  * Migrated all NFT-related form components from Chakra UI to a custom UI library, updating form controls, buttons, accordions, and input elements.
  * Updated error handling and helper text display to use new form components.
  * Simplified and modernized form structures for minting, lazy minting, metadata updates, and property controls.
  * Removed file upload support and reset functionality from the properties form.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-24 18:49:38 +00:00
MananTank 88d44aff50 Dashboard: Migrate Batch Upload NFTs from chakra to tailwind, UI improvements (#7689)
<!--

## 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 and enhancing the `FilePreview` component, improving the upload process for NFTs, and updating various components to utilize the new structure. Several files related to example CSVs were deleted, and new features were added for better file handling.

### Detailed summary
- Deleted example CSV files.
- Added `FilePreview` import in multiple components.
- Enhanced `FilePreview` styling and functionality.
- Updated `DownloadFileButton` to accept additional props.
- Refactored `BatchTable` to improve pagination and display logic.
- Improved `UploadStep` for better user experience during file uploads.
- Introduced `DelayedRevealConfiguration` and `SelectReveal` components for managing NFT reveal types.

> ✨ 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**
  * Replaced Chakra UI and third-party libraries with a custom UI system across batch upload components for a more unified look and feel.
  * Simplified and restyled batch minting forms, tables, upload steps, and reveal configuration.
  * Updated drag-and-drop functionality to use a dedicated DropZone component.
  * Streamlined table rendering and pagination with manual state management and local UI components.
  * Improved layout, styling, and accessibility for option selection, shuffle toggle, and modal dialogs.
  * Enhanced media preview styling and standardized file preview imports for improved consistency.
  * Added tabbed format selection and enriched instructional content with downloadable CSV/JSON examples in the upload step.
  * Improved submit button placement and added help links for user guidance during batch minting.
* **New Features**
  * Added customizable button variants and styling options to the file download button for better UI flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-24 18:17:47 +00:00
gregandGitHub c63b3497bd Feature: New Payments Overview Page (#7691) 2025-07-24 11:16:42 -07:00
74a21635e4 [Portal] Fix: Broken /connect Links (#7679)
Co-authored-by: Manan Tank <[email protected]>
2025-07-24 11:02:50 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
66b9bf3c6b Version Packages (#7694)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-24 02:00:52 -07:00
Joaquim VergesandGitHub cf22c23b45 [SDK] Optimize 4337 signature performance (#7693) 2025-07-24 00:47:45 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
170b020e17 Version Packages (#7682)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-23 19:25:46 -07:00
Joaquim VergesandGitHub 8260af3a92 [Dashboard] Replace modular contracts with Arbitrum Stylus contracts in explore page (#7685) 2025-07-22 19:15:42 -07:00
Joaquim VergesandGitHub b9154cc9e7 [API] Init API wrapper package (#7684) 2025-07-22 18:46:02 -07:00
Joaquim VergesandGitHub 08955f1588 [SDK] Expose getAuthToken from inApp and ecosystem wallets (#7683) 2025-07-22 18:26:46 -07:00
gregandGitHub 756295241a [SDK] Feature: Adds optional transactionId parameter to Bridge.status (#7681) 2025-07-22 15:11:04 -07:00
Joaquim VergesandGitHub 249679481e [Docs] Update redirects for wallets and payments paths (#7680) 2025-07-22 15:09:00 -07:00
Joaquim VergesandGitHub a491de4d7c [Docs] Refactor Contract and Tokens docs (#7676) 2025-07-22 17:20:00 +12:00
gregandGitHub 898efc56a9 [Portal] Fix: Test Jam Feedback (#7677) 2025-07-21 21:32:26 -07:00
c0e7ae95de Add external wallet filter and display (#7669)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-21 20:39:26 -07:00
Joaquim VergesandGitHub 1e8190a1f9 [Docs] Add frontend/backend tabs for transaction sending examples (#7670) 2025-07-21 19:36:18 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
aac187a76c Version Packages (#7662)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-21 16:35:51 -07:00
Manan TankandGitHub 03390c1e25 Portal: homepage UI improvements (#7667) 2025-07-21 16:35:29 -07:00
8d3b9c6a33 [Portal] Fix: update sidebar overflow behavior in DocLayout component (#7653)
Co-authored-by: Joaquim Verges <[email protected]>
2025-07-21 14:47:24 -07:00
MananTank ddef88866f [PRO-249] Portal: Update OG images (#7664)
<!--

## 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 metadata and image attributes across various pages in the `thirdweb` portal, particularly in the payments and vault sections. It modifies titles, icons, and descriptions to enhance clarity and consistency.

### Detailed summary
- Changed `metadata` for payments to use "payments" icon and updated titles.
- Updated vault metadata to include a new image with the "vault" icon.
- Modified various page descriptions for clarity and consistency.
- Added a new script for formatting in `package.json`.
- Removed unused code in `api/og/route.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 and updated metadata images and titles across several pages, including "Contracts", "Vault", and various "Payments" pages for improved visual representation.
  * Introduced support for a new "vault" icon in metadata image options.

* **Style**
  * Improved formatting and removed extraneous blank lines in code examples and UI components for better readability.
  * Cleaned up whitespace in multiple example code snippets.

* **Chores**
  * Added a new formatting script for consistent code style across the project.

* **Refactor**
  * Simplified Open Graph image layouts by removing certain UI elements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 20:42:32 +00:00
Manan TankandGitHub 8133fab9e4 [TOOL-5083] Portal: Fix header on mobile viewport (#7661) 2025-07-21 13:41:30 -07:00
Joaquim VergesandGitHub 37414eb54a [SDK] Fix native token fallback when insight query fails (#7666) 2025-07-21 13:39:29 -07:00
MananTank 22bf68a6f0 [TOOL-5075] SDK: Fix various alignment issues in components (#7657)
<!--

## 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 fixing various alignment issues in React components within the `thirdweb` package, enhancing hover states and visual consistency across different UI elements.

### Detailed summary
- Updated hover styles in several components to improve visual feedback.
- Adjusted `all` property to "unset" for better styling control.
- Modified colors and borders in `OTPLoginUI.tsx`, `ReceiveFunds.tsx`, `DepositScreen.tsx`, and others for consistency.
- Enhanced button styles in `MenuButton.tsx`, `WalletEntryButton.tsx`, and `NetworkSelector.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

* **Bug Fixes**
  * Resolved various alignment issues in React components to improve visual consistency across the interface.

* **Chores**
  * Updated internal documentation to reflect the latest patch addressing alignment fixes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 17:58:07 +00:00
EimanandGitHub 68dd4dc17e fixed playground import (#7660) 2025-07-21 10:46:58 -07:00
saminacodes ecf6dbf2aa Update portal label to TypeScript SDK (#7658)
```
<!--

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

[Portal] Fix: Update SDK title for TypeScript reference pages

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

## Notes for the reviewer

Updates the SDK title displayed on TypeScript reference pages from "Connect SDK" to "TypeScript SDK" for improved clarity and accuracy.

## How to test

Navigate to any `/references/typescript/v*/` page (e.g., `/references/typescript/v5/`). Verify the header displays "TypeScript SDK".

-->
```

---

**Open Background Agent:**
[Web](https://www.cursor.com/agents?id=bc-2dec36af-4b62-431c-b277-891c1b46bb48) · [Cursor](https://cursor.com/background-agent?bcId=bc-2dec36af-4b62-431c-b277-891c1b46bb48)

[Slack Thread](https://thirdwebdev.slack.com/archives/C04DYC3G5NZ/p1753110978408999?thread_ts=1753110978.408999&cid=C04DYC3G5NZ)

Learn more about [Background Agents](https://docs.cursor.com/background-agents)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming references from `Connect SDK` to `TypeScript SDK` and `thirdweb SDK` across multiple files in the project, enhancing clarity and consistency in the documentation and codebase.

### Detailed summary
- Updated `label` from `Connect SDKs` to `thirdweb SDKs`.
- Changed `name` from `Connect SDK` to `TypeScript SDK`.
- Modified `sdkTitle` from `Connect SDK` to `TypeScript SDK`.
- Adjusted example descriptions and titles to reflect `TypeScript SDK`.
- Updated installation instructions to refer to `TypeScript SDK`.
- Revised descriptions related to wallet support to mention `thirdweb SDKs`.
- Changed references in guides and documentation from `Connect SDK` to `TypeScript SDK`.

> ✨ 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**
  * Updated SDK naming from "Connect SDK" to "TypeScript SDK" and "thirdweb SDK" across documentation, guides, UI labels, and sidebar links for improved clarity and consistency.

* **Chores**
  * Removed the "Engine" API link from the main navigation header.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 16:52:47 +00:00
Phillip HoandGitHub 01daaceb21 only one file (#7659) 2025-07-21 23:23:18 +08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a5345808ec Version Packages (#7640)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-21 02:22:54 -07:00
Joaquim VergesandGitHub bfa0a42219 [SDK] Fix TransactionWidget when wallet has enough currency (#7656) 2025-07-21 02:13:17 -07:00
426bb8bbb2 [SDK] Fix: fixes auto-connect on native <ConnectEmbed /> component (#7650)
Co-authored-by: Joaquim Verges <[email protected]>
2025-07-20 18:09:51 -07:00
823af013ae [SDK] Fix: update hooks to use core transaction types (#7647)
Co-authored-by: Joaquim Verges <[email protected]>
2025-07-20 18:02:09 -07:00
gregandGitHub 98aea1e0b9 [Portal] Payments Docs (#7651) 2025-07-20 17:46:20 -07:00
Joaquim VergesandGitHub 620e294f8c [Engine] Update server wallet config types for EOA and EIP-7702 execution (#7652) 2025-07-20 17:43:13 -07:00
Joaquim VergesandGitHub 84eaded152 [Dashboard] Show EOA server wallets by default (#7645) 2025-07-20 16:26:55 -07:00
MananTank 31d7173432 Dashboard: Show upsell card on support page for free plan (#7648)
<!--

## 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 support functionality within the dashboard, particularly for teams on a free billing plan. It adds checks for the billing plan, modifies the `SupportHeader`, and integrates upsell content to encourage users to upgrade their plans.

### Detailed summary
- `SupportHeader` now accepts `isFreePlan` prop to conditionally render buttons.
- Redirects free plan users from creating support cases.
- Displays upsell content for free plan users in the support page.
- Added functionality to show/hide support forms based on billing plan.
- Updated `CreateSupportCase` component to handle dialog state for support case creation.

> ✨ 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**
  * Free plan users now see an upsell prompt encouraging upgrade to access the Support Center, with a clear list of benefits.
  * The Support Center title has been updated for improved clarity.
  * Support case creation is disabled for free plan users, showing an informative message with a billing link instead.
  * Conditional support action buttons are shown only for paid plans, adapting to the user's context.

* **Improvements**
  * Enhanced visual styling and layout for benefit descriptions in upsell prompts.
  * Error messages in the Support Center now display detailed information for easier troubleshooting.
  * Dialogs for creating support cases now open and close with explicit user control for better interaction.
  * The chat button is hidden on the Support Center page to reduce UI clutter.

* **Bug Fixes**
  * Free plan users are prevented from accessing the support case creation page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-18 21:17:54 +00:00
Yash094 a48329b697 [dashboard] - support in dashboard (#7608)
<!--

## 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 restructuring the support ticketing system by moving components and modifying links to streamline support interactions within the application.

### Detailed summary
- Deleted multiple support-related files and components.
- Changed `getTeamById` function to remove the `export` keyword.
- Updated support links to point to `/team/~/~/support`.
- Added new support ticket types and forms.
- Introduced new components for handling support tickets.
- Implemented ticket status handling and filtering.
- Enhanced the support case list display with filtering options.
- Created API endpoints for support ticket management.
- Integrated dynamic imports for various support forms.

> The following files were skipped due to too many changes: `apps/dashboard/src/@/components/chat/CustomChatContent.tsx`, `apps/dashboard/src/@/components/chat/CustomChats.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/CreateSupportCase.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**
  * Unified support ticket system integrated into team dashboards with AI chat assistant and dynamic product-specific support forms.
  * Support ticket listing with status filtering, search, detailed conversation views, and reply functionality.
  * Added "Support" link in team sidebar footer; updated support headers, layouts, and navigation flows.
  * Introduced monochrome mode for mini logo and refined chat UI elements.

* **Bug Fixes / Improvements**
  * Enhanced chat component styling and user experience.
  * Removed obsolete "Support" links from navigation menus and headers.
  * Updated button labels, including changing "Ask AI Assistant" to "Get Help".

* **Chores**
  * Removed legacy support ticket creation pages, components, and client/server logic to consolidate support workflows.
  * Cleaned deprecated components, imports, redirects, and updated error messages to point to the new support section.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-18 19:09:16 +00:00
Joaquim VergesandGitHub 6e62a3aa5c [Docs] Add Ecosystem Wallet to sidebar and update documentation (#7643) 2025-07-18 16:47:56 +12:00
Jonas DanielsandGitHub 746b0c9d8b Add mcp to team capabilities (#7636) 2025-07-17 17:46:55 -07:00
gregandGitHub 0f38a1287a [SDK] Feature: Hide the TransactionWidget contract label if unknown (#7642) 2025-07-17 16:32:05 -07:00
gregandGitHub 61bfebf5d8 Chore: Use /search route for routes page (#7639) 2025-07-17 13:40:29 -07:00
170c377445 Handle token balance retrieval failures (#7600)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-17 13:40:15 -07:00
Yash094 4cbf67181d Add etherlink chain (#7634)
<!--

## 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 new blockchain definitions for the Etherlink mainnet and testnet, enhancing the `thirdweb` package with additional chain support.

### Detailed summary
- Added `etherlink` mainnet definition in `packages/thirdweb/src/chains/chain-definitions/etherlink.ts`.
- Added `etherlinkTestnet` definition in `packages/thirdweb/src/chains/chain-definitions/etherlink-testnet.ts`.
- Exported `etherlink` and `etherlinkTestnet` in `packages/thirdweb/src/exports/chains.ts`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * Added support for the Etherlink mainnet and Etherlink testnet blockchain networks.
  * Users can now interact with these new chains within the platform.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 19:24:21 +00:00
arcoraven 6bf6108beb chore: webhooks docs (#7604)
<!--

## 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 new `Webhooks` section in the application, providing a sidebar navigation item, a dedicated layout, and detailed documentation on webhooks, including their configuration, management, and security practices.

### Detailed summary
- Added `sidebar` configuration for `Webhooks` in `sidebar.tsx`.
- Introduced `SDKCard` for `Webhooks` in `page.tsx`.
- Renamed `BAST_URL` to `BASE_URL` in `metadata.ts`.
- Created a new `Layout` component for `Webhooks` in `layout.tsx`.
- Defined metadata for `Webhooks` in `layout.tsx`.
- Added comprehensive documentation for webhooks in `page.mdx`, covering setup, management, event handling, and security.

> ✨ 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 "Webhooks" section in the backend APIs grid, accessible from the main portal.
  * Added comprehensive documentation for thirdweb Webhooks, including setup, management, security, and troubleshooting guides.
  * Implemented a dedicated sidebar and layout for the Webhooks documentation page for easier navigation.

* **Documentation**
  * Provided detailed instructions and code examples for implementing and verifying webhook integrations.

* **Bug Fixes**
  * Corrected a typo in the base URL constant used for metadata generation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 19:12:38 +00:00
Phillip HoandGitHub ce85be3346 chore: accordion, rename insight topic, test webhook endpoint (#7635) 2025-07-17 20:57:22 +08:00
MananTank e125e743a3 Dashboard: Temporarily remove gas sponsorship option in token creation (#7631)
<!--

## 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 temporarily disabling the gasless feature in two components: `launch-token.tsx` and `launch-nft.tsx`. The condition for enabling gasless functionality has been commented out and replaced with a static `false` value.

### Detailed summary
- In `launch-token.tsx`:
  - `canEnableGasless` is set to `false` with a comment about enabling it later.
  - `showGaslessSection` is set to `false`.

- In `launch-nft.tsx`:
  - `canEnableGasless` is set to `false` with a comment about enabling it later.
  - `showGaslessSection` is set to `false`.

> ✨ 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**
  * The gasless transaction feature and related UI have been temporarily disabled for both NFT and token launch flows.

* **Chores**
  * Updated internal logic to prevent access to the gasless sponsorship section until further notice.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 17:35:01 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
05da842b7a Version Packages (#7616)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-16 10:14:03 -07:00
MananTank 216b70475e Dashboard, Portal, Playground: Rename leftover usage of 'Universal Bridge' to 'Payments' in UI (#7611)
<!--

## 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 renaming references from `Universal Bridge` to `Payments` across various files in the codebase, reflecting a rebranding initiative.

### Detailed summary
- Changed `name` and `title` from `Universal Bridge` to `Payments` in multiple files.
- Updated labels and descriptions in UI components to reflect the new branding.
- Modified sidebar links and metadata to align with the new `Payments` terminology.
- Adjusted documentation references to `Payments` from `Universal Bridge`.

> ✨ 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

* **Style**
  * Updated all user-facing references of "Universal Bridge" to "Payments" across banners, page titles, headings, navigation links, sidebars, and documentation content for a consistent branding experience.

* **Documentation**
  * Revised documentation text, metadata, and quickstart guides to reflect the new "Payments" terminology and branding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 16:20:48 +00:00
iuwqyir 9d259d0836 add insight filter selection to webhook creation and update (#7598)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the webhook configuration components by introducing optional support for `ThirdwebClient` and `supportedChainIds`. It improves error handling and validation, particularly in the context of filtering for event and transaction webhooks.

### Detailed summary
- Made `thirdwebClient` optional in `useAbiProcessing` hook.
- Updated `CreateWebhookConfigModal`, `EditWebhookConfigModal`, `WebhooksOverview` to accept `client` and `supportedChainIds`.
- Enhanced error handling in `useQuery` for missing `thirdwebClient`.
- Introduced separate forms for event and transaction filters in `TopicSelectorModal`.
- Improved validation and fetching of ABIs in the filter forms.
- Added support for displaying ABI fetch status and results.
- Enhanced UI elements to accommodate new filter configurations.

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

<!-- end pr-codex -->

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

* **New Features**
  * Enhanced webhook topic selection with advanced, form-driven filtering for contract webhook topics, including multi-network and ABI-based options.
  * Added support for passing client and supported chain information throughout the webhooks dashboard for improved filtering and configuration.

* **Improvements**
  * Increased dialog width for filter configuration to improve usability.
  * Streamlined handling of webhook topic filters for consistency and flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 13:17:01 +00:00
Phillip HoandGitHub ca52cdd3bf chore: /token and /vault landing pages (#7633) 2025-07-16 20:26:13 +08:00
Joaquim VergesandGitHub cf68002922 [Docs] Update page.mdx (#7632)
Signed-off-by: Joaquim Verges <[email protected]>
2025-07-16 02:08:40 -07:00
MananTank 495c7f925d [TOOL-5047] Dashboard: Fix client object missing teamId on contract publish page (#7628)
<!--

## 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 `PublishContractPage` component to utilize the `getUserThirdwebClient` function for client initialization, enhancing user authentication handling during contract publishing.

### Detailed summary
- Added import for `getUserThirdwebClient` from `@/api/auth-token`.
- Removed import for `getClientThirdwebClient` from `@/constants/thirdweb-client.client`.
- Replaced the client initialization in `ContractPublishForm` with `userThirdwebClient` from `getUserThirdwebClient`.

> ✨ 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**
  * Updated the way the client is obtained for the contract publish form, improving consistency in client retrieval. No visible changes to user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 20:09:58 +00:00
iuwqyir 32add5f602 Display webhook secret in dashboard (#7610)
```
<!--

## title your PR with this format: "[Dashboard] Feature: Display and Mask Webhook Secret in Contracts Webhooks List"

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

## Notes for the reviewer

This PR introduces a new column to the Contracts Webhooks table to display the `webhook_secret`.

Key points:
- The secret is masked for security, showing `**********` followed by the last 3 characters (e.g., `**********xyz`).
- The full secret can be copied to the clipboard using the adjacent copy button.
- No backend changes were required as the `webhook_secret` is already available in the `WebhookResponse`.

## How to test

1. Navigate to a project's webhooks page on the dashboard (`/dashboard/[team_slug]/[project_slug]/webhooks`).
2. Observe the new "Webhook Secret" column in the table.
3. Verify that the secrets are displayed in the masked format (`**********xyz`).
4. Click the copy icon next to a masked secret and confirm that the full secret is copied to your clipboard.

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C085X0VQCF3/p1752487882125419?thread_ts=1752487882.125419&cid=C085X0VQCF3)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new function to mask webhook secrets for improved security and updates the `WebhooksTable` component to display the masked secret while allowing users to copy the original secret.

### Detailed summary
- Added `maskWebhookSecret` function to mask webhook secrets.
- Updated `WebhooksTable` to include a new column for "Webhook Secret."
- Displayed masked secret in the table with an option to copy the original secret.

> ✨ 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 new "Webhook Secret" column to the webhooks table, displaying masked webhook secrets for improved privacy.
  * Included a copy button to easily copy the full unmasked webhook secret to the clipboard.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 19:59:09 +00:00
gregandGitHub 5f74b3ca3e Chore: Update UB jsdocs to include types (#7629) 2025-07-15 11:50:42 -07:00
GiselleNessi 761cde6a32 [portal] add new support article (#7595)
<!--

## 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 `knowledge-base` section by adding new assets and a comprehensive guide for bulk updating NFT metadata, specifically for ERC-721A contracts.

### Detailed summary
- Added multiple image assets to the `knowledge-base`.
- Introduced a new guide in `page.mdx` for bulk updating NFT metadata.
- Explained concepts of batch and base URI in the context of NFTs.
- Provided step-by-step instructions for updating NFT metadata efficiently.

> ✨ 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 new sidebar entry for "Bulk update NFT metadata" under the "Contracts" section in the Troubleshoot category.
  * Introduced a comprehensive step-by-step guide on how to update metadata for multiple NFTs in bulk, including instructions, images, and helpful resources.

* **Documentation**
  * Expanded the knowledge base with detailed troubleshooting documentation for bulk updating NFT metadata.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 16:55:09 +00:00
Joaquim VergesandGitHub 9546b927d9 [Docs] Redesign sidebar and update wallet documentation (#7602) 2025-07-15 01:26:32 -07:00
jnsdls f82f885dad Update nebula rateLimit type to include perSecond and perMinute (#7614)
# Update Nebula Rate Limit Type

This PR updates the Nebula rate limit type to provide more granular control by replacing the single `rateLimit` number with an object containing `perSecond` and `perMinute` properties. The change affects:

- The `TeamCapabilities` type in the service-utils package
- Corresponding mock data and stubs in the dashboard app
- Added a changeset to document this patch update
2025-07-15 00:12:41 +00:00
jnsdls 34d2fddf49 Update error response URL for project settings (#7613)
# Update error response URL in service-utils

This PR updates the error response URLs in the service-utils package to point to the project-specific settings page instead of the generic API key creation page. The new URLs follow the format `https://thirdweb.com/${team.slug}/${project.slug}/settings`, providing users with a direct link to manage restrictions for their specific project.

The changes affect error messages for:
- Unauthorized domains
- Unauthorized bundle IDs
- Unauthorized services
- Unauthorized service actions

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Updated error messages for unauthorized access to provide project-specific URLs, directing users to the relevant project settings page for more accurate guidance.

* **Chores**
  * Added documentation to track the patch update related to error response URLs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 00:12:41 +00:00
a7544f75ce [Portal] Add next button to inner page footers (#7605)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-14 15:46:21 -07:00
gregandGitHub bd3d645be6 [SDK] Fix: Adds BuyWidget fallback when native isn't supported (#7601) 2025-07-14 12:06:18 -07:00
MananTank 5fedc2c040 [TOOL-5037] Dashboard: Remove autoConnect on /login page (#7609)
<!--

## 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 primarily focuses on refactoring import paths for better organization and consistency across the codebase, as well as enhancing the login and onboarding components, including UI adjustments and functionality updates.

### Detailed summary
- Updated import paths to use absolute imports instead of relative paths.
- Modified the `DashboardLayout` to enable auto-connect.
- Enhanced the `LoginPage` with a new footer message regarding phone login support.
- Adjusted the `Toaster` component to simplify its usage.
- Improved onboarding UI components for better user experience.

> ✨ 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 an option to enable automatic wallet connection in the app layout.

* **Refactor**
  * Standardized import paths across the dashboard for improved maintainability and clarity.
  * Updated usage of the notification component to a local version with simplified theming.
  * Improved layout and messaging on the login page, moving phone login deprecation notice to a dedicated footer and refining spacing.

* **Documentation**
  * Enhanced SEO and social sharing metadata on the login page.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 18:36:51 +00:00
Yash094 6c8a7cf2ba fix playground payments widget (#7607)
<!--

## 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 `CodeGen` component in the `playground-web` application by renaming a prop for consistency and clarity.

### Detailed summary
- Changed the prop name from `token` to `tokenAddress` in the `CodeGen` component.
- Maintained existing conditional rendering for other props such as `seller`, `title`, `image`, `description`, `paymentMethods`, and `currency`.

> ✨ 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**
  * Updated the generated code output to use the prop name `tokenAddress` instead of `token` when displaying the token address in the code example. This change does not affect any other visible functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 18:01:58 +00:00
Phillip HoandGitHub 4f02f6eeb7 chore: add webhook secret to table, fix api error (#7606) 2025-07-14 15:55:21 +08:00
72f75ea40b Update converter SDK reference (#7603)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-14 12:53:52 +08:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8b6a953933 Version Packages (#7594)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-13 10:27:06 +12:00
Joaquim VergesandGitHub 33b0b6dcb4 [Dashboard] Add server-wallet signAuthorization capability to wallet access token (#7530) 2025-07-12 02:53:53 -07:00
MananTank cdc7d95db2 [TOOL-4959] Dashboard: Asset page UI improvements (#7599)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining the UI components and enhancing the functionality of the `ERC20` public page. It includes improvements in layout, styling, and the introduction of new features related to token price data and analytics.

### Detailed summary
- Deleted unused files: `useTokenPriceData.ts` and `ContractCreatorBadge.tsx`.
- Updated `PageHeader` to change border style.
- Added `tooltip` prop in `CopyAddressButton`.
- Changed API key usage in `fetchTokenInfoFromBridge`.
- Enhanced `ThirdwebAreaChart` with better props handling.
- Introduced `GridPattern` component for background.
- Improved `RecentTransfers` UI and pagination buttons.
- Updated `PriceChart` and `TokenStats` to directly use passed props for token price data.
- Refactored `ContractHeaderUI` to improve layout and social link handling.

> ✨ 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 customizable grid background pattern component for enhanced visual design.
  * Added support for displaying token price data and analytics on ERC20 public pages, including conditional rendering of token statistics.
  * Enabled tooltip customization for the copy address button.

* **Bug Fixes**
  * The contract creator badge icon is now visible, and spacing between elements has been improved.

* **Refactor**
  * Simplified and unified chart headers and analytics section layouts.
  * Streamlined fetching and handling of token price data with a new asynchronous function and removed the previous hook.
  * Improved layout and container styling across ERC20 and NFT public pages, including card backgrounds, borders, and spacing.
  * Adjusted component structures for better visual separation and consistency.
  * Removed the contract creator badge component and replaced it with a simpler contract owner link.

* **Style**
  * Updated card and header styles for better visual hierarchy and clarity.
  * Enhanced recent transfers and contract header sections with improved padding, borders, and rounded corners.
  * Removed small shadow effects from cards and refined header background and border styles.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-12 05:50:29 +05:30
Manan TankandGitHub 592bf6f1fd [TOOL-5021] SDK: Fix theme in ConnectButton Details Modal Buy screen (#7589) 2025-07-12 09:21:50 +12:00
MananTank 8973174ad5 Dashboard: Fix erc20 asset page crash when number of tokens claimed is a fraction (#7597)
<!--

## 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 `SupplyClaimedProgress` component to use `claimedSupplyTokens` and `totalSupplyTokens` as `number` types instead of `bigint`, along with minor adjustments in related components and UI elements.

### Detailed summary
- Changed `enable-background` to `enableBackground` in `DiscordIcon.tsx`.
- Updated `SupplyClaimedProgress` props from `claimedSupply` and `totalSupply` (bigint) to `claimedSupplyTokens` and `totalSupplyTokens` (number).
- Adjusted calculations and conditions in `SupplyClaimedProgress` for new props.
- Modified test cases in stories to reflect new prop types.
- Updated UI elements to display supply values correctly based on the new number type.

> ✨ 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**
  * Updated supply progress components and related usage to use number types instead of bigints, with new prop names for clarity.
  * Improved handling and display of "unlimited" supply scenarios in supply progress components.
  * Adjusted percentage calculation and formatting for claimed supply progress.

* **Style**
  * Corrected SVG attribute naming in the Discord icon to ensure proper rendering.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 20:38:47 +00:00
Phillip HoandGitHub c160e91c08 chore: redirect /universal-bridge -> /payments (#7593) 2025-07-12 04:20:58 +08:00
gregandGitHub f33e31ad6e [SDK] Fix: only require missing token amount for transaction widget (#7591) 2025-07-11 02:01:57 -07:00
Joaquim VergesandGitHub 4111d0e638 [Dashboard] Add error handling and refactor vault account rotation (#7592) 2025-07-10 19:59:42 -07:00
joaquim-verges ffb7b6487b [Dashboard] use BuyWidget on /bridge (#7590)
Fixes TOOL-5023

<!--

## 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 updates the `UniversalBridgeEmbed` component to replace the `PayEmbed` with `BuyWidget` from the `thirdweb` library, modifying how payment options are handled and improving the integration with the `thirdweb` SDK.

### Detailed summary
- Replaced `PayEmbed` component with `BuyWidget`.
- Removed `payOptions` prop and its nested properties.
- Added `amount`, `chain`, and `tokenAddress` props to `BuyWidget`.
- Updated the theme handling to use `getSDKTheme`.

> ✨ 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**
  * Updated the embedded widget in the dashboard to use a new buy widget, offering a simplified and more direct interface for purchasing tokens.

* **Refactor**
  * Streamlined the widget’s input options for a more straightforward user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 23:09:34 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
77063e5c85 Version Packages (#7588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 15:51:49 -07:00
Joaquim VergesandGitHub 585eac2a6f [Dashboard] Replace vault access token with project secret key for authentication (#7570) 2025-07-10 15:32:36 -07:00
MananTank c54db522b3 Dashboard: Fix webhooks posthog feature flag conditional rendering (#7585)
<!--

## 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 centralized webhooks feature, enhancing the sidebar layout and analytics for webhooks. It adds loading states, integrates feature flags, and modifies the handling of webhooks in various components.

### Detailed summary
- Added `Loading` components using `GenericLoadingPage` in multiple webhook pages.
- Updated `ProjectSidebarLayout` to include `isCentralizedWebhooksFeatureFlagEnabled`.
- Enhanced `ProjectLayout` to fetch and utilize the centralized webhooks feature flag.
- Modified `WebhooksAnalyticsPage` to check for the centralized webhooks feature flag before redirecting.
- Adjusted `WebhooksLayout` to conditionally render links based on the centralized webhooks feature flag.
- Refactored `isFeatureFlagEnabled` function for improved feature flag handling.
- Updated `WebhooksPage` to include centralized webhooks feature flag checks and redirection logic.
- Cleaned up imports and ensured consistent use of `params` across various components.

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

<!-- end pr-codex -->

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

* **New Features**
  * Added multiple loading indicators across webhooks analytics, contracts, universal bridge, and sidebar routes for smoother user experience.
  * Sidebar and tab navigation dynamically update based on the centralized webhooks feature flag.

* **Bug Fixes**
  * Enhanced error handling with 404 responses for missing accounts, projects, or authentication on webhooks pages.
  * Redirects implemented when the centralized webhooks feature flag is disabled.

* **Refactor**
  * Updated feature flag checks to a new cached API accepting structured parameters.
  * Streamlined parameter handling and navigation logic for webhooks pages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 20:15:27 +00:00
Joaquim VergesandGitHub d62e9971ee [SDK] Fix inapp wallets showing up in all wallets list (#7586) 2025-07-10 13:14:57 -07:00
MananTank 7d796f5aa5 Dashboard: Fix theme for BuyWidget in chain page (#7583)
<!--

## 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 `BuyFundsSection` component by integrating theme support for the `BuyWidget` using the `useTheme` hook from `next-themes`. It adjusts the widget's theme based on the current application theme.

### Detailed summary
- Added `useTheme` hook to access the current theme.
- Imported `getSDKTheme` utility for theme handling.
- Passed the correct theme to the `BuyWidget` based on whether the theme is "dark" or "light".

> ✨ 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**
  * The Buy Funds section now adapts its appearance based on your selected theme (light or dark), providing a more consistent and personalized user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 19:57:29 +00:00
982 changed files with 25190 additions and 12626 deletions
+2 -2
View File
@@ -18,8 +18,8 @@
## Features
- Support for React & React-Native
- First party support for [Embedded Wallets](https://portal.thirdweb.com/connect/embedded-wallet/overview) (social/email login)
- First party support for [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview)
- First party support for [Embedded Wallets](https://portal.thirdweb.com/wallets) (social/email login)
- First party support for [Account Abstraction](https://portal.thirdweb.com/wallets/sponsor-gas)
- Instant connection to any chain with RPC Edge integration
- Integrated IPFS upload/download
- UI Components for connection, transactions, nft rendering
+3 -1
View File
@@ -9,7 +9,7 @@ module.exports = [
// -- build category
"/wallets",
"/account-abstraction",
"/universal-bridge",
"/payments",
"/auth",
"/in-app-wallets",
"/transactions",
@@ -62,4 +62,6 @@ module.exports = [
// -- universal bridge landing pages --
"/universal-bridge-regions/:region_slug",
"/enterprise",
"/token",
"/vault",
];
@@ -1,2 +0,0 @@
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description image animation_url external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description ipfs://ipfsHash/0 ipfs://ipfsHash/0 https://thirdweb.com #0098EE every row is a property
@@ -1,4 +0,0 @@
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
Token 1 Name,Token 1 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
Token 2 Name,Token 2 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description image animation_url external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description 0.png 0.mp4 https://thirdweb.com #0098EE every row is a property
3 Token 1 Name Token 1 Description 0.png 0.mp4 https://thirdweb.com #0098EE every row is a property
4 Token 2 Name Token 2 Description 0.png 0.mp4 https://thirdweb.com #0098EE every row is a property
@@ -1,2 +0,0 @@
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description https://thirdweb.com #0098EE every row is a property
+31 -6
View File
@@ -66,15 +66,20 @@ const projectRoute = "/team/:team_slug/:project_slug";
const projectPageRedirects = [
{
destination: `${projectRoute}/universal-bridge/:path*`,
destination: `${projectRoute}/payments/:path*`,
permanent: false,
source: `${projectRoute}/connect/pay/:path*`,
},
{
destination: `${projectRoute}/universal-bridge/:path*`,
destination: `${projectRoute}/payments/:path*`,
permanent: false,
source: `${projectRoute}/connect/universal-bridge/:path*`,
},
{
destination: `${projectRoute}/payments/:path*`,
permanent: false,
source: `${projectRoute}/universal-bridge/:path*`,
},
{
destination: `${projectRoute}/account-abstraction/:path*`,
permanent: false,
@@ -375,12 +380,22 @@ async function redirects() {
permanent: false,
source: "/template/:slug",
},
// redirect /connect/pay to /universal-bridge
// redirect /connect/pay to /payments
{
destination: "/universal-bridge",
permanent: false,
destination: "/payments",
permanent: true,
source: "/connect/pay",
},
{
destination: "/payments",
permanent: true,
source: "/universal-bridge",
},
{
destination: "/payments/:slug",
permanent: true,
source: "/universal-bridge/:slug",
},
// PREVIOUS CAMPAIGNS
{
destination: "/",
@@ -416,7 +431,7 @@ async function redirects() {
source: "/connect/account-abstraction",
},
{
destination: "/universal-bridge",
destination: "/payments",
permanent: false,
source: "/connect/universal-bridge",
},
@@ -440,9 +455,19 @@ async function redirects() {
permanent: false,
source: "/rpc-edge",
},
{
destination: "/payments",
permanent: false,
source: "/universal-bridge",
},
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
{
source: "/support/:path*",
destination: "/team/~/~/support",
permanent: false,
},
];
}
@@ -12,7 +12,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import { isVercel } from "@/utils/vercel";
import { verifyTurnstileToken } from "./verifyTurnstileToken";
import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken";
export async function getLoginPayload(
params: GenerateLoginPayloadParams,
@@ -1,15 +1,16 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { PostHog } from "posthog-node";
let posthogServer: PostHog | null = null;
let _posthogClient: PostHog | null = null;
function getPostHogServer(): PostHog | null {
if (!posthogServer && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthogServer = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
host: "https://us.i.posthog.com",
});
}
return posthogServer;
return _posthogClient;
}
/**
@@ -17,29 +18,40 @@ function getPostHogServer(): PostHog | null {
* @param flagKey - The feature flag key
* @param userEmail - The user's email address for filtering
*/
export async function isFeatureFlagEnabled(
flagKey: string,
userEmail?: string,
): Promise<boolean> {
// For localdev environments where Posthog is not running, enable all feature flags.
if (!posthogServer) {
return true;
}
try {
const client = getPostHogServer();
if (client && userEmail) {
const isEnabled = await client.isFeatureEnabled(flagKey, userEmail, {
personProperties: {
email: userEmail,
},
});
if (isEnabled !== undefined) {
return isEnabled;
}
export const isFeatureFlagEnabled = unstable_cache(
async (params: {
flagKey: string;
accountId: string;
email: string | undefined;
}): Promise<boolean> => {
const posthogClient = getPostHogServer();
if (!posthogClient) {
console.warn("Posthog client not set");
return true;
}
} catch (error) {
console.error(`Error checking feature flag ${flagKey}:`, error);
}
return false;
}
const { flagKey, accountId, email } = params;
try {
if (posthogClient && accountId) {
const isEnabled = await posthogClient.isFeatureEnabled(
flagKey,
accountId,
{
personProperties: email ? { email } : undefined,
},
);
if (isEnabled !== undefined) {
return isEnabled;
}
}
} catch (error) {
console.error(`Error checking feature flag ${flagKey}:`, error);
}
return false;
},
["is-feature-flag-enabled"],
{
revalidate: 3600, // 1 hour
},
);
+15
View File
@@ -413,3 +413,18 @@ export function reportUpsellShown(properties: UpsellParams) {
export function reportUpsellClicked(properties: UpsellParams) {
posthog.capture("upsell clicked", properties);
}
// ----------------------------
// PAYMENTS
// ----------------------------
/**
* ### Why do we need to report this event?
* - To track conversions on payment overview page
*
* ### Who is responsible for this event?
* @samina
*/
export function reportPaymentCardClick(properties: { id: string }) {
posthog.capture("payment card clicked", properties);
}
+143
View File
@@ -0,0 +1,143 @@
"use server";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { SupportTicket } from "../../app/(app)/team/[team_slug]/(team)/~/support/types/tickets";
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";
const ESCALATION_FEEDBACK_RATING = 9999;
export async function createSupportTicket(params: {
message: string;
teamSlug: string;
teamId: string;
title: string;
conversationId?: string;
}): Promise<{ data: SupportTicket } | { error: string }> {
const token = await getAuthToken();
if (!token) {
return { error: "No auth token available" };
}
try {
const walletAddress = await getAuthTokenWalletAddress();
const encodedTeamSlug = encodeURIComponent(params.teamSlug);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`;
// Build the payload for creating a conversation
// If the message does not already include wallet address, prepend it
let message = params.message;
if (!message.includes("Wallet address:")) {
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
}
const payload = {
markdown: message.trim(),
title: params.title,
};
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Accept-Encoding": "identity",
};
const response = await fetch(apiUrl, {
body,
headers,
method: "POST",
});
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
}
const createdConversation: SupportTicket = await response.json();
// Escalate to SIWA feedback endpoint if conversationId is provided
if (params.conversationId) {
try {
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
if (siwaUrl) {
await fetch(`${siwaUrl}/v1/chat/feedback`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(params.teamId ? { "x-team-id": params.teamId } : {}),
},
body: JSON.stringify({
conversationId: params.conversationId,
feedbackRating: ESCALATION_FEEDBACK_RATING,
}),
});
}
} catch (error) {
// Log error but don't fail the ticket creation
console.error("Failed to escalate to SIWA feedback:", error);
}
}
return { data: createdConversation };
} catch (error) {
return {
error: `Failed to create support ticket: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}
export async function sendMessageToTicket(request: {
ticketId: string;
teamSlug: string;
teamId: string;
message: string;
}): Promise<{ success: true } | { error: string }> {
if (!request.ticketId || !request.teamSlug) {
return { error: "Ticket ID and team slug are required" };
}
const token = await getAuthToken();
if (!token) {
return { error: "No auth token available" };
}
try {
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
const encodedTicketId = encodeURIComponent(request.ticketId);
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;
// Append /unthread send for customer messages to ensure proper routing
const messageWithUnthread = `${request.message.trim()}\n/unthread send`;
const payload = {
markdown: messageWithUnthread,
};
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Accept-Encoding": "identity",
...(request.teamId ? { "x-team-id": request.teamId } : {}),
};
const response = await fetch(apiUrl, {
body,
headers,
method: "POST",
});
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
}
return { success: true };
} catch (error) {
return {
error: `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export async function service_getTeamBySlug(slug: string) {
return null;
}
export function getTeamById(id: string) {
function getTeamById(id: string) {
return getTeamBySlug(id);
}
+52 -2
View File
@@ -37,7 +37,7 @@ type WebhookConfigsResponse =
};
interface CreateWebhookConfigRequest {
topics: { id: string; filters: object | null }[];
topicIdsWithFilters: { id: string; filters: object | null }[];
destinationUrl: string;
description: string;
isPaused?: boolean;
@@ -76,7 +76,7 @@ type TopicsResponse =
interface UpdateWebhookConfigRequest {
destinationUrl?: string;
topics?: { id: string; filters: object | null }[];
topicIdsWithFilters?: { id: string; filters: object | null }[];
description?: string;
isPaused?: boolean;
}
@@ -310,3 +310,53 @@ export async function deleteWebhookConfig(props: {
status: "success",
};
}
type TestDestinationUrlResponse =
| {
result: {
httpStatusCode: number;
httpResponseBody: string;
};
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export async function testDestinationUrl(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
destinationUrl: string;
}): Promise<TestDestinationUrlResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const resp = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/test-destination-url`,
{
body: JSON.stringify({ destinationUrl: props.destinationUrl }),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!resp.ok) {
return {
body: await resp.text(),
reason: "unknown",
status: "error",
};
}
return await resp.json();
}
@@ -1,23 +1,18 @@
"use client";
import {
Alert,
AlertIcon,
Flex,
FormControl,
Input,
InputGroup,
InputRightElement,
Textarea,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "chakra/button";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import { Text } from "chakra/text";
import { ChevronLeftIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
ArrowLeftIcon,
ArrowRightIcon,
EyeIcon,
EyeOffIcon,
HelpCircleIcon,
LockKeyholeIcon,
RefreshCcwIcon,
ShuffleIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721";
@@ -25,12 +20,14 @@ import type { NFTInput } from "thirdweb/utils";
import { z } from "zod";
import { FileInput } from "@/components/blocks/FileInput";
import { TransactionButton } from "@/components/tx-button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import type { ComponentWithChildren } from "@/types/component-with-children";
import { Button } from "@/components/ui/button";
import { Form, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { processInputData, shuffleData } from "@/utils/batch";
import { TabButtons } from "../ui/tabs";
import { BatchTable } from "./batch-table";
import { SelectOption } from "./lazy-mint-form/select-option";
import { UploadStep } from "./upload-step";
type DelayedSubmit = {
@@ -44,16 +41,6 @@ type InstantSubmit = {
type SubmitType = DelayedSubmit | InstantSubmit;
interface BatchLazyMintEVMProps {
nextTokenIdToMint: bigint;
canCreateDelayedRevealBatch: boolean;
onSubmit: (formData: SubmitType) => Promise<unknown>;
chainId: number;
client: ThirdwebClient;
}
type BatchLazyMintProps = BatchLazyMintEVMProps;
const BatchLazyMintFormSchema = z
.object({
confirmPassword: z
@@ -73,7 +60,7 @@ const BatchLazyMintFormSchema = z
name: z.string().min(1, "A name is required"),
})
.optional(),
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
revealType: z.literal("instant").or(z.literal("delayed")),
// shared logic
shuffle: z.boolean().default(false),
@@ -91,18 +78,21 @@ function useBatchLazyMintForm() {
return useForm<BatchLazyMintFormType>({
defaultValues: {
metadatas: [],
revealType: undefined,
revealType: "instant",
shuffle: false,
},
resolver: zodResolver(BatchLazyMintFormSchema),
});
}
export const BatchLazyMint: ComponentWithChildren<
BatchLazyMintProps & {
isLoggedIn: boolean;
}
> = (props) => {
export function BatchLazyMint(props: {
nextTokenIdToMint: bigint;
canCreateDelayedRevealBatch: boolean;
onSubmit: (formData: SubmitType) => Promise<unknown>;
chainId: number;
client: ThirdwebClient;
isLoggedIn: boolean;
}) {
const [step, setStep] = useState(0);
const form = useBatchLazyMintForm();
@@ -110,369 +100,415 @@ export const BatchLazyMint: ComponentWithChildren<
const nftMetadatas = form.watch("metadatas");
const hasError = !!form.getFieldState("metadatas", form.formState).error;
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles) => {
try {
await processInputData(acceptedFiles, (data) =>
form.setValue("metadatas", data),
);
} catch {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
if (nftMetadatas.length === 0) {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
},
});
const paginationPortalRef = useRef<HTMLDivElement>(null);
return (
<form
className="mt-4 flex w-full flex-col"
onSubmit={form.handleSubmit((data) => {
// first shuffle
const shuffledMetadatas = data.shuffle
? shuffleData(data.metadatas)
: data.metadatas;
<Form {...form}>
<form
className="mt-4 flex w-full flex-col"
onSubmit={form.handleSubmit(async (data) => {
// first shuffle
const shuffledMetadatas = data.shuffle
? shuffleData(data.metadatas)
: data.metadatas;
// check submit is instant
if (data.revealType === "instant") {
return props.onSubmit({
data: { metadatas: shuffledMetadatas },
revealType: "instant",
});
}
// validate password
if (!data.password) {
form.setError("password", {
message: "A password is required for delayed reveal.",
type: "validate",
});
return;
}
// validate placeholder
if (!data.placeHolder?.name) {
form.setError("placeHolder.name", {
message: "A name is required for delayed reveal.",
type: "validate",
});
}
// submit
return props.onSubmit({
data: {
metadata: shuffledMetadatas,
password: data.password,
placeholderMetadata: {
description: data.placeHolder?.description,
image: data.placeHolder?.image,
name: data.placeHolder?.name,
// check submit is instant
if (data.revealType === "instant") {
return props.onSubmit({
data: { metadatas: shuffledMetadatas },
revealType: "instant",
});
}
// validate password
if (!data.password) {
form.setError("password", {
message: "A password is required for delayed reveal.",
type: "validate",
});
return;
}
// validate placeholder
if (!data.placeHolder?.name) {
form.setError("placeHolder.name", {
message: "A name is required for delayed reveal.",
type: "validate",
});
}
// submit
await props.onSubmit({
data: {
metadata: shuffledMetadatas,
password: data.password,
placeholderMetadata: {
description: data.placeHolder?.description,
image: data.placeHolder?.image,
name: data.placeHolder?.name,
},
},
},
revealType: "delayed",
});
})}
>
{step === 0 ? (
<div className="flex flex-col gap-6">
{nftMetadatas.length > 0 ? (
<>
<BatchTable
client={props.client}
data={nftMetadatas}
nextTokenIdToMint={props.nextTokenIdToMint}
portalRef={paginationPortalRef}
/>
<div className="border-border border-t">
<div className="flex flex-col items-center justify-between p-0 md:flex-row md:p-4">
<div ref={paginationPortalRef} />
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
<Button
borderRadius="md"
isDisabled={!hasError}
onClick={() => {
form.reset();
}}
w={{ base: "100%", md: "auto" }}
>
Reset
</Button>
<Button
borderRadius="md"
colorScheme="primary"
onClick={() => setStep(1)}
w={{ base: "100%", md: "auto" }}
>
Next
</Button>
</div>
revealType: "delayed",
});
})}
>
{step === 0 ? (
<div>
{nftMetadatas.length > 0 ? (
<div className="pb-10">
<h2 className="text-2xl font-semibold tracking-tight mb-4">
Upload NFTs
</h2>
<BatchTable
client={props.client}
data={nftMetadatas}
nextTokenIdToMint={props.nextTokenIdToMint}
/>
<div className="mt-5 flex justify-end gap-4">
<Button
className="w-full md:w-auto gap-2"
disabled={!hasError}
onClick={() => {
form.reset();
}}
variant="outline"
>
<RefreshCcwIcon className="size-4" />
Reset
</Button>
<Button
className="w-full md:w-auto gap-2"
onClick={() => setStep(1)}
>
Next <ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
</>
) : (
<UploadStep
getInputProps={getInputProps}
getRootProps={getRootProps}
hasFailed={hasError}
isDragActive={isDragActive}
/>
)}
</div>
) : (
<>
<Flex align="center" justify="space-between" mb={2} py={4} w="100%">
<div className="flex flex-row items-center gap-2">
) : (
<UploadStep
hasFailed={hasError}
reset={() => {
form.reset();
}}
onDrop={async (acceptedFiles) => {
try {
await processInputData(acceptedFiles, (data) =>
form.setValue("metadatas", data),
);
} catch {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
if (nftMetadatas.length === 0) {
form.setError("metadatas", {
message: "Invalid metadata files",
type: "validate",
});
}
}}
/>
)}
</div>
) : (
<div className="container px-0 max-w-4xl pb-14">
<div className="flex mb-3">
<Button
className="text-muted-foreground"
className="gap-2 -translate-x-3 text-muted-foreground"
size="sm"
onClick={() => setStep(0)}
variant="ghost"
>
<ChevronLeftIcon className="size-5 cursor-pointer" />
<ArrowLeftIcon className="size-4" />
Back
</Button>
<Heading className="my-auto" size="title.md">
When will you reveal your NFTs?
</Heading>
</div>
</Flex>
<SelectReveal
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
client={props.client}
form={form}
/>
{form.watch("revealType") && (
<>
<CheckboxWithLabel>
<Checkbox
checked={form.watch("shuffle")}
className="mt-3"
onCheckedChange={(val) => form.setValue("shuffle", !!val)}
/>
<div className="flex flex-col items-center gap-1 md:flex-row">
<p>Shuffle the order of the NFTs before uploading.</p>
<em>This is an off-chain operation and is not provable.</em>
</div>
</CheckboxWithLabel>
<div className="w-full md:w-[61%]">
<TransactionButton
className="mt-4 w-full"
client={props.client}
disabled={!nftMetadatas.length}
isLoggedIn={props.isLoggedIn}
isPending={form.formState.isSubmitting}
transactionCount={1}
txChainID={props.chainId}
type="submit"
>
{form.formState.isSubmitting
? `Uploading ${nftMetadatas.length} NFTs`
: `Upload ${nftMetadatas.length} NFTs`}
</TransactionButton>
{props.children}
</div>
<Text mt={2} size="body.sm">
<UnderlineLink
className="text-primary-500"
href="https://support.thirdweb.com/dashboard/n5evQ4EfEjEifczEQaZ1hL/batch-upload-troubleshooting/5WMQFqfaUTU1C8NM8FtJ2X"
rel="noopener noreferrer"
target="_blank"
>
Experiencing issues uploading your files?
</UnderlineLink>
</Text>
</>
)}
</>
)}
</form>
);
};
interface SelectRevealProps {
<h2 className="text-2xl font-semibold tracking-tight mb-4">
Upload NFTs
</h2>
<SelectReveal
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
client={props.client}
form={form}
/>
<div className="h-4" />
<div className="space-y-6">
{form.watch("revealType") === "delayed" && (
<DelayedRevealConfiguration client={props.client} form={form} />
)}
{/* shuffle */}
<ShuffleNFTsCard
isShuffleEnabled={form.watch("shuffle")}
onShuffleChange={(val) => form.setValue("shuffle", val)}
/>
<div className="space-y-3">
<div className="flex justify-end">
<TransactionButton
client={props.client}
disabled={!nftMetadatas.length}
isLoggedIn={props.isLoggedIn}
isPending={form.formState.isSubmitting}
transactionCount={1}
txChainID={props.chainId}
type="submit"
>
{form.formState.isSubmitting
? `Uploading ${nftMetadatas.length} NFTs`
: `Upload ${nftMetadatas.length} NFTs`}
</TransactionButton>
</div>
<div className="flex justify-end">
<Link
className="text-sm flex items-center gap-2 text-muted-foreground hover:text-foreground"
href="https://portal.thirdweb.com/knowledge-base/troubleshoot/contracts/batch-upload"
rel="noopener noreferrer"
target="_blank"
>
<HelpCircleIcon className="size-4" />
Experiencing issues uploading your files?
</Link>
</div>
</div>
</div>
</div>
)}
</form>
</Form>
);
}
function ShuffleNFTsCard(props: {
isShuffleEnabled: boolean;
onShuffleChange: (val: boolean) => void;
}) {
return (
<div className="px-4 lg:px-6 py-8 border rounded-lg bg-card relative">
<div className="flex mb-3">
<div className="p-2 rounded-full border bg-card">
<ShuffleIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
Shuffle NFTs
</h3>
<p className="text-sm text-muted-foreground">
Shuffle the order of the NFTs before uploading. This is an off-chain
operation and is not provable
</p>
<Switch
checked={props.isShuffleEnabled}
onCheckedChange={(val) => props.onShuffleChange(!!val)}
className="absolute right-4 top-4 lg:right-6 lg:top-8"
/>
</div>
);
}
function SelectReveal(props: {
form: ReturnType<typeof useBatchLazyMintForm>;
canCreateDelayedRevealBatch: boolean;
client: ThirdwebClient;
}) {
const { form, canCreateDelayedRevealBatch } = props;
return (
<div>
{canCreateDelayedRevealBatch && (
<TabButtons
containerClassName="mb-8"
tabClassName="!text-sm"
tabs={[
{
name: "Reveal upon mint",
onClick: () => {
form.setValue("revealType", "instant");
// reset all fields related to delayed reveal
form.resetField("password");
form.resetField("confirmPassword");
form.resetField("placeHolder");
},
isActive: form.watch("revealType") === "instant",
},
{
name: "Delayed Reveal",
onClick: () => form.setValue("revealType", "delayed"),
isActive: form.watch("revealType") === "delayed",
},
]}
/>
)}
{form.watch("revealType") === "instant" && (
<div>
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
Reveal upon mint
</h2>
<p className="text-muted-foreground text-sm">
Collectors will immediately see the final NFT when they complete the
minting
</p>
</div>
)}
{form.watch("revealType") === "delayed" && (
<div>
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
Delayed Reveal
</h2>
<p className="text-muted-foreground text-sm">
Collectors will mint your placeholder image, then you reveal at a
later time
</p>
</div>
)}
</div>
);
}
const SelectReveal: React.FC<SelectRevealProps> = ({
form,
client,
canCreateDelayedRevealBatch,
}) => {
function DelayedRevealConfiguration(props: {
form: ReturnType<typeof useBatchLazyMintForm>;
client: ThirdwebClient;
}) {
const { form, client } = props;
const [show, setShow] = useState(false);
const imageUrl = form.watch("placeHolder.image");
return (
<Flex flexDir="column">
<Flex
flexDir={{ base: "column", md: "row" }}
gap={{ base: 3, md: 6 }}
mb={6}
>
<SelectOption
description="Collectors will immediately see the final NFT when they complete the minting"
isActive={form.watch("revealType") === "instant"}
name="Reveal upon mint"
onClick={() => form.setValue("revealType", "instant")}
/>
<SelectOption
description="Collectors will mint your placeholder image, then you reveal at a later time"
disabled={!canCreateDelayedRevealBatch}
disabledText="This contract doesn't implement Delayed Reveal"
isActive={form.watch("revealType") === "delayed"}
name="Delayed Reveal"
onClick={() => form.setValue("revealType", "delayed")}
/>
</Flex>
<div className="flex flex-col gap-3">
{form.watch("revealType") === "delayed" && (
<>
<Heading size="title.sm">Let&apos;s set a password</Heading>
<Alert borderRadius="lg" status="warning">
<AlertIcon />
You&apos;ll need this password to reveal your NFTs. Please save it
somewhere safe.
</Alert>
<div className="space-y-6">
{/* password */}
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
<div className="flex mb-3">
<div className="p-2 rounded-full border bg-card">
<LockKeyholeIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
Set password
</h3>
<p className="text-sm text-muted-foreground mb-4">
You'll need this password to reveal your NFTs. Please save it
somewhere safe
</p>
<Flex
flexDir={{ base: "column", md: "row" }}
gap={{ base: 4, md: 0 }}
>
<FormControl
isInvalid={
!!form.getFieldState("password", form.formState).error
}
isRequired
mr={4}
<div className="space-y-5">
{/* select password */}
<FormItem className="space-y-1">
<FormLabel>Password</FormLabel>
<div className="relative max-w-md">
<Input
{...form.register("password")}
className="pr-10"
type={show ? "text" : "password"}
/>
<Button
variant="ghost"
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 h-auto text-muted-foreground"
onClick={() => setShow(!show)}
>
<FormLabel>Password</FormLabel>
<InputGroup>
<Input
{...form.register("password")}
placeholder="Choose password"
type={show ? "text" : "password"}
/>
<InputRightElement cursor="pointer">
{show ? (
<EyeIcon
className="size-3"
onClick={() => setShow(!show)}
/>
) : (
<EyeOffIcon
className="size-3"
onClick={() => setShow(!show)}
/>
)}
</InputRightElement>
</InputGroup>
<FormErrorMessage>
{
form.getFieldState("password", form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
<FormControl
isInvalid={
!!form.getFieldState("confirmPassword", form.formState).error
}
isRequired
>
<FormLabel>Confirm password</FormLabel>
<Input
{...form.register("confirmPassword")}
placeholder="Confirm password"
type="password"
/>
<FormErrorMessage>
{
form.getFieldState("confirmPassword", form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
</Flex>
<div className="flex flex-col gap-5">
<Heading size="title.sm">Placeholder</Heading>
<FormControl
isInvalid={
!!form.getFieldState("placeHolder.image", form.formState)
.error
}
>
<FormLabel>Image</FormLabel>
<div className="w-auto md:w-[350px]">
<FileInput
accept={{ "image/*": [] }}
className="rounded border border-border transition-all duration-200"
client={client}
setValue={(file) =>
form.setValue("placeHolder.image", file)
}
showUploadButton
value={imageUrl}
/>
</div>
<FormHelperText>
You can optionally upload an image as the placeholder.
</FormHelperText>
<FormErrorMessage>
{
form.getFieldState("placeHolder.image", form.formState)
.error?.message
}
</FormErrorMessage>
</FormControl>
<FormControl
isInvalid={
!!form.getFieldState("placeHolder.name", form.formState).error
}
isRequired
>
<FormLabel>Name</FormLabel>
<Input
{...form.register("placeHolder.name")}
placeholder="eg. My NFT (Coming soon)"
/>
<FormErrorMessage>
{
form.getFieldState("placeHolder.name", form.formState).error
?.message
}
</FormErrorMessage>
</FormControl>
<FormControl
isInvalid={
!!form.getFieldState("placeHolder.name", form.formState).error
}
>
<FormLabel>Description</FormLabel>
<Textarea
{...form.register("placeHolder.description")}
placeholder="eg. Reveal on July 15th!"
/>
<FormErrorMessage>
{
form.getFieldState(
"placeHolder.description",
form.formState,
).error?.message
}
</FormErrorMessage>
</FormControl>
{show ? (
<EyeIcon className="size-4" />
) : (
<EyeOffIcon className="size-4" />
)}
</Button>
</div>
</>
)}
<FormMessage>
{form.getFieldState("password", form.formState).error?.message}
</FormMessage>
</FormItem>
{/* confirm password */}
<FormItem className="space-y-1">
<FormLabel>Confirm password</FormLabel>
<div className="relative max-w-md">
<Input
{...form.register("confirmPassword")}
className="pr-10"
type="password"
/>
</div>
<FormMessage>
{
form.getFieldState("confirmPassword", form.formState).error
?.message
}
</FormMessage>
</FormItem>
</div>
</div>
</Flex>
{/* placeholder */}
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
<div className="flex mb-3">
<div className="p-2 rounded-full border bg-card">
<EyeIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="font-semibold text-lg tracking-tight mb-0.5">
Set placeholder metadata
</h3>
<p className="text-sm text-muted-foreground mb-4">
The placeholder metadata will be displayed for all NFTs until you
reveal your NFTs
</p>
<div className="flex flex-col lg:flex-row gap-5">
<FormItem className="w-[200px]">
<FormLabel>Image</FormLabel>
<FileInput
accept={{ "image/*": [] }}
className="rounded border bg-background transition-all duration-200 "
client={client}
setValue={(file) => form.setValue("placeHolder.image", file)}
value={imageUrl}
/>
<FormMessage>
{
form.getFieldState("placeHolder.image", form.formState).error
?.message
}
</FormMessage>
</FormItem>
<div className="grow space-y-5 flex flex-col">
<FormItem>
<FormLabel>Name</FormLabel>
<Input
className="max-w-sm"
{...form.register("placeHolder.name")}
placeholder="My NFT (Coming soon)"
/>
<FormMessage>
{
form.getFieldState("placeHolder.name", form.formState).error
?.message
}
</FormMessage>
</FormItem>
<FormItem className="grow flex flex-col">
<FormLabel>Description</FormLabel>
<Textarea
{...form.register("placeHolder.description")}
className="grow"
placeholder="Reveal on July 15th!"
/>
<FormMessage>
{
form.getFieldState("placeHolder.description", form.formState)
.error?.message
}
</FormMessage>
</FormItem>
</div>
</div>
</div>
</div>
);
};
}
@@ -1,237 +1,183 @@
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
import {
Flex,
IconButton,
Portal,
Select,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import {
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { useMemo } from "react";
import { type Column, usePagination, useTable } from "react-table";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { NFTInput } from "thirdweb/utils";
import { FilePreview } from "@/components/blocks/file-preview";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { CodeClient } from "@/components/ui/code/code.client";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
interface BatchTableProps {
type BatchTableProps = {
data: NFTInput[];
portalRef: React.RefObject<HTMLDivElement | null>;
nextTokenIdToMint?: bigint;
client: ThirdwebClient;
}
};
export const BatchTable: React.FC<BatchTableProps> = ({
export function BatchTable({
data,
portalRef,
nextTokenIdToMint,
client,
}) => {
const columns = useMemo(() => {
let cols: Column<NFTInput>[] = [];
if (nextTokenIdToMint !== undefined) {
cols = cols.concat({
accessor: (_row, index) => String(nextTokenIdToMint + BigInt(index)),
Header: "Token ID",
});
}
}: BatchTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
cols = cols.concat([
{
accessor: (row) => row.image,
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
<FilePreview
className="size-24 shrink-0 rounded-lg object-contain"
client={client}
srcOrFile={value}
/>
),
Header: "Image",
},
{
accessor: (row) => row.animation_url,
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
<FilePreview
className="size-24 shrink-0 rounded-lg"
client={client}
srcOrFile={value}
/>
),
Header: "Animation Url",
},
{ accessor: (row) => row.name, Header: "Name" },
{
accessor: (row) => (
<ToolTipLabel label={row.description}>
<p className="line-clamp-6 whitespace-pre-wrap">
{row.description}
</p>
</ToolTipLabel>
),
Header: "Description",
},
{
accessor: (row) => row.attributes || row.properties,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
Cell: ({ cell }: { cell: any }) =>
cell.value ? (
<CodeClient
code={JSON.stringify(cell.value || {}, null, 2)}
lang="json"
scrollableClassName="max-w-[300px]"
/>
) : null,
Header: "Attributes",
},
{ accessor: (row) => row.external_url, Header: "External URL" },
{ accessor: (row) => row.background_color, Header: "Background Color" },
]);
return cols;
}, [nextTokenIdToMint, client]);
const totalPages = Math.ceil(data.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentData = data.slice(startIndex, endIndex);
const {
getTableProps,
getTableBodyProps,
headerGroups,
prepareRow,
// Instead of using 'rows', we'll use page,
page,
// which has only the rows for the active page
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// The rest of these things are super handy, too ;)
canPreviousPage,
canNextPage,
pageOptions,
pageCount,
gotoPage,
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable(
{
columns,
data,
initialState: {
pageIndex: 0,
pageSize: 50,
},
},
// will be fixed with @tanstack/react-table v8
// eslint-disable-next-line react-compiler/react-compiler
usePagination,
);
const showTokenId = nextTokenIdToMint !== undefined;
const showExternalUrl = data.some((row) => row.external_url);
const showBackgroundColor = data.some((row) => row.background_color);
const showAnimationUrl = data.some((row) => row.animation_url);
const showDescription = data.some((row) => row.description);
const showAttributes = data.some((row) => row.attributes || row.properties);
const showPagination = totalPages > 1;
// Render the UI for your table
return (
<Flex flexGrow={1} overflow="auto">
<TableContainer className="w-full" maxW="100%">
<Table {...getTableProps()}>
<Thead>
{headerGroups.map((headerGroup, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Tr {...headerGroup.getHeaderGroupProps()} key={index}>
{headerGroup.headers.map((column, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
<Th {...column.getHeaderProps()} border="none" key={i}>
<p className="text-muted-foreground">
{column.render("Header")}
</p>
</Th>
))}
</Tr>
))}
</Thead>
<Tbody {...getTableBodyProps()}>
{page.map((row, rowIndex) => {
prepareRow(row);
<div className="rounded-lg border bg-card">
<TableContainer className="border-0">
<Table>
<TableHeader>
<TableRow>
{showTokenId && <TableHead>Token ID</TableHead>}
<TableHead>Image</TableHead>
{showAnimationUrl && <TableHead>Animation Url</TableHead>}
<TableHead>Name</TableHead>
{showDescription && (
<TableHead className="min-w-[300px]">Description</TableHead>
)}
{showAttributes && <TableHead>Attributes</TableHead>}
{showExternalUrl && <TableHead>External URL</TableHead>}
{showBackgroundColor && <TableHead>Background Color</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{currentData.map((row, rowIndex) => {
const actualIndex = startIndex + rowIndex;
return (
<Tr
{...row.getRowProps()}
_last={{ borderBottomWidth: 0 }}
borderBottomWidth={1}
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={rowIndex}
<TableRow
className="border-b last:border-b-0"
key={actualIndex}
>
{row.cells.map((cell, cellIndex) => {
return (
<Td
{...cell.getCellProps()}
borderBottomWidth="inherit"
borderColor="borderColor"
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
key={cellIndex}
>
{cell.render("Cell")}
</Td>
);
})}
</Tr>
{/* Token ID */}
{showTokenId && (
<TableCell>
{String(nextTokenIdToMint + BigInt(actualIndex))}
</TableCell>
)}
{/* Image */}
<TableCell className="min-w-36">
<FilePreview
className="size-36 shrink-0 rounded-lg object-contain"
client={client}
srcOrFile={
typeof row.image === "string" ||
row.image instanceof File
? row.image
: undefined
}
/>
</TableCell>
{/* Animation Url */}
{showAnimationUrl && (
<TableCell className="min-w-36">
<FilePreview
className="size-36 shrink-0 rounded-lg"
client={client}
srcOrFile={
typeof row.animation_url === "string" ||
row.animation_url instanceof File
? row.animation_url
: undefined
}
/>
</TableCell>
)}
{/* Name */}
<TableCell>{row.name}</TableCell>
{/* Description */}
{showDescription && (
<TableCell className="max-w-xs">
<p className="whitespace-pre-wrap">{row.description}</p>
</TableCell>
)}
{/* Attributes */}
{showAttributes && (
<TableCell>
{row.attributes || row.properties ? (
<CodeClient
code={JSON.stringify(
row.attributes || row.properties || {},
null,
2,
)}
lang="json"
scrollableClassName="max-w-[300px] max-h-[400px]"
className="bg-background [&>*]:text-xs"
/>
) : null}
</TableCell>
)}
{/* External URL */}
{showExternalUrl && (
<TableCell>
{typeof row.external_url === "string" ? (
<ToolTipLabel label={row.external_url}>
<span>
{row.external_url.slice(0, 20) +
(row.external_url.length > 20 ? "..." : "")}
</span>
</ToolTipLabel>
) : row.external_url instanceof File ? (
<FilePreview
client={client}
srcOrFile={row.external_url}
/>
) : null}
</TableCell>
)}
{/* Background Color */}
{showBackgroundColor && (
<TableCell>{row.background_color}</TableCell>
)}
</TableRow>
);
})}
</Tbody>
</TableBody>
</Table>
</TableContainer>
<Portal containerRef={portalRef}>
<div className="flex w-full items-center justify-center">
<div className="flex flex-row items-center gap-2">
<IconButton
aria-label="first page"
icon={<ChevronFirstIcon className="size-4" />}
isDisabled={!canPreviousPage}
onClick={() => gotoPage(0)}
/>
<IconButton
aria-label="previous page"
icon={<ChevronLeftIcon className="size-4" />}
isDisabled={!canPreviousPage}
onClick={() => previousPage()}
/>
<p className="whitespace-nowrap">
Page <strong>{pageIndex + 1}</strong> of{" "}
<strong>{pageOptions.length}</strong>
</p>
<IconButton
aria-label="next page"
icon={<ChevronRightIcon className="size-4" />}
isDisabled={!canNextPage}
onClick={() => nextPage()}
/>
<IconButton
aria-label="last page"
icon={<ChevronLastIcon className="size-4" />}
isDisabled={!canNextPage}
onClick={() => gotoPage(pageCount - 1)}
/>
<Select
onChange={(e) => {
setPageSize(Number.parseInt(e.target.value as string, 10));
}}
value={pageSize}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</Select>
</div>
{showPagination && (
<div className="border-t py-5">
<PaginationButtons
activePage={currentPage}
totalPages={totalPages}
onPageClick={handlePageChange}
/>
</div>
</Portal>
</Flex>
)}
</div>
);
};
}
@@ -42,18 +42,18 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
disabled
? "pointer-events-none cursor-not-allowed bg-muted"
: "cursor-pointer",
isActive && "border-primary",
isActive && "bg-muted/50 border-active-border",
className,
)}
onClick={onClick}
{...divProps}
>
<div className="flex flex-row justify-between">
<div className="flex flex-row items-start">
<div className="mt-0.5 mr-2.5 flex h-4 w-4 items-center justify-center rounded-full border border-inverted text-inverted">
<div className="flex flex-row items-start gap-4">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-inverted text-inverted">
{isActive && <div className="h-2 w-2 rounded-full bg-inverted" />}
</div>
<div className="ml-4 flex flex-col gap-2 self-start">
<div>
<h4
className={cn(
"font-semibold text-sm",
@@ -62,7 +62,11 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
>
{name}
</h4>
{description && <p className="mt-1 text-sm">{description}</p>}
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
{infoText && (
@@ -1,137 +1,333 @@
import { UploadIcon } from "lucide-react";
import { ArrowDownToLineIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CodeClient } from "@/components/ui/code/code.client";
import { InlineCode } from "@/components/ui/inline-code";
import { UnorderedList } from "@/components/ui/List/List";
import { cn } from "@/lib/utils";
import { TabButtons } from "@/components/ui/tabs";
import { handleDownload } from "../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/download-file-button";
import { DropZone } from "../blocks/drop-zone/drop-zone";
interface UploadStepProps {
// biome-ignore lint/suspicious/noExplicitAny: FIXME
getRootProps: any;
// biome-ignore lint/suspicious/noExplicitAny: FIXME
getInputProps: any;
hasFailed: boolean;
isDragActive: boolean;
onDrop: (acceptedFiles: File[]) => void;
reset: () => void;
}
export const UploadStep: React.FC<UploadStepProps> = ({
getRootProps,
hasFailed,
isDragActive,
getInputProps,
}) => {
export function UploadStep(props: UploadStepProps) {
const [tab, setTab] = useState<"csv" | "json">("csv");
return (
<div className="flex flex-col items-center gap-8 md:flex-row">
<div className="relative aspect-square w-full md:w-1/2">
<div
className={cn(
"flex h-full cursor-pointer rounded-md border border-border hover:border-blue-500",
hasFailed ? "bg-red-200" : "bg-background",
)}
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="m-auto flex flex-col p-6">
<UploadIcon
className={cn(
"mx-auto mb-2 size-8",
hasFailed ? "text-red-500" : "text-gray-600",
)}
<div className="container px-0 max-w-3xl">
<h2 className="text-2xl font-semibold tracking-tight mb-4">
Upload NFTs
</h2>
<DropZone
className="w-full py-24"
onDrop={props.onDrop}
isError={props.hasFailed}
title={props.hasFailed ? "Invalid files" : "Upload files"}
description={
props.hasFailed
? "Make sure the uploaded files follow the requirements mentioned below"
: "Drag and drop the files or folder"
}
resetButton={{
label: "Reset",
onClick: props.reset,
}}
accept={undefined}
/>
<div className="h-8" />
<div className="w-full">
<TabButtons
tabs={[
{
isActive: tab === "csv",
name: "CSV",
onClick: () => setTab("csv"),
},
{
isActive: tab === "json",
name: "JSON",
onClick: () => setTab("json"),
},
]}
/>
<div className="h-6" />
<div className="space-y-8">
<Section title="Uploading Files">
<p>
Make sure to drag and drop the {tab === "csv" ? "CSV" : "JSON"}{" "}
and the assets at the same time or upload a folder with all the
files in it.
</p>
<p>
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension].
It doesn't matter at what number you begin. Example: 131.png,
132.png etc.
</p>
<p>
Images and other file types can be used in combination. They both
have to follow the asset naming convention above. Example: 0.png
and 0.mp4, 1.png and 1.glb etc.
</p>
</Section>
<Section title={`${tab === "csv" ? "CSV" : "JSON"} File Format`}>
<p>
The {tab === "csv" ? "CSV" : "JSON"} file must have a{" "}
<InlineCode code="name" /> {tab === "csv" ? "column" : "property"}
, which defines the name of the NFT
</p>
<p>
Other optional {tab === "csv" ? "columns" : "properties"} are{" "}
<InlineCode code="description" />,{" "}
<InlineCode code="background_color" />,{" "}
<InlineCode code="external_url" />,{" "}
<InlineCode code="animation_url" />,{" "}
{tab === "json" && (
<>
,
<InlineCode code="attributes" />
</>
)}{" "}
and <InlineCode code="supply" />
{". "}
{tab === "csv" &&
"All other columns will be treated as Attributes. For example: See 'eyes', 'nose' below."}
</p>
<ExampleCode
code={tab === "csv" ? csv_example_basic : json_example_basic}
fileNameWithExtension={
tab === "csv" ? "example.csv" : "example.json"
}
lang={tab === "csv" ? "csv" : "json"}
/>
{isDragActive ? (
<p className="text-center text-muted-foreground">
Drop the files here
</p>
) : (
<p
className={cn(
"text-center leading-[1.2]",
hasFailed ? "text-red-500" : "text-muted-foreground",
)}
>
{hasFailed
? `No valid CSV or JSON file found. Please make sure your NFT metadata includes at least a "name" field and try again.`
: "Drag & Drop files or folders here, or click to select files"}
</p>
)}
</div>
</Section>
<Section title="Map your assets to NFTs">
<p>
If you want to map your media files to your NFTs, you can add the
name of your files to the <InlineCode code="image" /> and{" "}
<InlineCode code="animation_url" />{" "}
{tab === "csv" ? "columns" : "properties"}.{" "}
<ExampleCode
code={
tab === "csv"
? csv_with_image_number_example
: json_with_image_number_example
}
fileNameWithExtension={
tab === "csv"
? "example-with-maps.csv"
: "example-with-maps.json"
}
lang={tab === "csv" ? "csv" : "json"}
/>
</p>
</Section>
<Section title="Using asset links instead of uploading assets">
<p>
When uploading files, assets will be uploaded and pinned to IPFS
automatically. If you already have the files uploaded elsewhere,
you can specify the IPFS or HTTP(s) links in the{" "}
<InlineCode code="image" /> and/or{" "}
<InlineCode code="animation_url" />{" "}
{tab === "csv" ? "columns" : "properties"}. instead of uploading
the assets and just upload a single{" "}
{tab === "csv" ? "CSV" : "JSON"} file.
<ExampleCode
code={
tab === "csv"
? csv_with_image_link_example
: json_with_image_link_example
}
fileNameWithExtension={
tab === "csv"
? "example-with-ipfs.csv"
: "example-with-ipfs.json"
}
lang={tab === "csv" ? "csv" : "json"}
/>
</p>
</Section>
</div>
</div>
<div className="flex w-full flex-col gap-2 md:w-1/2">
<p className="text-lg">Requirements</p>
<UnorderedList>
<li>
Files <em>must</em> contain one .csv or .json file with metadata. -{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example.csv"
>
Download example.csv
</a>
.{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example.json"
>
Download example.json
</a>
.
</li>
<li>
The csv <em>must</em> have a <InlineCode code="name" /> column,
which defines the name of the NFT.
</li>
<li>
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension]. It
doesn&apos;t matter at what number you begin. (Example:{" "}
<InlineCode code="131.png" />, <InlineCode code="132.png" />
).
</li>
<li>
Make sure to drag and drop the CSV/JSON and the images{" "}
<strong>at the same time</strong>.
</li>
</UnorderedList>
<p className="mt-4 text-lg">Options</p>
<UnorderedList>
<li>
Images and other file types can be used in combination.
<br />
<small>
They both have to follow the asset naming convention above.
(Example: <InlineCode code="0.png" /> and{" "}
<InlineCode code="0.mp4" />,<InlineCode code="1.png" /> and{" "}
<InlineCode code="1.glb" />, etc.)
</small>
</li>
<li>
When uploading files, we will upload them and pin them to IPFS
automatically for you. If you already have the files uploaded, you
can add an <InlineCode code="image" /> and/or
<InlineCode code="animation_url" /> column and add the IPFS hashes
there.{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example-with-ipfs.csv"
>
Download example.csv
</a>
</li>
<li>
If you want to make your media files map to your NFTs, you can add
the name of your files to the <InlineCode code="image" /> and
<InlineCode code="animation_url" /> column.{" "}
<a
className="text-link-foreground hover:text-foreground"
download
href="/assets/examples/example-with-maps.csv"
>
Download example.csv
</a>
</li>
</UnorderedList>
</div>
</div>
);
};
}
function Section(props: { title: string; children: React.ReactNode }) {
return (
<section>
<h2 className="mb-1 font-medium text-base text-foreground">
{props.title}
</h2>
<div className="space-y-1.5 text-muted-foreground leading-relaxed">
{props.children}
</div>
</section>
);
}
function ExampleCode(props: {
code: string;
lang: "csv" | "json";
fileNameWithExtension: string;
}) {
return (
<div className="!my-3 relative">
<CodeClient
code={props.code}
lang={props.lang}
scrollableClassName="max-h-[300px] bg-card"
/>
<Button
className="absolute top-3.5 right-14 mt-[1px] h-auto bg-background p-2"
onClick={() => {
handleDownload({
fileContent: props.code,
fileFormat: props.lang === "csv" ? "text/csv" : "application/json",
fileNameWithExtension: props.fileNameWithExtension,
});
}}
size="sm"
variant="outline"
>
<ArrowDownToLineIcon className="size-3" />
</Button>
</div>
);
}
const csv_example_basic = `\
name,description,background_color,eyes,nose
Token 0 Name,Token 0 Description,#0098EE,red,green
Token 1 Name,Token 1 Description,#0098EE,blue,yellow`;
const json_example_basic = `\
[
{
"name": "Token 0 Name",
"description": "Token 0 Description",
"background_color": "#0098EE",
"attributes": [
{
"trait_type": "eyes",
"value": "red"
},
{
"trait_type": "nose",
"value": "green"
}
]
},
{
"name": "Token 1 Name",
"description": "Token 1 Description",
"background_color": "#0098EE",
"attributes": [
{
"trait_type": "eyes",
"value": "blue"
},
{
"trait_type": "nose",
"value": "yellow"
}
]
}
]`;
const csv_with_image_link_example = `\
name,description,image,animation_url,background_color,eyes,nose
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,#0098EE,red,green
Token 1 Name,Token 1 Description,ipfs://ipfsHash/1,ipfs://ipfsHash/1,#0098EE,blue,yellow`;
const json_with_image_link_example = `\
[
{
"name": "Token 0 Name",
"description": "Token 0 Description",
"image": "ipfs://ipfsHash/0",
"animation_url": "ipfs://ipfsHash/0",
"attributes": [
{
"trait_type": "eyes",
"value": "red"
},
{
"trait_type": "nose",
"value": "green"
}
]
},
{
"name": "Token 1 Name",
"description": "Token 1 Description",
"image": "ipfs://ipfsHash/1",
"animation_url": "ipfs://ipfsHash/1",
"attributes": [
{
"trait_type": "eyes",
"value": "blue"
},
{
"trait_type": "nose",
"value": "yellow"
}
]
}
]`;
const csv_with_image_number_example = `\
name,description,image,animation_url,background_color,eyes,nose
Token 0 Name,Token 0 Description,0.png,0.mp4,#0098EE,red,green
Token 1 Name,Token 1 Description,1.png,1.mp4,#0098EE,blue,yellow`;
const json_with_image_number_example = `\
[
{
"name": "Token 0 Name",
"description": "Token 0 Description",
"image": "0.png",
"animation_url": "0.mp4",
"attributes": [
{
"trait_type": "eyes",
"value": "red"
},
{
"trait_type": "nose",
"value": "green"
}
]
},
{
"name": "Token 1 Name",
"description": "Token 1 Description",
"image": "1.png",
"animation_url": "1.mp4",
"attributes": [
{
"trait_type": "eyes",
"value": "blue"
},
{
"trait_type": "nose",
"value": "yellow"
}
]
}
]`;
@@ -1,4 +1,3 @@
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
import { FilePlusIcon, UploadIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import {
@@ -8,6 +7,7 @@ import {
useDropzone,
} from "react-dropzone";
import type { ThirdwebClient } from "thirdweb";
import { FilePreview } from "@/components/blocks/file-preview";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
title: string;
description?: string;
titleClassName?: string;
headerClassName?: string;
};
customHeader?: React.ReactNode;
// chart config
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
emptyChartState?: React.ReactElement;
margin?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
};
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
return (
<Card className={props.className}>
{props.header && (
<CardHeader>
<CardHeader className={props.header.headerClassName}>
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
{props.header.title}
</CardTitle>
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
{props.emptyChartState}
</EmptyChartState>
) : (
<AreaChart accessibilityLayer data={props.data}>
<AreaChart
accessibilityLayer
data={props.data}
margin={{
right: props.margin?.right ?? 0,
left: props.margin?.left ?? 0,
bottom: props.margin?.bottom ?? 10,
top: props.margin?.top ?? 0,
}}
>
<CartesianGrid vertical={false} />
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
<XAxis
@@ -37,7 +37,12 @@ export function FilePreview(props: {
if (!objectUrl) {
return (
<div className={cn("flex items-center justify-center", props.className)}>
<div
className={cn(
"flex items-center justify-center bg-accent",
props.className,
)}
>
<ImageOffIcon className="size-6 text-muted-foreground" />
</div>
);
@@ -56,7 +61,10 @@ export function FilePreview(props: {
return (
<MediaRenderer
className={cn(props.className, "[&>div]:!bg-muted")}
className={cn(
props.className,
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative overflow-hidden",
)}
client={props.client}
src={objectUrl}
/>
@@ -1,6 +1,6 @@
"use client";
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
import { CrownIcon, LockIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import type { Team } from "@/api/team";
@@ -111,18 +111,15 @@ export function UpsellContent(props: {
<CardContent className="space-y-6">
{props.benefits && props.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 className="font-semibold text-foreground text-sm capitalize text-center">
What you'll get
</h4>
<div className="grid gap-2">
<div className="grid gap-1.5">
{props.benefits.map((benefit) => (
<div
className="flex items-center gap-3"
className="flex items-center justify-center gap-3 text-center text-balance"
key={benefit.description}
>
<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 className="text-xs" variant="secondary">
@@ -116,13 +116,12 @@ export function ChatBar(props: {
) : (
<Button
aria-label="Send"
className="!h-auto w-auto border border-nebula-pink-foreground p-2 disabled:opacity-100"
className="!h-auto w-auto p-2 disabled:opacity-100"
disabled={message.trim() === "" || props.isConnectingWallet}
onClick={() => {
if (message.trim() === "") return;
handleSubmit(message);
}}
variant="pink"
>
<ArrowUpIcon className="size-4" />
</Button>
@@ -1,8 +1,10 @@
"use client";
import { MessageCircleIcon, XIcon } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { createThirdwebClient } from "thirdweb";
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
import { cn } from "@/lib/utils";
@@ -14,22 +16,22 @@ const client = createThirdwebClient({
});
export function CustomChatButton(props: {
isLoggedIn: boolean;
networks: "mainnet" | "testnet" | "all" | null;
isFloating: boolean;
pageType: "chain" | "contract" | "support";
label: string;
examplePrompts: string[];
authToken: string | undefined;
teamId: string | undefined;
authToken: string;
team: Team;
clientId: string | undefined;
requireLogin?: boolean;
}) {
const layoutSegments = useSelectedLayoutSegments();
const [isOpen, setIsOpen] = useState(false);
const [hasBeenOpened, setHasBeenOpened] = useState(false);
const closeModal = useCallback(() => setIsOpen(false), []);
const ref = useRef<HTMLDivElement>(null);
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
return null;
}
return (
<>
{/* Inline Button (not floating) */}
@@ -54,14 +56,14 @@ export function CustomChatButton(props: {
ref={ref}
>
{/* Header with close button */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center justify-between border-b px-4 py-4">
<div className="flex items-center gap-2 font-semibold text-lg">
<MessageCircleIcon className="size-5 text-muted-foreground" />
{props.label}
</div>
<Button
aria-label="Close chat"
className="h-auto w-auto p-1 text-muted-foreground"
className="h-auto w-auto p-1 text-muted-foreground rounded-full"
onClick={closeModal}
size="icon"
variant="ghost"
@@ -80,9 +82,7 @@ export function CustomChatButton(props: {
message: prompt,
title: prompt,
}))}
networks={props.networks}
requireLogin={props.requireLogin}
teamId={props.teamId}
team={props.team}
/>
)}
</div>
@@ -1,44 +1,36 @@
"use client";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWalletConnectionStatus } from "thirdweb/react";
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
import { ChatBar } from "./ChatBar";
import type { UserMessage, UserMessageContent } from "./CustomChats";
import { type CustomChatMessage, CustomChats } from "./CustomChats";
import type { ExamplePrompt, NebulaContext } from "./types";
import type { ExamplePrompt } from "./types";
export default function CustomChatContent(props: {
authToken: string | undefined;
teamId: string | undefined;
team: Team;
clientId: string | undefined;
client: ThirdwebClient;
examplePrompts: ExamplePrompt[];
networks: NebulaContext["networks"];
requireLogin?: boolean;
}) {
if (props.requireLogin !== false && !props.authToken) {
return <LoggedOutStateChatContent />;
}
return (
<CustomChatContentLoggedIn
authToken={props.authToken || ""}
client={props.client}
clientId={props.clientId}
examplePrompts={props.examplePrompts}
teamId={props.teamId}
team={props.team}
/>
);
}
function CustomChatContentLoggedIn(props: {
authToken: string;
teamId: string | undefined;
team: Team;
clientId: string | undefined;
client: ThirdwebClient;
examplePrompts: ExamplePrompt[];
@@ -55,6 +47,9 @@ function CustomChatContentLoggedIn(props: {
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
const connectionStatus = useActiveWalletConnectionStatus();
const [showSupportForm, setShowSupportForm] = useState(false);
const [productLabel, setProductLabel] = useState("");
const handleSendMessage = useCallback(
async (userMessage: UserMessage) => {
const abortController = new AbortController();
@@ -96,7 +91,7 @@ function CustomChatContentLoggedIn(props: {
headers: {
Authorization: `Bearer ${props.authToken}`,
"Content-Type": "application/json",
...(props.teamId ? { "x-team-id": props.teamId } : {}),
"x-team-id": props.team.id,
...(props.clientId ? { "x-client-id": props.clientId } : {}),
},
method: "POST",
@@ -132,7 +127,7 @@ function CustomChatContentLoggedIn(props: {
setEnableAutoScroll(false);
}
},
[props.authToken, props.clientId, props.teamId, sessionId],
[props.authToken, props.clientId, props.team.id, sessionId],
);
const handleFeedback = useCallback(
@@ -165,7 +160,7 @@ function CustomChatContentLoggedIn(props: {
headers: {
Authorization: `Bearer ${props.authToken}`,
"Content-Type": "application/json",
...(props.teamId ? { "x-team-id": props.teamId } : {}),
"x-team-id": props.team.id,
},
method: "POST",
});
@@ -188,9 +183,20 @@ function CustomChatContentLoggedIn(props: {
// Consider implementing retry logic here
}
},
[sessionId, props.authToken, props.teamId, messages],
[sessionId, props.authToken, props.team.id, messages],
);
const handleAddSuccessMessage = useCallback((message: string) => {
setMessages((prev) => [
...prev,
{
type: "assistant" as const,
text: message,
request_id: undefined,
},
]);
}, []);
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
return (
<div className="flex grow flex-col overflow-hidden">
@@ -212,6 +218,12 @@ function CustomChatContentLoggedIn(props: {
sessionId={sessionId}
setEnableAutoScroll={setEnableAutoScroll}
useSmallText
showSupportForm={showSupportForm}
setShowSupportForm={setShowSupportForm}
productLabel={productLabel}
setProductLabel={setProductLabel}
team={props.team}
addSuccessMessage={handleAddSuccessMessage}
/>
)}
<ChatBar
@@ -245,42 +257,6 @@ function CustomChatContentLoggedIn(props: {
);
}
function LoggedOutStateChatContent() {
const pathname = usePathname();
return (
<div className="flex grow flex-col items-center justify-center p-4">
<div className="mb-4 flex justify-center">
<div className="rounded-full border p-1">
<div className="rounded-full border bg-card p-2">
<NebulaIcon className="size-7 text-muted-foreground" />
</div>
</div>
</div>
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
How can I help you <br className="max-sm:hidden" />
today?
</h1>
<div className="h-3" />
<p className="text-base text-muted-foreground">
Sign in to use AI Assistant
</p>
<div className="h-5" />
<Button asChild>
<Link
className="w-full max-w-96 gap-2"
href={`/login?next=${encodeURIComponent(pathname)}`}
>
Sign in
<ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
);
}
function EmptyStateChatPageContent(props: {
sendMessage: (message: UserMessage) => void;
examplePrompts: { title: string; message: string }[];
@@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: {
return (
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
<div className="mb-4 flex justify-center">
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/20 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-1">
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/40 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-2">
<NebulaIcon className="size-7 text-nebula-pink-foreground" />
<div className="rounded-full border p-1 bg-muted/20">
<div className="rounded-full border p-2 bg-inverted">
<ThirdwebMiniLogo
isMonoChrome
className="size-7 text-inverted-foreground"
/>
</div>
</div>
</div>
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
</h1>
<div className="h-6" />
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
<div className="flex flex-col items-center justify-center gap-2.5 overflow-hidden">
{props.examplePrompts.map((prompt) => (
<Button
disabled={false}
key={prompt.title}
className="rounded-full text-xs sm:text-sm truncate bg-card w-fit h-auto py-1.5 whitespace-pre-wrap"
onClick={() =>
props.sendMessage({
content: [
@@ -1,15 +1,26 @@
import {
AlertCircleIcon,
MessageCircleIcon,
ArrowRightIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Team } from "@/api/team";
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { cn } from "@/lib/utils";
import { Reasoning } from "./Reasoning";
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm";
import { Button } from "../ui/button";
import { TextShimmer } from "../ui/text-shimmer";
// Define local types
export type UserMessageContent = { type: "text"; text: string };
@@ -46,11 +57,18 @@ export function CustomChats(props: {
enableAutoScroll: boolean;
useSmallText?: boolean;
sendMessage: (message: UserMessage) => void;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
showSupportForm: boolean;
setShowSupportForm: (v: boolean) => void;
productLabel: string;
setProductLabel: (v: string) => void;
team: Team;
addSuccessMessage?: (message: string) => void;
}) {
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
const scrollAnchorRef = useRef<HTMLDivElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const [supportTicketCreated, setSupportTicketCreated] = useState(false);
// auto scroll to bottom when messages change
// eslint-disable-next-line no-restricted-syntax
@@ -86,6 +104,8 @@ export function CustomChats(props: {
chatScrollContainer.addEventListener("wheel", disableScroll);
}, [setEnableAutoScroll, enableAutoScroll]);
const [showSupportFormDialog, setShowSupportFormDialog] = useState(false);
return (
<div
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
@@ -123,6 +143,66 @@ export function CustomChats(props: {
sendMessage={props.sendMessage}
sessionId={props.sessionId}
/>
{/* Support Case Button/Form in last assistant message */}
{message.type === "assistant" &&
index === props.messages.length - 1 && (
<>
{/* Only show button/form if ticket not created */}
{!supportTicketCreated && (
<div className="mt-3 pl-12">
<Dialog
open={showSupportFormDialog}
onOpenChange={setShowSupportFormDialog}
>
<Button
onClick={() => setShowSupportFormDialog(true)}
size="sm"
variant="outline"
className="rounded-full bg-card"
>
Create Support Case
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Button>
<DialogContent className="p-0 bg-card">
<DynamicHeight>
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
Create Support Case
</DialogTitle>
<p className="text-muted-foreground text-sm">
Let's create a detailed support case for
our technical team.
</p>
</DialogHeader>
<SupportTicketForm
team={props.team}
productLabel={props.productLabel}
setProductLabel={props.setProductLabel}
conversationId={props.sessionId}
closeForm={() => {
setShowSupportFormDialog(false);
}}
onSuccess={() => {
props.setShowSupportForm(false);
props.setProductLabel("");
setSupportTicketCreated(true);
// Add success message as a regular assistant message
if (props.addSuccessMessage) {
const supportPortalUrl = `/team/${props.team.slug}/~/support`;
props.addSuccessMessage(
`Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`,
);
}
}}
/>
</DynamicHeight>
</DialogContent>
</Dialog>
</div>
)}
</>
)}
</div>
);
})}
@@ -143,7 +223,7 @@ function RenderMessage(props: {
nextMessage: CustomChatMessage | undefined;
authToken: string;
sessionId: string | undefined;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
}) {
const { message } = props;
@@ -179,13 +259,16 @@ function RenderMessage(props: {
<div
className={cn(
"flex size-9 items-center justify-center rounded-full",
message.type === "assistant" && "border bg-card",
message.type === "assistant" && "border bg-inverted",
message.type === "error" && "border",
message.type === "presence" && "border bg-card",
message.type === "presence" && "border bg-inverted",
)}
>
{(message.type === "presence" || message.type === "assistant") && (
<MessageCircleIcon className="size-5 text-muted-foreground" />
<ThirdwebMiniLogo
className="size-4 text-inverted-foreground"
isMonoChrome
/>
)}
{message.type === "error" && (
@@ -227,7 +310,7 @@ function RenderMessage(props: {
function CustomFeedbackButtons(props: {
message: CustomChatMessage & { type: "assistant" };
messageIndex: number;
onFeedback: (messageIndex: number, feedback: 1 | -1) => void;
onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
className?: string;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -240,9 +323,9 @@ function CustomFeedbackButtons(props: {
await props.onFeedback(props.messageIndex, feedback);
} catch (_e) {
// Handle error silently
} finally {
setIsSubmitting(false);
}
setIsSubmitting(false);
};
// Don't show buttons if feedback already given
@@ -251,25 +334,26 @@ function CustomFeedbackButtons(props: {
}
return (
<div className={cn("flex gap-2", props.className)}>
<button
aria-label="Thumbs up"
className="text-muted-foreground transition-colors hover:text-green-500 disabled:opacity-50"
<div className={cn("flex gap-1.5", props.className)}>
<Button
aria-label="Upvote"
variant="outline"
disabled={isSubmitting}
onClick={() => handleFeedback(1)}
type="button"
className="size-8 p-0 rounded-lg bg-card"
>
<ThumbsUpIcon className="size-5" />
</button>
<button
aria-label="Thumbs down"
className="text-muted-foreground transition-colors hover:text-red-500 disabled:opacity-50"
<ThumbsUpIcon className="size-3.5" />
</Button>
<Button
aria-label="Downvote"
variant="outline"
disabled={isSubmitting}
onClick={() => handleFeedback(-1)}
type="button"
className="size-8 p-0 rounded-lg bg-card"
>
<ThumbsDownIcon className="size-5" />
</button>
<ThumbsDownIcon className="size-3.5" />
</Button>
</div>
);
}
@@ -296,7 +380,11 @@ function RenderResponse(props: {
);
case "presence":
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
return (
<div className="h-8 flex items-center">
<TextShimmer text="Reasoning..." />
</div>
);
case "error":
return (
@@ -325,19 +413,15 @@ function StyledMarkdownRenderer(props: {
}) {
return (
<MarkdownRenderer
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
code={{
className: "bg-transparent",
className: "bg-card",
ignoreFormattingErrors: true,
}}
inlineCode={{ className: "border-none" }}
li={{ className: "text-foreground" }}
markdownText={props.text}
p={{
className:
props.type === "assistant"
? "text-foreground"
: "text-foreground leading-normal",
className: props.type === "assistant" ? "" : "leading-normal",
}}
skipHtml
/>
@@ -1,62 +0,0 @@
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { TextShimmer } from "@/components/ui/text-shimmer";
import { cn } from "@/lib/utils";
export function Reasoning(props: { isPending: boolean; texts: string[] }) {
const [_isOpen, setIsOpen] = useState(false);
const isOpen = props.isPending ? true : _isOpen;
const showAll = !props.isPending;
const lastText = props.texts[props.texts.length - 1];
return (
<DynamicHeight>
<Button
className="h-auto w-auto items-center gap-1 px-0 py-1 pb-2 font-normal text-base text-muted-foreground hover:text-foreground hover:no-underline"
onClick={() => setIsOpen(!isOpen)}
size="sm"
variant="link"
>
{props.isPending ? (
<TextShimmer text="Reasoning..." />
) : (
<span className="text-foreground">Reasoning</span>
)}
{!props.isPending && (
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen && "rotate-180",
)}
/>
)}
</Button>
{isOpen && (
<>
{showAll && props.texts.length > 0 && (
<ul className="list-none space-y-1.5">
{props.texts.map((text) => (
<li
className="fade-in-0 animate-in text-muted-foreground text-sm leading-relaxed duration-300"
key={text}
>
{text.trim()}
</li>
))}
</ul>
)}
{!showAll && lastText && (
<div className="text-muted-foreground text-sm leading-relaxed">
{lastText.trim()}
</div>
)}
</>
)}
</DynamicHeight>
);
}
@@ -6,12 +6,6 @@ export type ExamplePrompt = {
// TODO - remove "Nebula" wording and simplify types
export type NebulaContext = {
chainIds: string[] | null;
walletAddress: string | null;
networks: "mainnet" | "testnet" | "all" | null;
};
type NebulaUserMessageContentItem =
| {
type: "image";
@@ -1,7 +1,6 @@
"use client";
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
import { doLogout } from "@app/login/auth-actions";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -14,6 +13,7 @@ import {
useActiveAccount,
useConnectModal,
} from "thirdweb/react";
import { doLogout } from "@/actions/auth-actions";
import { resetAnalytics } from "@/analytics/reset";
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
@@ -661,7 +661,7 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</UnderlineLink>{" "}
in your applications. This will ensure account contracts are
deployed for your users only when they need it.
@@ -1,15 +1,4 @@
import {
Flex,
FormControl,
IconButton,
Input,
InputGroup,
InputRightElement,
Tooltip,
} from "@chakra-ui/react";
import { Button } from "chakra/button";
import { FormErrorMessage, FormLabel } from "chakra/form";
import { BanIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from "lucide-react";
import { PlusIcon, TrashIcon, XIcon } from "lucide-react";
import { useEffect } from "react";
import {
type ArrayPath,
@@ -25,7 +14,16 @@ import {
type WatchObserver,
} from "react-hook-form";
import type { ThirdwebClient } from "thirdweb";
import { FileInput } from "@/components/blocks/FileInput";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type OptionalPropertiesInput = {
[key: string]: string | number;
@@ -46,17 +44,15 @@ interface IPropertiesFormControlProps<
client: ThirdwebClient;
}
export const PropertiesFormControl = <
export function PropertiesFormControl<
TFieldValues extends IPropertyFieldValues,
>({
control,
register,
watch,
errors,
setValue,
client,
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) => {
const { fields, append, remove, replace } = useFieldArray({
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) {
const { fields, append, remove } = useFieldArray({
control,
name: "attributes" as ArrayPath<TFieldValues>,
});
@@ -72,19 +68,7 @@ export const PropertiesFormControl = <
return (
<div className="flex flex-col gap-4">
<Flex align="center" direction="row" justify="space-between">
<FormLabel m={0}>Attributes</FormLabel>
<Button
colorScheme="red"
// biome-ignore lint/suspicious/noExplicitAny: FIXME
onClick={() => replace([{ trait_type: "", value: "" } as any])}
rightIcon={<BanIcon className="size-4" />}
size="xs"
variant="outline"
>
Reset
</Button>
</Flex>
<FormLabel>Attributes</FormLabel>
{fields.map((field, index) => {
// biome-ignore lint/suspicious/noExplicitAny: FIXME
const keyError = (errors as any)?.attributes?.[index]?.trait_type
@@ -92,102 +76,108 @@ export const PropertiesFormControl = <
// biome-ignore lint/suspicious/noExplicitAny: FIXME
const valueError = (errors as any)?.attributes?.[index]?.value
?.message as string | undefined;
const isInvalid = !!(keyError || valueError);
const _isInvalid = !!(keyError || valueError);
return (
<div className="flex flex-row items-center gap-2" key={field.id}>
<FormControl
className="flex flex-row items-start gap-3"
isInvalid={isInvalid}
>
<FormControl isInvalid={!!keyError}>
<Input
{...register(
`attributes.${index}.trait_type` as Path<TFieldValues>,
)}
placeholder="trait_type"
/>
<FormErrorMessage>{keyError}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!valueError}>
{watch(
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
) instanceof File ? (
<InputGroup>
<Input
isDisabled
value={
watch(`attributes.${index}.value` as Path<TFieldValues>)
.name
}
/>
<InputRightElement>
<TrashIcon
className="size-4 cursor-pointer text-red-300 hover:text-red-200"
onClick={() =>
setValue(
`attributes.${index}.value` as Path<TFieldValues>,
"" as PathValue<TFieldValues, Path<TFieldValues>>,
)
}
<div className="flex flex-row items-start gap-3 flex-1">
<FormField
control={control}
name={`attributes.${index}.trait_type` as Path<TFieldValues>}
render={({ field: traitField }) => (
<FormItem className="flex-1">
<FormControl>
<Input
{...traitField}
placeholder="trait_type"
className={cn(
"bg-card",
keyError && "border-destructive",
)}
/>
</InputRightElement>
</InputGroup>
) : (
<InputGroup>
<Input
{...register(
`attributes.${index}.value` as Path<TFieldValues>,
)}
placeholder="value"
/>
<InputRightElement>
<Tooltip label="Upload file" shouldWrapChildren>
<FileInput
client={client}
setValue={(file) => {
setValue(
`attributes.${index}.value` as Path<TFieldValues>,
file as PathValue<
TFieldValues,
Path<TFieldValues>
>,
);
}}
>
<UploadIcon className="size-4 text-muted-foreground" />
</FileInput>
</Tooltip>
</InputRightElement>
</InputGroup>
</FormControl>
{keyError && <FormMessage>{keyError}</FormMessage>}
</FormItem>
)}
<FormErrorMessage>{valueError}</FormErrorMessage>
</FormControl>
</FormControl>
<IconButton
aria-label="remove key value pair"
colorScheme="red"
icon={<XIcon />}
/>
<FormField
control={control}
name={`attributes.${index}.value` as Path<TFieldValues>}
render={({ field: valueField }) => (
<FormItem className="flex-1">
<FormControl>
{watch(
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
) instanceof File ? (
<div className="relative">
<Input
disabled
value={
watch(
`attributes.${index}.value` as Path<TFieldValues>,
).name
}
className="pr-10 bg-card"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() =>
setValue(
`attributes.${index}.value` as Path<TFieldValues>,
"" as PathValue<
TFieldValues,
Path<TFieldValues>
>,
)
}
>
<TrashIcon className="size-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
) : (
<div className="relative">
<Input
{...valueField}
placeholder="value"
className={`pr-10 bg-card ${valueError ? "border-destructive" : ""}`}
/>
</div>
)}
</FormControl>
{valueError && <FormMessage>{valueError}</FormMessage>}
</FormItem>
)}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full p-0 size-10 bg-card"
onClick={() => remove(index)}
size="xs"
variant="ghost"
/>
>
<XIcon className="size-4" />
</Button>
</div>
);
})}
<div className="flex flex-row gap-2">
<Button
colorScheme="purple"
leftIcon={<PlusIcon className="size-5" />}
className="rounded-full"
onClick={() =>
// biome-ignore lint/suspicious/noExplicitAny: FIXME
append({ trait_type: undefined, value: undefined } as any)
}
size="sm"
>
<PlusIcon className="size-4 mr-2" />
Add Row
</Button>
</div>
</div>
);
};
}
@@ -47,7 +47,7 @@ export function AnnouncementBanner() {
return (
<AnnouncementBannerUI
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
label="We have re-branded our Engine, Universal Bridge, and Connect products. Please read the full blog post for details on changes"
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
trackingLabel="product-rebrand"
/>
);
@@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
@@ -63,6 +64,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
<CreateProjectDialogUI
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
console.error(
"Failed to create vault account and access token",
error,
);
throw error;
});
return {
project: res.project,
secret: res.secret,
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
address: string;
className?: string;
iconClassName?: string;
tooltip?: string;
variant?:
| "primary"
| "default"
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
copyIconPosition={props.copyIconPosition}
textToCopy={props.address}
textToShow={shortenedAddress}
tooltip="Copy Address"
tooltip={props.tooltip || "Copy Address"}
variant={props.variant}
/>
);
@@ -1,3 +1,4 @@
import { useId } from "react";
import { cn } from "@/lib/utils";
export function DotsBackgroundPattern(props: { className?: string }) {
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
/>
);
}
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number;
height?: number;
x?: number;
y?: number;
squares?: Array<[x: number, y: number]>;
strokeDasharray?: string;
className?: string;
[key: string]: unknown;
}
export function GridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = "0",
squares,
className,
...props
}: GridPatternProps) {
const id = useId();
return (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
className,
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible" role="presentation">
{squares.map(([x, y]) => (
<rect
strokeWidth="0"
key={`${x}-${y}`}
width={width - 1}
height={height - 1}
x={x * width + 1}
y={y * height + 1}
/>
))}
</svg>
)}
</svg>
);
}
@@ -26,7 +26,6 @@ const buttonVariants = cva(
link: "underline-offset-4 hover:underline",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
secondary:
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
"rounded-lg border border-border bg-card text-card-foreground",
className,
)}
ref={ref}
@@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
/>
)}
<Button asChild className="py-2 text-sm" variant="primary">
<Link href="/support" rel="noopener noreferrer" target="_blank">
Visit support site
<Button asChild className="py-2 text-sm" variant="default">
<Link
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
Contact Support
</Link>
</Button>
</div>
+20 -5
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useActiveAccount } from "thirdweb/react";
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
// FIXME: We keep repeating types, API server should provide them
@@ -311,26 +312,40 @@ export type RotateSecretKeyAPIReturnType = {
data: {
secret: string;
secretMasked: string;
secretHash: string;
};
};
export async function rotateSecretKeyClient(params: {
teamId: string;
projectId: string;
}) {
export async function rotateSecretKeyClient(params: { project: Project }) {
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
});
if (!res.ok) {
throw new Error(res.error);
}
try {
// if the project has an encrypted vault admin key, rotate it as well
const service = params.project.services.find(
(service) => service.name === "engineCloud",
);
if (service?.encryptedAdminKey) {
await rotateVaultAccountAndAccessToken({
project: params.project,
projectSecretKey: res.data.data.secret,
projectSecretHash: res.data.data.secretHash,
});
}
} catch (error) {
console.error("Failed to rotate vault admin key", error);
}
return res.data;
}
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
enable-background="new 0 0 100 100"
enableBackground="new 0 0 100 100"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
{...props}
+8 -1
View File
@@ -57,7 +57,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
},
nebula: {
enabled: true,
rateLimit: 1000,
rateLimit: {
perSecond: 1000,
perMinute: 1000,
},
},
pay: {
enabled: true,
@@ -82,6 +85,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
totalFileSizeBytesLimit: 1_000_000_000,
},
},
mcp: {
enabled: true,
rateLimit: 10,
},
},
createdAt: new Date().toISOString(),
dedicatedSupportChannel: null,
+3 -3
View File
@@ -4,14 +4,14 @@ import type { JSX } from "react";
const PLEASE_REACH_OUT_MESSAGE = (
<span>
If you believe this is incorrect or the error persists, please visit our{" "}
If you believe this is incorrect or the error persists, please{" "}
<Link
className="font-semibold underline"
href="/support"
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
support site
contact support
</Link>
.
</span>
@@ -10,7 +10,7 @@ import {
type SearchParams,
} from "./components/server/routes-table";
const title = "Universal Bridge Routes: Swap, Bridge, and Onramp";
const title = "Payments Routes: Swap, Bridge, and Onramp";
const description =
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
@@ -66,7 +66,7 @@ export default async function RoutesPage(props: {
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with Universal Bridge
Get Started with thirdweb Payments
</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
@@ -1,6 +1,5 @@
import "server-only";
import type { Address } from "thirdweb";
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import type { Route } from "./types/route";
@@ -10,21 +9,13 @@ export async function getRoutes({
offset,
originQuery,
destinationQuery,
originChainId,
destinationChainId,
originTokenAddress,
destinationTokenAddress,
}: {
limit?: number;
offset?: number;
originQuery?: string;
destinationQuery?: string;
originChainId?: number;
destinationChainId?: number;
originTokenAddress?: Address;
destinationTokenAddress?: Address;
} = {}) {
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes`);
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
if (limit) {
url.searchParams.set("limit", limit.toString());
}
@@ -37,20 +28,7 @@ export async function getRoutes({
if (destinationQuery) {
url.searchParams.set("destinationQuery", destinationQuery);
}
if (originChainId) {
url.searchParams.set("originChainId", originChainId.toString());
}
if (destinationChainId) {
url.searchParams.set("destinationChainId", destinationChainId.toString());
}
if (originTokenAddress) {
url.searchParams.set("originTokenAddress", originTokenAddress);
}
if (destinationTokenAddress) {
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
}
url.searchParams.set("sortBy", "popularity");
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
const routesResponse = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
@@ -1,12 +1,15 @@
"use client";
import { useTheme } from "next-themes";
import { defineChain, type ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyWidget } from "thirdweb/react";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyFundsSection(props: {
chain: ChainMetadata;
client: ThirdwebClient;
}) {
const { theme } = useTheme();
return (
<section className="flex flex-col gap-4 items-center justify-center">
<BuyWidget
@@ -14,6 +17,7 @@ export function BuyFundsSection(props: {
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.chain.chainId)}
client={props.client}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
</section>
);
@@ -3,7 +3,7 @@ import type { ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { mapV4ChainToV5Chain } from "../../../../../../../../@/utils/map-chains";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { ChainIcon } from "../../../../components/server/chain-icon";
import { AddChainToWallet } from "../client/add-chain-to-wallet";
@@ -19,7 +19,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { mapV4ChainToV5Chain } from "../../../../../../@/utils/map-chains";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
@@ -1,10 +1,10 @@
import { getAddress, getContract, isAddress } from "thirdweb";
import { localhost } from "thirdweb/chains";
import { getUserThirdwebClient } from "@/api/auth-token";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
export async function getContractPageParamsInfo(params: {
contractAddress: string;
@@ -1,18 +1,5 @@
"use client";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
FormControl,
Input,
Textarea,
} from "@chakra-ui/react";
import { Button } from "chakra/button";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import { type Dispatch, type SetStateAction, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -31,6 +18,24 @@ import { OpenSeaPropertyBadge } from "@/components/badges/opensea";
import { FileInput } from "@/components/blocks/FileInput";
import { PropertiesFormControl } from "@/components/contracts/properties.shared";
import { TransactionButton } from "@/components/tx-button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useTxNotifications } from "@/hooks/useTxNotifications";
import type { NFTMetadataInputLimited } from "@/types/modified-types";
import { parseAttributes } from "@/utils/parseAttributes";
@@ -53,13 +58,13 @@ type UpdateNftMetadataForm = {
isLoggedIn: boolean;
};
export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
export function UpdateNftMetadata({
contract,
nft,
useUpdateMetadata,
setOpen,
isLoggedIn,
}) => {
}: UpdateNftMetadataForm) {
const address = useActiveAccount()?.address;
const transformedQueryData = useMemo(() => {
@@ -90,7 +95,7 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
register,
watch,
handleSubmit,
formState: { errors, isDirty },
formState: { errors },
} = form;
const setFile = (file: File) => {
@@ -112,229 +117,304 @@ export const UpdateNftMetadata: React.FC<UpdateNftMetadataForm> = ({
);
return (
<form
className="flex flex-col gap-6"
id={UPDATE_METADATA_FORM_ID}
onSubmit={handleSubmit(async (data) => {
if (!address) {
toast.error("Please connect your wallet to update metadata.");
return;
}
<Form {...form}>
<form
className="flex flex-col gap-6"
id={UPDATE_METADATA_FORM_ID}
onSubmit={handleSubmit(async (data) => {
if (!address) {
toast.error("Please connect your wallet to update metadata.");
return;
}
try {
const newMetadata = parseAttributes({
...data,
animation_url: data.animation_url || nft.metadata.animation_url,
image: data.image || nft.metadata.image,
});
try {
const newMetadata = parseAttributes({
...data,
animation_url: data.animation_url || nft.metadata.animation_url,
image: data.image || nft.metadata.image,
});
const transaction = useUpdateMetadata
? // For Drop contracts, we need to call the `updateBatchBaseURI` method
nft.type === "ERC721"
? updateMetadata721({
contract,
newMetadata,
targetTokenId: BigInt(nft.id),
})
: updateMetadata1155({
contract,
newMetadata,
targetTokenId: BigInt(nft.id),
})
: // For Collection contracts, we need to call the `setTokenURI` method
nft.type === "ERC721"
? updateTokenURI721({
contract,
newMetadata,
tokenId: BigInt(nft.id),
})
: updateTokenURI1155({
contract,
newMetadata,
tokenId: BigInt(nft.id),
});
await sendAndConfirmTx.mutateAsync(transaction, {
onError: (error) => {
console.error(error);
},
onSuccess: () => {
setOpen(false);
},
});
const transaction = useUpdateMetadata
? // For Drop contracts, we need to call the `updateBatchBaseURI` method
nft.type === "ERC721"
? updateMetadata721({
contract,
newMetadata,
targetTokenId: BigInt(nft.id),
})
: updateMetadata1155({
contract,
newMetadata,
targetTokenId: BigInt(nft.id),
})
: // For Collection contracts, we need to call the `setTokenURI` method
nft.type === "ERC721"
? updateTokenURI721({
contract,
newMetadata,
tokenId: BigInt(nft.id),
})
: updateTokenURI1155({
contract,
newMetadata,
tokenId: BigInt(nft.id),
});
await sendAndConfirmTx.mutateAsync(transaction, {
onError: (error) => {
console.error(error);
},
onSuccess: () => {
setOpen(false);
},
});
updateMetadataNotifications.onSuccess();
} catch (err) {
console.error(err);
updateMetadataNotifications.onError(err);
}
})}
>
<FormControl isInvalid={!!errors.name} isRequired>
<FormLabel>Name</FormLabel>
<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">
<FileInput
className="shrink-0 rounded border border-border transition-all duration-200"
client={contract.client}
helperText={nft?.metadata ? "New Media" : "Media"}
previewMaxWidth="200px"
selectOrUpload="Upload"
setValue={setFile}
showPreview
showUploadButton
value={media}
/>
</div>
<FormHelperText>
You can upload image, audio, video, html, text, pdf, and 3d model
files here.
</FormHelperText>
<FormErrorMessage>
{mediaFileError?.message as unknown as string}
</FormErrorMessage>
</FormControl>
{showCoverImageUpload && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
accept={{ "image/*": [] }}
className="rounded border border-border transition-all"
client={contract.client}
previewMaxWidth="200px"
setValue={(file) => setValue("image", file)}
showUploadButton
value={image}
/>
<FormHelperText>
You can optionally upload an image as the cover of your NFT.
</FormHelperText>
<FormErrorMessage>
{errors?.image?.message as unknown as string}
</FormErrorMessage>
</FormControl>
)}
<FormControl isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} />
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
</FormControl>
<PropertiesFormControl
client={contract.client}
control={control}
// biome-ignore lint/suspicious/noExplicitAny: FIXME
errors={errors as any}
register={register}
setValue={setValue}
watch={watch}
/>
<Accordion
allowToggle={!(errors.background_color || errors.external_url)}
index={errors.background_color || errors.external_url ? [0] : undefined}
updateMetadataNotifications.onSuccess();
} catch (err) {
console.error(err);
updateMetadataNotifications.onError(err);
}
})}
>
<AccordionItem>
<AccordionButton justifyContent="space-between" px={0}>
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="!px-0 flex flex-col gap-6">
<FormControl isInvalid={!!errors.background_color}>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
</FormLabel>
<Input max="6" {...register("background_color")} />
<FormHelperText>
Must be a six-character hexadecimal with a pre-pended #.
</FormHelperText>
<FormErrorMessage>
{errors?.background_color?.message}
</FormErrorMessage>
</FormControl>
{!(external_url instanceof File) && (
<FormControl isInvalid={!!errors.external_url}>
<FormLabel>
External URL <OpenSeaPropertyBadge />
</FormLabel>
<Input {...register("external_url")} />
<FormHelperText>
This is the URL that will appear below the asset&apos;s image
on OpenSea and will allow users to leave OpenSea and view the
item on your site.
</FormHelperText>
<FormErrorMessage>
{errors?.external_url?.message as unknown as string}
</FormErrorMessage>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} className="bg-card" />
</FormControl>
)}
<FormMessage />
</FormItem>
)}
/>
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
onChange={(e) => {
setValue("image", e.target.value);
}}
value={typeof image === "string" ? image : ""}
/>
<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>
)}
<FormItem>
<FormLabel>Media</FormLabel>
<div className="flex flex-row flex-wrap gap-3">
<FileInput
className="shrink-0 rounded border border-border transition-all duration-200"
client={contract.client}
helperText={nft?.metadata ? "New Media" : "Media"}
previewMaxWidth="200px"
selectOrUpload="Upload"
setValue={setFile}
showPreview
showUploadButton
value={media}
/>
</div>
<FormDescription>
You can upload image, audio, video, html, text, pdf, and 3d model
files here.
</FormDescription>
{mediaFileError && (
<FormMessage>
{mediaFileError.message as unknown as string}
</FormMessage>
)}
</FormItem>
{!(animation_url instanceof File) && (
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
value={typeof animation_url === "string" ? animation_url : ""}
/>
<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>
{showCoverImageUpload && (
<FormField
control={control}
name="image"
render={() => (
<FormItem>
<FormLabel>Cover Image</FormLabel>
<FormControl>
<FileInput
accept={{ "image/*": [] }}
className="rounded border border-border transition-all"
client={contract.client}
previewMaxWidth="200px"
setValue={(file) => {
setValue("image", file);
}}
showUploadButton
value={image}
/>
</FormControl>
<FormDescription>
You can optionally upload an image as the cover of your NFT.
</FormDescription>
<FormMessage />
</FormItem>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
isDisabled={sendAndConfirmTx.isPending}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
/>
)}
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} className="bg-card" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PropertiesFormControl
client={contract.client}
disabled={!isDirty}
form={UPDATE_METADATA_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={sendAndConfirmTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
control={control}
// biome-ignore lint/suspicious/noExplicitAny: FIXME
errors={errors as any}
register={register}
setValue={setValue}
watch={watch}
/>
<Accordion
type="single"
className="-mx-1 border-t border-border"
collapsible
defaultValue={
errors.background_color || errors.external_url
? "advanced"
: undefined
}
>
Update NFT
</TransactionButton>
</div>
</form>
<AccordionItem value="advanced">
<AccordionTrigger className="justify-between px-1">
<h3 className="text-base font-medium">Advanced Options</h3>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-6 px-1">
<FormField
control={control}
name="background_color"
render={({ field }) => (
<FormItem>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
</FormLabel>
<FormControl>
<Input max="6" {...field} className="bg-card" />
</FormControl>
<FormDescription>
Must be a six-character hexadecimal with a pre-pended #.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!(external_url instanceof File) && (
<FormField
control={control}
name="external_url"
render={({ field }) => (
<FormItem>
<FormLabel>
External URL <OpenSeaPropertyBadge />
</FormLabel>
<FormControl>
<Input
{...field}
className="bg-card"
value={
typeof field.value === "string" ? field.value : ""
}
/>
</FormControl>
<FormDescription>
This is the URL that will appear below the asset&apos;s
image on OpenSea and will allow users to leave OpenSea
and view the item on your site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!(image instanceof File) && (
<FormField
control={control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
setValue("image", e.target.value);
}}
value={typeof image === "string" ? image : ""}
className="bg-card"
/>
</FormControl>
<FormDescription>
If you already have your NFT image pre-uploaded to a
URL, you can specify it here instead of uploading the
media file
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!(animation_url instanceof File) && (
<FormField
control={control}
name="animation_url"
render={({ field }) => (
<FormItem>
<FormLabel>Animation URL</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
value={
typeof animation_url === "string"
? animation_url
: ""
}
className="bg-card"
/>
</FormControl>
<FormDescription>
If you already have your NFT Animation URL pre-uploaded
to a URL, you can specify it here instead of uploading
the media file
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
disabled={sendAndConfirmTx.isPending}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
form={UPDATE_METADATA_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={sendAndConfirmTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
Update NFT
</TransactionButton>
</div>
</form>
</Form>
);
};
}
@@ -1,5 +1,5 @@
import { notFound, redirect } from "next/navigation";
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
import { getAuthTokenWalletAddress } from "@/api/auth-token";
import type { ProjectMeta } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/types";
import { redirectToContractLandingPage } from "../../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/utils";
import { buildContractPagePath } from "../../_utils/contract-page-path";
@@ -60,59 +60,61 @@ export const BatchLazyMintButton: React.FC<BatchLazyMintButtonProps> = ({
Batch Upload
</Button>
</SheetTrigger>
<SheetContent className="min-w-full overflow-y-auto">
<SheetHeader>
<SheetTitle className="text-left">Upload NFTs</SheetTitle>
<SheetContent className="!w-full !max-w-full overflow-y-auto p-0">
<SheetHeader className="sr-only">
<SheetTitle>Upload NFTs</SheetTitle>
</SheetHeader>
<BatchLazyMint
canCreateDelayedRevealBatch={canCreateDelayedRevealBatch}
chainId={contract.chain.id}
client={contract.client}
isLoggedIn={isLoggedIn}
nextTokenIdToMint={nextTokenIdToMintQuery.data || 0n}
onSubmit={async ({ revealType, data }) => {
try {
const tx = (() => {
switch (true) {
// lazy mint erc721
case revealType === "instant" && isErc721: {
return ERC721Ext.lazyMint({
contract,
nfts: data.metadatas,
});
<div className="p-4 lg:p-6">
<BatchLazyMint
canCreateDelayedRevealBatch={canCreateDelayedRevealBatch}
chainId={contract.chain.id}
client={contract.client}
isLoggedIn={isLoggedIn}
nextTokenIdToMint={nextTokenIdToMintQuery.data || 0n}
onSubmit={async ({ revealType, data }) => {
try {
const tx = (() => {
switch (true) {
// lazy mint erc721
case revealType === "instant" && isErc721: {
return ERC721Ext.lazyMint({
contract,
nfts: data.metadatas,
});
}
// lazy mint erc1155
case revealType === "instant" && !isErc721: {
return ERC1155Ext.lazyMint({
contract,
nfts: data.metadatas,
});
}
// delayed reveal erc721
case revealType === "delayed": {
return ERC721Ext.createDelayedRevealBatch({
contract,
metadata: data.metadata,
password: data.password,
placeholderMetadata: data.placeholderMetadata,
});
}
default: {
throw new Error("Invalid reveal type");
}
}
// lazy mint erc1155
case revealType === "instant" && !isErc721: {
return ERC1155Ext.lazyMint({
contract,
nfts: data.metadatas,
});
}
// delayed reveal erc721
case revealType === "delayed": {
return ERC721Ext.createDelayedRevealBatch({
contract,
metadata: data.metadata,
password: data.password,
placeholderMetadata: data.placeholderMetadata,
});
}
default: {
throw new Error("Invalid reveal type");
}
}
})();
})();
await sendTxMutation.mutateAsync(tx);
await sendTxMutation.mutateAsync(tx);
txNotifications.onSuccess();
setOpen(false);
} catch (error) {
console.error(error);
txNotifications.onError(error);
}
}}
/>
txNotifications.onSuccess();
setOpen(false);
} catch (error) {
console.error(error);
txNotifications.onError(error);
}
}}
/>
</div>
</SheetContent>
</Sheet>
</MinterOnly>
@@ -1,18 +1,5 @@
"use client";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Divider,
FormControl,
Input,
Textarea,
} from "@chakra-ui/react";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -24,7 +11,24 @@ import { OpenSeaPropertyBadge } from "@/components/badges/opensea";
import { FileInput } from "@/components/blocks/FileInput";
import { PropertiesFormControl } from "@/components/contracts/properties.shared";
import { TransactionButton } from "@/components/tx-button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useTxNotifications } from "@/hooks/useTxNotifications";
import type { NFTMetadataInputLimited } from "@/types/modified-types";
import { parseAttributes } from "@/utils/parseAttributes";
@@ -42,12 +46,12 @@ type LazyMintNftFormParams = {
isLoggedIn: boolean;
};
export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
export function LazyMintNftForm({
contract,
isErc721,
setOpen,
isLoggedIn,
}) => {
}: LazyMintNftFormParams) {
const address = useActiveAccount()?.address;
const sendAndConfirmTx = useSendAndConfirmTransaction();
@@ -81,7 +85,7 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
);
return (
<>
<Form {...form}>
<form
className="mt-6 flex flex-col gap-6"
id={LAZY_MINT_FORM_ID}
@@ -112,16 +116,21 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
}
})}
>
<div className="flex flex-col gap-2">
<Heading size="subtitle.md">Metadata </Heading>
<Divider />
</div>
<FormControl isInvalid={!!errors.name} isRequired>
<FormLabel>Name</FormLabel>
<Input autoFocus {...register("name")} />
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!mediaFileError}>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} className="bg-card" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Media</FormLabel>
<div>
<FileInput
@@ -136,39 +145,58 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
value={media}
/>
</div>
<FormHelperText>
<FormDescription>
You can upload image, audio, video, html, text, pdf, and 3d model
files here.
</FormHelperText>
<FormErrorMessage>
{mediaFileError?.message as unknown as string}
</FormErrorMessage>
</FormControl>
</FormDescription>
{mediaFileError && (
<FormMessage>
{mediaFileError.message as unknown as string}
</FormMessage>
)}
</FormItem>
{showCoverImageUpload && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
accept={{ "image/*": [] }}
className="rounded border border-border transition-all"
client={contract.client}
previewMaxWidth="200px"
setValue={(file) => setValue("image", file)}
showUploadButton
value={image}
/>
<FormHelperText>
You can optionally upload an image as the cover of your NFT.
</FormHelperText>
<FormErrorMessage>
{errors?.image?.message as unknown as string}
</FormErrorMessage>
</FormControl>
<FormField
control={control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Cover Image</FormLabel>
<FormControl>
<FileInput
accept={{ "image/*": [] }}
className="rounded-lg bg-card border border-border transition-all"
client={contract.client}
previewMaxWidth="200px"
setValue={(file) => setValue("image", file)}
showUploadButton
value={image}
/>
</FormControl>
<FormDescription>
You can optionally upload an image as the cover of your NFT.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} />
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
</FormControl>
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} className="bg-card" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PropertiesFormControl
client={contract.client}
control={control}
@@ -178,112 +206,156 @@ export const LazyMintNftForm: React.FC<LazyMintNftFormParams> = ({
setValue={setValue}
watch={watch}
/>
<Accordion
allowToggle={!(errors.background_color || errors.external_url)}
index={
errors.background_color || errors.external_url ? [0] : undefined
type="single"
collapsible
className="-mx-1 border-t border-border"
defaultValue={
errors.background_color || errors.external_url
? "advanced"
: undefined
}
>
<AccordionItem>
<AccordionButton justifyContent="space-between" px={0}>
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="!px-0 flex flex-col gap-6">
<FormControl isInvalid={!!errors.background_color}>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
</FormLabel>
<Input max="6" {...register("background_color")} />
<FormHelperText>
Must be a six-character hexadecimal with a pre-pended #.
</FormHelperText>
<FormErrorMessage>
{errors?.background_color?.message}
</FormErrorMessage>
</FormControl>
<AccordionItem value="advanced">
<AccordionTrigger className="justify-between px-1">
<h3 className="text-base font-medium">Advanced Options</h3>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-6 px-1">
<FormField
control={control}
name="background_color"
render={({ field }) => (
<FormItem>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
</FormLabel>
<FormControl>
<Input max="6" {...field} className="bg-card" />
</FormControl>
<FormDescription>
Must be a six-character hexadecimal with a pre-pended #.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!(external_url instanceof File) && (
<FormControl isInvalid={!!errors.external_url}>
<FormLabel>
External URL <OpenSeaPropertyBadge />
</FormLabel>
<Input {...register("external_url")} />
<FormHelperText>
This is the URL that will appear below the asset&apos;s
image on OpenSea and will allow users to leave OpenSea and
view the item on your site.
</FormHelperText>
<FormErrorMessage>
{errors?.external_url?.message as unknown as string}
</FormErrorMessage>
</FormControl>
<FormField
control={control}
name="external_url"
render={({ field }) => (
<FormItem>
<FormLabel>
External URL <OpenSeaPropertyBadge />
</FormLabel>
<FormControl>
<Input
{...field}
className="bg-card"
value={
typeof field.value === "string" ? field.value : ""
}
/>
</FormControl>
<FormDescription>
This is the URL that will appear below the asset&apos;s
image on OpenSea and will allow users to leave OpenSea
and view the item on your site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
{...register("image")}
onChange={(e) => {
setValue("image", e.target.value);
}}
value={image}
/>
<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>
<FormField
control={control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
setValue("image", e.target.value);
}}
value={typeof image === "string" ? image : ""}
className="bg-card"
/>
</FormControl>
<FormDescription>
If you already have your NFT image pre-uploaded to a
URL, you can specify it here instead of uploading the
media file
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!(animation_url instanceof File) && (
<FormControl isInvalid={!!errors.animation_url}>
<FormLabel>Animation URL</FormLabel>
<Input
{...register("animation_url")}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
value={animation_url}
/>
<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>
<FormField
control={control}
name="animation_url"
render={({ field }) => (
<FormItem>
<FormLabel>Animation URL</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
value={
typeof animation_url === "string"
? animation_url
: ""
}
className="bg-card"
/>
</FormControl>
<FormDescription>
If you already have your NFT Animation URL pre-uploaded
to a URL, you can specify it here instead of uploading
the media file
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</AccordionPanel>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
className="mr-3"
disabled={sendAndConfirmTx.isPending}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
disabled={!isDirty}
form={LAZY_MINT_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={sendAndConfirmTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
Lazy Mint NFT
</TransactionButton>
</div>
</form>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
className="mr-3"
disabled={sendAndConfirmTx.isPending}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
disabled={!isDirty}
form={LAZY_MINT_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={sendAndConfirmTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
Lazy Mint NFT
</TransactionButton>
</div>
</>
</Form>
);
};
}
@@ -1,19 +1,5 @@
"use client";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Divider,
FormControl,
Input,
Textarea,
} from "@chakra-ui/react";
import { Button } from "chakra/button";
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
import { Heading } from "chakra/heading";
import type { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -25,6 +11,24 @@ import { OpenSeaPropertyBadge } from "@/components/badges/opensea";
import { FileInput } from "@/components/blocks/FileInput";
import { PropertiesFormControl } from "@/components/contracts/properties.shared";
import { TransactionButton } from "@/components/tx-button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useTxNotifications } from "@/hooks/useTxNotifications";
import type { NFTMetadataInputLimited } from "@/types/modified-types";
import { parseAttributes } from "@/utils/parseAttributes";
@@ -42,12 +46,12 @@ type NFTMintForm = {
isLoggedIn: boolean;
};
export const NFTMintForm: React.FC<NFTMintForm> = ({
export function NFTMintForm({
contract,
isErc721,
setOpen,
isLoggedIn,
}) => {
}: NFTMintForm) {
const address = useActiveAccount()?.address;
const form = useForm<
NFTMetadataInputLimited & {
@@ -84,7 +88,7 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
);
return (
<>
<Form {...form}>
<form
className="mt-6 flex flex-col gap-6"
id={MINT_FORM_ID}
@@ -126,16 +130,21 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
}
})}
>
<div className="flex flex-col gap-2">
<Heading size="subtitle.md">Metadata xx</Heading>
<Divider />
</div>
<FormControl isInvalid={!!errors.name} isRequired>
<FormLabel>Name</FormLabel>
<Input autoFocus {...register("name")} />
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!mediaFileError}>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} className="bg-card" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Media</FormLabel>
<div>
<FileInput
@@ -150,51 +159,80 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
value={media}
/>
</div>
<FormHelperText>
<FormDescription>
You can upload image, audio, video, html, text, pdf, and 3d model
files here.
</FormHelperText>
<FormErrorMessage>
{mediaFileError?.message as unknown as string}
</FormErrorMessage>
</FormControl>
</FormDescription>
{mediaFileError && (
<FormMessage>
{mediaFileError.message as unknown as string}
</FormMessage>
)}
</FormItem>
{showCoverImageUpload && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Cover Image</FormLabel>
<FileInput
accept={{ "image/*": [] }}
className="shrink-0 rounded border border-border transition-all"
client={contract.client}
previewMaxWidth="200px"
setValue={(file) => setValue("image", file)}
showUploadButton
value={image}
/>
<FormHelperText>
You can optionally upload an image as the cover of your NFT.
</FormHelperText>
<FormErrorMessage>
{errors?.image?.message as unknown as string}
</FormErrorMessage>
</FormControl>
<FormField
control={control}
name="image"
render={() => (
<FormItem>
<FormLabel>Cover Image</FormLabel>
<FormControl>
<FileInput
accept={{ "image/*": [] }}
className="shrink-0 rounded border border-border transition-all"
client={contract.client}
previewMaxWidth="200px"
setValue={(file) => setValue("image", file)}
showUploadButton
value={image}
/>
</FormControl>
<FormDescription>
You can optionally upload an image as the cover of your NFT.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} />
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
</FormControl>
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} className="bg-card" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!isErc721 && (
<FormControl isInvalid={!!errors.supply} isRequired>
<FormLabel>Initial Supply</FormLabel>
<Input
pattern="[0-9]"
step="1"
type="number"
{...register("supply")}
/>
<FormErrorMessage>{errors?.supply?.message}</FormErrorMessage>
</FormControl>
<FormField
control={control}
name="supply"
render={({ field }) => (
<FormItem>
<FormLabel>Initial Supply</FormLabel>
<FormControl>
<Input
pattern="[0-9]"
step="1"
type="number"
{...field}
className="bg-card"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<PropertiesFormControl
client={contract.client}
control={control}
@@ -204,109 +242,154 @@ export const NFTMintForm: React.FC<NFTMintForm> = ({
setValue={setValue}
watch={watch}
/>
<Accordion
allowToggle={!(errors.background_color || errors.external_url)}
index={
errors.background_color || errors.external_url ? [0] : undefined
type="single"
className="-mx-1 border-t border-border"
collapsible
defaultValue={
errors.background_color || errors.external_url
? "advanced"
: undefined
}
>
<AccordionItem>
<AccordionButton justifyContent="space-between" px={0}>
<Heading size="subtitle.md">Advanced Options</Heading>
<AccordionIcon />
</AccordionButton>
<AccordionPanel className="!px-0 flex flex-col gap-6">
<FormControl isInvalid={!!errors.background_color}>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
</FormLabel>
<Input max="6" {...register("background_color")} />
<FormHelperText>
Must be a six-character hexadecimal with a pre-pended #.
</FormHelperText>
<FormErrorMessage>
{errors?.background_color?.message}
</FormErrorMessage>
</FormControl>
<AccordionItem value="advanced">
<AccordionTrigger className="justify-between px-1">
<h3 className="text-base font-medium">Advanced Options</h3>
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-6 px-1">
<FormField
control={control}
name="background_color"
render={({ field }) => (
<FormItem>
<FormLabel>
Background Color <OpenSeaPropertyBadge />
</FormLabel>
<FormControl>
<Input max="6" {...field} className="bg-card" />
</FormControl>
<FormDescription>
Must be a six-character hexadecimal with a pre-pended #.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!(external_url instanceof File) && (
<FormControl isInvalid={!!errors.external_url}>
<FormLabel>
External URL <OpenSeaPropertyBadge />
</FormLabel>
<Input {...register("external_url")} />
<FormHelperText>
This is the URL that will appear below the asset&apos;s
image on OpenSea and will allow users to leave OpenSea and
view the item on your site.
</FormHelperText>
<FormErrorMessage>
{errors?.external_url?.message as unknown as string}
</FormErrorMessage>
</FormControl>
)}
{!(image instanceof File) && (
<FormControl isInvalid={!!errors.image}>
<FormLabel>Image URL</FormLabel>
<Input
onChange={(e) => {
setValue("image", e.target.value);
}}
value={image}
{!(external_url instanceof File) && (
<FormField
control={control}
name="external_url"
render={({ field }) => (
<FormItem>
<FormLabel>
External URL <OpenSeaPropertyBadge />
</FormLabel>
<FormControl>
<Input
{...field}
className="bg-card"
value={
typeof field.value === "string" ? field.value : ""
}
/>
</FormControl>
<FormDescription>
This is the URL that will appear below the
asset&apos;s image on OpenSea and will allow users to
leave OpenSea and view the item on your site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
value={animation_url}
{!(image instanceof File) && (
<FormField
control={control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
setValue("image", e.target.value);
}}
value={image}
className="bg-card"
/>
</FormControl>
<FormDescription>
If you already have your NFT image pre-uploaded to a
URL, you can specify it here instead of uploading the
asset
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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>
)}
{!(animation_url instanceof File) && (
<FormField
control={control}
name="animation_url"
render={({ field }) => (
<FormItem>
<FormLabel>Animation URL</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
setValue("animation_url", e.target.value);
}}
value={animation_url}
className="bg-card"
/>
</FormControl>
<FormDescription>
If you already have your NFT Animation URL
pre-uploaded to a URL, you can specify it here instead
of uploading the asset
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
disabled={sendAndConfirmTx.isPending}
className="mr-3"
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
disabled={!isDirty}
form={MINT_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={sendAndConfirmTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
Mint NFT
</TransactionButton>
</div>
</form>
<div className="mt-8 flex flex-row justify-end gap-3">
<Button
isDisabled={sendAndConfirmTx.isPending}
mr={3}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
<TransactionButton
client={contract.client}
disabled={!isDirty}
form={MINT_FORM_ID}
isLoggedIn={isLoggedIn}
isPending={sendAndConfirmTx.isPending}
transactionCount={1}
txChainID={contract.chain.id}
type="submit"
>
Mint NFT
</TransactionButton>
</div>
</>
</Form>
);
};
}
@@ -1,5 +1,5 @@
import type { ThirdwebContract } from "thirdweb";
import { getAuthTokenWalletAddress } from "../../../../../../../../@/api/auth-token";
import { getAuthTokenWalletAddress } from "@/api/auth-token";
import { getPublishedByCardProps, PublishedByUI } from "./published-by-ui";
interface PublishedByProps {
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
export function PageHeader(props: { containerClassName?: string }) {
return (
<div className="border-b border-dashed">
<div className="border-b bg-card">
<header
className={cn(
"container flex max-w-8xl justify-between py-3",
@@ -17,28 +17,57 @@ export const Variants: Story = {
function StoryVariants() {
return (
<div className="container max-w-md space-y-10 py-10">
<BadgeContainer label="5.5 / Unlimited Supply">
<SupplyClaimedProgress
claimedSupplyTokens={5.5}
totalSupplyTokens="unlimited"
/>
</BadgeContainer>
<BadgeContainer label="10 / Unlimited Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply="unlimited" />
<SupplyClaimedProgress
claimedSupplyTokens={10}
totalSupplyTokens="unlimited"
/>
</BadgeContainer>
<BadgeContainer label="500/1000 Supply">
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
<SupplyClaimedProgress
claimedSupplyTokens={500}
totalSupplyTokens={1000}
/>
</BadgeContainer>
<BadgeContainer label="123.45/1000 Supply">
<SupplyClaimedProgress
claimedSupplyTokens={123.45}
totalSupplyTokens={1000}
/>
</BadgeContainer>
<BadgeContainer label="10/1M Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
<SupplyClaimedProgress
claimedSupplyTokens={10}
totalSupplyTokens={1000000}
/>
</BadgeContainer>
<BadgeContainer label="0/1M Supply">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
<SupplyClaimedProgress
claimedSupplyTokens={0}
totalSupplyTokens={1000000}
/>
</BadgeContainer>
<BadgeContainer label="10/10 Supply">
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
<SupplyClaimedProgress
claimedSupplyTokens={10}
totalSupplyTokens={10}
/>
</BadgeContainer>
<BadgeContainer label="0 Supply - don't show anything">
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
<SupplyClaimedProgress claimedSupplyTokens={0} totalSupplyTokens={0} />
</BadgeContainer>
</div>
);
@@ -3,16 +3,16 @@ import { Progress } from "@/components/ui/progress";
import { supplyFormatter } from "../nft/format";
export function SupplyClaimedProgress(props: {
claimedSupply: bigint;
totalSupply: bigint | "unlimited";
claimedSupplyTokens: number;
totalSupplyTokens: number | "unlimited";
}) {
// if total supply is unlimited
if (props.totalSupply === "unlimited") {
if (props.totalSupplyTokens === "unlimited") {
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)} /{" "}
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
<InfinityIcon aria-label="Unlimited" className="size-4" />
</span>
</p>
@@ -20,29 +20,29 @@ export function SupplyClaimedProgress(props: {
}
// if total supply is 0 - don't show anything
if (props.totalSupply === 0n) {
if (props.totalSupplyTokens === 0) {
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;
const soldPercentage =
(props.claimedSupplyTokens / props.totalSupplyTokens) * 100;
const percentToShow = soldPercentage.toFixed(2);
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)}
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
{supplyFormatter.format(props.totalSupplyTokens)}
</span>
</div>
<Progress className="h-2.5" value={soldPercentage} />
<p className="font-medium text-muted-foreground text-xs">
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
{soldPercentage.toFixed(2)}% Sold
{percentToShow === "0.00" ? "~0.00%" : `${percentToShow}%`} Sold
</p>
</div>
);
@@ -0,0 +1,51 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
export type TokenPriceData = {
price_usd: number;
price_usd_cents: number;
percent_change_24h: number;
market_cap_usd: number;
volume_24h_usd: number;
volume_change_24h: number;
holders: number;
historical_prices: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
};
export async function getTokenPriceData(params: {
chainId: number;
contractAddress: string;
}) {
try {
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
);
url.searchParams.set("include_historical_prices", "true");
url.searchParams.set("chain_id", params.chainId.toString());
url.searchParams.set("address", params.contractAddress);
url.searchParams.set("include_holders", "true");
const res = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
});
if (!res.ok) {
console.error("Failed to fetch token price data", await res.text());
return undefined;
}
const json = await res.json();
const priceData = json.data[0] as TokenPriceData | undefined;
return priceData;
} catch {
return undefined;
}
}
@@ -1,19 +0,0 @@
import type { ThirdwebContract } from "thirdweb";
import { WalletAddress } from "@/components/blocks/wallet-address";
export function ContractCreatorBadge(props: {
contractCreator: string;
clientContract: ThirdwebContract;
}) {
return (
<div className="flex items-center gap-1.5 bg-card rounded-full px-2.5 py-1.5 border hover:bg-accent">
<span className="text-xs text-foreground">By</span>
<WalletAddress
address={props.contractCreator}
className="py-0 text-xs h-auto !no-underline"
client={props.clientContract.client}
iconClassName="size-3.5 hidden"
/>
</div>
);
}
@@ -1,4 +1,9 @@
import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react";
import {
ExternalLinkIcon,
GlobeIcon,
Settings2Icon,
UserIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
@@ -19,7 +24,6 @@ import { YoutubeIcon } from "@/icons/brand-icons/YoutubeIcon";
import { ChainIconClient } from "@/icons/ChainIcon";
import { cn } from "@/lib/utils";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { ContractCreatorBadge } from "./ContractCreatorBadge";
const platformToIcons: Record<string, React.FC<{ className?: string }>> = {
discord: DiscordIcon,
@@ -44,6 +48,7 @@ export function ContractHeaderUI(props: {
socialUrls: object;
imageClassName?: string;
contractCreator: string | null;
className?: string;
}) {
const socialUrls = useMemo(() => {
const socialUrlsValue: { name: string; href: string }[] = [];
@@ -64,41 +69,45 @@ export function ContractHeaderUI(props: {
?.replace("Mainnet", "")
.trim();
const explorersToShow = getExplorersToShow(props.chainMetadata);
const validBlockExplorer = getExplorerToShow(props.chainMetadata);
return (
<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={cn(
"size-20 shrink-0 rounded-full border bg-muted",
props.imageClassName,
)}
fallback={
<div className="flex items-center justify-center font-bold text-3xl text-muted-foreground/80">
{props.name[0]}
</div>
}
src={
props.image
? resolveSchemeWithErrorHandler({
client: props.clientContract.client,
uri: props.image,
})
: ""
}
/>
<div
className={cn(
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
props.className,
)}
>
<div className="flex">
<div className="border p-1 rounded-full bg-card">
<Img
className={cn("size-28 shrink-0 rounded-full bg-muted")}
fallback={
<div className="flex items-center justify-center font-bold text-5xl text-muted-foreground/50 capitalize">
{props.name[0]}
</div>
}
src={
props.image
? resolveSchemeWithErrorHandler({
client: props.clientContract.client,
uri: props.image,
})
: ""
}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2.5 flex-1">
{/* top row */}
<div className="flex flex-col gap-3">
<div className="flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center">
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
<h1 className="font-semibold text-3xl tracking-tighter lg:text-5xl">
{props.name}
</h1>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 ">
<Link
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
href={`/${props.chainMetadata.slug}`}
@@ -113,12 +122,15 @@ export function ContractHeaderUI(props: {
)}
</Link>
<CopyAddressButton
address={props.clientContract.address}
className="rounded-full bg-card w-[30px] h-[30px] p-0 [&>span]:hidden [&>svg]:text-foreground"
copyIconPosition="left"
variant="outline"
/>
{props.contractCreator &&
validBlockExplorer &&
props.contractCreator !== ZERO_ADDRESS && (
<SocialLink
href={`${validBlockExplorer.url}/address/${props.contractCreator}`}
name="Contract Owner"
icon={UserIcon}
/>
)}
{socialUrls
.toSorted((a, b) => {
@@ -148,13 +160,14 @@ export function ContractHeaderUI(props: {
</div>
{/* bottom row */}
<div className="flex flex-row flex-wrap items-center gap-2">
{props.contractCreator && props.contractCreator !== ZERO_ADDRESS && (
<ContractCreatorBadge
clientContract={props.clientContract}
contractCreator={props.contractCreator}
/>
)}
<div className="flex flex-col items-end sm:flex-row flex-wrap sm:items-center gap-2 absolute top-6 right-0 sm:static w-1/2 sm:w-full">
<CopyAddressButton
address={props.clientContract.address}
className="rounded-full bg-card px-2.5 py-1.5 text-xs"
copyIconPosition="left"
tooltip="Copy contract address"
variant="outline"
/>
<ToolTipLabel
contentClassName="max-w-[300px]"
@@ -180,13 +193,13 @@ export function ContractHeaderUI(props: {
</Button>
</ToolTipLabel>
{explorersToShow?.map((validBlockExplorer) => (
{validBlockExplorer && (
<BadgeLink
href={`${validBlockExplorer.url.endsWith("/") ? validBlockExplorer.url : `${validBlockExplorer.url}/`}address/${props.clientContract.address}`}
key={validBlockExplorer.url}
name={validBlockExplorer.name}
/>
))}
)}
{/* TODO - render social links here */}
</div>
@@ -204,19 +217,22 @@ function isValidUrl(url: string) {
}
}
function getExplorersToShow(chainMetadata: ChainMetadata) {
const validBlockExplorers = chainMetadata.explorers
?.filter((e) => e.standard === "EIP3091")
?.slice(0, 2);
function getExplorerToShow(chainMetadata: ChainMetadata) {
const validBlockExplorers = chainMetadata.explorers?.filter(
(e) => e.standard === "EIP3091",
);
return validBlockExplorers?.slice(0, 1);
return validBlockExplorers?.[0];
}
function BadgeLink(props: { name: string; href: string }) {
function BadgeLink(props: { name: string; href: string; className?: string }) {
return (
<Button
asChild
className="!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize"
className={cn(
"!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize",
props.className,
)}
variant="outline"
>
<Link href={props.href} rel="noopener noreferrer" target="_blank">
@@ -6,13 +6,11 @@ import { useMemo, useState } from "react";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTokenPriceData } from "../_hooks/useTokenPriceData";
import type { TokenPriceData } from "../_apis/token-price-data";
function PriceChartUI(props: {
isPending: boolean;
showTimeOfDay: boolean;
data: Array<{
date: string;
@@ -29,7 +27,7 @@ function PriceChartUI(props: {
<ThirdwebAreaChart
cardContentClassName="p-0"
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="border-none bg-background p-0"
className="border-none bg-transparent p-0"
config={{
price: {
color: "hsl(var(--chart-1))",
@@ -38,7 +36,7 @@ function PriceChartUI(props: {
}}
data={data}
hideLabel={false}
isPending={props.isPending}
isPending={false}
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
toolTipValueFormatter={(value) => {
return tokenPriceUSDFormatter.format(value as number);
@@ -49,7 +47,7 @@ function PriceChartUI(props: {
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 10,
maximumFractionDigits: 8,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
@@ -92,20 +90,14 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
export function TokenStats(params: {
chainId: number;
contractAddress: string;
tokenPriceData: TokenPriceData;
}) {
const tokenPriceQuery = useTokenPriceData(params);
const [interval, setInterval] = useState<Interval>("max");
const tokenPriceData = tokenPriceQuery.data;
const filteredHistoricalPrices = useMemo(() => {
const currentDate = new Date();
if (tokenPriceData?.type === "no-data") {
return [];
}
return tokenPriceData?.data?.historical_prices.filter((item) => {
return params.tokenPriceData.historical_prices.filter((item) => {
const date = new Date(item.date);
const maxDiff =
interval === "24h"
@@ -120,106 +112,75 @@ export function TokenStats(params: {
return differenceInCalendarDays(currentDate, date) <= maxDiff;
});
}, [tokenPriceData, interval]);
}, [params.tokenPriceData, interval]);
const priceUsd =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.price_usd;
const priceUsd = params.tokenPriceData.price_usd;
const percentChange24h = params.tokenPriceData.percent_change_24h;
const marketCap = params.tokenPriceData.market_cap_usd;
const holders = params.tokenPriceData.holders;
const percentChange24h =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.percent_change_24h;
const formattedAbsChange = percentChangeFormatter.format(
Math.abs(percentChange24h),
);
const marketCap =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.market_cap_usd;
const isAlmostZeroChange =
formattedAbsChange === percentChangeFormatter.format(0);
const holders =
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
? "N/A"
: tokenPriceData?.data?.holders;
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
return (
<div>
<div className="bg-card rounded-lg border">
{/* price and change */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
<div>
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
<div className="flex items-center gap-2">
<SkeletonContainer
loadedData={priceUsd}
render={(v) => {
return (
<p className="font-bold text-4xl tracking-tight">
{typeof v === "number"
? tokenPriceUSDFormatter.format(v)
: v}
</p>
);
}}
skeletonData={10.0001}
/>
<SkeletonContainer
loadedData={percentChange24h}
render={(data) => {
if (typeof data === "string") {
return null;
}
const formattedAbsChange = percentChangeFormatter.format(
Math.abs(data),
);
const isAlmostZero =
formattedAbsChange === percentChangeFormatter.format(0);
return (
<Badge
className="gap-2 text-sm"
variant={
isAlmostZero
? "default"
: data > 0
? "success"
: "destructive"
}
>
<div className="flex items-center gap-0.5">
{isAlmostZero ? null : data > 0 ? (
<ArrowUpIcon className="size-3" />
) : (
<ArrowDownIcon className="size-3" />
)}
<span>
{isAlmostZero ? "~0%" : `${formattedAbsChange}%`}
</span>
</div>
<span>(1d)</span>
</Badge>
);
}}
skeletonData={0.001}
/>
<div className="flex items-center gap-3">
<p
className={cn(
"font-bold text-3xl lg:text-4xl tracking-tight",
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
)}
>
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
</p>
<Badge
className="gap-2 lg:text-sm text-xs px-1.5"
variant={
isAlmostZeroChange
? "default"
: percentChange24h > 0
? "success"
: "destructive"
}
>
<div className="flex items-center gap-0.5">
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
<ArrowUpIcon className="size-3" />
) : (
<ArrowDownIcon className="size-3" />
)}
<span>
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
</span>
</div>
<span>(1d)</span>
</Badge>
</div>
</div>
<IntervalSelector interval={interval} setInterval={setInterval} />
</div>
<div className="h-4" />
<PriceChartUI
data={filteredHistoricalPrices || []}
isPending={tokenPriceQuery.isPending}
showTimeOfDay={interval === "24h"}
/>
<div className="px-0">
<PriceChartUI
data={filteredHistoricalPrices || []}
showTimeOfDay={interval === "24h"}
/>
</div>
<div className="mt-8 flex gap-6 border-y border-dashed py-4 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:border-dashed [&>*:not(:first-child)]:pl-5 [&>*]:grow">
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
<TokenStat
label="Market Cap"
skeletonData={"1000000000"}
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
value={
typeof marketCap === "number"
@@ -230,7 +191,6 @@ export function TokenStats(params: {
<TokenStat
label="Number of Holders"
skeletonData={"1000000000"}
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
value={
// show 0 value as N/A
@@ -246,9 +206,8 @@ export function TokenStats(params: {
);
}
function TokenStat<T extends string | number>(props: {
value: T | undefined;
skeletonData: T;
function TokenStat(props: {
value: string | number;
label: string;
tooltip: string;
}) {
@@ -261,17 +220,9 @@ function TokenStat<T extends string | number>(props: {
</ToolTipLabel>
</div>
<div className="flex">
<SkeletonContainer
loadedData={props.value}
render={(v) => {
return (
<p className="font-semibold text-foreground text-xl tracking-tight">
{v}
</p>
);
}}
skeletonData={props.skeletonData}
/>
<p className="font-semibold text-foreground text-xl tracking-tight">
{props.value}
</p>
</div>
</div>
);
@@ -296,7 +247,7 @@ function IntervalSelector(props: {
{Object.entries(intervals).map(([key, value]) => (
<Button
className={cn(
"rounded-full",
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
props.interval === key && "border-active-border bg-accent",
)}
key={key}
@@ -47,8 +47,8 @@ function RecentTransfersUI(props: {
}) {
return (
<div>
<div className="mb-4">
<h2 className="font-semibold text-xl tracking-tight">
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
Recent Transfers
</h2>
<p className="text-muted-foreground text-sm">
@@ -57,7 +57,7 @@ function RecentTransfersUI(props: {
</p>
</div>
<TableContainer className="rounded-b-none">
<TableContainer className="rounded-b-none rounded-t-none">
<Table>
<TableHeader>
<TableRow>
@@ -159,7 +159,7 @@ function RecentTransfersUI(props: {
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
<Button
className="gap-1.5 bg-background"
className="gap-1.5 bg-background rounded-full"
disabled={props.page === 0 || props.isPending}
onClick={() => props.setPage(props.page - 1)}
size="sm"
@@ -169,7 +169,7 @@ function RecentTransfersUI(props: {
Previous
</Button>
<Button
className="gap-1.5 bg-background"
className="gap-1.5 bg-background rounded-full"
disabled={props.isPending || props.data.length < props.rowsPerPage}
onClick={() => props.setPage(props.page + 1)}
size="sm"
@@ -222,23 +222,21 @@ export function RecentTransfers(props: {
});
return (
<div>
<RecentTransfersUI
client={props.clientContract.client}
data={tokenQuery.data ?? []}
explorerUrl={
props.chainMetadata.explorers?.[0]?.url ||
`https://thirdweb.com/${props.chainMetadata.slug}`
}
isPending={tokenQuery.isPending}
page={page}
rowsPerPage={rowsPerPage}
setPage={setPage}
tokenMetadata={{
decimals: props.decimals,
symbol: props.tokenSymbol,
}}
/>
</div>
<RecentTransfersUI
client={props.clientContract.client}
data={tokenQuery.data ?? []}
explorerUrl={
props.chainMetadata.explorers?.[0]?.url ||
`https://thirdweb.com/${props.chainMetadata.slug}`
}
isPending={tokenQuery.isPending}
page={page}
rowsPerPage={rowsPerPage}
setPage={setPage}
tokenMetadata={{
decimals: props.decimals,
symbol: props.tokenSymbol,
}}
/>
);
}
@@ -39,8 +39,8 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { tryCatch } from "@/utils/try-catch";
import { getSDKTheme } from "../../../../../../../../../../@/utils/sdk-component-theme";
import { PublicPageConnectButton } from "../../../_components/PublicPageConnectButton";
import { SupplyClaimedProgress } from "../../../_components/supply-claimed-progress";
import { TokenPrice } from "../../../_components/token-price";
@@ -325,13 +325,13 @@ export function TokenDropClaim(props: {
<div className="h-4" />
<SupplyClaimedProgress
claimedSupply={BigInt(
claimedSupplyTokens={Number(
toTokens(props.claimCondition.supplyClaimed, props.decimals),
)}
totalSupply={
totalSupplyTokens={
props.claimCondition.maxClaimableSupply === maxUint256
? "unlimited"
: BigInt(
: Number(
toTokens(
props.claimCondition.maxClaimableSupply,
props.decimals,
@@ -29,38 +29,38 @@ export function ContractAnalyticsOverview(props: {
});
return (
<div>
<div className="flex flex-col">
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
<p className="mb-3 text-muted-foreground text-sm">
View trends in unique wallets, transactions, and events over time for
this contract
</p>
</div>
<ThirdwebAreaChart
cardContentClassName="p-0"
chartClassName="aspect-[1.2] lg:aspect-[2.6]"
className="border-none bg-background p-0"
config={{
events: {
color: "hsl(var(--chart-3))",
label: "Events",
},
transactions: {
color: "hsl(var(--chart-2))",
label: "Transactions",
},
wallets: {
color: "hsl(var(--chart-1))",
label: "Unique Wallets",
},
}}
data={data || []}
hideLabel={false}
isPending={isPending}
showLegend
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
/>
</div>
<ThirdwebAreaChart
chartClassName="aspect-[1.2] lg:aspect-[3]"
cardContentClassName="px-0 pb-0"
margin={{
bottom: 20,
}}
header={{
headerClassName: "p-4 lg:p-6",
title: "Analytics",
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
description:
"View trends of transactions, events and unique wallets interacting with this contract over time",
}}
config={{
events: {
color: "hsl(var(--chart-5))",
label: "Events",
},
transactions: {
color: "hsl(var(--chart-8))",
label: "Transactions",
},
wallets: {
color: "hsl(var(--chart-1))",
label: "Unique Wallets",
},
}}
data={data || []}
hideLabel={false}
isPending={isPending}
showLegend
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
/>
);
}
@@ -1,56 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { isProd } from "@/constants/env-utils";
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
type TokenPriceData = {
price_usd: number;
price_usd_cents: number;
percent_change_24h: number;
market_cap_usd: number;
volume_24h_usd: number;
volume_change_24h: number;
holders: number;
historical_prices: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
};
export function useTokenPriceData(params: {
chainId: number;
contractAddress: string;
}) {
return useQuery({
queryFn: async () => {
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
);
url.searchParams.set("include_historical_prices", "true");
url.searchParams.set("chain_id", params.chainId.toString());
url.searchParams.set("address", params.contractAddress);
url.searchParams.set("include_holders", "true");
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
const res = await fetch(url);
if (!res.ok) {
throw new Error(await res.text());
}
const json = await res.json();
const priceData = json.data[0] as TokenPriceData | undefined;
return priceData
? {
data: priceData,
type: "data-found" as const,
}
: { type: "no-data" as const };
},
queryKey: ["token-price-chart", params.chainId, params.contractAddress],
refetchInterval: 5000,
refetchOnWindowFocus: false,
retry: false,
retryOnMount: false,
});
}
@@ -1,4 +1,6 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
export async function fetchTokenInfoFromBridge(params: {
chainId: number;
@@ -10,7 +12,7 @@ export async function fetchTokenInfoFromBridge(params: {
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
{
headers: {
"x-client-id": params.clientId,
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
},
);
@@ -2,9 +2,11 @@ import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { getContractMetadata } from "thirdweb/extensions/common";
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
import { GridPattern } from "@/components/ui/background-patterns";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { getContractCreator } from "../_components/getContractCreator";
import { PageHeader } from "../_components/PageHeader";
import { getTokenPriceData } from "./_apis/token-price-data";
import { ContractHeaderUI } from "./_components/ContractHeader";
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
@@ -25,6 +27,7 @@ export async function ERC20PublicPage(props: {
tokenDecimals,
tokenInfo,
functionSelectors,
tokenPriceData,
] = await Promise.all([
getContractMetadata({
contract: props.serverContract,
@@ -39,6 +42,10 @@ export async function ERC20PublicPage(props: {
tokenAddress: props.serverContract.address,
}),
resolveFunctionSelectors(props.serverContract),
getTokenPriceData({
chainId: props.serverContract.chain.id,
contractAddress: props.serverContract.address,
}),
]);
if (!contractMetadata.image && tokenInfo) {
@@ -78,62 +85,73 @@ export async function ERC20PublicPage(props: {
return (
<div className="flex grow flex-col">
<PageHeader />
<div className="container flex max-w-8xl grow flex-col">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
name={contractMetadata.name}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
/>
<PageHeader containerClassName="max-w-5xl" />
<div className="h-6" />
<div className="border-b">
<div className="container max-w-5xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
name={contractMetadata.name}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
/>
</div>
</div>
<div className="flex flex-col gap-8 pb-20 xl:flex-row">
<div className="flex grow flex-col gap-8 overflow-hidden">
{activeClaimCondition ? (
<div className="border-b border-dashed pb-6">
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
</div>
) : (
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
<div className="flex grow flex-col gap-8">
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
/>
<div className="sm:w-[420px] z-10">{buyEmbed}</div>
</div>
{tokenPriceData ? (
<>
<TokenStats
chainId={props.chainMetadata.chainId}
contractAddress={props.clientContract.address}
tokenPriceData={tokenPriceData}
/>
)}
<div className="xl:hidden">{buyEmbed}</div>
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
{!activeClaimCondition && (
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
)}
</div>
<div className="hidden xl:block xl:w-96">
<div className="-mt-6 sticky top-0 pt-6">{buyEmbed}</div>
</div>
</>
) : (
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
)}
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
</div>
</div>
</div>
@@ -17,26 +17,29 @@ export function NFTPublicPageLayout(props: {
return (
<div className="flex grow flex-col">
<PageHeader containerClassName="max-w-8xl" />
<div className="border-b">
<div className="container max-w-8xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={props.contractCreator}
image={
typeof props.contractMetadata.image === "string"
? props.contractMetadata.image
: undefined
}
name={props.contractMetadata.name}
socialUrls={
typeof props.contractMetadata.social_urls === "object" &&
props.contractMetadata.social_urls !== null
? props.contractMetadata.social_urls
: {}
}
symbol={props.contractMetadata.symbol}
/>
</div>
</div>
<div className="container flex max-w-8xl grow flex-col">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={props.contractCreator}
image={
typeof props.contractMetadata.image === "string"
? props.contractMetadata.image
: undefined
}
imageClassName="rounded-lg"
name={props.contractMetadata.name}
socialUrls={
typeof props.contractMetadata.social_urls === "object" &&
props.contractMetadata.social_urls !== null
? props.contractMetadata.social_urls
: {}
}
symbol={props.contractMetadata.symbol}
/>
{props.children}
</div>
</div>
@@ -10,6 +10,7 @@ import type { ChainMetadata } from "thirdweb/chains";
import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
import { claimTo } from "thirdweb/extensions/erc1155";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import * as z from "zod";
import {
reportAssetBuyFailed,
@@ -225,8 +226,12 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
{claimCondition.data ? (
<SupplyClaimedProgress
claimedSupply={claimCondition.data.supplyClaimed}
totalSupply={claimCondition.data.maxClaimableSupply}
claimedSupplyTokens={Number(claimCondition.data.supplyClaimed)}
totalSupplyTokens={
claimCondition.data.maxClaimableSupply === maxUint256
? "unlimited"
: Number(claimCondition.data.maxClaimableSupply)
}
/>
) : (
<Skeleton className="h-[62px] w-full" />
@@ -231,8 +231,10 @@ export function BuyNFTDropUI(props: BuyNFTDropUIProps) {
)}
<SupplyClaimedProgress
claimedSupply={BigInt(props.totalNFTs) - props.totalUnclaimedSupply}
totalSupply={BigInt(props.totalNFTs)}
claimedSupplyTokens={Number(
BigInt(props.totalNFTs) - props.totalUnclaimedSupply,
)}
totalSupplyTokens={Number(props.totalNFTs)}
/>
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/50 p-3">
@@ -1,6 +1,7 @@
import { ChevronDownIcon } from "lucide-react";
import { headers } from "next/headers";
import Link from "next/link";
import { getAuthToken } from "@/api/auth-token";
import {
Breadcrumb,
BreadcrumbItem,
@@ -14,7 +15,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getAuthToken } from "../../../../../../@/api/auth-token";
import {
AllFilters,
ChainOptionsFilter,
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getAuthToken } from "../../../../../@/api/auth-token";
import { getAuthToken } from "@/api/auth-token";
import {
AllFilters,
ChainOptionsFilter,
@@ -3,8 +3,8 @@ import "server-only";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { cn } from "@/lib/utils";
import { fallbackChainIcon } from "@/utils/chain-icons";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons";
export async function ChainIcon(props: {
iconUrl?: string;
@@ -2,9 +2,9 @@ import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { fetchDeployMetadata } from "thirdweb/contract";
import { getUserThirdwebClient } from "@/api/auth-token";
import { ContractPublishForm } from "@/components/contract-components/contract-publish-form";
import { getActiveAccountCookie, getJWTCookie } from "@/constants/cookie";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { getLatestPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
@@ -68,14 +68,15 @@ export default async function PublishContractPage(
redirect(`/login?next=${encodeURIComponent(pathname)}`);
}
const userThirdwebClient = await getUserThirdwebClient({
teamId: undefined,
});
return (
<div className="container flex max-w-[1130px] flex-col gap-8 py-8">
<ChakraProviderSetup>
<ContractPublishForm
client={getClientThirdwebClient({
jwt: token,
teamId: undefined,
})}
client={userThirdwebClient}
isLoggedIn={!!token}
onPublishSuccess={async () => {
"use server";
@@ -90,144 +90,6 @@ const MARKETS = {
name: "Marketplace",
} satisfies ExploreCategory;
const MODULAR_CONTRACTS = {
contracts: [
// erc721 drop
[
"thirdweb.eth/ERC721CoreInitializable",
[
"deployer.thirdweb.eth/ClaimableERC721",
"deployer.thirdweb.eth/BatchMetadataERC721",
"deployer.thirdweb.eth/RoyaltyERC721",
],
{
description: "ERC721 NFTs that anyone can mint.",
title: "Modular NFT Drop",
},
],
// erc721 token
[
"thirdweb.eth/ERC721CoreInitializable",
[
"deployer.thirdweb.eth/MintableERC721",
"deployer.thirdweb.eth/BatchMetadataERC721",
"deployer.thirdweb.eth/TransferableERC721",
],
{
description: "ERC721 NFTs that only owners can mint.",
title: "Modular NFT Collection",
},
],
// open edition 721
[
"thirdweb.eth/ERC721CoreInitializable",
[
"deployer.thirdweb.eth/ClaimableERC721",
"deployer.thirdweb.eth/OpenEditionMetadataERC721",
"deployer.thirdweb.eth/RoyaltyERC721",
],
{
description: "ERC721 NFTs with identical metadata.",
title: "Modular Open Edition",
},
],
// erc1155 drop
[
"thirdweb.eth/ERC1155CoreInitializable",
[
"deployer.thirdweb.eth/ClaimableERC1155",
"deployer.thirdweb.eth/BatchMetadataERC1155",
"deployer.thirdweb.eth/RoyaltyERC1155",
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
],
{
description: "ERC1155 NFTs that others can mint.",
title: "Modular Edition Drop",
},
],
// erc1155 token
[
"thirdweb.eth/ERC1155CoreInitializable",
[
"deployer.thirdweb.eth/MintableERC1155",
"deployer.thirdweb.eth/BatchMetadataERC1155",
"deployer.thirdweb.eth/TransferableERC1155",
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
],
{
description: "ERC1155 NFTs that only owners can mint.",
title: "Modular Edition",
},
],
// erc20 drop
[
"thirdweb.eth/ERC20CoreInitializable",
[
"deployer.thirdweb.eth/ClaimableERC20",
"deployer.thirdweb.eth/TransferableERC20",
],
{
description: "ERC20 Tokens that others can mint.",
title: "Modular Token Drop",
},
],
// erc20 token
[
"thirdweb.eth/ERC20CoreInitializable",
[
"deployer.thirdweb.eth/MintableERC20",
"deployer.thirdweb.eth/TransferableERC20",
],
{
description: "ERC20 Tokens that only owners can mint.",
title: "Modular Token",
},
],
],
description:
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
displayName: "Modular Contracts",
id: "modular-contracts",
name: "modular",
} satisfies ExploreCategory;
const SUPERCHAIN = {
contracts: [
// erc20 token + superchain
[
"thirdweb.eth/ERC20CoreInitializable",
[
"deployer.thirdweb.eth/MintableERC20",
"deployer.thirdweb.eth/TransferableERC20",
"deployer.thirdweb.eth/SuperChainInterop",
],
{
description: "ERC20 Tokens that only owners can mint.",
title: "Modular Superchain Token",
},
],
// erc20 drop + superchain
[
"thirdweb.eth/ERC20CoreInitializable",
[
"deployer.thirdweb.eth/ClaimableERC20",
"deployer.thirdweb.eth/TransferableERC20",
"deployer.thirdweb.eth/SuperChainInterop",
],
{
description: "ERC20 Tokens that others can mint.",
title: "Modular Superchain Token Drop",
},
],
],
description: "Modular contracts with OP Superchain support",
displayName: "Modular Superchain Contracts",
id: "modular-superchain-contracts",
isBeta: true,
name: "Modular Superchain Contracts",
showInExplore: true,
} satisfies ExploreCategory;
const AIRDROP = {
contracts: ["thirdweb.eth/Airdrop"],
description:
@@ -313,12 +175,26 @@ const SMART_WALLET = {
showInExplore: true,
} satisfies ExploreCategory;
const STYLUS = {
contracts: [
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC1155",
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC721",
"0x6453a486d52e0EB6E79Ec4491038E2522a926936/StylusAirdropERC20",
],
description:
"Airdrop your NFTs or tokens to a large number of recipients. Built with Arbitrum Stylus.",
displayName: "Arbitrum Stylus Contracts",
id: "stylus",
name: "Stylus",
showInExplore: true,
isBeta: true,
} satisfies ExploreCategory;
const CATEGORIES: Record<string, ExploreCategory> = {
[POPULAR.id]: POPULAR,
[MODULAR_CONTRACTS.id]: MODULAR_CONTRACTS,
[SUPERCHAIN.id]: SUPERCHAIN,
[NFTS.id]: NFTS,
[MARKETS.id]: MARKETS,
[STYLUS.id]: STYLUS,
[DROPS.id]: DROPS,
[SMART_WALLET.id]: SMART_WALLET,
[AIRDROP.id]: AIRDROP,
@@ -1,5 +1,5 @@
import { AppFooter } from "@/components/footers/app-footer";
import { ErrorProvider } from "../../../@/contexts/error-handler";
import { ErrorProvider } from "@/contexts/error-handler";
export default function DashboardLayout(props: { children: React.ReactNode }) {
return (
@@ -1,10 +1,10 @@
import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { PublishedContract } from "@/components/contracts/published-contract";
import { Separator } from "@/components/ui/separator";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { getAuthToken } from "../../../../../../@/api/auth-token";
import { getRawAccount } from "../../../../account/settings/getAccount";
import { PublishedActions } from "../../components/contract-actions-published.client";
import { DeployContractHeader } from "../../components/contract-header";
@@ -4,7 +4,7 @@ import { getUserThirdwebClient } from "@/api/auth-token";
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { CustomContractForm } from "@/components/contract-components/contract-deploy-form/custom-contract";
import { loginRedirect } from "../../../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
type DeployFormForUriProps = {
contractMetadata: FetchDeployMetadataResult | null;
@@ -1,67 +0,0 @@
import { type ReactElement, useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
type ProblemAreaItem = {
label: string;
component: ReactElement;
};
const ACCOUNT_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Pricing inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Billing inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Usage inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Other",
},
];
export default function AccountSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
name="extraInfo_Problem_Area"
onValueChange={setProblemArea}
options={ACCOUNT_PROBLEM_AREAS.map((o) => o.label)}
promptText="Select a problem area"
required={true}
value={problemArea}
/>
{ACCOUNT_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component}
</>
);
}
@@ -1,66 +0,0 @@
import { type ReactElement, useState } from "react";
import { AttachmentForm } from "../shared/SupportForm_AttachmentUploader";
import { DescriptionInput } from "../shared/SupportForm_DescriptionInput";
import { SupportForm_SelectInput } from "../shared/SupportForm_SelectInput";
type ProblemAreaItem = {
label: string;
component: ReactElement;
};
const OTHER_PROBLEM_AREAS: ProblemAreaItem[] = [
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "General inquiry",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Security",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Feedback",
},
{
component: (
<>
<DescriptionInput />
<AttachmentForm />
</>
),
label: "Other",
},
];
export default function OtherSupportForm() {
const [problemArea, setProblemArea] = useState<string>("");
return (
<>
<SupportForm_SelectInput
formLabel="Problem area"
name="extraInfo_Problem_Area"
onValueChange={setProblemArea}
options={OTHER_PROBLEM_AREAS.map((o) => o.label)}
promptText="Select a problem area"
required={true}
value={problemArea}
/>
{OTHER_PROBLEM_AREAS.find((o) => o.label === problemArea)?.component}
</>
);
}
@@ -1,17 +0,0 @@
import { Label } from "@/components/ui/label";
export const AttachmentForm = () => {
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor="files">
Attachments
</Label>
<input
accept="image/*,video/*,"
multiple
name="attachments"
type="file"
/>
</div>
);
};
@@ -1,58 +0,0 @@
import { useId } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type MinimalTeam = {
name: string;
id: string;
};
type Props = {
teams: MinimalTeam[];
selectedTeamId: string | undefined;
onChange: (teamId: string) => void;
};
export const SupportForm_TeamSelection = (props: Props) => {
const selectedTeamName = props.teams.find(
(t) => t.id === props.selectedTeamId,
)?.name;
const teamId = useId();
return (
<div className="flex flex-col items-start gap-2">
<Label className="relative" htmlFor={teamId}>
Select Team
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
</Label>
<Select
name="teamId"
onValueChange={(selectedId) => {
props.onChange(selectedId);
}}
value={props.selectedTeamId}
>
<SelectTrigger id={teamId}>
<SelectValue placeholder="Select a Team">
{selectedTeamName}
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,27 +0,0 @@
import { Label } from "@/components/ui/label";
type Props = {
placeholder?: string;
};
const defaultDescription = "@YourHandle";
export const SupportForm_TelegramInput = (props: Props) => {
return (
<div className="flex flex-col items-start gap-2 bg-[#0a0a0a]">
<Label className="relative" htmlFor="telegram">
Telegram
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
</Label>
<input
autoComplete="off"
className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
maxLength={50}
name="telegram"
placeholder={props.placeholder ?? defaultDescription}
type="text"
/>
</div>
);
};
@@ -1,208 +0,0 @@
"use server";
import "server-only";
import { getTeamById } from "@/api/team";
import { getAuthTokenWalletAddress } from "../../../../../../@/api/auth-token";
import { getRawAccount } from "../../../../account/settings/getAccount";
import { loginRedirect } from "../../../../login/loginRedirect";
type State = {
success: boolean;
message: string;
};
const UNTHREAD_API_KEY = process.env.UNTHREAD_API_KEY || "";
const planToCustomerId = {
accelerate: process.env.UNTHREAD_ACCELERATE_TIER_ID as string,
free: process.env.UNTHREAD_FREE_TIER_ID as string,
growth: process.env.UNTHREAD_GROWTH_TIER_ID as string,
pro: process.env.UNTHREAD_PRO_TIER_ID as string,
scale: process.env.UNTHREAD_SCALE_TIER_ID as string,
// treat starter as free
starter: process.env.UNTHREAD_FREE_TIER_ID as string,
} as const;
function isValidPlan(plan: string): plan is keyof typeof planToCustomerId {
return plan in planToCustomerId;
}
function prepareEmailTitle(
product: string,
problemArea: string,
email: string,
name: string,
) {
const title =
product && problemArea
? `${product}: ${problemArea} (${email})`
: `New ticket from ${name} (${email})`;
return title;
}
function prepareEmailBody(props: {
product: string;
markdownInput: string;
email: string;
name: string;
extraInfoInput: Record<string, string>;
walletAddress: string;
telegramHandle: string;
}) {
const {
extraInfoInput,
email,
walletAddress,
product,
name,
markdownInput,
telegramHandle,
} = props;
// Update `markdown` to include the infos from the form
const extraInfo = Object.keys(extraInfoInput)
.filter((key) => key.startsWith("extraInfo_"))
.map((key) => {
const prettifiedKey = `# ${key
.replace("extraInfo_", "")
.replaceAll("_", " ")}`;
return `${prettifiedKey}: ${extraInfoInput[key] ?? "N/A"}\n`;
})
.join("");
const markdown = `# Email: ${email}
# Name: ${name}
# Telegram: ${telegramHandle}
# Wallet address: ${walletAddress}
# Product: ${product}
${extraInfo}
# Message:
${markdownInput}
`;
return markdown;
}
export async function createTicketAction(
_previousState: State,
formData: FormData,
) {
const teamId = formData.get("teamId")?.toString();
if (!teamId) {
return {
message: "teamId is required",
success: false,
};
}
const team = await getTeamById(teamId);
if (!team) {
return {
message: `Team with id "${teamId}" not found`,
success: false,
};
}
const [walletAddress, account] = await Promise.all([
getAuthTokenWalletAddress(),
getRawAccount(),
]);
if (!walletAddress || !account) {
loginRedirect("/support");
}
const customerId = isValidPlan(team.supportPlan)
? // fall back to "free" tier
planToCustomerId[team.supportPlan] || planToCustomerId.free
: // fallback to "free" tier
planToCustomerId.free;
const product = formData.get("product")?.toString() || "";
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
const telegramHandle = formData.get("telegram")?.toString() || "";
const title = prepareEmailTitle(
product,
problemArea,
account.email || "",
account.name || "",
);
const keyVal: Record<string, string> = {};
for (const key of formData.keys()) {
keyVal[key] = formData.get(key)?.toString() || "";
}
const markdown = prepareEmailBody({
email: account.email || "",
extraInfoInput: keyVal,
markdownInput: keyVal.markdown || "",
name: account.name || "",
product,
telegramHandle: telegramHandle,
walletAddress: walletAddress,
});
const content = {
customerId,
emailInboxId: process.env.UNTHREAD_EMAIL_INBOX_ID,
markdown,
onBehalfOf: {
email: account.email,
id: account.id,
name: account.name,
},
status: "open",
title,
triageChannelId: process.env.UNTHREAD_TRIAGE_CHANNEL_ID,
type: "email",
};
// check files
const files = formData.getAll("files") as File[];
if (files.length > 10) {
return { message: "You can only attach 10 files at once.", success: false };
}
if (files.some((file) => file.size > 10 * 1024 * 1024)) {
return { message: "The max file size is 20MB.", success: false };
}
// add the content
formData.append("json", JSON.stringify(content));
const KEEP_FIELDS = ["attachments", "json"];
const keys = [...formData.keys()];
// delete everything except attachments off of the form data
for (const key of keys) {
if (!KEEP_FIELDS.includes(key)) {
formData.delete(key);
}
}
// actually create the ticket
const res = await fetch("https://api.unthread.io/api/conversations", {
body: formData,
headers: {
"X-Api-Key": UNTHREAD_API_KEY,
},
method: "POST",
});
if (!res.ok) {
console.error(
"Failed to create ticket",
res.status,
res.statusText,
await res.text(),
);
return {
message: "Failed to create ticket, please try again later.",
success: false,
};
}
return {
message: "Ticket created successfully",
success: true,
};
}
@@ -1,183 +0,0 @@
"use client";
import { SupportForm_SelectInput } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_SelectInput";
import { SupportForm_TeamSelection } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TeamSelection";
import { SupportForm_TelegramInput } from "@app/(dashboard)/support/create-ticket/components/contact-forms/shared/SupportForm_TelegramInput";
import dynamic from "next/dynamic";
import {
type ReactElement,
useActionState,
useEffect,
useRef,
useState,
} from "react";
import { useFormStatus } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { createTicketAction } from "./create-ticket.action";
const ConnectSupportForm = dynamic(() => import("./contact-forms/connect"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const EngineSupportForm = dynamic(() => import("./contact-forms/engine"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const ContractSupportForm = dynamic(() => import("./contact-forms/contracts"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const AccountSupportForm = dynamic(() => import("./contact-forms/account"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const OtherSupportForm = dynamic(() => import("./contact-forms/other"), {
loading: () => <Skeleton className="h-12" />,
ssr: false,
});
const productOptions: { label: string; component: ReactElement }[] = [
{
component: <ConnectSupportForm />,
label: "Connect",
},
{
component: <EngineSupportForm />,
label: "Engine",
},
{
component: <ContractSupportForm />,
label: "Contracts",
},
{
component: <AccountSupportForm />,
label: "Account",
},
{
component: <OtherSupportForm />,
label: "Other",
},
];
function ProductAreaSelection(props: {
productLabel: string;
setProductLabel: (val: string) => void;
}) {
const { productLabel, setProductLabel } = props;
return (
<div className="flex flex-col gap-6">
<SupportForm_SelectInput
formLabel="What do you need help with?"
name="product"
onValueChange={setProductLabel}
options={productOptions.map((o) => o.label)}
promptText="Select a product"
required={true}
value={productLabel}
/>
{productOptions.find((o) => o.label === productLabel)?.component}
</div>
);
}
export function CreateTicket(props: {
teams: {
name: string;
id: string;
}[];
}) {
const formRef = useRef<HTMLFormElement>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
props.teams[0]?.id,
);
const [productLabel, setProductLabel] = useState("");
const [state, formAction] = useActionState(createTicketAction, {
message: "",
success: false,
});
// needed here
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!state.message) {
return;
}
if (state.success) {
toast.success(state.message);
} else {
toast.error(state.message);
}
}, [state.success, state.message]);
return (
<form
action={formAction}
className="rounded-lg border bg-card"
ref={formRef}
>
<div className="px-4 py-6 lg:px-6">
<h2 className="font-semibold text-2xl tracking-tight">Get Support</h2>
<p className="text-muted-foreground">
We are here to help. Ask product questions, report problems, or leave
feedback.
</p>
<div className="h-7" />
<div className="flex flex-col gap-6">
{/* Don't conditionally render this - it has be rendered to submit the input values */}
<div className={cn(props.teams.length === 1 && "hidden")}>
<SupportForm_TeamSelection
onChange={(teamId) => setSelectedTeamId(teamId)}
selectedTeamId={selectedTeamId}
teams={props.teams}
/>
</div>
<SupportForm_TelegramInput />
<ProductAreaSelection
productLabel={productLabel}
setProductLabel={setProductLabel}
/>
</div>
</div>
<div className="h-7" />
<div className="flex justify-end gap-3 border-t px-4 py-6 lg:px-6">
<Button
onClick={() => {
formRef.current?.reset();
setProductLabel("");
}}
variant="outline"
>
Reset
</Button>
<SubmitButton />
</div>
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button
className="flex min-w-24 flex-row gap-2"
disabled={pending}
type="submit"
>
{pending && <Spinner className="size-4" />}
{pending ? "Submitting" : "Submit"}
</Button>
);
}
@@ -1,48 +0,0 @@
import Link from "next/link";
import { getTeams } from "@/api/team";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { loginRedirect } from "../../../login/loginRedirect";
import { CreateTicket } from "./components/create-ticket.client";
export default async function Page() {
const teams = await getTeams();
const pagePath = "/support/create-ticket";
if (!teams || teams.length === 0) {
loginRedirect(pagePath);
}
return (
<div>
<Breadcrumb className="border-border border-b px-6 py-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/support">Support</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Get Support</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="container max-w-[750px] py-10">
<CreateTicket
teams={teams.map((t) => ({
id: t.id,
name: t.name,
}))}
/>
</div>
</div>
);
}
@@ -1,12 +0,0 @@
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
{children}
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

@@ -1,155 +0,0 @@
import {
BookOpenIcon,
ChevronRightIcon,
HomeIcon,
WalletIcon,
} from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getTeams } from "@/api/team";
import { CustomChatButton } from "@/components/chat/CustomChatButton";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EngineIcon } from "@/icons/EngineIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { PayIcon } from "@/icons/PayIcon";
import { siwaExamplePrompts } from "./definitions";
export const metadata: Metadata = {
openGraph: {
title: "thirdweb Support",
},
title: "thirdweb Support",
};
const HELP_PRODUCTS = [
{
description: "Try out our interactive playground to get started",
icon: HomeIcon,
title: "Playground",
viewAllUrl:
"https://playground.thirdweb.com/connect/sign-in/button?tab=code",
},
{
description: "Create and manage crypto wallets",
icon: WalletIcon,
title: "Wallets",
viewAllUrl: "https://portal.thirdweb.com/connect",
},
{
description: "Enable payments on any tokens on any chain",
icon: PayIcon,
title: "Payments",
viewAllUrl: "https://portal.thirdweb.com/pay/troubleshoot",
},
{
description: "Perform read and write transactions onchain",
icon: EngineIcon,
title: "Transactions",
viewAllUrl: "https://portal.thirdweb.com/engine/v3/troubleshoot",
},
{
description: "Query and analyze blockchain data",
icon: InsightIcon,
title: "Insight",
viewAllUrl: "https://portal.thirdweb.com/insight",
},
{
description: "API interface for LLMs",
icon: NebulaIcon,
title: "Nebula",
viewAllUrl: "https://portal.thirdweb.com/nebula",
},
] as const;
export default async function SupportPage() {
const [authToken, accountAddress] = await Promise.all([
getAuthToken(),
getAuthTokenWalletAddress(),
]);
const teams = await getTeams();
const teamId = teams?.[0]?.id ?? undefined;
return (
<main className="flex flex-col gap-12 pb-12">
<div className="bg-gradient-to-b from-card/0 to-card py-20">
<header className="container flex flex-col items-center gap-8">
<div className="rounded-full bg-gradient-to-r from-[#F213A4] to-[#5204BF] p-2">
<div className="rounded-full bg-background p-6 shadow-md">
<BookOpenIcon className="size-8" />
</div>
</div>
<div className="flex max-w-2xl flex-col items-center gap-2">
<h1 className="text-center font-bold text-4xl md:text-balance md:text-5xl">
How can we help?
</h1>
<p className="text-center text-lg text-muted-foreground">
Get instant answers with Nebula AI, our onchain support assistant.
Still need help? You can also create a support case to reach our
team.
</p>
<div className="mt-6 flex w-full flex-col items-center gap-3">
<CustomChatButton
authToken={authToken || undefined}
clientId={undefined}
examplePrompts={siwaExamplePrompts}
isFloating={false}
isLoggedIn={!!accountAddress}
label="Ask AI for support"
networks="all"
pageType="support"
teamId={teamId}
/>
<Link
className="text-muted-foreground text-sm hover:underline"
href="/support/create-ticket"
rel="noopener noreferrer"
target="_blank"
>
Open a support case
</Link>
</div>
</div>
</header>
</div>
<section className="container flex flex-col gap-6">
<h2 className="font-bold text-3xl">Support Articles</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3">
{HELP_PRODUCTS.map((product) => (
<Card className="relative col-span-1 bg-card" key={product.title}>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-2">
{product.icon && <product.icon className="size-5" />}
<CardTitle className="text-xl">{product.title}</CardTitle>
</div>
<Button
asChild
className="absolute top-4 right-2 flex flex-row gap-1 px-2 text-muted-foreground text-sm hover:text-foreground"
size="sm"
variant="ghost"
>
<Link href={product.viewAllUrl} target="_blank">
<span>View</span>
<ChevronRightIcon className="size-4" />
</Link>
</Button>
</CardHeader>
<CardContent key={product.description}>
{product.description && (
<p className="mb-2 text-muted-foreground text-sm">
{product.description}
</p>
)}
</CardContent>
</Card>
))}
</div>
</section>
</main>
);
}
@@ -63,14 +63,14 @@ export const HexConverter = () => {
<Card className="flex flex-col gap-2 p-4">
<p>
Or use the
Convert values using the{" "}
<a
className="underline"
href="https://portal.thirdweb.com/typescript/v5"
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</a>
:
</p>
@@ -49,14 +49,14 @@ export const Keccak256Converter = () => {
<Card className="flex flex-col gap-2 p-4">
<p>
Or use the{" "}
Convert values using the{" "}
<a
className="underline"
href="https://portal.thirdweb.com/typescript/v5"
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</a>
:
</p>
@@ -308,7 +308,7 @@ ${Object.keys(populatedTransaction)
rel="noopener noreferrer"
target="_blank"
>
Connect SDK example
TypeScript SDK example
</a>
<CodeClient code={state.codeExample} lang="ts" />
</div>
@@ -79,14 +79,14 @@ export const WeiConverter = () => {
<Card className="flex flex-col gap-2 p-4">
<p>
Or use the{" "}
Convert values using the{" "}
<a
className="underline"
href="https://portal.thirdweb.com/typescript/v5"
rel="noopener noreferrer"
target="_blank"
>
Connect SDK
thirdweb SDK
</a>
:
</p>
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "@/actions/auth-actions";
import { createTeam } from "@/actions/createTeam";
import { resetAnalytics } from "@/analytics/reset";
import type { Project } from "@/api/projects";
@@ -12,7 +13,6 @@ import { CustomConnectWallet } from "@/components/connect-wallet";
import { LazyCreateProjectDialog } from "@/components/project/create-project-modal/LazyCreateAPIKeyDialog";
import type { Account } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { doLogout } from "../../login/auth-actions";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -1,8 +1,8 @@
import {
getProjectContracts,
type ProjectContract,
} from "../../../../../@/api/getProjectContracts";
import { fetchChainWithLocalOverrides } from "../../../../../@/utils/fetchChainWithLocalOverrides";
} from "@/api/getProjectContracts";
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
export async function getSortedDeployedContracts(params: {
onlyMainnet?: boolean;
@@ -1,5 +1,6 @@
import type React from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
import { getProjects } from "@/api/projects";
import { getTeams, type Team } from "@/api/team";
import { AppFooter } from "@/components/footers/app-footer";
@@ -7,12 +8,8 @@ import { AnnouncementBanner } from "@/components/misc/AnnouncementBanner";
import { TabPathLinks } from "@/components/ui/tabs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import type { Account } from "@/hooks/useApi";
import {
getAuthToken,
getAuthTokenWalletAddress,
} from "../../../@/api/auth-token";
import { loginRedirect } from "@/utils/redirects";
import { TWAutoConnect } from "../components/autoconnect";
import { loginRedirect } from "../login/loginRedirect";
import { AccountHeader } from "./components/AccountHeader";
import { getValidAccount } from "./settings/getAccount";
@@ -1,9 +1,9 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeams } from "@/api/team";
import { getMemberByAccountId } from "@/api/team-members";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getAuthToken } from "../../../@/api/auth-token";
import { loginRedirect } from "../login/loginRedirect";
import { loginRedirect } from "@/utils/redirects";
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
import { getValidAccount } from "./settings/getAccount";
@@ -2,13 +2,13 @@
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { upload } from "thirdweb/storage";
import { doLogout } from "@/actions/auth-actions";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { apiServerProxy } from "@/actions/proxies";
import { updateAccount } from "@/actions/updateAccount";
import { resetAnalytics } from "@/analytics/reset";
import type { Account } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { doLogout } from "../../login/auth-actions";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
export function AccountSettingsPage(props: {
@@ -285,7 +285,7 @@ function DeleteAccountCard(props: {
variant="outline"
>
<Link
href="/support/create-ticket"
href="/team/~/~/support"
rel="noopener noreferrer"
target="_blank"
>
@@ -1,8 +1,8 @@
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { Account } from "@/hooks/useApi";
import { getAuthToken } from "../../../../@/api/auth-token";
import { loginRedirect } from "../../login/loginRedirect";
import { isAccountOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
import { loginRedirect } from "@/utils/redirects";
import { isAccountOnboardingComplete } from "../../../login/onboarding/isOnboardingRequired";
/**
* Just get the account object without enforcing onboarding.

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