Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1732e63bb8 Version Packages (#8075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 02:02:04 -07:00
Joaquim VergesandGitHub 7b8ceebb63 [SDK] Show policy error for 7702 transactions (#8077) 2025-09-19 01:53:09 -07:00
MananTank 1d90437d34 Portal: Add SwapWidget in bridge/swap page (#8072)
<!--

## 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 documentation for the `SwapWidget` component, simplifying the example code, and enhancing the user experience by adding new features and usage guidelines.

### Detailed summary
- Added `DocImage` component to display `swap-dark.png`.
- Simplified the swap interface example to use the `SwapWidget`.
- Introduced a live playground section for the `SwapWidget`.
- Added configuration options for default token selection in the widget.
- Removed the complex swap interface code for clarity.
- Included tips for gas optimization and route caching.

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

<!-- end pr-codex -->
2025-09-19 00:32:32 +00:00
Joaquim VergesandGitHub 59616d0082 [Portal] Add documentation for third-party library adapters (#8074) 2025-09-18 17:31:46 -07:00
MananTank 32980f854b [MNY-190] Playground: Add SwapWidget (#8069)
<!--

## 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 significant updates to the `SwapWidget` component, including a new `persistTokenSelections` prop for managing token selection persistence. It also enhances the UI components and integrates a new bridge feature.

### Detailed summary
- Added `persistTokenSelections` prop to `SwapWidget`.
- Updated `SwapWidgetPlayground` for better token management.
- Introduced `BridgeNetworkSelector` and `CurrencySelector` components.
- Added `bridgeFeatureCards` to the feature metadata.
- Enhanced `LeftSection` for improved token and currency selection.
- Updated the sidebar to include the bridge section.
- Refactored network selection logic to use the bridge API.
- Improved code formatting and organization in multiple files.

> ✨ 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
  - SwapWidget gains a persistTokenSelections prop to opt out of saving token choices.
  - Added a Swap Widget Playground with live preview, generated code view, theme/currency/prefill/branding controls, and UI/Code tabs.
  - Playground is integrated into site navigation and feature cards under a new Bridge section.

- Chores
  - Playground now uses bridge-supported chains and improved TypeScript formatting for generated snippets.
  - Added a reusable currency selector for widget configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 00:17:37 +00:00
MananTank f3b3b89fca Dashboard: Add tracking for SwapWidget impression, chain pageview (#8071)
<!--

## 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 analytics tracking for chain page views and enhances the reporting for swap widgets in the dashboard application.

### Detailed summary
- Added `ChainPageView` component to track impressions of chain pages.
- Integrated `reportChainPageview` function in the analytics module.
- Enhanced `BuyAndSwapEmbed` to report when the swap widget is shown.

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

- Chores
  - Added one-time telemetry when the Swap widget is displayed to improve usage insights.
  - Tracks the page context (asset, bridge, or chain) when the widget first appears.
  - Added one-time telemetry to record chain pageviews (captures the chain identifier) when visiting chain pages.
  - Integrated with existing UI; no impact on behavior, user action, or performance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-18 21:03:00 +00:00
Jonas DanielsandGitHub 44826925d0 Add Stripe checkout for Growth plan subscription (#8068) 2025-09-17 22:41:55 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
00dcc346bf Version Packages (#8063)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-18 10:58:25 +12:00
Manan TankandGitHub e0738d68d2 SDK, Dashboard: Fix SwapWidget setting same token for buy and sell in chain page (#8067) 2025-09-17 15:47:19 -07:00
Joaquim VergesandGitHub e160deb8b7 [SDK] Fix wallet reconnection for previously connected wallets (#8066) 2025-09-17 15:18:13 -07:00
Manan TankandGitHub a6f846fc1e [BLD-286] SDK: add stable classNames to elements in Connect UI (#8065) 2025-09-17 13:34:27 -07:00
Manan TankandGitHub 0e3f3460ad Add SwapWidget in SDK, add in dashboard, BuyWidget UI improvements (#8044) 2025-09-17 12:57:58 -07:00
MananTank 552f7025c1 Dashboard: Fix Import Engine form submit not working (#8064)
<!--

## 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 dialog for importing engine instances in the `ImportEngineButton` component, enhancing user interaction by managing the dialog state and updating UI elements.

### Detailed summary
- Added `useState` to manage the dialog's open state.
- Changed navigation from `router.push` to `router.refresh` after import.
- Wrapped the dialog in a conditional open state.
- Updated the layout and styling of the dialog content and links.
- Modified form field styles for better UI consistency.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* New Features
  * Import dialog now closes automatically after a successful import and refreshes the current page.
  * Added an in-dialog warning beneath the URL field for clearer guidance.
  * Updated “Get started” link destination.

* Style
  * Redesigned import dialog with improved layout, spacing, rounded content, and a bottom action bar; inputs use card styling.
  * Refined labels, placeholders, and header padding for clearer readability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-17 15:58:39 +00:00
MananTank 15f083912c SDK: Fix onSuccess prop not called properly on CheckoutWidget and BuyWidget (#8054)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR addresses an issue with the `onSuccess` callback not being triggered properly in the `CheckoutWidget` and `BuyWidget` components, improving the handling of buy completion and post-transaction actions.

### Detailed summary
- Renamed `handleBuyComplete` to `handleDoneOrContinueClick`.
- Adjusted the invocation of `onComplete` to only occur when `uiOptions.mode` is not "transaction".
- Updated the dependency array for `handleExecutionComplete` to include `onComplete` and `uiOptions.mode`.
- Changed the `onDone` prop in `SuccessScreen` to use `handleDoneOrContinueClick`.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed an issue where the success callback in checkout and buy widgets did not fire reliably.
  * Improved non-transaction flows so completion triggers after execution finishes (instead of on Done), ensuring proper state reset and more consistent behavior.

* **Chores**
  * Added a patch-level changeset entry documenting the fix.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-17 10:47:49 +00:00
GiselleNessi 04862de9e4 feat: add feedback collection UI to navigation (#7895)
<!--

## 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 feature for collecting user feedback within the application. It implements feedback submission forms in both desktop and mobile components, capturing user feedback and sending it to PostHog for analysis.

### Detailed summary
- Added `reportProductFeedback` function in `report.ts` to handle feedback submissions.
- Implemented feedback submission in the `SecondaryNavLinks` component.
- Added feedback dropdown with a form in `SecondaryNavLinks`.
- Integrated feedback functionality in the `MobileBurgerMenuButton` component.
- Included success notifications using `toast` for feedback submissions in both 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**
  * Replaced external Feedback link with an in-app feedback panel in the header and mobile menu.
  * Panel includes a textarea (max 1000 chars), Cancel and Submit actions; Submit is disabled when empty, shows a success message, clears input and closes the panel.
  * Adds an in-panel “Contact support” link for technical issues and preserves existing header controls.

* **Style**
  * Expandable/collapsible feedback UI with visual indicators, improved positioning and mobile menu scrolling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-16 16:37:22 +00:00
ea46572947 [Dashboard] Display eip7702 support status (#8060)
Co-authored-by: Cursor Agent <[email protected]>
2025-09-15 17:39:14 -07:00
Joaquim VergesandGitHub f276dcfd76 [SDK] Filter out AGW from searchable wallets (#8062) 2025-09-15 17:38:51 -07:00
Joaquim VergesandGitHub 6495b94910 [SDK] Simplify wallet connection code and add Abstract wallet support (#8061) 2025-09-15 16:29:01 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
210f631cba Version Packages (#8053)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-15 11:01:51 -07:00
gregandGitHub 557f0e29e8 Fix: Add brief pause after onramp (#8052) 2025-09-15 10:06:47 -07:00
Joaquim VergesandGitHub f9bbf8101c [Nebula] Add deprecation banner with migration information (#8051) 2025-09-14 17:10:33 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
59d226512c Version Packages (#8049)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-14 04:21:13 -07:00
Joaquim VergesandGitHub 778bf2cc4b [SDK] Add wallet validation before executing transactions (#8050) 2025-09-14 04:06:05 -07:00
Joaquim VergesandGitHub 97e5080fdf [SDK] Fix fiat payments with no wallet connected (#8048) 2025-09-14 03:54:57 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9f9fadf242 Version Packages (#8047)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-13 03:08:48 -07:00
Joaquim VergesandGitHub 224c9d034f [SDK] Upgrade WalletConnect and improve chain switching reliability (#8046) 2025-09-13 02:54:32 -07:00
Jonas DanielsandGitHub 8b8dfd9bf9 Handle ENS resolution errors gracefully (#8045) 2025-09-12 16:52:09 -07:00
Jonas DanielsandGitHub 70e52188a1 Add tier6 for SMS country pricing (#8043) 2025-09-12 16:51:43 -07:00
MananTank c82b606cd2 [MNY-166] Dashboard: Add Dex Screener charts in public ERC20 token page (#8035)
<!--

## 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 removing the `token-price-data` API and the `PriceChart` component, while introducing a new `DexScreener` feature that displays token price information based on the chain ID and contract address.

### Detailed summary
- Deleted `token-price-data.ts` and `PriceChart.tsx`.
- Added `mapChainIdToDexScreenerChainSlug` to map chain IDs to DexScreener chain slugs.
- Introduced `DexScreener` and `DexScreenerIframe` components for displaying token price data.
- Updated `erc20.tsx` to integrate `DexScreener` and remove token price fetching logic.

> ✨ 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 embedded DexScreener chart on ERC20 public pages for supported chains with automatic light/dark theming and responsive SSR fallback.
  * Introduced chain-to-DexScreener slug mapping for type-safe embeds.

* **Refactor**
  * Removed token price data fetching and the TokenStats/price chart UI from ERC20 public pages.

* **Style**
  * Widened layout to max-w-7xl, simplified Buy section spacing, and moved DexScreener before analytics; analytics and recent transfers now sit in a unified bottom container.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-12 19:25:59 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1c6b12449b Version Packages (#8040)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 04:43:43 -07:00
Joaquim VergesandGitHub ea8061048a [SDK] Fix signAuthorization for 1193 providers (#8042) 2025-09-12 04:35:10 -07:00
Joaquim VergesandGitHub 3ad9f7deba [SDK] Allow passing overrides to common extension functions (#8041) 2025-09-11 18:58:34 -07:00
Joaquim VergesandGitHub 0b454050b1 [SDK] Accept pre-signed authorizations for 7702 account transactions (#8038) 2025-09-11 17:07:44 -07:00
Joaquim VergesandGitHub dc1f7ab2ee [SDK] Support full MetaMask disconnection on wallet disconnect (#8039) 2025-09-11 16:47:40 -07:00
GiselleNessi bd17c08891 Fix/feedback api endpoints (#8024)
<!--

## 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 input validation and error handling in the `submitSupportFeedback` and `checkFeedbackStatus` functions, as well as improving the `SupportCaseDetails` component for better user experience during feedback submission.

### Detailed summary
- Improved input validation in `submitSupportFeedback` for `ticketId` and `rating`.
- Enhanced error handling with more specific error messages.
- Added comprehensive validation for API response structure.
- Updated `SupportCaseDetails` to handle status check errors non-blockingly.
- Adjusted feedback submission logic to improve user experience and error reporting.
- Refactored button and feedback display logic for clarity and usability.

> ✨ 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**
  * Interactive 5‑star rating, accessible controls, auto‑resizing feedback textarea, and integrated Send Feedback button with spinner.
  * “Checking feedback status…” loader and clear “Thank you” state after successful submission.

* **Improvements**
  * Non‑blocking status check with a user‑facing warning if verification fails.
  * Faster, clearer error messages and timeouts for network/server issues; form validation prevents invalid submissions.
  * Feedback resets and success toasts on submission.

* **Bug Fixes**
  * Feedback UI no longer fails when status verification errors occur; malformed API responses handled gracefully.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-11 09:03:02 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
79e3694e37 Version Packages (#8037)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-10 13:41:10 -07:00
Joaquim VergesandGitHub ef15ba7c43 [SDK] Pass through authorizationList for injected providers (#8036) 2025-09-10 13:30:46 -07:00
MananTank a724bc39a2 [BLD-247] Add propagation time in domain restrictions in project settings page (#8025)
<!--

## 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 `bottomText` prop of the `SettingsCard` component in the `ProjectGeneralSettingsPage.tsx` file to provide more detailed information regarding domain restrictions for web applications.

### Detailed summary
- Changed `bottomText` from a string to a fragment containing:
  - A message about applicability for web applications.
  - An additional note that changes to domain restrictions may take up to 5 minutes to take effect.

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

<!-- end pr-codex -->

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

* **Documentation**
  * Clarified Allowed Domains setting copy to state it's only applicable for web applications.
  * Added guidance that domain restriction changes may take up to 5 minutes to take effect.
  * Purely a UI copy update; no functional or behavioral changes.
  * Improves in-app guidance and reduces configuration confusion.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 23:23:31 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2eaff98d8a Version Packages (#8030)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 16:22:34 -07:00
Joaquim VergesandGitHub 1759112d72 [SDK] Add signAuthorization support to 1193 provider (#8029) 2025-09-09 16:14:27 -07:00
MananTank 85d6074e92 [BLD-277] Fix Server Wallets balance fetching for custom chains (#8026)
<!--

## 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 code to utilize a new hook, `useGetV5DashboardChain`, which replaces the previous `defineChain` function in multiple files. This change enhances the code's readability and maintainability by standardizing the method of retrieving chain information.

### Detailed summary
- Replaced `defineChain` with `useGetV5DashboardChain` in `useAbiProcessing.ts`.
- Updated the `chain` variable initialization in `ServerWalletTableRow` to use `useV5DashboardChain`.
- Modified the `chain` parameter in `WalletBalanceCell` to utilize `useV5DashboardChain` instead of `defineChain`.

> ✨ 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**
  * Unified chain handling across wallet tables and webhook ABI processing using a new dashboard adapter, improving consistency and reliability.
  * Streamlined balance and smart account address resolution to reduce redundant computations and potential mismatches.
  * Internal imports and hooks updated without changing public interfaces or user workflows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 19:13:00 +00:00
Joaquim VergesandGitHub 2f6f3a0947 [Dashboard] Update analytics API integration and improve number formatting (#8023) 2025-09-09 04:16:47 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
4284b9d77b Version Packages (#8008)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 00:39:51 -07:00
b149019318 [SDK] Feature: added glyph wallet in extra wallets (#7973)
Co-authored-by: Joaquim Verges <[email protected]>
2025-09-08 23:46:49 -07:00
Joaquim VergesandGitHub e2ebb602dc thirdweb AI inside dashboard (#8003) 2025-09-08 17:10:07 -07:00
MananTank 2d76cf9040 [BLD-259] Playground, Portal: Add revalidation for API Reference page (#8022)
<!--

## 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 metadata and content for the API reference pages in the `portal` and `playground-web` applications, enhancing the descriptions and titles to better reflect the purpose of the APIs. It also introduces a revalidation period for both pages.

### Detailed summary
- Updated `metadata` in `apps/portal/src/app/reference/page.tsx`:
  - Changed `title` from "API Reference" to "thirdweb API Reference".
  - Changed `description` from "Complete REST API reference for all thirdweb services" to "A Unified interface for Web3 development".
- Added `revalidate` property with value `3600` in `apps/portal/src/app/reference/page.tsx`.
- In `apps/playground-web/src/app/reference/page.tsx`:
  - Imported `ScalarApiReference` and `createMetadata`.
  - Added `metadata` with `title` "thirdwebAPI Reference" and `description` "A Unified interface for Web3 development".
  - Added `revalidate` property with value `3600`.

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

<!-- end pr-codex -->
2025-09-08 23:03:32 +00:00
MananTank e7d0899e76 [MNY-163] Portal: Fix broken links in payments sidebar (#8021)
<!--

## 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 `sidebar` configuration in two files: `sidebar.tsx` for the bridge and payments sections. It removes an entry and modifies the `href` properties for navigation links to ensure correct routing.

### Detailed summary
- Removed the `Playground` entry from the `sidebar` in `apps/portal/src/app/bridge/sidebar.tsx`.
- Updated the `href` for the `Playground` entry in `apps/portal/src/app/payments/sidebar.tsx` to point to `/payments`.
- Changed `href` values for `Sell Tokens`, `Swap Tokens`, `Get Token Prices`, and `Get Routes` to use absolute paths.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Corrected Payments sidebar destinations: Playground now points to the dedicated payments playground, and Bridge-related items use absolute paths for reliable, consistent navigation across the app.

* **Chores**
  * Removed the obsolete Playground link from the Bridge sidebar to streamline navigation.
  * Cleaned up an unused icon import.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-08 23:03:32 +00:00
MananTank 88001cfe49 [BLD-271] Portal: Change Header nav link order (#8013)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the `links` array in the `Header.tsx` file by removing and then re-adding the `Payments` and `Bridge` items, thereby changing their order within the array.

### Detailed summary
- Removed `Payments` link from the `links` array.
- Removed `Bridge` link from the `links` array.
- Re-added `Payments` link to the `links` array.
- Re-added `Bridge` link to the `links` array.

> ✨ 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 top navigation order: Payments and Bridge now appear after Contracts instead of immediately after Wallets.
  * Applies to both desktop navigation and the mobile menu, improving consistency across views without changing the available links or destinations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-08 22:28:57 +00:00
MananTank a9c388a613 Dashboard: Add Bridge in framer paths (#8006)
<!--

## 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 `apps/dashboard/framer-rewrites.js` file by adding a new route for monetization.

### Detailed summary
- Added the route `"/monetize/bridge"` to the existing array of routes in `apps/dashboard/framer-rewrites.js`.

> ✨ 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 public route: /monetize/bridge. Users can now directly access the Monetize Bridge experience via this URL, enabling easier navigation and deep-linking from emails, bookmarks, or external references. Existing routes remain unchanged, so current links and workflows continue to work as before. This addition improves discoverability and provides a clearer, direct path to the relevant monetization flow.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-08 19:59:33 +00:00
nischitpra 4ff1cf7979 fix polygon amoy gasstation url (#8007)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on a patch for the `thirdweb` library that addresses a fix for the Polygon amoy gas station URL.

### Detailed summary
- Introduced a patch for the `thirdweb` library.
- Fixed the URL for the Polygon amoy gas station.

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

<!-- end pr-codex -->

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

* **Bug Fixes**
  * Corrected the Polygon Amoy gas station endpoint, improving fee estimation and transaction reliability on that network.

* **Chores**
  * Prepared a patch release entry reflecting this fix.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-08 18:41:17 +00:00
nischitpra e78a984b4e polygon amoy gasstation update (#8004)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the gas fee data source for the Polygon network in the `fee-data.ts` file, specifically changing the URL for the testnet case.

### Detailed summary
- In the `fee-data.ts` file, the return URL for the case `80002` has been changed from `"https://gasstation-testnet.polygon.technology/v2"` to `"https://gasstation.polygon.technology/amoy"`.

> ✨ 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**
  * Updated the gas fee data source for Polygon Amoy (chain 80002) to the current gas station endpoint, improving accuracy and reliability of priority fee estimates.
  * Enhances transaction success rates and fee predictions for users interacting with the Amoy network.
  * No changes for Polygon mainnet (137).

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-08 12:45:14 +00:00
YashandGitHub e37bd8e994 feat: move insight page to APIs dropdown (#7991) 2025-09-07 16:12:39 -07:00
Joaquim VergesandGitHub a94cf80dbd [Dashboard] Update analytics API integration and add error logging (#8001) 2025-09-07 04:36:12 -07:00
gregandGitHub f789e7663a [Portal] Feature: Bridge page (#7998) 2025-09-06 12:54:42 -07:00
a51f4a0031 Add chat context controls to playground (#7999)
Co-authored-by: Cursor Agent <[email protected]>
2025-09-05 18:15:57 -07:00
Joaquim VergesandGitHub d06a15ecbb [Docs] Add OpenAI SDK documentation page (#7997) 2025-09-05 17:12:33 -07:00
Joaquim VergesandGitHub 61fddefcb1 [SDK] Add documentation for create7702MinimalAccount function (#7996) 2025-09-05 17:11:55 -07:00
FirekeeperandGitHub 14fe9ee2b9 Remove ability to disable Transactions (#7995) 2025-09-06 04:17:40 +07:00
MananTank 4ca407dbf0 [MNY-139] Dashboard, Portal, Playground: Add Bridge Product (#7984)
<!--

## 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 payment and bridge functionalities in the dashboard and playground applications. It includes UI improvements, new components, and refactoring for better organization and usability.

### Detailed summary
- Deleted `RouteDiscoveryCard.tsx`.
- Replaced `BridgeIcon` export.
- Added route rewrites in `next.config.mjs`.
- Updated navigation links in `navLinks.ts`.
- Modified `useTokensData.ts` to include price parameters.
- Refactored `CreatePaymentWebhookButton` to a function.
- Enhanced layout and styling in various components, including `ProjectSidebarLayout`, `EmptyState`, and `QuickstartSection`.
- Introduced `ProjectSettingsBreadcrumb` for better navigation.
- Added support for locked widgets in payment components.
- Improved `PaymentLinksTable` and `PaymentHistory` components with new table structures.
- Enhanced `FeatureCard` and `PayAnalytics` with updated styling and structure.
- Created `CheckoutPlayground` and `TransactionPlayground` components for better payment handling.
- Reorganized the bridge section with improved UI and functionality.

> The following files were skipped due to too many changes: `apps/playground-web/src/app/payments/transactions/page.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**
  * Bridge page with analytics, route discovery, and Quickstart; CSV export for payment history; new Buy, Checkout, and Transaction playgrounds.

* **Navigation**
  * Added "Bridge" to project sidebar; Webhooks consolidated with tabs; Payments page streamlined; redirect from /payments/ui-components → /payments and removed UI Components page.

* **UI/UX Improvements**
  * Redesigned analytics and payment history table; updated FeatureCard and EmptyState visuals; removed "Swap Tokens" card; settings pages use breadcrumbs; assorted copy and spacing tweaks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-05 20:17:41 +00:00
0xFirekeeper f714e5d810 Update API reference links to use local /reference paths (#7993)
Replaced external 'https://api.thirdweb.com/reference' links with local '/reference' paths across documentation, sidebar, and metadata files for consistency and improved navigation within the portal.

Closes BLD-221

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the reference URLs from absolute paths to relative paths across various components in the application. This change aims to streamline navigation within the app and improve consistency in referencing API documentation.

### Detailed summary
- Changed `href` values from absolute URLs to relative paths in multiple files:
  - `Header.tsx`
  - `ai/sidebar.tsx`
  - `wallets/sidebar.tsx`
  - `contracts/sidebar.tsx`
  - `ai/llm-txt/page.mdx`
  - `payments/page.mdx`
  - `nebula/api-reference` files
  - `transactions/sponsor/page.mdx`
  - `page.tsx`
  - `Document/APIEndpointMeta/OpenApiEndpoint.tsx`
  - `wallets/server/page.mdx`
  - `transactions/page.mdx`

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

<!-- end pr-codex -->

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

- Documentation
  - Updated API Reference links across the site (AI, Contracts, Transactions, Payments, Wallets, Nebula endpoints, Home, and Header) to use internal paths (/reference and anchored routes).
- Bug Fixes
  - API Reference links now navigate within the docs site instead of opening an external domain, improving navigation continuity.
- Refactor
  - Standardized reference URL handling to consistently use relative routes for a unified in-app experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-05 19:49:28 +00:00
Yash094 a328250be1 feat: Add api to project view section (#7992)
<!--

## 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 `IntegrateAPIKeyCodeTabs` component by adding new tab keys for `api` and `curl`, along with their respective code examples for API integration. It also updates the default selected tab to `api`.

### Detailed summary
- Added new `TabKey` values: `api` and `curl`.
- Updated `tabNames` to include labels for `api` and `curl`.
- Changed default tab state from `ts` to `api`.
- Introduced `apiCodeExample` function for API integration code.
- Introduced `curlCodeExample` function for cURL integration code.

> ✨ 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 API and cURL tabs to the API integration UI with ready-to-copy code examples (JavaScript fetch and Bash curl) for server-side wallet API calls.
  * Set the API tab as the default selection and updated tab order to include API and cURL.
  * Examples auto-fill the masked secret key when available and include a sample identifier, improving first-time setup guidance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-05 15:49:11 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
928704ad5c Version Packages (#7990)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-05 00:43:09 -07:00
Joaquim VergesandGitHub acbbf2039f [Playground] Add Engine user transactions example to playground (#7470) 2025-09-05 00:37:54 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
4375e7331f Version Packages (#7989)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-05 13:35:47 +12:00
FirekeeperandGitHub 22d736cb24 Refactor wallet user API integration to use tw api (#7987) 2025-09-05 08:09:24 +07:00
0xFirekeeper bb89561a1b Fetch minimal account address from bundler (#7919)
```
<!--

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

Replaces the hardcoded `MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS` with a dynamic fetch from the bundler using the `tw_getDelegationContract` RPC method.

This change:
- Introduces an async function `getDelegationContractAddress` to fetch the address.
- Implements caching for the fetched address to prevent redundant RPC calls.
- Updates `_sendTxWithAuthorization` and `is7702MinimalAccount` to handle the async nature and use the dynamically fetched address.
- Ensures type safety for address fields.

## How to test

Unit tests cover the functionality. A temporary test was created and verified during development.
```
pnpm test packages/thirdweb/src/wallets/in-app/core/eip7702/minimal-account.ts
```

-->
```

---
[Slack Thread](https://thirdwebdev.slack.com/archives/C085FEPFLN9/p1756220164604699?thread_ts=1756220164.604699&cid=C085FEPFLN9)

<a href="https://cursor.com/background-agent?bcId=bc-874d9753-17ac-4429-8dfb-c3d6776f6711">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg">
    <source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg">
    <img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg">
  </picture>
</a>
<a href="https://cursor.com/agents?id=bc-874d9753-17ac-4429-8dfb-c3d6776f6711">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg">
    <source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg">
    <img alt="Open in Web" src="https://cursor.com/open-in-web.svg">
  </picture>
</a>

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `erc7702` extension, refining event filters, and modifying function signatures to enhance clarity and correctness. It also introduces delegation contract handling in the `minimal-account` implementation.

### Detailed summary
- Updated function signatures and event filters in multiple files to replace `signer` with `user` and `newSigner`.
- Improved data structures in the generated files for `MinimalAccount`.
- Added delegation contract address fetching in `minimal-account.ts`.
- Adjusted transaction handling logic to use the delegation contract address.
- Enhanced error handling for RPC responses.

> ✨ 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
  - Minimal account updated: token-receiver callbacks, interface support check, payable receive, new executeWithSig entrypoint, and a ValueReceived event; SessionCreated/Executed events now include user/newSigner.

- Bug Fixes
  - Delegation contract resolved dynamically via bundler with caching and improved error handling.
  - Address normalization for transactions and nonce lookups to reduce mismatch errors.
  - Removed a redundant error from the ABI.

- Tests
  - Tests updated to assert renamed event fields (user/newSigner).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 22:22:30 +00:00
Joaquim VergesandGitHub c92081b056 [AI SDK Provider] Update sign swap action type with token fields (#7988) 2025-09-04 15:22:00 -07:00
Olexandr88andGitHub dc57b850d3 chore: add links to Twitter and Telegram (#7983)
Signed-off-by: Olexandr88 <[email protected]>
2025-09-05 04:35:54 +07:00
Manan TankandGitHub 6429a238c1 [BLD-231] Playground: Add API reference, Remove Insight and Payments API playgrounds (#7986) 2025-09-05 04:35:43 +07:00
MananTank 769019dd0b [BLD-242] Dashboard: Fix Notification overflow, UI Improvements (#7985)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the UI of the notification components in the dashboard. It includes updates to layout, spacing, and styles, improving the overall user experience and visual consistency.

### Detailed summary
- Updated `PopoverContent` with new dimensions and layout.
- Enhanced `NotificationList` with improved tab spacing and structure.
- Adjusted styles for empty state messages in `InboxTab` and `ArchiveTab`.
- Modified `NotificationEntry` layout and added icons for better aesthetics.
- Changed button styles and interactions for improved usability.

> ✨ 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**
  * Refreshed Notifications panel: larger, rounded popover with improved spacing, overflow handling, and updated alignment.
  * Redesigned notification items: bell capsule indicator, clearer card-like layout, hover-revealed “Mark as read,” and an outline CTA with arrow icon.
  * Improved list layout: flexible, smoother scrolling, simplified tab styling, and refined spacing.
  * Enhanced empty states for Inbox and Archive with centered, card-style icon visuals.

* **Refactor**
  * Reorganized component layouts for consistency and responsiveness without changing behavior or public APIs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 18:09:34 +00:00
MananTank 488a3af154 [MNY-106] Playground: Add overview pages for all sections in sidebar (#7977)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the UI components for various sections in a web application, particularly by introducing `OverviewPage` components for different features and updating the layout for better organization and presentation.

### Detailed summary
- Updated `className` for `PromptInput` in `chat-container.tsx`.
- Added `OverviewPage` components for `Tokens`, `AI`, `Contracts`, `Payments`, `Wallets`, `Transactions`, and `Account Abstraction`.
- Introduced `FeatureCard` metadata for various features in `pages-metadata.ts`.
- Refactored the main `Page` component to use `FeatureSection` for organized display.
- Removed hardcoded feature cards from the main `Page` component.

> ✨ 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 overview pages for AI, Wallets, Headless Components, Transactions, Contracts, Payments, Tokens, and Account Abstraction.
  - Introduced reusable OverviewPage and FeatureCard components.
  - Centralized feature-card metadata for all categories.
  - Updated navigation to add “Overview” entries and renamed “AI SDK” to “AI SDK Integration”.
- Refactor
  - Home page rewritten to a data-driven layout using shared components and metadata with consistent grid rendering.
- Style
  - Tweaked AI chat input borders and top-edge rounding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 17:50:50 +00:00
MananTank 8a29e36934 Portal: Remove unused pages, empty pages, Fix missing H1s (#7979)
<!--

## 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 the deletion of several `.mdx` files related to the `pay` and `account` sections, while adding new content to the `nebula` API reference and making updates to the `engine` wallet configurations.

### Detailed summary
- Deleted multiple `.mdx` files in the `pay` and `account` sections.
- Added new API endpoints in `nebula`:
  - `Execute Action`
  - `Get Session`
  - `List Sessions`
  - `Clear Session`
  - `Create Session`
  - `Delete Session`
  - `Update Session`
- Updated headings for clarity in existing `.mdx` files related to wallet configurations.

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

<!-- end pr-codex -->
2025-09-04 17:44:16 +00:00
MananTank ea98087d41 Portal: UI tweaks/fixes (#7978)
<!--

## 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 code structure in the `apps/portal` directory, improving styles, and enhancing the functionality of various elements such as tabs, lists, and layouts.

### Detailed summary
- Adjusted class names for styling consistency.
- Reduced icon sizes in several components.
- Removed unnecessary background styles from `InlineParameterItem`.
- Simplified `UnorderedList` and `OrderedList` components.
- Enhanced `FeatureCard` layout and styles.
- Updated tab triggers by removing redundant margin classes.
- Streamlined payment-related documentation and examples.
- Improved overall readability and structure in multiple markdown files.

> ✨ 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
  - Simplified tab headers by removing platform icons across multiple pages.
  - Updated layout and spacing: Insight grid tweaks, refined DocLayout text color, toned-down lists and parameter blocks, redesigned FeatureCard, and streamlined Table of Contents styling.

- Documentation
  - Replaced several tabbed sections with single-flow content; some pages now link directly to live demos.
  - Updated examples: Send now illustrates a multi-step execution flow; corrected/cleaned samples for Sell and other payments pages.
  - Transactions and Contracts pages use direct endpoint rendering in places for clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 17:44:16 +00:00
Yash094 030349bb92 remove looking for engine on project view page (#7981)
<!--

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

https://linear.app/thirdweb/issue/BLD-238/remove-looking-for-engine-hint-from-project-view

## 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 rendering logic in the `page.tsx` file for a specific team page, particularly around the display of a billing plan alert and the conditions under which it is shown.

### Detailed summary
- Replaced a conditional rendering of the free billing plan alert from a ternary operator to a logical AND (`&&`) operator.
- Removed the `DismissibleAlert` component that provided information about engines and projects.
- Kept the `Changelog` component intact.

> ✨ 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 an issue where teams on paid plans saw an unnecessary alert on the team page. Paid teams will no longer see this message.
  * Free plan teams continue to see the upgrade banner as before.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 15:29:59 +00:00
MananTank 17d40afda3 Dashboard: fix payments tx history table crashing on onramp type purchase (#7982)
<!--

## 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 payment-related types and logic in the application to improve type safety and clarity. It introduces a new `BridgePayment` type, updates the `TableRow` component, and modifies how payments are filtered and processed.

### Detailed summary
- Changed `Payment` type to `BridgePayment` in `TableRow` component.
- Updated `originAmount` and `destinationAmount` to use `BigInt`.
- Introduced `BridgePaymentType` and `OnrampPaymentType`.
- Modified `getCSVData` to accept only `BridgePayment` types.
- Added `isBridgePayment` function to filter payments.

> ✨ 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
  - Payments views and CSV export now explicitly show only bridge payments; onramp entries are excluded.

- Bug Fixes
  - Improved accuracy for large origin/destination amounts by converting amounts to integer form before token conversion.
  - Consistent token conversions across table, links, and CSV exports.

- Refactor
  - Payment data reorganized into distinct bridge vs onramp variants to improve filtering and export consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 14:45:15 +00:00
YashandGitHub bc6309ddc7 small fix to set API section as default for explorer code snippets (#7980) 2025-09-04 16:45:08 +07:00
f47eda6842 add api snippets to explorer section (#7971)
Co-authored-by: 0xFirekeeper <[email protected]>
2025-09-04 05:47:50 +07:00
2328985699 Add API Reference section to portal app (#7968)
Co-authored-by: Manan Tank <[email protected]>
2025-09-04 04:39:35 +07:00
MananTank 18b7f9104f Dashboard: Fix Import Contract Modal submit button loading state (#7970)
<!--

## 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 conditional rendering logic in the `modal.tsx` file to use the `form.formState.isSubmitting` state instead of `addContractToProject.isPending`. This change ensures that the button reflects the correct submission state during the import process.

### Detailed summary
- Changed the conditional check for displaying the loading spinner from `addContractToProject.isPending` to `form.formState.isSubmitting`.
- Updated the button text from "Importing" to reflect the new state condition.

> ✨ 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**
  * Import Contract modal: The submit button now accurately reflects the submission state. It shows a spinner and the label changes to “Importing” while the form is submitting, and reverts to the standard icon with “Import” when idle. This improves user feedback, reduces confusion, and helps prevent duplicate submissions by clearly indicating when an import is in progress.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 20:15:25 +00:00
MananTank 82be4fa53a [MNY-142] Dashboard: Do not render BuyWidget if token is not supported by Bridge (#7975)
<!--

## 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 `ERC20PublicPage` component to utilize `tokenInfoFromUB` instead of `tokenInfo` for setting the contract image. It also modifies the logic to conditionally render the `BuyEmbed` component based on whether the token is supported.

### Detailed summary
- Replaced `tokenInfo` with `tokenInfoFromUB` for contract image assignment.
- Removed the `buyEmbed` variable; integrated its logic directly into the render method.
- Added a new `showBuyEmbed` variable to determine if `BuyEmbed` should be displayed.
- Updated the rendering of `BuyEmbed` to use `claimConditionMeta`.

> ✨ 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
  - Buy section now appears only when supported by the token or when a claim condition exists.
  - Unified buy experience: automatically shows either a direct buy flow or a claim flow based on available conditions.
- Bug Fixes
  - More reliable token icon fallback to reduce missing/blank images.
- Style
  - Preserved decorative layout while conditionally rendering the buy UI for a cleaner, more consistent appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 20:04:08 +00:00
MananTank dbb2614306 [BLD-228] SDK/React: Do not exclude specified wallets from all wallets list in Connect UI (#7974)
<!--

## 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 removing the `specifiedWallets` prop from various components related to wallet selection in the `thirdweb` package, streamlining the wallet filtering logic.

### Detailed summary
- Removed `specifiedWallets` prop from the `WalletAuth` component.
- Updated `ConnectModalContent` to remove `specifiedWallets` and utilize `recommendedWallets`.
- Modified `AllWalletsUI` to eliminate `specifiedWallets` and simplify wallet filtering logic.

> ✨ 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
  - Simplified the wallet selection list to a standardized set across the Connect modal and in-app auth.
  - Removed custom list filtering, ensuring a consistent experience regardless of entry point.
  - The list now excludes in-app, embedded, and smart wallets from the “All Wallets” view.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-03 17:43:59 +00:00
517 changed files with 28150 additions and 14473 deletions
+2
View File
@@ -172,6 +172,8 @@ See our [open source page](https://thirdweb.com/open-source) for more informatio
- [Documentation](https://portal.thirdweb.com/)
- [Templates](https://thirdweb.com/templates)
- [YouTube](https://www.youtube.com/c/thirdweb)
- [X/Twitter](https://x.com/thirdweb)
- [Telegram](https://t.me/officialthirdweb)
## Support
+2
View File
@@ -64,3 +64,5 @@ ANALYTICS_SERVICE_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
GROWTH_PLAN_SKU=""
PAYMENT_METHOD_CONFIGURATION=""
+1
View File
@@ -64,4 +64,5 @@ module.exports = [
"/enterprise",
"/token",
"/vault",
"/monetize/bridge",
];
+1
View File
@@ -8,6 +8,7 @@
],
"ignoreBinaries": ["only-allow"],
"ignoreDependencies": [
"@thirdweb-dev/api",
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"thirdweb",
+4
View File
@@ -21,8 +21,11 @@
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
@@ -34,6 +37,7 @@
"compare-versions": "^6.1.0",
"date-fns": "4.1.0",
"fast-xml-parser": "^5.2.5",
"fetch-event-stream": "0.1.5",
"fuse.js": "7.1.0",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
+1
View File
@@ -14,6 +14,7 @@ type ProxyActionParams = {
body?: string;
headers?: Record<string, string>;
parseAsText?: boolean;
signal?: AbortSignal;
};
type ProxyActionResult<T> =
+62 -1
View File
@@ -1,8 +1,13 @@
import "server-only";
import { headers } from "next/headers";
import Stripe from "stripe";
import type { Team } from "@/api/team/get-team";
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
import {
GROWTH_PLAN_SKU,
PAYMENT_METHOD_CONFIGURATION,
STRIPE_SECRET_KEY,
} from "@/constants/server-envs";
let existingStripe: Stripe | undefined;
@@ -72,3 +77,59 @@ export async function getStripeBalance(customerId: string) {
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
return customer.balance === 0 ? 0 : customer.balance / -100;
}
export async function fetchClientSecret(team: Team) {
"use server";
const origin = (await headers()).get("origin");
const stripe = getStripe();
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
line_items: [
{
// Provide the exact Price ID (for example, price_1234) of
// the product you want to sell
price: GROWTH_PLAN_SKU,
quantity: 1,
},
],
mode: "subscription",
return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: "auto",
},
payment_method_collection: "always",
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
});
if (!session.client_secret) {
throw new Error("No client secret found");
}
return session.client_secret;
}
export async function getStripeSessionById(sessionId: string) {
const session = await getStripe().checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent"],
});
return session;
}
+129 -27
View File
@@ -81,33 +81,6 @@ export function reportOnboardingStarted() {
posthog.capture("onboarding started");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that select a paid plan during onboarding
* - To know **which** plan was selected
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelected(properties: {
plan: Team["billingPlan"];
}) {
posthog.capture("onboarding plan selected", properties);
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that skip the plan-selection step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelectionSkipped() {
posthog.capture("onboarding plan selection skipped");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that invite members during onboarding
@@ -161,6 +134,18 @@ export function reportOnboardingMembersUpsellPlanSelected(properties: {
posthog.capture("onboarding members upsell plan selected", properties);
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that completed the team member step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportTeamMemberStepCompleted() {
posthog.capture("onboarding members completed");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that completed onboarding
@@ -250,6 +235,68 @@ export function reportAssetBuySuccessful(properties: {
});
}
type TokenSwapParams = {
buyTokenChainId: number;
buyTokenAddress: string;
sellTokenChainId: number;
sellTokenAddress: string;
pageType: "asset" | "bridge" | "chain";
};
/**
* ### Why do we need to report this event?
* - To track number of successful token swaps from the token page
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
posthog.capture("token swap successful", properties);
}
/**
* ### Why do we need to report this event?
* - To track impressions of the swap widget
* - To create a funnel "swap widget shown" -> "swap widget successful" to understand the conversion rate
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportSwapWidgetShown(properties: {
pageType: "asset" | "bridge" | "chain";
}) {
posthog.capture("swap widget shown", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed token swaps from the token page
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapFailed(
properties: TokenSwapParams & {
errorMessage: string;
},
) {
posthog.capture("token swap failed", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled token swaps from the token page
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapCancelled(properties: TokenSwapParams) {
posthog.capture("token swap cancelled", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed asset purchases from the token page
@@ -272,6 +319,26 @@ export function reportAssetBuyFailed(properties: {
});
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled asset purchases from the token page
* - To track the errors that users encounter when trying to purchase an asset
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportAssetBuyCancelled(properties: {
chainId: number;
contractType: AssetContractType;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy cancelled", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
});
}
// Assets Landing Page ----------------------------
/**
@@ -477,6 +544,17 @@ export function reportAssetPageview(properties: {
posthog.capture("asset pageview", properties);
}
/**
* ### Why do we need to report this event?
* - To understand which chains are being viewed the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportChainPageview(properties: { chainId: number }) {
posthog.capture("chain pageview", properties);
}
/**
* ### Why do we need to report this event?
* - To track the usage of fund wallet modal
@@ -547,3 +625,27 @@ export function reportChainInfraRpcOmissionAgreed(properties: {
}) {
posthog.capture("chain infra checkout rpc omission agreed", properties);
}
// ----------------------------
// FEEDBACK
// ----------------------------
/**
* ### Why do we need to report this event?
* - To track user feedback and sentiment about the product
* - To identify common issues or feature requests
* - To measure user satisfaction and engagement
* - To prioritize product improvements based on user input
*
* ### Who is responsible for this event?
* @gisellechacon
*/
export function reportProductFeedback(properties: {
feedback: string;
source: "desktop" | "mobile";
}) {
posthog.capture("product feedback submitted", {
feedback: properties.feedback,
source: properties.source,
});
}
@@ -297,38 +297,53 @@ export type PaymentsResponse = {
totalCount: number;
};
};
type BridgePaymentType = "buy" | "sell" | "transfer";
type OnrampPaymentType = "onramp";
export type Payment = {
// common
id: string;
blockNumber?: bigint;
transactionId: string;
createdAt: string;
clientId: string;
sender: string;
receiver: string;
developerFeeRecipient: string;
developerFeeBps: number;
transactions: Array<{
chainId: number;
transactionHash: string;
}>;
status: "PENDING" | "COMPLETED" | "FAILED" | "NOT_FOUND";
type: "buy" | "sell" | "transfer";
originAmount: bigint;
destinationAmount: bigint;
purchaseData: unknown;
originToken: {
address: string;
symbol: string;
decimals: number;
chainId: number;
};
destinationAmount: string;
destinationToken: {
address: string;
symbol: string;
decimals: number;
chainId: number;
};
createdAt: string;
};
purchaseData: unknown;
} & (
| {
type: BridgePaymentType;
transactionId: string;
blockNumber?: string;
sender: string;
developerFeeRecipient: string;
developerFeeBps: number;
originAmount: string;
originToken: {
address: string;
symbol: string;
decimals: number;
chainId: number;
};
}
| {
onrampId: string;
type: OnrampPaymentType;
}
);
export type BridgePayment = Extract<Payment, { type: BridgePaymentType }>;
export async function getPayments(props: {
clientId: string;
@@ -0,0 +1,173 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import type { Chain, ThirdwebClient } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import {
reportAssetBuyCancelled,
reportAssetBuyFailed,
reportAssetBuySuccessful,
reportSwapWidgetShown,
reportTokenSwapCancelled,
reportTokenSwapFailed,
reportTokenSwapSuccessful,
} from "@/analytics/report";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyAndSwapEmbed(props: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
pageType: "asset" | "bridge" | "chain";
}) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
const isMounted = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (isMounted.current) {
return;
}
isMounted.current = true;
reportSwapWidgetShown({
pageType: props.pageType,
});
}, [props.pageType]);
return (
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col">
<div className="flex gap-2.5 p-4 border-b border-dashed">
<TabButton
label="Swap"
onClick={() => setTab("swap")}
isActive={tab === "swap"}
/>
<TabButton
label="Buy"
onClick={() => setTab("buy")}
isActive={tab === "buy"}
/>
</div>
{tab === "buy" && (
<BuyWidget
amount={props.buyAmount || "1"}
chain={props.chain}
className="!rounded-2xl !w-full !border-none"
title=""
client={props.client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
const errorMessage = parseError(e);
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
error: errorMessage,
});
}
}}
onCancel={() => {
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}
}}
onSuccess={() => {
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}
}}
theme={themeObj}
tokenAddress={props.tokenAddress as `0x${string}`}
paymentMethods={["card"]}
/>
)}
{tab === "swap" && (
<SwapWidget
client={props.client}
theme={themeObj}
className="!rounded-2xl !border-none !w-full"
prefill={{
sellToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// only set `buyToken` as "Native token" if `sellToken` is not a "native token" already
buyToken: props.tokenAddress
? {
chainId: props.chain.id,
}
: undefined,
}}
onError={(error, quote) => {
const errorMessage = parseError(error);
reportTokenSwapFailed({
errorMessage: errorMessage,
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
sellTokenChainId: quote.intent.originChainId,
sellTokenAddress: quote.intent.originTokenAddress,
pageType: props.pageType,
});
}}
onSuccess={(quote) => {
reportTokenSwapSuccessful({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
sellTokenChainId: quote.intent.originChainId,
sellTokenAddress: quote.intent.originTokenAddress,
pageType: props.pageType,
});
}}
onCancel={(quote) => {
reportTokenSwapCancelled({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
sellTokenChainId: quote.intent.originChainId,
sellTokenAddress: quote.intent.originTokenAddress,
pageType: props.pageType,
});
}}
/>
)}
</div>
);
}
function TabButton(props: {
label: string;
onClick: () => void;
isActive: boolean;
}) {
return (
<Button
onClick={props.onClick}
className={cn(
"rounded-full text-muted-foreground px-5 text-base bg-accent",
props.isActive && "text-foreground border-foreground",
)}
variant="outline"
>
{props.label}
</Button>
);
}
@@ -1,4 +1,5 @@
"use client";
import type React from "react";
import { type Dispatch, type SetStateAction, useMemo } from "react";
import { CodeClient } from "@/components/ui/code/code.client";
@@ -6,11 +7,14 @@ import { TabButtons } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
export type CodeEnvironment =
| "api"
| "javascript"
| "typescript"
| "react"
| "react-native"
| "unity";
| "dotnet"
| "unity"
| "curl";
type SupportedEnvironment = {
environment: CodeEnvironment;
@@ -20,6 +24,10 @@ type SupportedEnvironment = {
type CodeSnippet = Partial<Record<CodeEnvironment, string>>;
const Environments: SupportedEnvironment[] = [
{
environment: "api",
title: "API",
},
{
environment: "javascript",
title: "JavaScript",
@@ -36,10 +44,18 @@ const Environments: SupportedEnvironment[] = [
environment: "react-native",
title: "React Native",
},
{
environment: "dotnet",
title: ".NET",
},
{
environment: "unity",
title: "Unity",
},
{
environment: "curl",
title: "cURL",
},
];
interface CodeSegmentProps {
@@ -122,9 +138,14 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
: activeEnvironment === "react" ||
activeEnvironment === "react-native"
? "tsx"
: activeEnvironment === "unity"
: activeEnvironment === "unity" ||
activeEnvironment === "dotnet"
? "cpp"
: activeEnvironment
: activeEnvironment === "api"
? "javascript"
: activeEnvironment === "curl"
? "bash"
: activeEnvironment
}
/>
)}
@@ -3,7 +3,6 @@
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { AppFooter } from "@/components/footers/app-footer";
import {
Collapsible,
CollapsibleContent,
@@ -91,7 +90,6 @@ export function FullWidthSidebarLayout(props: {
<main className="flex min-w-0 grow flex-col max-sm:w-full">
{children}
</main>
<AppFooter />
</div>
</div>
);
@@ -0,0 +1,23 @@
import { GridPattern } from "@/components/ui/background-patterns";
export function GridPatternEmbedContainer(props: {
children: React.ReactNode;
}) {
return (
<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 hidden lg:block"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
/>
<div className="sm:w-[420px] z-10">{props.children}</div>
</div>
);
}
@@ -23,7 +23,7 @@ type Action =
component: React.ReactNode;
};
function Action(props: { action: Action; variant?: "default" | "secondary" }) {
function Action(props: { action: Action; variant?: "default" | "outline" }) {
const action = props.action;
return "component" in action ? (
@@ -31,10 +31,7 @@ function Action(props: { action: Action; variant?: "default" | "secondary" }) {
) : (
<Button
asChild
className={cn(
"rounded-full",
props.variant === "secondary" && "border border-border",
)}
className={cn("rounded-full")}
size="sm"
variant={props.variant}
>
@@ -60,7 +57,7 @@ export type ProjectPageHeaderProps = {
icon: React.FC<{ className?: string }>;
isProjectIcon?: boolean;
actions: {
primary: Action;
primary?: Action;
secondary?: Action;
} | null;
@@ -114,7 +111,7 @@ export function ProjectPageHeader(props: ProjectPageHeaderProps) {
{props.actions && (
<div className="hidden lg:flex items-center gap-3">
{props.actions.secondary && (
<Action action={props.actions.secondary} variant="secondary" />
<Action action={props.actions.secondary} variant="outline" />
)}
{props.actions.primary && (
@@ -132,7 +129,7 @@ export function ProjectPageHeader(props: ProjectPageHeaderProps) {
{props.title}
</h2>
{/* description */}
<p className="text-sm text-muted-foreground line-clamp-3 md:line-clamp-2">
<p className="text-sm text-muted-foreground text-pretty">
{props.description}
</p>
</div>
@@ -145,7 +142,7 @@ export function ProjectPageHeader(props: ProjectPageHeaderProps) {
)}
{props.actions?.secondary && (
<Action action={props.actions.secondary} variant="secondary" />
<Action action={props.actions.secondary} variant="outline" />
)}
</div>
)}
@@ -87,7 +87,7 @@ export function ChatBar(props: {
<PopoverContent className="w-72">
<div>
<p className="mb-3 text-muted-foreground text-sm">
Get access to image uploads by signing in to Nebula
Get access to image uploads by signing in to thirdweb
</p>
<Button
className="w-full"
@@ -28,7 +28,10 @@ export function CustomChatButton(props: {
const closeModal = useCallback(() => setIsOpen(false), []);
const ref = useRef<HTMLDivElement>(null);
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
if (
(layoutSegments[0] === "~" && layoutSegments[1] === "support") ||
layoutSegments.includes("ai")
) {
return null;
}
@@ -65,6 +65,23 @@ export default function Component() {
events: [preparedEvent]
});
}`,
unity: `using Thirdweb;
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
chainId: {{chainId}},
);
// Listen to contract events
var events = await contract.Events("{{function}}");`,
dotnet: `using Thirdweb;
// Get your contract
var contract = await ThirdwebContract.Create(client, "{{contract_address}}", {{chainId}});
// Listen to contract events
var events = await contract.Events("{{function}}");`,
},
install: {
javascript: "npm i thirdweb",
@@ -73,6 +90,9 @@ export default function Component() {
unity: `// Download the .unitypackage from the latest release:
// https://github.com/thirdweb-dev/unity-sdk/releases
// and drag it into your project`,
dotnet: `// Install the thirdweb SDK via NuGet:
// dotnet add package Thirdweb
// Or search for "Thirdweb" in your NuGet package manager`,
},
read: {
javascript: `import { readContract } from "thirdweb";
@@ -100,6 +120,33 @@ export default function Component() {
params: [{{args}}]
});
}`,
unity: `using Thirdweb;
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
chainId: {{chainId}}
);
// Read from the contract
var result = await contract.Read<T>(
"{{function}}",
{{args}}
);`,
dotnet: `using Thirdweb;
// Get your contract
var contract = await ThirdwebContract.Create(
client,
"{{contract_address}}",
{{chainId}}
);
// Read from the contract
var result = await contract.Read<T>(
"{{function}}",
{{args}}
);`,
},
setup: {
javascript: `import { createThirdwebClient, getContract } from "thirdweb";
@@ -165,11 +212,19 @@ function App() {
`,
unity: `using Thirdweb;
// Reference the SDK
var sdk = ThirdwebManager.Instance.SDK;
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
chainId: {{chainId}}
);`,
dotnet: `using Thirdweb;
// Get your contract
var contract = sdk.GetContract("{{contract_address}}");`,
var contract = await ThirdwebContract.Create(
client,
"{{contract_address}}",
{{chainId}}
);`,
},
write: {
javascript: `import { prepareContractCall, sendTransaction } from "thirdweb";
@@ -213,6 +268,123 @@ export default function Component() {
sendTransaction(transaction);
}
}`,
unity: `using Thirdweb;
// Get your contract
var contract = await ThirdwebManager.Instance.GetContract(
address: "{{contract_address}}",
chainId: {{chainId}}
);
// Write to the contract
var transactionReceipt = await contract.Write(
wallet,
contract,
"{{function}}",
weiValue,
{{args}}
);`,
dotnet: `using Thirdweb;
// Get your contract
var contract = await ThirdwebContract.Create(
client,
"{{contract_address}}",
{{chainId}}
);
// Write to the contract
var transactionReceipt = await contract.Write(
wallet,
contract,
"{{function}}",
weiValue,
{{args}}
);`,
},
api: {
read: `const response = await fetch('https://api.thirdweb.com/v1/contracts/read', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-secret-key': '<YOUR_SECRET_KEY>'
},
body: JSON.stringify({
calls: [
{
contractAddress: "{{contract_address}}",
method: "{{function}}",
params: [{{args}}]
}
],
chainId: {{chainId}}
})
});
const data = await response.json();`,
write: `const response = await fetch('https://api.thirdweb.com/v1/contracts/write', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-secret-key': '<YOUR_SECRET_KEY>'
},
body: JSON.stringify({
calls: [
{
contractAddress: "{{contract_address}}",
method: "{{function}}",
params: [{{args}}]
}
],
chainId: {{chainId}},
from: "<YOUR_WALLET_ADDRESS>"
})
});
const data = await response.json();`,
events: `const response = await fetch('https://api.thirdweb.com/v1/contracts/{{chainId}}/{{contract_address}}/events?eventSignature={{function}}', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-secret-key': '<YOUR_SECRET_KEY>'
}
});
const data = await response.json();`,
},
curl: {
read: `curl https://api.thirdweb.com/v1/contracts/read \\
--request POST \\
--header 'Content-Type: application/json' \\
--header 'x-secret-key: <YOUR_SECRET_KEY>' \\
--data '{
"calls": [
{
"contractAddress": "{{contract_address}}",
"method": "{{function}}",
"params": [{{args}}]
}
],
"chainId": {{chainId}}
}'`,
write: `curl -X POST https://api.thirdweb.com/v1/contracts/write \\
-H "Content-Type: application/json" \\
-H "x-secret-key: <YOUR_SECRET_KEY>" \\
-d '{
"calls": [
{
"contractAddress": "{{contract_address}}",
"method": "{{function}}",
"params": [{{args}}]
}
],
"chainId": {{chainId}},
"from": "<YOUR_WALLET_ADDRESS>"
}'`,
events: `curl https://api.thirdweb.com/v1/contracts/{{chainId}}/{{contract_address}}/events?eventSignature={{function}} \\
--request GET \\
--header 'Content-Type: application/json' \\
--header 'x-secret-key: <YOUR_SECRET_KEY>'`,
},
};
@@ -269,20 +441,17 @@ return (
public async void ConnectWallet()
{
// Reference to your Thirdweb SDK
var sdk = ThirdwebManager.Instance.SDK;
// Configure the connection
var connection = new WalletConnection(
provider: WalletProvider.SmartWallet, // The wallet provider you want to connect to (Required)
chainId: 1, // The chain you want to connect to (Required)
password: "myEpicPassword", // If using a local wallet as personal wallet (Optional)
email: "[email protected]", // If using an email wallet as personal wallet (Optional)
personalWallet: WalletProvider.LocalWallet // The personal wallet you want to use with your Account (Optional)
var wallet = await ConnectWallet(
new WalletOptions(
provider: WalletProvider.InAppWallet,
chainId: {{chainId}},
inAppWalletOptions: new InAppWalletOptions(
authprovider: AuthProvider.Google,
executionMode: ExecutionMode.EIP7702Sponsored
)
)
);
// Connect the wallet
string address = await sdk.wallet.Connect(connection);
string address = await wallet.GetAddress();
}`,
},
},
@@ -490,6 +659,9 @@ export function formatSnippet(
: "event",
});
break;
case "api":
// For API, we don't need special extension handling, just use the standard templates
break;
}
}
// end hacks on hacks on hacks -- now just hacks on hacks from here on out
@@ -533,7 +705,7 @@ export function CodeOverview(props: {
| undefined;
const [environment, setEnvironment] = useState<CodeEnvironment>(
defaultEnvironment || "javascript",
defaultEnvironment || "api",
);
const [tab, setTab] = useState("write");
@@ -677,7 +849,13 @@ export function CodeOverview(props: {
</div>
)}
<div className="flex flex-col gap-2">
{environment === "react-native" || environment === "unity" ? (
{environment === "api" || environment === "curl" ? (
<p className="text-sm text-muted-foreground">
No SDK install required for API/cURL. Call the HTTP endpoints
directly. Keep your secret key server-side; use client IDs in
frontends.
</p>
) : environment === "react-native" || environment === "unity" ? (
<p className="text-sm text-muted-foreground">
Install the latest version of the SDK.{" "}
<UnderlineLink
@@ -800,32 +978,76 @@ export function CodeOverview(props: {
<CodeSegment
environment={environment}
setEnvironment={setEnvironment}
snippet={formatSnippet(
// biome-ignore lint/suspicious/noExplicitAny: FIXME
COMMANDS[tab as keyof typeof COMMANDS] as any,
{
snippet={(() => {
const selectedFn =
tab === "read" ? read : tab === "write" ? write : event;
const commandsKey = tab as "read" | "write" | "events";
const baseSnippet = formatSnippet(COMMANDS[commandsKey], {
args:
abi
?.filter(
(f) => f.type === "function" || f.type === "event",
)
?.find(
(f) =>
f.name ===
(tab === "read"
? read?.name
: tab === "write"
? write?.name
: event?.name),
)
?.find((f) => f.name === selectedFn?.name)
?.inputs.map((i) => i.name || "") || [],
chainId,
contractAddress,
extensionNamespace,
fn: tab === "read" ? read : tab === "write" ? write : event,
},
)}
fn: selectedFn,
});
// Add API snippet if it exists
const apiSnippet = COMMANDS.api[commandsKey]
? formatSnippet(
{ api: COMMANDS.api[commandsKey] },
{
args:
abi
?.filter(
(f) =>
f.type === "function" || f.type === "event",
)
?.find((f) => f.name === selectedFn?.name)
?.inputs.map((i) => i.name || "") || [],
chainId,
contractAddress,
extensionNamespace,
fn: selectedFn,
},
)
: {};
// Add cURL snippet if it exists
const curlSnippet = COMMANDS.curl[commandsKey]
? formatSnippet(
{ curl: COMMANDS.curl[commandsKey] },
{
args:
abi
?.filter(
(f) =>
f.type === "function" || f.type === "event",
)
?.find((f) => f.name === selectedFn?.name)
?.inputs.map((i) => i.name || "") || [],
chainId,
contractAddress,
extensionNamespace,
fn: selectedFn,
},
)
: {};
return {
...baseSnippet,
...apiSnippet,
...curlSnippet,
};
})()}
/>
</div>
</div>
@@ -68,7 +68,7 @@ const ContractFunction: React.FC<{
function ContractFunctionInner(props: ContractFunctionProps) {
const { contract, fn } = props;
const [environment, setEnvironment] = useState<CodeEnvironment>("javascript");
const [environment, setEnvironment] = useState<CodeEnvironment>("api");
const functionSelectorQuery = useContractFunctionSelectors(contract);
const functionSelectors = functionSelectorQuery.data || [];
const isERC721Query = useReadContract(ERC721Ext.isERC721, { contract });
@@ -112,7 +112,7 @@ function ContractFunctionInner(props: ContractFunctionProps) {
: "write"
: ("events" as const);
const codeSnippet = formatSnippet(COMMANDS[commandsKey], {
const baseSnippet = formatSnippet(COMMANDS[commandsKey], {
args: fn.inputs?.map((i) => i.name || ""),
chainId: contract.chain.id,
contractAddress: contract.address,
@@ -120,6 +120,40 @@ function ContractFunctionInner(props: ContractFunctionProps) {
fn,
});
// Safety check: Only include API snippet if the command exists in COMMANDS.api
const apiSnippet = COMMANDS.api[commandsKey]
? formatSnippet(
{ api: COMMANDS.api[commandsKey] },
{
args: fn.inputs?.map((i) => i.name || ""),
chainId: contract.chain.id,
contractAddress: contract.address,
extensionNamespace,
fn,
},
)
: {};
// Safety check: Only include cURL snippet if the command exists in COMMANDS.curl
const curlSnippet = COMMANDS.curl[commandsKey]
? formatSnippet(
{ curl: COMMANDS.curl[commandsKey] },
{
args: fn.inputs?.map((i) => i.name || ""),
chainId: contract.chain.id,
contractAddress: contract.address,
extensionNamespace,
fn,
},
)
: {};
const codeSnippet = {
...baseSnippet,
...apiSnippet,
...curlSnippet,
};
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center flex-wrap gap-2 border-b pb-3 mb-3">
@@ -231,13 +231,13 @@ function ImportForm(props: {
</Button>
) : (
<Button className="gap-2" type="submit">
{addContractToProject.isPending ? (
{form.formState.isSubmitting ? (
<Spinner className="size-4" />
) : (
<ArrowDownToLineIcon className="size-4" />
)}
{addContractToProject.isPending ? "Importing" : "Import"}
{form.formState.isSubmitting ? "Importing" : "Import"}
</Button>
)}
</div>
@@ -1,7 +1,79 @@
import type {
ListUserWalletsData,
ListUserWalletsResponses,
} from "@thirdweb-dev/api";
import { configure, listUserWallets } from "@thirdweb-dev/api";
import type { WalletUser } from "thirdweb/wallets";
import { THIRDWEB_EWS_API_HOST } from "@/constants/urls";
import { THIRDWEB_API_HOST } from "@/constants/urls";
import type { SearchType } from "./types";
// Configure the API client to use the correct base URL
configure({
override: {
baseUrl: THIRDWEB_API_HOST,
},
});
// Extract types from the generated API
type APIWallet = ListUserWalletsResponses[200]["result"]["wallets"][0];
type APIProfile = APIWallet["profiles"][0];
// Transform API response to match existing WalletUser format
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
return {
id: getProfileId(apiWallet.profiles[0]) || "",
linkedAccounts: apiWallet.profiles.map((profile) => {
// Create details object based on the profile data
let details:
| { email: string; [key: string]: string }
| { phone: string; [key: string]: string }
| { address: string; [key: string]: string }
| { id: string; [key: string]: string };
const profileId = getProfileId(profile);
if ("email" in profile && profile.email) {
details = { email: profile.email, id: profileId };
} else if ("phone" in profile && profile.phone) {
details = { phone: profile.phone, id: profileId };
} else if ("walletAddress" in profile && profile.walletAddress) {
details = { address: profile.walletAddress, id: profileId };
} else {
details = { id: profileId };
}
return {
type: profile.type,
details,
};
}),
wallets: apiWallet.address
? [
{
address: apiWallet.address,
createdAt: apiWallet.createdAt || new Date().toISOString(),
type: "enclave" as const,
},
]
: [],
};
}
// Helper function to safely get ID from any profile type
function getProfileId(profile: APIProfile | undefined): string {
if (!profile) return "";
if ("id" in profile) {
return profile.id;
} else if ("credentialId" in profile) {
return profile.credentialId;
} else if ("identifier" in profile) {
return profile.identifier;
}
return "";
}
export async function searchUsers(
authToken: string,
clientId: string | undefined,
@@ -10,50 +82,57 @@ export async function searchUsers(
searchType: SearchType,
query: string,
): Promise<WalletUser[]> {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
try {
// Prepare query parameters
const queryParams: ListUserWalletsData["query"] = {
limit: 50,
};
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
// Add search parameter based on search type
switch (searchType) {
case "email":
queryParams.email = query;
break;
case "phone":
queryParams.phone = query;
break;
case "id":
queryParams.id = query;
break;
case "address":
queryParams.address = query;
break;
case "externalWallet":
queryParams.externalWalletAddress = query;
break;
}
// Use the generated API function with Bearer authentication
const response = await listUserWallets({
query: queryParams,
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
...(ecosystemSlug && {
"x-ecosystem-id": `ecosystem.${ecosystemSlug}`,
}),
},
});
// Handle response
if (response.error || !response.data) {
console.error(
"Error searching users:",
response.error || "No data returned",
);
return [];
}
return response.data.result.wallets.map(transformToWalletUser);
} catch (error) {
console.error("Error searching users:", error);
return [];
}
// Add search parameter based on search type
switch (searchType) {
case "email":
url.searchParams.append("email", query);
break;
case "phone":
url.searchParams.append("phone", query);
break;
case "id":
url.searchParams.append("id", query);
break;
case "address":
url.searchParams.append("address", query);
break;
case "externalWallet":
url.searchParams.append("externalWalletAddress", query);
break;
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to search users: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
return data.users as WalletUser[];
}
@@ -56,7 +56,8 @@ export function NotificationsButton(props: { accountId: string }) {
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="end"
className="max-h-[90vh] min-h-[500px] w-[400px] max-w-md p-0"
sideOffset={8}
className="max-h-[90vh] min-h-[550px] w-[450px] max-w-md p-0 flex flex-col rounded-xl overflow-hidden"
>
<NotificationList {...manager} />
</PopoverContent>
@@ -7,7 +7,7 @@ import {
parseISO,
subDays,
} from "date-fns";
import { ArchiveIcon } from "lucide-react";
import { ArchiveIcon, ArrowUpRightIcon, BellIcon } from "lucide-react";
import { useMemo } from "react";
import type { Notification } from "@/api/notifications";
import { Button } from "@/components/ui/button";
@@ -42,39 +42,48 @@ export function NotificationEntry({
}, [notification.createdAt]);
return (
<div className="flex flex-row py-1.5">
{onMarkAsRead && (
<div className="min-h-full w-1 shrink-0 rounded-r-lg bg-primary" />
)}
<div className="flex w-full flex-row justify-between gap-2 border-b px-4 py-2 transition-colors last:border-b-0">
<div className="flex items-start gap-3 p-4 border-b group hover:bg-card last:border-b-0">
<div className="p-2.5 rounded-full border bg-card">
<BellIcon className="size-4 text-muted-foreground" />
</div>
<div className="flex w-full flex-row justify-between gap-2 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-1 space-y-1">
<div className="flex-1 space-y-1.5">
<p className="text-sm">{notification.description}</p>
{timeAgo && (
<p className="text-muted-foreground text-xs">{timeAgo}</p>
)}
<div className="flex flex-row justify-between gap-2 pt-1">
<Button asChild className="px-0" size="sm" variant="link">
<div className="flex flex-row justify-between gap-2 pt-2">
<Button
asChild
size="sm"
variant="outline"
className="rounded-full bg-card gap-2"
>
<a
href={notification.ctaUrl}
rel="noopener noreferrer"
target="_blank"
>
{notification.ctaText}
<ArrowUpRightIcon className="size-4 text-muted-foreground" />
</a>
</Button>
</div>
</div>
</div>
{onMarkAsRead && (
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onMarkAsRead(notification.id)}
size="icon"
variant="ghost"
>
<ArchiveIcon className="h-4 w-4" />
</Button>
<div className="flex items-center">
<Button
className="p-2 h-auto opacity-0 group-hover:opacity-100 rounded-full text-muted-foreground hover:text-foreground"
onClick={() => onMarkAsRead(notification.id)}
size="sm"
variant="ghost"
aria-label="Archive notification"
>
<ArchiveIcon className="size-4" />
</Button>
</div>
)}
</div>
</div>
@@ -21,15 +21,15 @@ export function NotificationList(props: ReturnType<typeof useNotifications>) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<div className="flex w-full flex-1 flex-col">
<div className="flex flex-col overflow-hidden grow">
<TabButtons
tabClassName="!text-sm hover:!bg-transparent !px-0 !py-1"
tabContainerClassName="space-x-6 px-4 pt-2"
tabClassName="hover:bg-transparent"
tabContainerClassName="pt-1.5 px-4"
tabs={[
{
isActive: activeTab === "inbox",
name: (
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
Inbox
{props.unreadNotificationsCount > 0 && (
<Badge variant="secondary">
@@ -49,7 +49,7 @@ export function NotificationList(props: ReturnType<typeof useNotifications>) {
/>
<div
className="h-full w-full flex-1 overflow-y-auto"
className="flex-1 overflow-y-auto overscroll-contain flex flex-col grow"
ref={scrollContainerRef}
>
{activeTab === "inbox" ? (
@@ -93,9 +93,9 @@ function InboxTab(
},
) {
return props.unreadNotifications.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<BellIcon className="h-4 w-4 text-muted-foreground" />
<div className="flex grow flex-col items-center justify-center gap-4 p-4 text-center">
<div className="p-2 bg-card rounded-full border">
<BellIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground text-sm">No new notifications</p>
</div>
@@ -131,9 +131,9 @@ function ArchiveTab(
},
) {
return props.archivedNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<ArchiveIcon className="h-4 w-4 text-muted-foreground" />
<div className="flex grow flex-col items-center justify-center gap-4 p-4 text-center">
<div className="p-2 bg-card rounded-full border">
<ArchiveIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-muted-foreground text-sm">No archived notifications</p>
</div>
@@ -0,0 +1,45 @@
"use client";
import type React from "react";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
interface ImageUploadProps {
value: File | undefined;
onChange?: (files: File[]) => void;
children?: React.ReactNode;
variant?: React.ComponentProps<typeof Button>["variant"];
className?: string;
multiple?: boolean;
accept: string;
}
export function ImageUploadButton(props: ImageUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
props.onChange?.(files);
};
return (
<div>
<Button
className={props.className}
onClick={() => fileInputRef.current?.click()}
variant={props.variant}
>
{props.children}
</Button>
<input
accept={props.accept}
aria-label="Upload image"
className="hidden"
multiple={props.multiple}
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
</div>
);
}
@@ -29,3 +29,6 @@ export const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
export const NEXT_PUBLIC_DEMO_ENGINE_URL =
process.env.NEXT_PUBLIC_DEMO_ENGINE_URL || "";
export const NEXT_PUBLIC_THIRDWEB_AI_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com";
@@ -91,3 +91,24 @@ if (REDIS_URL) {
REDIS_URL,
);
}
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
if (GROWTH_PLAN_SKU) {
experimental_taintUniqueValue(
"Do not pass GROWTH_PLAN_SKU to the client",
process,
GROWTH_PLAN_SKU,
);
}
export const PAYMENT_METHOD_CONFIGURATION =
process.env.PAYMENT_METHOD_CONFIGURATION || "";
if (PAYMENT_METHOD_CONFIGURATION) {
experimental_taintUniqueValue(
"Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
process,
PAYMENT_METHOD_CONFIGURATION,
);
}
+2 -3
View File
@@ -1,6 +1,5 @@
export const THIRDWEB_EWS_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_EWS_API_HOST ||
"https://in-app-wallet.thirdweb.com";
export const THIRDWEB_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb-dev.com";
export const THIRDWEB_PAY_DOMAIN =
process.env.NEXT_PUBLIC_PAY_URL || "pay.thirdweb-dev.com";
+114 -29
View File
@@ -1,9 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
ListUserWalletsData,
ListUserWalletsResponses,
} from "@thirdweb-dev/api";
import { configure, listUserWallets } from "@thirdweb-dev/api";
import { useActiveAccount } from "thirdweb/react";
import type { WalletUser } from "thirdweb/wallets";
import { THIRDWEB_EWS_API_HOST } from "@/constants/urls";
import { THIRDWEB_API_HOST } from "@/constants/urls";
import { embeddedWalletsKeys } from "../query-keys/cache-keys";
// Configure the API client to use the correct base URL
configure({
override: {
baseUrl: THIRDWEB_API_HOST,
},
});
// Extract types from the generated API
type APIWallet = ListUserWalletsResponses[200]["result"]["wallets"][0];
type APIProfile = APIWallet["profiles"][0];
const fetchAccountList = ({
jwt,
clientId,
@@ -18,37 +34,104 @@ const fetchAccountList = ({
pageNumber: number;
}) => {
return async () => {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
try {
// Prepare query parameters for the new API
const queryParams: ListUserWalletsData["query"] = {
page: pageNumber,
limit: 50, // Keep the same page size
};
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
// Use the generated API function with Bearer authentication
const response = await listUserWallets({
query: queryParams,
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
...(ecosystemSlug && {
"x-ecosystem-id": `ecosystem.${ecosystemSlug}`,
}),
},
});
// Handle response
if (response.error || !response.data) {
const errorMessage =
typeof response.error === "string"
? response.error
: "No data returned";
throw new Error(errorMessage);
}
// Transform the response to match the expected format
return {
users: response.data.result.wallets.map(transformToWalletUser),
hasMore: response.data.result.pagination.hasMore ?? false,
};
} catch (error) {
console.error("Failed to fetch wallets:", error);
throw error;
}
url.searchParams.append("page", pageNumber.toString());
const res = await fetch(url.href, {
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
if (!res.ok) {
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
}
const json = await res.json();
return json as {
users: WalletUser[];
};
};
};
// Transform API response to match existing WalletUser format
function transformToWalletUser(apiWallet: APIWallet): WalletUser {
return {
id: getProfileId(apiWallet.profiles[0]) || "",
linkedAccounts: apiWallet.profiles.map((profile) => {
// Create details object based on the profile data
let details:
| { email: string; [key: string]: string }
| { phone: string; [key: string]: string }
| { address: string; [key: string]: string }
| { id: string; [key: string]: string };
const profileId = getProfileId(profile);
if ("email" in profile && profile.email) {
details = { email: profile.email, id: profileId };
} else if ("phone" in profile && profile.phone) {
details = { phone: profile.phone, id: profileId };
} else if ("walletAddress" in profile && profile.walletAddress) {
details = { address: profile.walletAddress, id: profileId };
} else {
details = { id: profileId };
}
return {
type: profile.type,
details,
};
}),
wallets: apiWallet.address
? [
{
address: apiWallet.address,
createdAt: apiWallet.createdAt || new Date().toISOString(),
type: "enclave" as const,
},
]
: [],
};
}
// Helper function to safely get ID from any profile type
function getProfileId(profile: APIProfile | undefined): string {
if (!profile) return "";
if ("id" in profile) {
return profile.id;
} else if ("credentialId" in profile) {
return profile.credentialId;
} else if ("identifier" in profile) {
return profile.identifier;
}
return "";
}
export function useEmbeddedWallets(params: {
clientId?: string;
ecosystemSlug?: string;
@@ -98,6 +181,7 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
while (true) {
const res = await queryClient.fetchQuery<{
users: WalletUser[];
hasMore: boolean;
}>({
queryFn: fetchAccountList({
clientId,
@@ -113,12 +197,13 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
),
});
if (res.users.length === 0) {
responses.push(...res.users);
if (!res.hasMore) {
break;
}
page++;
responses.push(...res.users);
}
return responses;
@@ -0,0 +1 @@
export { BringToFrontIcon as BridgeIcon } from "lucide-react";
+6 -3
View File
@@ -17,18 +17,21 @@ export async function resolveEns(
ensName: await resolveName({
address: ensNameOrAddress,
client,
}),
}).catch(() => null),
};
}
if (!isValidENSName(ensNameOrAddress)) {
throw new Error("Invalid ENS name");
return {
address: null,
ensName: null,
};
}
const address = await resolveAddress({
client,
name: ensNameOrAddress,
});
}).catch(() => null);
if (!address) {
return {
+1 -1
View File
@@ -184,7 +184,7 @@ async function fetchContractName(chainId: number, contractAddress: string) {
return name;
}
const formatNumber = (num: number) => {
export const formatNumber = (num: number) => {
if (num >= 1000000) {
return `${(num / 1000000).toLocaleString(undefined, {
maximumFractionDigits: 1,
+1 -1
View File
@@ -147,4 +147,4 @@ export type ApiKeyPayConfigValidationSchema = z.infer<
>;
// FIXME: Remove
export const HIDDEN_SERVICES = ["relayer", "chainsaw"];
export const HIDDEN_SERVICES = ["relayer", "chainsaw", "engineCloud"];
+29
View File
@@ -279,3 +279,32 @@ export function newAccountStub(overrides?: Partial<Account>): Account {
...overrides,
};
}
export function randomLorem(length: number) {
const loremWords = [
"lorem",
"ipsum",
"dolor",
"sit",
"amet",
"consectetur",
"adipiscing",
"elit",
"sed",
"do",
"eiusmod",
"tempor",
"incididunt",
"ut",
"labore",
"et",
"dolore",
"magna",
"aliqua",
];
return Array.from({ length }, () => {
const randomIndex = Math.floor(Math.random() * loremWords.length);
return loremWords[randomIndex];
}).join(" ");
}
@@ -1,24 +1,24 @@
"use client";
import { useTheme } from "next-themes";
import { defineChain, type ThirdwebClient } from "thirdweb";
import type { ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyWidget } from "thirdweb/react";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
export function BuyFundsSection(props: {
chain: ChainMetadata;
client: ThirdwebClient;
}) {
const { theme } = useTheme();
return (
<section className="flex flex-col gap-4 items-center justify-center">
<BuyWidget
amount="0"
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.chain.chainId)}
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
client={props.client}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
// eslint-disable-next-line no-restricted-syntax
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
tokenAddress={undefined}
pageType="chain"
/>
</section>
</GridPatternEmbedContainer>
);
}
@@ -0,0 +1,13 @@
"use client";
import { reportChainPageview } from "@/analytics/report";
import { useEffectOnce } from "@/hooks/useEffectOnce";
export function ChainPageView(props: { chainId: number }) {
useEffectOnce(() => {
reportChainPageview({
chainId: props.chainId,
});
});
return null;
}
@@ -43,9 +43,7 @@ function useChainStatswithRPC(_rpcUrl: string) {
const latency = (performance.now() - startTimeStamp).toFixed(0);
const blockNumber = Number.parseInt(json.result.number, 16);
const blockGasLimit = Number.parseInt(json.result.gasLimit, 16);
return {
blockGasLimit,
blockNumber,
latency,
};
@@ -61,8 +59,70 @@ function useChainStatswithRPC(_rpcUrl: string) {
});
}
function useEIP7702Support(_rpcUrl: string) {
let rpcUrl = _rpcUrl.replace(
// eslint-disable-next-line no-template-curly-in-string
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is expected in this case
"${THIRDWEB_API_KEY}",
NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
);
// based on the environment hit dev or production
if (hostnameEndsWith(rpcUrl, "rpc.thirdweb.com")) {
if (!isProd) {
rpcUrl = rpcUrl.replace("rpc.thirdweb.com", "rpc.thirdweb-dev.com");
}
}
return useQuery({
enabled: !!rpcUrl,
queryFn: async () => {
try {
const res = await fetch(rpcUrl, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "eth_estimateGas",
params: [
{
from: "0xdeadbeef00000000000000000000000000000000",
to: "0xdeadbeef00000000000000000000000000000000",
data: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
value: "0x0",
},
"latest",
{
"0xdeadbeef00000000000000000000000000000000": {
code: "0xef01000000000000000000000000000000000000000001",
},
},
],
}),
method: "POST",
});
const json = await res.json();
// If the response has a valid result object, EIP-7702 is enabled
return {
isSupported: !!json.result,
};
} catch {
// If the request fails or errors, EIP-7702 is not supported
return {
isSupported: false,
};
}
},
queryKey: ["eip-7702-support", { rpcUrl }],
refetchInterval: false, // Don't refetch this as it's unlikely to change
refetchOnWindowFocus: false,
});
}
export function ChainLiveStats(props: { rpc: string }) {
const stats = useChainStatswithRPC(props.rpc);
const eip7702Support = useEIP7702Support(props.rpc);
return (
<>
@@ -107,8 +167,8 @@ export function ChainLiveStats(props: { rpc: string }) {
)}
</PrimaryInfoItem>
{/* Block Height */}
<PrimaryInfoItem title="Block Height" titleIcon={<PulseDot />}>
{/* Latest Block */}
<PrimaryInfoItem title="Latest Block" titleIcon={<PulseDot />}>
{stats.isError ? (
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
) : stats.data ? (
@@ -120,16 +180,37 @@ export function ChainLiveStats(props: { rpc: string }) {
)}
</PrimaryInfoItem>
{/* Block Gas Limit */}
<PrimaryInfoItem title="Block Gas Limit" titleIcon={<PulseDot />}>
{stats.isError ? (
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
) : stats.data ? (
<p className="fade-in-0 animate-in">
{stats.data.blockGasLimit ?? "N/A"}
</p>
{/* EIP-7702 */}
<PrimaryInfoItem
title="EIP-7702"
titleIcon={
eip7702Support.data ? (
eip7702Support.data.isSupported ? (
<ToolTipLabel label="Enabled">
<CircleCheckIcon className="size-4 text-success-text" />
</ToolTipLabel>
) : (
<ToolTipLabel label="Disabled">
<XIcon className="size-4 text-destructive-text" />
</ToolTipLabel>
)
) : eip7702Support.isError ? (
<ToolTipLabel label="Disabled">
<XIcon className="size-4 text-destructive-text" />
</ToolTipLabel>
) : null
}
>
{eip7702Support.data ? (
eip7702Support.data.isSupported ? (
"Available"
) : (
"Not Available"
)
) : eip7702Support.isError ? (
"Not Available"
) : (
<div className="flex h-[28px] w-[140px] py-1">
<div className="flex h-[28px] w-[80px] py-1">
<Skeleton className="h-full w-full" />
</div>
)}
@@ -24,6 +24,7 @@ import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
import { ChainPageView } from "./components/client/chain-pageview";
import { ChainHeader } from "./components/server/chain-header";
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
@@ -71,6 +72,7 @@ export default async function ChainPageLayout(props: {
return (
<div className="flex grow flex-col">
<ChainPageView chainId={chain.chainId} />
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
@@ -19,6 +19,12 @@ export async function isInsightSupportedForChain(
);
if (!res.ok) {
const errorText = await res.text();
console.error(
"failed to fetch chain services for chain",
chainId,
errorText,
);
return false;
}
@@ -10,6 +10,7 @@ import {
} from "@/components/analytics/date-range-selector";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { formatNumber } from "@/lib/search";
import {
type AnalyticsQueryParams,
type TotalQueryResult,
@@ -376,7 +377,7 @@ function AnalyticsStatUI(props: { label: string; data: number | undefined }) {
<SkeletonContainer
loadedData={props.data}
render={(v) => {
return <dd className="font-normal text-xl">{v.toLocaleString()}</dd>;
return <dd className="font-normal text-xl">{formatNumber(v)}</dd>;
}}
skeletonData={10000}
/>
@@ -3,8 +3,8 @@ import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
import { getVercelEnv } from "@/utils/vercel";
type InsightAggregationEntry = {
event_signature: string;
time: string;
topic_0: string;
day: string;
count: number;
};
@@ -26,12 +26,18 @@ export async function getContractEventBreakdown(params: {
startDate?: Date;
endDate?: Date;
}): Promise<EventBreakdownEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"group_by=topic_0 as event_signature",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(*) as count",
"group_by=day",
"group_by=topic_0",
`limit=${daysDifference * 10}`, // at most 10 topics per day
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -42,14 +48,13 @@ export async function getContractEventBreakdown(params: {
.filter(Boolean)
.join("&");
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`,
{
headers: {
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
},
const url = `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`;
const res = await fetch(url, {
headers: {
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
},
);
});
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
@@ -63,17 +68,16 @@ export async function getContractEventBreakdown(params: {
if (
typeof value === "object" &&
value !== null &&
"time" in value &&
"day" in value &&
"count" in value &&
"event_signature" in value &&
typeof value.event_signature === "string" &&
typeof value.time === "string" &&
typeof value.count === "number"
"topic_0" in value &&
typeof value.topic_0 === "string" &&
typeof value.day === "string"
) {
collectedAggregations.push({
count: value.count,
event_signature: value.event_signature,
time: value.time,
count: Number(value.count),
topic_0: value.topic_0,
day: value.day,
});
}
}
@@ -84,15 +88,15 @@ export async function getContractEventBreakdown(params: {
> = new Map();
for (const value of collectedAggregations) {
const mapKey = value.time;
const mapKey = value.day;
let valueForDay = dayToFunctionBreakdownMap.get(mapKey);
if (!valueForDay) {
valueForDay = {};
dayToFunctionBreakdownMap.set(mapKey, valueForDay);
}
valueForDay[value.event_signature] =
(valueForDay[value.event_signature] || 0) + value.count;
valueForDay[value.topic_0] =
(valueForDay[value.topic_0] || 0) + value.count;
}
const values: EventBreakdownEntry[] = [];
@@ -30,11 +30,17 @@ export async function getContractEventAnalytics(params: {
startDate?: Date;
endDate?: Date;
}): Promise<AnalyticsEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(block_timestamp) as count",
"group_by=day",
`limit=${daysDifference}`,
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -45,17 +51,17 @@ export async function getContractEventAnalytics(params: {
.filter(Boolean)
.join("&");
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`,
{
headers: {
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
},
const url = `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`;
const res = await fetch(url, {
headers: {
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
},
);
});
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch events analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
@@ -67,14 +73,13 @@ export async function getContractEventAnalytics(params: {
if (
typeof value === "object" &&
value !== null &&
"time" in value &&
"day" in value &&
"count" in value &&
typeof value.time === "string" &&
typeof value.count === "number"
typeof value.day === "string"
) {
values.push({
count: value.count,
time: new Date(value.time),
count: Number(value.count),
time: new Date(value.day),
});
}
}
@@ -26,12 +26,18 @@ export async function getContractFunctionBreakdown(params: {
startDate?: Date;
endDate?: Date;
}): Promise<FunctionBreakdownEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"group_by=day",
"group_by=function_selector",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(*) as count",
`limit=${daysDifference * 10}`, // at most 10 functions per day
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -68,17 +74,16 @@ export async function getContractFunctionBreakdown(params: {
if (
typeof value === "object" &&
value !== null &&
"time" in value &&
"day" in value &&
"count" in value &&
"function_selector" in value &&
typeof value.function_selector === "string" &&
typeof value.time === "string" &&
typeof value.count === "number"
typeof value.day === "string"
) {
collectedAggregations.push({
count: value.count,
count: Number(value.count),
function_selector: value.function_selector,
time: value.time,
time: value.day,
});
}
}
@@ -30,11 +30,17 @@ export async function getContractTransactionAnalytics(params: {
startDate?: Date;
endDate?: Date;
}): Promise<TransactionAnalyticsEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(block_timestamp) as count",
"group_by=day",
`limit=${daysDifference}`,
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -55,7 +61,8 @@ export async function getContractTransactionAnalytics(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch transaction analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
@@ -67,14 +74,13 @@ export async function getContractTransactionAnalytics(params: {
if (
typeof tx === "object" &&
tx !== null &&
"time" in tx &&
"day" in tx &&
"count" in tx &&
typeof tx.time === "string" &&
typeof tx.count === "number"
typeof tx.day === "string"
) {
returnValue.push({
count: tx.count,
time: new Date(tx.time),
count: Number(tx.count),
time: new Date(tx.day),
});
}
}
@@ -30,11 +30,18 @@ export async function getContractUniqueWalletAnalytics(params: {
startDate?: Date;
endDate?: Date;
}): Promise<TransactionAnalyticsEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(distinct from_address) as count",
"group_by=day",
"aggregate=count(distinct from_address)",
`limit=${daysDifference}`,
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -55,10 +62,12 @@ export async function getContractUniqueWalletAnalytics(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch wallet analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
console.log("wallet analytics json", json);
const aggregations = Object.values(json.aggregations[0]);
const returnValue: TransactionAnalyticsEntry[] = [];
@@ -67,14 +76,13 @@ export async function getContractUniqueWalletAnalytics(params: {
if (
typeof tx === "object" &&
tx !== null &&
"time" in tx &&
"day" in tx &&
"count" in tx &&
typeof tx.time === "string" &&
typeof tx.count === "number"
typeof tx.day === "string"
) {
returnValue.push({
count: tx.count,
time: new Date(tx.time),
count: Number(tx.count),
time: new Date(tx.day),
});
}
}
@@ -6,7 +6,7 @@ type InsightResponse = {
aggregations: [
{
0: {
total: number;
count: number;
};
},
];
@@ -19,10 +19,9 @@ export async function getTotalContractEvents(params: {
contractAddress: string;
chainId: number;
}): Promise<{ count: number }> {
const queryParams = [
`chain=${params.chainId}`,
"aggregate=count(block_number) as total",
].join("&");
const queryParams = [`chain=${params.chainId}`, "aggregate=count()"].join(
"&",
);
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`,
@@ -34,12 +33,13 @@ export async function getTotalContractEvents(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch events analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
return {
count: json.aggregations[0][0].total,
count: json.aggregations[0][0].count,
};
}
@@ -6,7 +6,7 @@ type InsightResponse = {
aggregations: [
{
0: {
total: number;
count: number;
};
},
];
@@ -19,10 +19,9 @@ export async function getTotalContractTransactions(params: {
contractAddress: string;
chainId: number;
}): Promise<{ count: number }> {
const queryParams = [
`chain=${params.chainId}`,
"aggregate=count(block_number) as total",
].join("&");
const queryParams = [`chain=${params.chainId}`, "aggregate=count()"].join(
"&",
);
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`,
@@ -34,12 +33,15 @@ export async function getTotalContractTransactions(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(
`Failed to fetch transactions analytics data: ${errorText}`,
);
}
const json = (await res.json()) as InsightResponse;
return {
count: json.aggregations[0][0].total,
count: json.aggregations[0][0].count,
};
}
@@ -6,7 +6,7 @@ type InsightResponse = {
aggregations: [
{
0: {
total: number;
count: number;
};
},
];
@@ -21,7 +21,7 @@ export async function getTotalContractUniqueWallets(params: {
}): Promise<{ count: number }> {
const queryParams = [
`chain=${params.chainId}`,
"aggregate=count(distinct from_address) as total",
"aggregate=count(distinct from_address)",
].join("&");
const res = await fetch(
@@ -34,12 +34,15 @@ export async function getTotalContractUniqueWallets(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(
`Failed to fetch unique wallets analytics data: ${errorText}`,
);
}
const json = (await res.json()) as InsightResponse;
return {
count: json.aggregations[0][0].total,
count: json.aggregations[0][0].count,
};
}
@@ -1,51 +0,0 @@
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,48 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import type { Chain, ThirdwebClient } from "thirdweb";
import { BuyWidget } from "thirdweb/react";
import {
reportAssetBuyFailed,
reportAssetBuySuccessful,
} from "@/analytics/report";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyTokenEmbed(props: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string;
}) {
const { theme } = useTheme();
return (
<BuyWidget
amount="1"
chain={props.chain}
className="!rounded-xl !w-full"
client={props.client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
const errorMessage = parseError(e);
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
error: errorMessage,
});
}}
onSuccess={() => {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
tokenAddress={props.tokenAddress as `0x${string}`}
/>
);
}
@@ -1,264 +0,0 @@
"use client";
import { differenceInCalendarDays, format } from "date-fns";
import { ArrowDownIcon, ArrowUpIcon, InfoIcon } from "lucide-react";
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 { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { TokenPriceData } from "../_apis/token-price-data";
function PriceChartUI(props: {
showTimeOfDay: boolean;
data: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
}) {
const data = props.data.map((item) => ({
price: item.price_usd,
time: new Date(item.date).getTime(),
}));
return (
<ThirdwebAreaChart
cardContentClassName="p-0"
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="border-none bg-transparent p-0"
config={{
price: {
color: "hsl(var(--chart-1))",
label: "Price",
},
}}
data={data}
hideLabel={false}
isPending={false}
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
toolTipValueFormatter={(value) => {
return tokenPriceUSDFormatter.format(value as number);
}}
/>
);
}
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 8,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
style: "currency",
});
const marketCapFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
style: "currency",
});
const holdersFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
notation: "compact",
});
const percentChangeFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
return (_v: string, item: unknown) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(
new Date(time),
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
);
}
return undefined;
};
}
export function TokenStats(params: {
chainId: number;
contractAddress: string;
tokenPriceData: TokenPriceData;
}) {
const [interval, setInterval] = useState<Interval>("max");
const filteredHistoricalPrices = useMemo(() => {
const currentDate = new Date();
return params.tokenPriceData.historical_prices.filter((item) => {
const date = new Date(item.date);
const maxDiff =
interval === "24h"
? 1
: interval === "7d"
? 7
: interval === "30d"
? 30
: interval === "1y"
? 365
: Number.MAX_SAFE_INTEGER;
return differenceInCalendarDays(currentDate, date) <= maxDiff;
});
}, [params.tokenPriceData, interval]);
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 formattedAbsChange = percentChangeFormatter.format(
Math.abs(percentChange24h),
);
const isAlmostZeroChange =
formattedAbsChange === percentChangeFormatter.format(0);
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
return (
<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 p-4 lg:p-6">
<div>
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
<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="px-0">
<PriceChartUI
data={filteredHistoricalPrices || []}
showTimeOfDay={interval === "24h"}
/>
</div>
<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"
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"
? marketCapFormatter.format(marketCap)
: marketCap
}
/>
<TokenStat
label="Number of Holders"
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
value={
// show 0 value as N/A
holders === 0
? "N/A"
: typeof holders === "number"
? holdersFormatter.format(holders)
: holders
}
/>
</div>
</div>
);
}
function TokenStat(props: {
value: string | number;
label: string;
tooltip: string;
}) {
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<p className="text-muted-foreground text-sm">{props.label}</p>
<ToolTipLabel label={props.tooltip}>
<InfoIcon className="size-3.5 text-muted-foreground" />
</ToolTipLabel>
</div>
<div className="flex">
<p className="font-semibold text-foreground text-xl tracking-tight">
{props.value}
</p>
</div>
</div>
);
}
type Interval = "24h" | "7d" | "30d" | "1y" | "max";
function IntervalSelector(props: {
interval: Interval;
setInterval: (timeframe: Interval) => void;
}) {
const intervals: Record<Interval, string> = {
"1y": "1Y",
"7d": "1W",
"24h": "1D",
"30d": "1M",
max: "MAX",
};
return (
<div className="flex gap-2">
{Object.entries(intervals).map(([key, value]) => (
<Button
className={cn(
"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}
onClick={() => props.setInterval(key as Interval)}
size="sm"
tabIndex={0}
variant="outline"
>
{value}
</Button>
))}
</div>
);
}
@@ -0,0 +1,77 @@
export const mapChainIdToDexScreenerChainSlug = {
8453: "base",
1: "ethereum",
56: "bsc",
369: "pulsechain",
137: "polygon",
2741: "abstract",
43114: "avalanche",
999: "hyperliquid",
480: "worldchain",
42161: "arbitrum",
388: "cronos",
59144: "linea",
1514: "story",
397: "near",
295: "hedera",
146: "sonic",
10: "optimism",
80094: "berachain",
57073: "ink",
130: "unichain",
5000: "mantle",
324: "zksync",
466: "apechain",
1116: "core",
250: "fantom",
1868: "soneium",
81457: "blast",
2000: "dogechain",
14: "flare",
2040: "vana",
4337: "beam",
109: "shibarium",
747: "flowevm",
1088: "metis",
1030: "conflux",
43113: "avalanchedfk",
534352: "scroll",
747474: "katana",
42220: "celo",
1284: "moonbeam",
4200: "merlinchain",
2222: "kava",
39797: "energi",
34443: "mode",
252: "fraxtal",
48900: "zircuit",
20: "elastos",
100: "gnosischain",
204: "opbnb",
169: "manta",
1313161554: "aurora",
3073: "movement",
4689: "iotex",
23294: "oasissapphire",
6001: "bouncebit",
42170: "arbitrumnova",
1101: "polygonzkevm",
40: "telos",
592: "astar",
42262: "oasisemerald",
1285: "moonriver",
245022934: "neonevm",
7777777: "zora",
122: "fuse",
321: "kcc",
1234: "stepnetwork",
106: "velas",
167000: "taiko",
288: "boba",
42766: "zkfair",
32520: "bitgert",
82: "meter",
} as const;
export type DexScreenerChainSlug =
(typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug];
@@ -0,0 +1,58 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { ClientOnly } from "@/components/blocks/client-only";
import type { DexScreenerChainSlug } from "./dex-screener-chains";
function DexScreenerIframe(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
const { theme } = useTheme();
const iframeUrl = useMemo(() => {
const resolvedTheme = theme === "light" ? "light" : "dark";
const url = new URL("https://dexscreener.com");
url.pathname = `${props.chain}/${props.contractAddress}`;
url.searchParams.set("embed", "1");
url.searchParams.set("loadChartSettings", "0");
url.searchParams.set("chartTheme", resolvedTheme);
url.searchParams.set("theme", resolvedTheme);
url.searchParams.set("trades", "1");
url.searchParams.set("chartStyle", "1");
url.searchParams.set("chartLeftToolbar", "0");
url.searchParams.set("chartType", "usd");
url.searchParams.set("interval", "15");
url.searchParams.set("chartDefaultOnMobile", "1");
return url.toString();
}, [theme, props.chain, props.contractAddress]);
return (
<iframe
className="w-full h-[500px] lg:h-[1000px] rounded-lg overflow-hidden border bg-card"
src={iframeUrl}
title="DexScreener"
></iframe>
);
}
export function DexScreener(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
return (
<ClientOnly
ssr={
<div className="w-full h-[500px] lg:h-[1000px] rounded-lg border bg-card items-center justify-center flex">
<Spinner className="size-10" />
</div>
}
>
<DexScreenerIframe
chain={props.chain}
contractAddress={props.contractAddress}
/>
</ClientOnly>
);
}
@@ -3,18 +3,18 @@ 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 { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { HAS_USED_DASHBOARD } from "@/constants/cookie";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { AssetPageView } from "../_components/asset-page-view";
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";
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
import { TokenStats } from "./_components/PriceChart";
import { DexScreener } from "./_components/dex-screener";
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
import { RecentTransfers } from "./_components/RecentTransfers";
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
@@ -28,9 +28,8 @@ export async function ERC20PublicPage(props: {
contractMetadata,
activeClaimCondition,
tokenDecimals,
tokenInfo,
tokenInfoFromUB,
functionSelectors,
tokenPriceData,
] = await Promise.all([
getContractMetadata({
contract: props.serverContract,
@@ -45,14 +44,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) {
contractMetadata.image = tokenInfo.iconUri;
if (!contractMetadata.image && tokenInfoFromUB) {
contractMetadata.image = tokenInfoFromUB.iconUri;
}
const [contractCreator, claimConditionCurrencyMeta] = await Promise.all([
@@ -70,32 +65,23 @@ export async function ERC20PublicPage(props: {
const cookieStore = await cookies();
const isDashboardUser = cookieStore.has(HAS_USED_DASHBOARD);
const buyEmbed = (
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={
activeClaimCondition && claimConditionCurrencyMeta
? {
activeClaimCondition,
claimConditionCurrency: claimConditionCurrencyMeta,
}
: undefined
}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
);
const claimConditionMeta =
activeClaimCondition && claimConditionCurrencyMeta
? {
activeClaimCondition,
claimConditionCurrency: claimConditionCurrencyMeta,
}
: undefined;
const isUBSupported = !!tokenInfoFromUB;
const showBuyEmbed = isUBSupported || claimConditionMeta;
return (
<div className="flex grow flex-col">
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
<PageHeader containerClassName="max-w-5xl" />
<PageHeader containerClassName="max-w-7xl" />
<div className="border-b">
<div className="container max-w-5xl">
<div className="container max-w-7xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
@@ -114,45 +100,45 @@ export async function ERC20PublicPage(props: {
</div>
</div>
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
<div className="h-8" />
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
<div className="container max-w-7xl pb-10">
<DexScreener
chain={
mapChainIdToDexScreenerChainSlug[
props.chainMetadata
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
]
}
contractAddress={props.clientContract.address}
/>
</div>
)}
<div className="container flex max-w-7xl grow flex-col 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}
/>
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
</>
) : (
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
)}
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
<RecentTransfers
chainMetadata={props.chainMetadata}
@@ -185,10 +171,12 @@ function BuyEmbed(props: {
}) {
if (!props.claimConditionMeta) {
return (
<BuyTokenEmbed
<BuyAndSwapEmbed
chain={props.clientContract.chain}
client={props.clientContract.client}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
pageType="asset"
/>
);
}
@@ -62,8 +62,8 @@ export const products = [
description: "The most powerful AI for interacting with the blockchain",
icon: NebulaIcon,
id: "nebula",
link: "https://thirdweb.com/nebula",
name: "Nebula",
link: "https://thirdweb.com/ai",
name: "thirdweb AI",
},
] satisfies Array<{
name: string;
@@ -1,7 +1,13 @@
"use client";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { reportProductFeedback } from "@/analytics/report";
import { NotificationsButton } from "@/components/notifications/notification-button";
import { NavLink } from "@/components/ui/NavLink";
import type { Account } from "@/hooks/useApi";
import { AccountButton } from "./account-button.client";
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
@@ -31,6 +37,32 @@ export function SecondaryNav(props: {
}
export function SecondaryNavLinks() {
const [showFeedbackDropdown, setShowFeedbackDropdown] = useState(false);
const [modalFeedback, setModalFeedback] = useState("");
const handleModalSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
// Report feedback to PostHog
reportProductFeedback({
feedback: modalFeedback,
source: "desktop",
});
// Show success notification
toast.success("Feedback submitted successfully!", {
description: "Thank you for your feedback. We'll review it shortly.",
});
setModalFeedback("");
setShowFeedbackDropdown(false);
};
const handleModalCancel = () => {
setModalFeedback("");
setShowFeedbackDropdown(false);
};
return (
<div className="flex items-center gap-6">
<ResourcesDropdownButton />
@@ -44,14 +76,77 @@ export function SecondaryNavLinks() {
Docs
</Link>
<Link
className="text-muted-foreground text-sm hover:text-foreground"
href="https://feedback.thirdweb.com"
rel="noopener noreferrer"
target="_blank"
>
Feedback
</Link>
<div className="relative">
<button
type="button"
onClick={() => setShowFeedbackDropdown(!showFeedbackDropdown)}
className="text-muted-foreground text-sm hover:text-foreground border border-border px-3 py-1.5 rounded-full hover:bg-muted transition-colors"
>
Feedback
</button>
{showFeedbackDropdown && (
<div
id="feedback-dropdown"
role="dialog"
aria-labelledby="feedback-heading"
className="absolute top-full right-0 mt-2 bg-background border border-border rounded-2xl p-3 w-96 z-50"
>
<h2
id="feedback-heading"
className="text-foreground text-base font-sans mb-2"
>
Share your feedback with us:
</h2>
<form onSubmit={handleModalSubmit} className="contents">
<label htmlFor="feedback-text" className="sr-only">
Feedback
</label>
<textarea
id="feedback-text"
value={modalFeedback}
onChange={(e) => setModalFeedback(e.target.value)}
maxLength={1000}
aria-describedby="feedback-help"
className="w-full bg-background text-foreground rounded-lg p-4 min-h-[120px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans mb-4 text-sm"
placeholder="Tell us what you think..."
/>
<div className="flex items-start justify-between gap-4">
<div className="text-muted-foreground text-xs font-sans">
<div>Have a technical issue?</div>
<div>
<NavLink
href="/team/~/support"
className="underline hover:text-foreground transition-colors"
>
Contact support
</NavLink>
.
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
type="button"
onClick={handleModalCancel}
className="bg-transparent text-foreground px-4 py-1.5 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!modalFeedback.trim()}
aria-disabled={!modalFeedback.trim()}
className="bg-primary text-primary-foreground px-4 py-1.5 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Submit
</button>
</div>
</div>
</form>
</div>
)}
</div>
</div>
);
}
@@ -1,6 +1,8 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
LogOutIcon,
MenuIcon,
MoonIcon,
@@ -12,8 +14,11 @@ import {
import Link from "next/link";
import { useTheme } from "next-themes";
import { useLayoutEffect, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { reportProductFeedback } from "@/analytics/report";
import { Button } from "@/components/ui/button";
import { NavLink } from "@/components/ui/NavLink";
import { Separator } from "@/components/ui/separator";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { useEns } from "@/hooks/contract-hooks";
@@ -36,6 +41,8 @@ export function MobileBurgerMenuButton(
},
) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showFeedbackSection, setShowFeedbackSection] = useState(false);
const [modalFeedback, setModalFeedback] = useState("");
const { setTheme, theme } = useTheme();
const ensQuery = useEns({
addressOrEnsName:
@@ -44,6 +51,29 @@ export function MobileBurgerMenuButton(
});
// const [isCMDSearchModalOpen, setIsCMDSearchModalOpen] = useState(false);
const handleModalSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
// Report feedback to PostHog
reportProductFeedback({
feedback: modalFeedback,
source: "mobile",
});
// Show success notification
toast.success("Feedback submitted successfully!", {
description: "Thank you for your feedback. We'll review it shortly.",
});
setModalFeedback("");
setShowFeedbackSection(false);
};
const handleModalCancel = () => {
setModalFeedback("");
setShowFeedbackSection(false);
};
useLayoutEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = "hidden";
@@ -71,7 +101,7 @@ export function MobileBurgerMenuButton(
</Button>
{isMenuOpen && (
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200">
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200 overflow-y-auto">
<Button
className="!h-auto absolute top-4 right-4 p-1"
onClick={() => setIsMenuOpen(false)}
@@ -191,14 +221,78 @@ export function MobileBurgerMenuButton(
Docs
</Link>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://feedback.thirdweb.com"
rel="noopener noreferrer"
target="_blank"
>
Feedback
</Link>
<div className="flex flex-col gap-3">
<button
type="button"
className="flex items-center justify-between text-muted-foreground hover:text-foreground"
onClick={() => setShowFeedbackSection(!showFeedbackSection)}
>
<span>Feedback</span>
{showFeedbackSection ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</button>
{showFeedbackSection && (
<div className="pl-0 pr-4 space-y-4 mb-6">
<h3
id="mobile-feedback-heading"
className="text-sm font-medium text-foreground mb-2"
>
Share your feedback with us:
</h3>
<form onSubmit={handleModalSubmit} className="contents">
<label htmlFor="mobile-feedback-text" className="sr-only">
Feedback
</label>
<textarea
id="mobile-feedback-text"
value={modalFeedback}
onChange={(e) => setModalFeedback(e.target.value)}
maxLength={1000}
aria-describedby="mobile-feedback-help"
className="w-full bg-background text-foreground rounded-lg p-3 min-h-[100px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans text-sm"
placeholder="Tell us what you think..."
/>
<div className="flex flex-col gap-3">
<p
id="mobile-feedback-help"
className="text-muted-foreground text-xs"
>
Have a technical issue?{" "}
<NavLink
href="/team/~/support"
className="underline hover:text-foreground transition-colors"
>
Contact support
</NavLink>
.
</p>
<div className="flex gap-3">
<button
type="button"
onClick={handleModalCancel}
className="flex-1 bg-transparent text-foreground px-3 py-2 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!modalFeedback.trim()}
aria-disabled={!modalFeedback.trim()}
className="flex-1 bg-primary text-primary-foreground px-3 py-2 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Submit
</button>
</div>
</div>
</form>
</div>
)}
</div>
</div>
<div className="h-6" />
@@ -0,0 +1,452 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { type ProjectService, SERVICES } from "@thirdweb-dev/service-utils";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { reportOnboardingCompleted } from "@/analytics/report";
import type { Project } from "@/api/project/projects";
import type { CreateProjectPrefillOptions } from "@/components/project/create-project-modal";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card } from "@/components/ui/card";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { createProjectClient } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
);
export function CreateProjectFormOnboarding(props: {
prefill?: CreateProjectPrefillOptions;
enableNebulaServiceByDefault: boolean;
teamSlug: string;
teamId: string;
}) {
const [screen, setScreen] = useState<
{ id: "create" } | { id: "api-details"; project: Project; secret: string }
>({ id: "create" });
return (
<Card>
<DynamicHeight>
{screen.id === "create" && (
<CreateProjectForm
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,
};
}}
enableNebulaServiceByDefault={props.enableNebulaServiceByDefault}
onProjectCreated={(params) => {
setScreen({
id: "api-details",
project: params.project,
secret: params.secret,
});
}}
prefill={props.prefill}
/>
)}
{screen.id === "api-details" && (
<CreatedProjectDetails
onComplete={() => {
setScreen({ id: "create" });
}}
project={screen.project}
secret={screen.secret}
teamSlug={props.teamSlug}
/>
)}
</DynamicHeight>
</Card>
);
}
const createProjectFormSchema = z.object({
domains: projectDomainsSchema,
name: projectNameSchema,
});
type CreateProjectFormSchema = z.infer<typeof createProjectFormSchema>;
function CreateProjectForm(props: {
createProject: (param: Partial<Project>) => Promise<{
project: Project;
secret: string;
}>;
prefill?: CreateProjectPrefillOptions;
enableNebulaServiceByDefault: boolean;
onProjectCreated: (params: { project: Project; secret: string }) => void;
}) {
const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">();
const createProject = useMutation({
mutationFn: props.createProject,
});
const form = useForm<CreateProjectFormSchema>({
defaultValues: {
domains: props.prefill?.domains || "",
name: props.prefill?.name || "",
},
resolver: zodResolver(createProjectFormSchema),
});
function handleAPICreation(values: { name: string; domains: string }) {
const servicesToEnableByDefault = props.enableNebulaServiceByDefault
? ALL_PROJECT_SERVICES
: ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula");
const formattedValues: Partial<Project> = {
domains: toArrFromList(values.domains),
name: values.name,
// enable all services
services: servicesToEnableByDefault.map((srv) => {
if (srv.name === "storage") {
return {
actions: srv.actions.map((sa) => sa.name),
name: srv.name,
} satisfies ProjectService;
}
if (srv.name === "pay") {
return {
actions: [],
name: "pay",
payoutAddress: null,
} satisfies ProjectService;
}
return {
actions: [],
name: srv.name,
} satisfies ProjectService;
}),
};
createProject.mutate(formattedValues, {
onError: () => {
toast.error("Failed to create a project");
},
onSuccess: (data) => {
props.onProjectCreated(data);
toast.success("Project created successfully");
},
});
}
const handleSubmit = form.handleSubmit((values) => {
if (!values.domains) {
setShowAlert("no-domain");
} else if (values.domains === "*") {
setShowAlert("any-domain");
} else {
handleAPICreation({
domains: values.domains,
name: values.name,
});
}
});
if (showAlert) {
return (
<DomainsAlert
isCreating={createProject.isPending}
onGoBack={() => setShowAlert(undefined)}
onProceed={() => {
handleAPICreation({
domains: form.getValues("domains"),
name: form.getValues("name"),
});
}}
type={showAlert}
/>
);
}
return (
<Form {...form}>
<form autoComplete="off" onSubmit={handleSubmit}>
<div className="p-6">
<header className="mb-4">
<h2 className="text-2xl">Create Project</h2>
</header>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input
className="bg-card"
placeholder="My Project"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="relative">
<CheckboxWithLabel className="absolute top-0 right-0 text-foreground">
<Checkbox
checked={form.watch("domains") === "*"}
onCheckedChange={(checked) => {
form.setValue("domains", checked ? "*" : "", {
shouldDirty: true,
});
}}
/>
Allow all domains
</CheckboxWithLabel>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>Allowed Domains</FormLabel>
<FormControl>
<Textarea
className="bg-card"
placeholder="thirdweb.com, *.example.com, localhost:3000"
{...field}
/>
</FormControl>
<FormMessage />
<div className="pt-1">
<FormDescription className="mb-2 text-sm leading-relaxed">
Prevent third-parties from using your Client ID by
restricting access to allowed domains. Highly
recommended for frontend applications.
</FormDescription>
<ul className="flex list-disc flex-col gap-2 pl-3 text-muted-foreground text-sm [&>li]:pl-1 ">
<li>
Authorize all domains with{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
*
</span>
<br />
<span>
Example:{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
*.thirdweb.com
</span>{" "}
accepts all{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
.thirdweb.com
</span>{" "}
sites
</span>
</li>
<li>
Authorize local URLs with{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
{"localhost:<port>"}
</span>
</li>
<li>Separate domains by commas or new lines</li>
</ul>
</div>
</FormItem>
)}
/>
</div>
</div>
</div>
<footer className="flex gap-4 border-border border-t p-6 md:gap-2">
<Button
className="min-w-28 gap-2 ml-auto"
disabled={createProject.isPending}
type="submit"
>
{createProject.isPending && <Spinner className="size-4" />}
Create Projecct
</Button>
</footer>
</form>
</Form>
);
}
function DomainsAlert(props: {
type: "no-domain" | "any-domain";
isCreating: boolean;
onProceed: () => void;
onGoBack: () => void;
}) {
return (
<div>
<div className="mb-4 p-6">
<header>
<h2 className="text-2xl">
{props.type === "no-domain" && "No Domains Configured"}
{props.type === "any-domain" && "Unrestricted Web Access"}
</h2>
<p>
{props.type === "no-domain" &&
"This will deny requests from all origins, rendering the key unusable in frontend applications. Proceed only if you intend to use this key in server or native apps environments."}
{props.type === "any-domain" &&
"Requests from all origins will be authorized. Your key can be used by any website without restriction"}
</p>
</header>
</div>
<footer className="!justify-between flex gap-4 border-border border-t bg-card p-6">
<Button
className="min-w-28 gap-2"
onClick={props.onGoBack}
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Update Domains
</Button>
<Button
className="min-w-28 gap-2"
disabled={props.isCreating}
onClick={props.onProceed}
type="button"
variant="destructive"
>
{props.isCreating && <Spinner className="size-4" />}
Proceed
</Button>
</footer>
</div>
);
}
function CreatedProjectDetails(props: {
project: Project;
secret: string;
onComplete: () => void;
teamSlug: string | undefined;
}) {
const [secretStored, setSecretStored] = useState(false);
const router = useDashboardRouter();
const clientId = props.project.publishableKey;
return (
<div>
<div className="p-6">
<header>
<h2 className="text-2xl">{props.project.name}</h2>
</header>
<div className="h-3" />
<section>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application.
</p>
<CopyTextButton
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
copyIconPosition="right"
textToCopy={clientId}
textToShow={clientId}
tooltip="Copy Client ID"
/>
</section>
<div className="h-6" />
<section>
<h3>Secret Key</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies and authenticates your application from a backend.
</p>
<CopyTextButton
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
copyIconPosition="right"
textToCopy={props.secret || ""}
textToShow={props.secret || ""}
tooltip="Copy Secret Key"
/>
</section>
<div className="h-3" />
<Alert variant="destructive">
<AlertTitle>Do not share or expose your secret key</AlertTitle>
<AlertDescription>
<div className="mb-5">
Secret keys cannot be recovered. If you lose your secret key, you
will need to rotate the secret key or create a new Project.
</div>
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={secretStored}
onCheckedChange={(v) => {
setSecretStored(!!v);
}}
/>
I confirm that I've securely stored my secret key
</CheckboxWithLabel>
</AlertDescription>
</Alert>
</div>
<footer className="flex border-border border-t bg-card p-6">
{props.teamSlug && (
<Button
className="min-w-28 gap-2 ml-auto"
disabled={!secretStored}
onClick={() => {
reportOnboardingCompleted();
router.push(`/team/${props.teamSlug}/${props.project.slug}`);
}}
>
View Project
<ArrowRightIcon className="size-4" />
</Button>
)}
</footer>
</div>
);
}
@@ -0,0 +1,29 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team/get-team";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { CreateProjectFormOnboarding } from "./_components/create-project-form";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team || !authToken) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={4}>
<CreateProjectFormOnboarding
enableNebulaServiceByDefault={true}
teamSlug={team.slug}
teamId={team.id}
/>
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,31 @@
"use client";
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import type { Team } from "@/api/team/get-team";
const stripePromise = loadStripe(
// biome-ignore lint/style/noNonNullAssertion: TODO: fix this later
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
export function GrowthPlanCheckout(props: {
team: Team;
fetchClientSecret: (team: Team) => Promise<string>;
}) {
return (
<div id="checkout">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{
fetchClientSecret: () => props.fetchClientSecret(props.team),
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
@@ -1,13 +1,23 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import {
fetchClientSecret,
getStripeSessionById,
} from "@/actions/stripe-actions";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team/get-team";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { pollWithTimeout } from "@/utils/pollWithTimeout";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { PlanSelector } from "./_components/plan-selector";
import { GrowthPlanCheckout } from "./_components/stripe-checkout";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
searchParams: Promise<{ session_id?: string }>;
}) {
const params = await props.params;
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
@@ -17,18 +27,47 @@ export default async function Page(props: {
notFound();
}
async function getTeam() {
"use server";
const resolvedTeam = await getTeamBySlug(params.team_slug);
if (!resolvedTeam) {
return team as Team;
// IF we have a session id, check if the session was completed successfully
let error: string | null = null;
if (searchParams.session_id) {
const session = await getStripeSessionById(searchParams.session_id);
switch (session.status) {
case "complete": {
// poll the team until it shows as "growth" plan (to handle race condition with stripe webhook)
await pollWithTimeout({
shouldStop: async () => {
const refreshedTeam = await getTeamBySlug(team.slug);
return refreshedTeam?.billingPlan === "growth";
},
timeoutMs: 5000,
});
redirect(`/get-started/team/${team.slug}/add-members`);
break;
}
case "open": {
error =
"Something went wrong when trying to process your payment method. Please try again.";
break;
}
default: {
error = "Please try again later.";
break;
}
}
return resolvedTeam;
}
return (
<TeamOnboardingLayout currentStep={2}>
<PlanSelector getTeam={getTeam} team={team} />
<div className="rounded-lg border bg-card overflow-hidden py-4 md:py-8">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to proces subscription</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<GrowthPlanCheckout team={team} fetchClientSecret={fetchClientSecret} />
</div>
</TeamOnboardingLayout>
);
}
@@ -1,6 +1,7 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { useEffect, useMemo } from "react";
@@ -24,6 +25,9 @@ export function AppRouterProviders(props: {
children: React.ReactNode;
autoConnect: boolean;
}) {
// get the current pathname
const pathname = usePathname();
return (
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
@@ -36,6 +40,9 @@ export function AppRouterProviders(props: {
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
forcedTheme={
pathname.startsWith("/get-started") ? "dark" : undefined
}
>
<Toaster richColors />
<SanctionedAddressesChecker>
@@ -4,7 +4,6 @@ import { getInAppWalletUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProjects, type Project } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { Changelog } from "./_components/Changelog";
@@ -63,15 +62,8 @@ export default async function Page(props: {
team={team}
/>
{team.billingPlan === "free" ? (
{team.billingPlan === "free" && (
<FreePlanUpsellBannerUI highlightPlan="growth" teamSlug={team.slug} />
) : (
<DismissibleAlert
preserveState={true}
description="Engines, contracts, project settings, and more are now managed within projects. Open or create a project to access them."
localStorageId={`${team.id}-engines-alert`}
title="Looking for Engines?"
/>
)}
<Changelog />
@@ -34,7 +34,7 @@ interface SupportCaseDetailsProps {
team: Team;
}
export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
const [replyMessage, setReplyMessage] = useState("");
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [localMessages, setLocalMessages] = useState(ticket.messages || []);
@@ -42,6 +42,15 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
// rating/feedback
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState("");
// non-blocking warning when status check fails
const [statusCheckFailed, setStatusCheckFailed] = useState(false);
// Helper function to handle status check errors consistently
const handleStatusCheckError = (_error: unknown) => {
// Set degraded state for warning banner
setStatusCheckFailed(true);
return;
};
// Check if feedback has already been submitted for this ticket
const feedbackStatusQuery = useQuery({
@@ -49,18 +58,25 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
queryFn: async () => {
const result = await checkFeedbackStatus(ticket.id);
if ("error" in result) {
throw new Error(result.error);
handleStatusCheckError(result.error);
return false; // Non-blocking: allow feedback submission despite status check failure
}
// Clear degraded state on success
if (statusCheckFailed) setStatusCheckFailed(false);
return result.hasFeedback;
},
enabled: ticket.status === "closed",
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 1, // Reduce retries since we want non-blocking behavior
});
const feedbackSubmitted = feedbackStatusQuery.data ?? false;
const isLoading = feedbackStatusQuery.isLoading;
const hasError = feedbackStatusQuery.isError;
// query never throws; use local degraded flag for the inline warning
const hasError = statusCheckFailed;
const handleStarClick = (starIndex: number) => {
setRating(starIndex + 1);
@@ -70,29 +86,36 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
const submitFeedbackMutation = useMutation({
mutationFn: async () => {
if (rating === 0) {
throw new Error("Please select a rating");
const error = "Please select a rating";
throw new Error(error);
}
const result = await submitSupportFeedback({
rating,
feedback,
ticketId: ticket.id,
});
if ("error" in result) {
// Add more specific error information
throw new Error(result.error);
}
return result;
},
onSuccess: () => {
toast.success("Thank you for your feedback!");
setRating(0);
setFeedback("");
// mark as submitted immediately
queryClient.setQueryData(["feedbackStatus", ticket.id], true);
},
onError: (err) => {
console.error("Failed to submit feedback:", err);
const msg = err instanceof Error ? err.message : String(err ?? "");
let message = "Failed to submit feedback. Please try again.";
if (/network|fetch/i.test(msg)) {
message = "Network error. Please check your connection and try again.";
} else if (
@@ -102,6 +125,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
} else if (/API Server error/i.test(msg)) {
message = "Server error. Please try again later.";
}
toast.error(message);
},
});
@@ -157,8 +181,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
}
setReplyMessage("");
} catch (error) {
console.error("Failed to send reply:", error);
} catch {
toast.error("Failed to send Message. Please try again.");
// Remove the optimistic message on error
@@ -233,73 +256,77 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
</div>
)}
{ticket.status === "closed" && !isLoading && !feedbackSubmitted && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
This ticket is closed. Give us a quick rating to let us know how
we did!
</p>
{hasError && (
<p className="text-destructive text-xs mt-2">
Couldn't verify prior feedback right now — you can still submit
a rating.
{ticket.status === "closed" &&
!isLoading &&
(!feedbackSubmitted || hasError) && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
This ticket is closed. Give us a quick rating to let us know how
we did!
</p>
)}
{hasError && (
<div className="mt-2">
<p className="text-destructive text-xs">
Couldn't verify prior feedback right now — you can still
submit a rating.
</p>
</div>
)}
<div className="flex gap-2 mb-6 mt-4">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={`star-${starValue}`}
<div className="flex gap-2 mb-6 mt-4">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={`star-${starValue}`}
type="button"
onClick={() => handleStarClick(starValue - 1)}
className="transition-colors"
aria-label={`Rate ${starValue} out of 5 stars`}
>
<StarIcon
size={32}
className={cn(
"transition-colors",
starValue <= rating
? "text-pink-500 fill-current stroke-current"
: "text-muted-foreground fill-current stroke-current",
"hover:text-pink-500",
)}
strokeWidth={starValue <= rating ? 2 : 1}
/>
</button>
))}
</div>
<div className="relative">
<AutoResizeTextarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Optional: Tell us how we can improve."
maxLength={1000}
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
/>
<Button
type="button"
onClick={() => handleStarClick(starValue - 1)}
className="transition-colors"
aria-label={`Rate ${starValue} out of 5 stars`}
onClick={handleSendFeedback}
disabled={submitFeedbackMutation.isPending || rating === 0}
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
variant="secondary"
size="sm"
>
<StarIcon
size={32}
className={cn(
"transition-colors",
starValue <= rating
? "text-pink-500 fill-current stroke-current"
: "text-muted-foreground fill-current stroke-current",
"hover:text-pink-500",
)}
strokeWidth={starValue <= rating ? 2 : 1}
/>
</button>
))}
{submitFeedbackMutation.isPending ? (
<>
<Spinner className="size-4 mr-2" />
Sending...
</>
) : (
"Send Feedback"
)}
</Button>
</div>
</div>
)}
<div className="relative">
<AutoResizeTextarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Optional: Tell us how we can improve."
maxLength={1000}
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
/>
<Button
type="button"
onClick={handleSendFeedback}
disabled={submitFeedbackMutation.isPending || rating === 0}
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
variant="secondary"
size="sm"
>
{submitFeedbackMutation.isPending ? (
<>
<Spinner className="size-4 mr-2" />
Sending...
</>
) : (
"Send Feedback"
)}
</Button>
</div>
</div>
)}
{ticket.status === "closed" && feedbackSubmitted && (
{ticket.status === "closed" && feedbackSubmitted && !hasError && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
Thank you for your feedback! We appreciate your input and will use
@@ -455,3 +482,5 @@ function TicketMessage(props: { message: SupportMessage }) {
</div>
);
}
export { SupportCaseDetails };
@@ -13,7 +13,16 @@ export async function submitSupportFeedback({
ticketId: string;
}): Promise<FeedbackSubmitResult> {
try {
// Fail fast on missing configuration
// Basic input validation first - fail fast on invalid input
if (!ticketId?.trim()) {
return { error: "ticketId is required." };
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { error: "Rating must be an integer between 1 and 5." };
}
// Configuration validation after input validation
const siwaUrl =
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
@@ -27,15 +36,6 @@ export async function submitSupportFeedback({
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
}
// Basic input validation/normalization
if (!ticketId?.trim()) {
return { error: "ticketId is required." };
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { error: "Rating must be an integer between 1 and 5." };
}
const normalizedFeedback = (feedback ?? "")
.toString()
.trim()
@@ -49,6 +49,7 @@ export async function submitSupportFeedback({
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 10_000);
const response = await fetch(`${siwaUrl}/v1/csat/saveCSATFeedback`, {
method: "POST",
cache: "no-store",
@@ -62,13 +63,33 @@ export async function submitSupportFeedback({
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
const error = `API Server error: ${response.status} - ${errorText}`;
return { error };
}
return { success: true };
try {
const data = await response.json();
// Validate response structure
if (typeof data !== "object" || data === null) {
return { error: "Invalid response format from API" };
}
if (!data.success) {
return { error: "API returned unsuccessful response" };
}
return { success: true };
} catch (_jsonError) {
return { error: "Invalid JSON response from API" };
}
} catch (error) {
console.error("Feedback submission error:", error);
return { error: "Internal server error" };
if (error instanceof Error) {
if (error.name === "AbortError") return { error: "Request timeout" };
if (error instanceof TypeError) return { error: "Network error" };
return { error: error.message };
}
return { error: "Unknown error occurred" };
}
}
@@ -76,12 +97,12 @@ export async function checkFeedbackStatus(
ticketId: string,
): Promise<FeedbackStatusResult> {
try {
// Basic input validation
// Basic input validation first - fail fast on invalid input
if (!ticketId?.trim()) {
return { error: "ticketId is required." };
}
// Fail fast on missing configuration
// Configuration validation after input validation
const siwaUrl =
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
@@ -95,29 +116,71 @@ export async function checkFeedbackStatus(
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
}
const fullUrl = `${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(ticketId)}`;
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 10_000);
const response = await fetch(fullUrl, {
method: "GET",
cache: "no-store",
headers: {
"Content-Type": "application/json",
"x-service-api-key": apiKey,
const response = await fetch(
`${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(
ticketId.trim(),
)}`,
{
method: "GET",
cache: "no-store",
headers: {
"Content-Type": "application/json",
"x-service-api-key": apiKey,
},
signal: ac.signal,
},
signal: ac.signal,
}).finally(() => clearTimeout(t));
).finally(() => clearTimeout(t));
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
const error = `API Server error: ${response.status} - ${errorText}`;
return { error };
}
let data: {
has_feedback: boolean;
feedback_data: {
id: string;
rating: number | null;
feedback: string | null;
ticket_id: string | null;
created_at: string;
} | null;
};
try {
data = await response.json();
} catch (_jsonError) {
return { error: "Invalid JSON response from API" };
}
// Comprehensive validation of the API response structure
if (
typeof data.has_feedback !== "boolean" ||
(data.feedback_data != null &&
(typeof data.feedback_data !== "object" ||
typeof data.feedback_data.id !== "string" ||
(data.feedback_data.rating != null &&
typeof data.feedback_data.rating !== "number") ||
(data.feedback_data.feedback != null &&
typeof data.feedback_data.feedback !== "string") ||
(data.feedback_data.ticket_id != null &&
typeof data.feedback_data.ticket_id !== "string") ||
typeof data.feedback_data.created_at !== "string"))
) {
return { error: "Invalid response format from API" };
}
const data = await response.json();
return { hasFeedback: data.has_feedback };
} catch (error) {
console.error("Feedback status check error:", error);
return { error: "Internal server error" };
if (error instanceof Error) {
if (error.name === "AbortError") return { error: "Request timeout" };
if (error instanceof TypeError) return { error: "Network error" };
return { error: error.message };
}
return { error: "Unknown error occurred" };
}
}
@@ -0,0 +1,102 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { AiAnalytics } from "./chart";
import { AiSummary } from "./chart/Summary";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const { team_slug, project_slug } = params;
const [project, authToken] = await Promise.all([
getProject(team_slug, project_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/team/${team_slug}/${project_slug}/ai`);
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
const defaultRange = "last-30" as DurationId;
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
header={{
icon: NebulaIcon,
client,
title: "AI Analytics",
description: "Track your AI app usage and performance",
actions: null,
links: [
{
href: "https://portal.thirdweb.com/ai/chat",
type: "docs",
},
{
href: "https://api.thirdweb.com/reference#tag/ai/post/ai/chat",
type: "api",
},
{
href: "https://playground.thirdweb.com/ai/chat",
type: "playground",
},
],
}}
>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
<AiSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
range={range}
/>
<AiAnalytics
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
</div>
</ProjectPage>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,290 @@
import { stream } from "fetch-event-stream";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs";
import type {
NebulaContext,
NebulaSwapData,
NebulaTxData,
NebulaUserMessage,
} from "./types";
export async function promptNebula(params: {
message: NebulaUserMessage;
sessionId: string;
authToken: string;
handleStream: (res: ChatStreamedResponse) => void;
abortController: AbortController;
context: undefined | NebulaContext;
project: Project;
}) {
const body: Record<string, string | boolean | object> = {
messages: [params.message],
session_id: params.sessionId,
stream: true,
};
if (params.context) {
body.context = {
chain_ids: params.context.chainIds || [],
networks: params.context.networks,
wallet_address: params.context.walletAddress,
};
}
try {
const events = await stream(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/chat`, {
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${params.authToken}`,
"x-team-id": params.project.teamId,
"x-client-id": params.project.publishableKey,
"Content-Type": "application/json",
},
method: "POST",
signal: params.abortController.signal,
});
for await (const _event of events) {
if (!_event.data) {
continue;
}
const event = _event as ChatStreamedEvent;
switch (event.event) {
case "delta": {
params.handleStream({
data: {
v: JSON.parse(event.data).v,
},
event: "delta",
});
break;
}
case "presence": {
params.handleStream({
data: JSON.parse(event.data),
event: "presence",
});
break;
}
case "image": {
const data = JSON.parse(event.data) as {
data: {
width: number;
height: number;
url: string;
};
request_id: string;
};
params.handleStream({
data: data.data,
event: "image",
request_id: data.request_id,
});
break;
}
case "action": {
const data = JSON.parse(event.data);
if (data.type === "sign_transaction") {
try {
const parsedTxData = data.data as NebulaTxData;
params.handleStream({
data: parsedTxData,
event: "action",
request_id: data.request_id,
type: "sign_transaction",
});
} catch (e) {
console.error("failed to parse action data", e, { event });
}
}
if (data.type === "sign_swap") {
try {
const swapData = data.data as NebulaSwapData;
params.handleStream({
data: swapData,
event: "action",
request_id: data.request_id,
type: "sign_swap",
});
} catch (e) {
console.error("failed to parse action data", e, { event });
}
}
break;
}
case "error": {
const data = JSON.parse(event.data) as {
code: number;
error: {
message: string;
};
};
params.handleStream({
data: {
code: data.code,
errorMessage: data.error.message,
},
event: "error",
});
break;
}
case "init": {
const data = JSON.parse(event.data);
params.handleStream({
data: {
request_id: data.request_id,
session_id: data.session_id,
},
event: "init",
});
break;
}
case "context": {
const data = JSON.parse(event.data) as {
data: string;
request_id: string;
session_id: string;
};
const contextData = JSON.parse(data.data) as {
wallet_address: string;
chain_ids: number[];
networks: NebulaContext["networks"];
};
params.handleStream({
data: contextData,
event: "context",
});
break;
}
case "ping": {
break;
}
default: {
console.warn("unhandled event", event);
}
}
}
} catch (error) {
console.error("failed to stream events", error);
params.handleStream({
data: {
code: 500,
errorMessage: `Failed to stream events: ${error instanceof Error ? error.message : "Unknown error"}`,
},
event: "error",
});
params.abortController.abort();
}
}
type ChatStreamedResponse =
| {
event: "init";
data: {
session_id: string;
request_id: string;
};
}
| {
event: "presence";
data: {
session_id: string;
request_id: string;
source: "user" | "reviewer" | (string & {});
data: string;
};
}
| {
event: "delta";
data: {
v: string;
};
}
| {
event: "action";
type: "sign_transaction";
data: NebulaTxData;
request_id: string;
}
| {
event: "action";
type: "sign_swap";
data: NebulaSwapData;
request_id: string;
}
| {
event: "image";
data: {
width: number;
height: number;
url: string;
};
request_id: string;
}
| {
event: "context";
data: {
wallet_address: string;
chain_ids: number[];
networks: NebulaContext["networks"];
};
}
| {
event: "error";
data: {
code: number;
errorMessage: string;
};
};
type ChatStreamedEvent =
| {
event: "init";
data: string;
}
| {
event: "presence";
data: string;
}
| {
event: "delta";
data: string;
}
| {
event: "image";
data: string;
}
| {
event: "action";
type: "sign_transaction" | "sign_swap";
data: string;
}
| {
event: "context";
data: string;
}
| {
event: "error";
data: string;
}
| {
event: "ping";
data: string;
};
@@ -0,0 +1,28 @@
"use server";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs";
import { fetchWithAuthToken } from "./fetchWithAuthToken";
export async function submitFeedback(params: {
project: Project;
sessionId: string;
requestId: string;
rating: "good" | "bad" | "neutral";
}) {
const res = await fetchWithAuthToken({
body: {
feedback_rating:
params.rating === "good" ? 1 : params.rating === "bad" ? -1 : 0,
request_id: params.requestId,
session_id: params.sessionId,
},
endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/feedback`,
method: "POST",
project: params.project,
});
if (!res.ok) {
throw new Error("Failed to submit feedback");
}
}
@@ -0,0 +1,62 @@
"use server";
import { getAuthToken } from "@/api/auth-token";
import type { Project } from "@/api/project/projects";
type FetchWithKeyOptions = {
endpoint: string;
project: Project;
timeout?: number;
} & (
| {
method: "POST" | "PUT";
body: Record<string, unknown>;
}
| {
method: "GET" | "DELETE";
}
);
export async function fetchWithAuthToken(options: FetchWithKeyOptions) {
const timeout = options.timeout || 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error("No auth token found");
}
const response = await fetch(options.endpoint, {
body: "body" in options ? JSON.stringify(options.body) : undefined,
headers: {
Accept: "application/json",
Authorization: `Bearer ${authToken}`,
"x-team-id": options.project.teamId,
"x-client-id": options.project.publishableKey,
"Content-Type": "application/json",
},
method: options.method,
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 504) {
throw new Error("Request timed out. Please try again.");
}
const data = await response.text();
throw new Error(`HTTP error! status: ${response.status}: ${data}`);
}
return response;
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
@@ -0,0 +1,123 @@
"use server";
import type { Project } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs";
import { fetchWithAuthToken } from "./fetchWithAuthToken";
import type {
DeletedSessionInfo,
NebulaContext,
SessionInfo,
TruncatedSessionInfo,
UpdatedSessionInfo,
} from "./types";
export async function createSession(params: {
project: Project;
context: NebulaContext | undefined;
}) {
const body: Record<string, string | boolean | object> = {};
if (params.context) {
body.context = {
chain_ids: params.context.chainIds || [],
networks: params.context.networks,
wallet_address: params.context.walletAddress,
};
}
const res = await fetchWithAuthToken({
project: params.project,
body: body,
endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session`,
method: "POST",
});
if (!res.ok) {
const error = await res.text();
throw new Error(`Failed to create session: ${error}`);
}
const data = await res.json();
return data.result as SessionInfo;
}
export async function updateSession(params: {
project: Project;
sessionId: string;
contextFilters: NebulaContext | undefined;
}) {
const body: Record<string, string | boolean | object> = {};
if (params.contextFilters) {
body.context = {
chain_ids: params.contextFilters.chainIds || [],
networks: params.contextFilters.networks,
wallet_address: params.contextFilters.walletAddress,
};
}
const res = await fetchWithAuthToken({
body: body,
endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`,
method: "PUT",
project: params.project,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as UpdatedSessionInfo;
}
export async function deleteSession(params: {
project: Project;
sessionId: string;
}) {
const res = await fetchWithAuthToken({
endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`,
method: "DELETE",
project: params.project,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as DeletedSessionInfo;
}
export async function getSessions(params: { project: Project }) {
const res = await fetchWithAuthToken({
endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/list`,
method: "GET",
project: params.project,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as TruncatedSessionInfo[];
}
export async function getSessionById(params: {
project: Project;
sessionId: string;
}) {
const res = await fetchWithAuthToken({
endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`,
method: "GET",
project: params.project,
});
if (!res.ok) {
throw new Error("Failed to update session");
}
const data = await res.json();
return data.result as SessionInfo;
}
@@ -0,0 +1,121 @@
type SessionContextFilter = {
chain_ids: string[] | null;
wallet_address: string | null;
};
type NebulaUserMessageContentItem =
| {
type: "image";
image_url: string | null;
b64: string | null;
}
| {
type: "text";
text: string;
}
| {
type: "transaction";
transaction_hash: string;
chain_id: number;
};
export type NebulaUserMessageContent = NebulaUserMessageContentItem[];
export type NebulaUserMessage = {
role: "user";
content: NebulaUserMessageContent;
};
export type NebulaSessionHistoryMessage =
| {
role: "assistant" | "action" | "image";
content: string;
timestamp: number;
}
| {
role: "user";
content: NebulaUserMessageContent | string;
};
export type SessionInfo = {
id: string;
account_id: string;
model_name: string;
archive_at: string | null;
can_execute: boolean;
created_at: string;
deleted_at: string | null;
history: Array<NebulaSessionHistoryMessage> | null;
updated_at: string;
archived_at: string | null;
title: string | null;
is_public: boolean | null;
context: SessionContextFilter | null;
// memory
// action: array<object> | null; <-- type of this is not available on https://nebula-api.thirdweb-dev.com/docs#/default/get_session_session__session_id__get
};
export type UpdatedSessionInfo = {
title: string;
model_name: string;
account_id: string;
context: SessionContextFilter | null;
};
export type DeletedSessionInfo = {
id: string;
deleted_at: string;
};
export type TruncatedSessionInfo = {
created_at: string;
id: string;
updated_at: string;
title: string | null;
};
export type NebulaTxData = {
chain_id: number;
data: `0x${string}`;
to: string;
value?: string;
};
export type NebulaContext = {
chainIds: string[] | null;
walletAddress: string | null;
networks: "mainnet" | "testnet" | "all" | null;
};
export type NebulaSwapData = {
action: string;
transaction: {
chain_id: number;
to: `0x${string}`;
data: `0x${string}`;
value?: string;
};
to_token: {
address: `0x${string}`;
amount: string;
chain_id: number;
decimals: number;
symbol: string;
};
from_token: {
address: `0x${string}`;
amount: string;
chain_id: number;
decimals: number;
symbol: string;
};
intent: {
amount: string;
destination_chain_id: number;
destination_token_address: `0x${string}`;
origin_chain_id: number;
origin_token_address: `0x${string}`;
receiver: `0x${string}`;
sender: `0x${string}`;
};
};
@@ -0,0 +1,65 @@
import { notFound } from "next/navigation";
import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getSessionById, getSessions } from "../../api/session";
import { ChatPageContent } from "../../components/ChatPageContent";
import { ChatPageLayout } from "../../components/ChatPageLayout";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
session_id: string;
}>;
}) {
const [params] = await Promise.all([props.params]);
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
notFound();
}
if (!project) {
notFound();
}
const client = await getUserThirdwebClient({ teamId: project.teamId });
const [session, sessions] = await Promise.all([
getSessionById({
project: project,
sessionId: params.session_id,
}).catch(() => undefined),
getSessions({
project: project,
}).catch(() => []),
]);
if (!session) {
notFound();
}
return (
<ChatPageLayout
team_slug={params.team_slug}
project={project}
authToken={authToken}
client={client}
accountAddress={""}
sessions={sessions}
>
<ChatPageContent
project={project}
accountAddress={""}
authToken={authToken}
client={client}
initialParams={undefined}
session={session}
type="new-chat"
/>
</ChatPageLayout>
);
}
@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { subDays } from "date-fns";
import { ThirdwebProvider } from "thirdweb/react";
import { projectStub, randomLorem } from "@/storybook/stubs";
import { storybookThirdwebClient } from "@/storybook/utils";
import { ChatPageLayout } from "../../components/ChatPageLayout";
import { ChatHistoryPageUI } from "./ChatHistoryPage";
const meta = {
component: Variant,
decorators: [
(Story) => (
<ThirdwebProvider>
<Story />
</ThirdwebProvider>
),
],
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/history",
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TenChats: Story = {
args: {
length: 10,
},
};
export const ZeroChats: Story = {
args: {
length: 0,
},
};
export const OneChat: Story = {
args: {
length: 1,
},
};
export const PrefillSearch: Story = {
args: {
length: 10,
prefillSearch: "xxxxxxxxxxxx",
},
};
function createRandomSessions(length: number) {
const sessions = [];
for (let i = 0; i < length; i++) {
sessions.push({
created_at: new Date().toISOString(),
id: Math.random().toString(),
title: randomLorem(Math.floor(5 + Math.random() * 15)),
updated_at: subDays(
new Date(),
Math.floor(Math.random() * 10),
).toISOString(),
});
}
return sessions;
}
function Variant(props: { length: number; prefillSearch?: string }) {
return (
<ChatPageLayout
accountAddress="0x1234567890"
authToken="xxxxxxxx"
sessions={createRandomSessions(props.length)}
project={projectStub("xxxxx", "team-1")}
client={storybookThirdwebClient}
team_slug="team-1"
>
<ChatHistoryPageUI
deleteSession={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
prefillSearch={props.prefillSearch}
sessions={createRandomSessions(props.length)}
team_slug="team-1"
project={projectStub("xxxxx", "team-1")}
/>
</ChatPageLayout>
);
}
@@ -0,0 +1,243 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { ScrollShadow } from "@workspace/ui/components/scroll-shadow";
import { Spinner } from "@workspace/ui/components/spinner";
import { formatDistance } from "date-fns";
import Fuse from "fuse.js";
import {
EllipsisIcon,
MessageCircleIcon,
SearchIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { deleteSession } from "../../api/session";
import type { TruncatedSessionInfo } from "../../api/types";
import { useSessionsWithLocalOverrides } from "../../hooks/useSessionsWithLocalOverrides";
import { deletedSessionsStore } from "../../stores";
export function ChatHistoryPage(props: {
sessions: TruncatedSessionInfo[];
prefillSearch?: string;
team_slug: string;
project: Project;
}) {
return (
<ChatHistoryPageUI
{...props}
deleteSession={async (s) => {
await deleteSession({
project: props.project,
sessionId: s,
});
}}
/>
);
}
export function ChatHistoryPageUI(props: {
sessions: TruncatedSessionInfo[];
prefillSearch?: string;
deleteSession: (sessionId: string) => Promise<void>;
team_slug: string;
project: Project;
}) {
const [searchVal, setSearchVal] = useState(props.prefillSearch ?? "");
const allSessions = useSessionsWithLocalOverrides(props.sessions);
const fuse = useMemo(() => {
return new Fuse(allSessions, {
keys: [
{
name: "title",
weight: 1,
},
],
threshold: 0.5,
});
}, [allSessions]);
const filteredSessions = useMemo(() => {
if (!searchVal) {
return allSessions;
}
return fuse.search(searchVal).map((e) => e.item);
}, [allSessions, searchVal, fuse]);
return (
<div className="flex grow flex-col overflow-hidden">
<header className="border-b py-4">
<h1 className="px-6 font-semibold text-lg tracking-tight">All Chats</h1>
</header>
<SearchInput
onValueChange={setSearchVal}
placeholder="Search for a chat"
value={searchVal}
/>
{filteredSessions.length > 0 && (
<ScrollShadow
className="flex-1"
scrollableClassName="max-h-full py-6"
shadowClassName="z-10"
shadowColor="hsl(var(--background))"
>
{filteredSessions.length > 0 && (
<div className="container flex max-w-[800px] flex-col gap-5">
{filteredSessions.map((session) => (
<SessionCard
project={props.project}
team_slug={props.team_slug}
deleteSession={props.deleteSession}
key={session.id + session.updated_at + session.created_at}
session={session}
/>
))}
</div>
)}
</ScrollShadow>
)}
{/* No Search Results */}
{allSessions.length > 0 && filteredSessions.length === 0 && (
<div className="flex grow flex-col justify-center rounded-lg">
<div className="flex flex-col items-center">
<div className="rounded-xl border bg-card p-2">
<MessageCircleIcon className="size-5 text-muted-foreground" />
</div>
<div className="h-4" />
<h3 className="text-center"> No Chats Found </h3>
<div className="h-1" />
<p className="text-center text-muted-foreground text-sm">
Try a different search term
</p>
</div>
</div>
)}
{/* No Chats at all */}
{allSessions.length === 0 && (
<div className="flex grow flex-col justify-center">
<div className="flex flex-col items-center">
<div className="rounded-xl border bg-card p-2">
<MessageCircleIcon className="size-5 text-muted-foreground" />
</div>
<div className="h-4" />
<h3 className="text-center"> No Chats Created </h3>
<div className="h-1" />
<p className="text-center text-muted-foreground text-sm">
Start a new chat to get started.
</p>
<div className="h-5" />
<Button asChild size="sm">
<Link href={`/team/${props.team_slug}/${props.project.slug}/ai`}>
New Chat
</Link>
</Button>
</div>
</div>
)}
</div>
);
}
function SearchInput(props: {
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative">
<Input
className="!border-b h-auto rounded-none border-0 px-6 py-4 pl-11 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => props.onValueChange(e.target.value)}
placeholder={props.placeholder}
value={props.value}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-5 size-4 text-muted-foreground" />
</div>
);
}
// TODO - add delete chat confirmation dialog
function SessionCard(props: {
session: TruncatedSessionInfo;
project: Project;
team_slug: string;
deleteSession: (sessionId: string) => Promise<void>;
}) {
const deleteChat = useMutation({
mutationFn: () => {
return props.deleteSession(props.session.id);
},
onSuccess: () => {
const prev = deletedSessionsStore.getValue();
deletedSessionsStore.setValue([...prev, props.session.id]);
},
});
return (
<div className="relative overflow-hidden rounded-lg border bg-card p-4">
<h3 className="line-clamp-3 break-all">
<Link
className="before:absolute before:inset-0"
href={`/team/${props.team_slug}/${props.project.slug}/ai/chat/${props.session.id}`}
prefetch={false}
>
{props.session.title || "Untitled"}
</Link>
</h3>
<div className="mt-4 flex items-center justify-between gap-6 border-t pt-3">
<p className="text-muted-foreground text-sm">
Updated{" "}
{formatDistance(new Date(props.session.updated_at), new Date(), {
addSuffix: true,
})}
</p>
<Popover>
<PopoverTrigger asChild>
<Button
className="relative z-10 h-auto w-auto p-1.5"
variant="ghost"
>
<EllipsisIcon className="size-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1" side="top">
<Button
className="!text-destructive-text flex w-full justify-start gap-2.5 px-2"
onClick={() => {
const promise = deleteChat.mutateAsync();
toast.promise(promise, {
error: "Failed to delete chat",
success: "Chat deleted successfully",
});
}}
variant="ghost"
>
{deleteChat.isPending ? (
<Spinner className="size-4" />
) : (
<TrashIcon className="size-4" />
)}
Delete
</Button>
</PopoverContent>
</Popover>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { getProject } from "@/api/project/projects";
import { getSessions } from "../../api/session";
import { ChatHistoryPage } from "./ChatHistoryPage";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [params] = await Promise.all([props.params]);
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
notFound();
}
const sessions = await getSessions({ project }).catch(() => []);
return (
<ChatHistoryPage
sessions={sessions}
project={project}
team_slug={params.team_slug}
/>
);
}
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { storybookThirdwebClient } from "@/storybook/utils";
import { type AssetBalance, AssetsSectionUI } from "./AssetsSection";
const meta = {
component: AssetsSectionUI,
decorators: [
(Story) => (
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
<Story />
</div>
),
],
title: "AI/AssetsSection",
} satisfies Meta<typeof AssetsSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const tokensStub: AssetBalance[] = [
{
balance: "10000000000000000000000",
chain_id: 8453,
decimals: 18,
name: "Broge",
symbol: "BROGE",
token_address: "0xe8e55a847bb446d967ef92f4580162fb8f2d3f38",
},
{
balance: "2",
chain_id: 8453,
decimals: 18,
name: "clBTC",
symbol: "clBTC",
token_address: "0x8d2757ea27aabf172da4cca4e5474c76016e3dc5",
},
{
balance: "1000000000000000000",
chain_id: 8453,
decimals: 18,
name: "BASED USA",
symbol: "USA",
token_address: "0xb56d0839998fd79efcd15c27cf966250aa58d6d3",
},
{
balance: "48888800000000000000",
chain_id: 8453,
decimals: 18,
name: "BearPaw",
symbol: "PAW",
token_address: "0x600c9b69a65fb6d2551623a53ddef17b050233cd",
},
{
balance: "69000000000000000000",
chain_id: 8453,
decimals: 18,
name: "Degen Point Of View",
symbol: "POV",
token_address: "0x4c96a67b0577358894407af7bc3158fc1dffbeb5",
},
{
balance: "6237535850425",
chain_id: 8453,
decimals: 18,
name: "Wrapped Ether",
symbol: "WETH",
token_address: "0x4200000000000000000000000000000000000006",
},
];
export const MultipleAssets: Story = {
args: {
client: storybookThirdwebClient,
data: tokensStub,
isPending: false,
},
};
export const SingleAsset: Story = {
args: {
client: storybookThirdwebClient,
data: tokensStub.slice(0, 1),
isPending: false,
},
};
export const EmptyAssets: Story = {
args: {
client: storybookThirdwebClient,
data: [],
isPending: false,
},
};
export const Loading: Story = {
args: {
client: storybookThirdwebClient,
data: [],
isPending: true,
},
};
@@ -0,0 +1,254 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react";
import Link from "next/link";
import {
defineChain,
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
toTokens,
} from "thirdweb";
import {
Blobbie,
TokenIcon,
TokenProvider,
useActiveAccount,
useActiveWalletChain,
} from "thirdweb/react";
import { getWalletBalance } from "thirdweb/wallets";
import { Skeleton } from "@/components/ui/skeleton";
import { isProd } from "@/constants/env-utils";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { ChainIconClient } from "@/icons/ChainIcon";
export type AssetBalance = {
chain_id: number;
token_address: string;
balance: string;
name: string;
symbol: string;
decimals: number;
};
export function AssetsSectionUI(props: {
data: AssetBalance[];
isPending: boolean;
client: ThirdwebClient;
}) {
if (props.data.length === 0 && !props.isPending) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1">
<div className="rounded-full border p-1">
<XIcon className="size-4" />
</div>
<div className="text-muted-foreground text-sm">No Assets </div>
</div>
);
}
return (
<div className="flex flex-col gap-1">
{!props.isPending &&
props.data.map((asset) => (
<AssetItem
asset={asset}
client={props.client}
key={`${asset.chain_id}-${asset.token_address}`}
/>
))}
{props.isPending &&
new Array(10).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: for the placeholder this is explicitly the key
<SkeletonAssetItem key={index} />
))}
</div>
);
}
function SkeletonAssetItem() {
return (
<div className="flex h-[48px] items-center gap-2 px-2 py-1">
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-32 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</div>
);
}
function AssetItem(props: { asset: AssetBalance; client: ThirdwebClient }) {
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(props.asset.chain_id);
const isNativeToken = props.asset.token_address === NATIVE_TOKEN_ADDRESS;
const chain = useV5DashboardChain(props.asset.chain_id);
return (
<TokenProvider
address={props.asset.token_address}
chain={chain}
// eslint-disable-next-line no-restricted-syntax
client={props.client}
>
<div className="relative flex h-[48px] items-center gap-2.5 rounded-lg px-2 py-1 hover:bg-accent">
<div className="relative">
<TokenIcon
className="size-8 rounded-full border"
fallbackComponent={
<Blobbie
address={props.asset.token_address}
className="size-8 rounded-full"
/>
}
loadingComponent={
<Blobbie
address={props.asset.token_address}
className="size-8 rounded-full"
/>
}
/>
{!isNativeToken && (
<div className="-right-0.5 -bottom-0.5 absolute rounded-full border bg-background p-0.5">
<ChainIconClient
className="size-3.5"
client={props.client}
src={chainMeta?.icon?.url || ""}
/>
</div>
)}
</div>
<div className="flex min-w-0 flex-col text-sm">
<Link
className="truncate font-medium before:absolute before:inset-0"
href={
isNativeToken
? `https://thirdweb.com/${props.asset.chain_id}`
: `https://thirdweb.com/${props.asset.chain_id}/${props.asset.token_address}`
}
target="_blank"
>
{props.asset.name}
</Link>
<p className="truncate text-muted-foreground text-sm">
{`${toTokens(BigInt(props.asset.balance), props.asset.decimals)} ${props.asset.symbol}`}
</p>
</div>
</div>
</TokenProvider>
);
}
export function AssetsSection(props: { client: ThirdwebClient }) {
const account = useActiveAccount();
const activeChain = useActiveWalletChain();
const assetsQuery = useQuery({
enabled: !!account && !!activeChain,
queryFn: async () => {
if (!account || !activeChain) {
return [];
}
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`,
);
url.searchParams.set("limit", "50");
url.searchParams.set("metadata", "true");
url.searchParams.set("include_spam", "false");
url.searchParams.set("clientId", props.client.clientId);
for (const chain of chains) {
url.searchParams.append("chain", chain.toString());
}
const response = await fetch(url.toString());
const json = (await response.json()) as {
data: AssetBalance[];
};
const tokensToShowOnTop = new Set(
[
// base
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // usdc,
"0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", // wbtc
"0x4200000000000000000000000000000000000006", // wrapped eth
// ethereum
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // usdc
"0xdac17f958d2ee523a2206206994597c13d831ec7", // usdt
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52", // bnb
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // weth
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", // wbtc
// optimism
"0x4200000000000000000000000000000000000042", // op token
"0xdc6ff44d5d932cbd77b52e5612ba0529dc6226f1", // world coin
"0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", // usdt
"0x0b2c639c533813f4aa9d7837caf62653d097ff85", // usdc
"0x4200000000000000000000000000000000000006", // wrapped eth
// polygon
"0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // weth
"0xc2132d05d31c914a87c6611c10748aeb04b58e8f", // usdt
"0x3BA4c387f786bFEE076A58914F5Bd38d668B42c3", // bnb
"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // usdc
"0x2791bca1f2de4661ed88a30c99a7a9449aa84174", // usdc.e
].map((x) => getAddress(x)),
);
return json.data.sort((a, b) => {
if (tokensToShowOnTop.has(getAddress(a.token_address))) {
return -1;
}
if (tokensToShowOnTop.has(getAddress(b.token_address))) {
return 1;
}
return 0;
});
},
queryKey: ["v1/tokens/erc20", account?.address, activeChain?.id],
});
const nativeBalances = useQuery({
queryFn: async () => {
if (!account || !activeChain) {
return [];
}
const chains = [...new Set([1, 8453, 10, 137, activeChain.id])];
const result = await Promise.allSettled(
chains.map((chain) =>
getWalletBalance({
address: account.address,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(chain),
client: props.client,
}),
),
);
return result
.filter((r) => r.status === "fulfilled")
.map((r) => ({
balance: r.value.value.toString(),
chain_id: r.value.chainId,
decimals: r.value.decimals,
name: r.value.name,
symbol: r.value.symbol,
token_address: r.value.tokenAddress,
}))
.filter((x) => x.balance !== "0") as AssetBalance[];
},
queryKey: ["getWalletBalance", account?.address, activeChain?.id],
});
const isPending = assetsQuery.isPending || nativeBalances.isPending;
const data = [...(nativeBalances.data ?? []), ...(assetsQuery.data ?? [])];
return (
<AssetsSectionUI client={props.client} data={data} isPending={isPending} />
);
}
@@ -0,0 +1,720 @@
/** biome-ignore-all lint/a11y/useSemanticElements: TODO */
"use client";
import { useMutation } from "@tanstack/react-query";
import { Spinner } from "@workspace/ui/components/spinner";
import {
ArrowUpIcon,
CheckIcon,
ChevronDownIcon,
CircleStopIcon,
CopyIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import {
AccountAvatar,
AccountBlobbie,
AccountName,
AccountProvider,
useActiveWallet,
WalletIcon,
WalletName,
WalletProvider,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import type { Wallet } from "thirdweb/wallets";
import { Img } from "@/components/blocks/Img";
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { ImageUploadButton } from "@/components/ui/image-upload-button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
import { cn } from "@/lib/utils";
import type { NebulaContext, NebulaUserMessage } from "../api/types";
export type WalletMeta = {
walletId: Wallet["id"];
address: string;
};
const maxAllowedImagesPerMessage = 4;
function showSigninToUploadImagesToast() {
toast.error("Sign in to upload images to Nebula", {
position: "top-right",
});
}
export function ChatBar(props: {
sendMessage: (message: NebulaUserMessage) => void;
isChatStreaming: boolean;
abortChatStream: () => void;
prefillMessage: string | undefined;
className?: string;
context: NebulaContext | undefined;
setContext: (context: NebulaContext | undefined) => void;
showContextSelector: boolean;
client: ThirdwebClient;
connectedWallets: WalletMeta[];
setActiveWallet: (wallet: WalletMeta) => void;
isConnectingWallet: boolean;
allowImageUpload: boolean;
onLoginClick: undefined | (() => void);
placeholder: string;
}) {
const [message, setMessage] = useState(props.prefillMessage || "");
const selectedChainIds = props.context?.chainIds?.map((x) => Number(x)) || [];
const firstChainId = selectedChainIds[0];
const [images, setImages] = useState<
Array<{ file: File; b64: string | undefined }>
>([]);
const [isDragOver, setIsDragOver] = useState(false);
function handleSubmit(message: string) {
const userMessage: NebulaUserMessage = {
content: [{ text: message, type: "text" }],
role: "user",
};
if (images.length > 0) {
for (const image of images) {
if (image.b64) {
userMessage.content.push({
b64: image.b64,
image_url: null,
type: "image",
});
}
}
}
props.sendMessage(userMessage);
setMessage("");
setImages([]);
}
const uploadImageMutation = useMutation({
mutationFn: async (image: File) => {
return toBase64(image);
},
});
const supportedFileTypes = ["image/jpeg", "image/png", "image/webp"];
async function handleImageUpload(files: File[]) {
const totalFiles = files.length + images.length;
if (totalFiles > maxAllowedImagesPerMessage) {
toast.error(
`You can only upload up to ${maxAllowedImagesPerMessage} images at a time`,
{
position: "top-right",
},
);
return;
}
const validFiles: File[] = [];
for (const file of files) {
if (!supportedFileTypes.includes(file.type)) {
toast.error("Unsupported file type", {
description: `File: ${file.name}`,
position: "top-right",
});
continue;
}
if (file.size <= 5 * 1024 * 1024) {
validFiles.push(file);
} else {
toast.error("Image is larger than 5MB", {
description: `File: ${file.name}`,
position: "top-right",
});
}
}
try {
const urls = await Promise.all(
validFiles.map(async (image) => {
const b64 = await uploadImageMutation.mutateAsync(image);
return { b64: b64, file: image };
}),
);
setImages((prev) => [...prev, ...urls]);
} catch (e) {
console.error(e);
toast.error("Failed to upload image", {
position: "top-right",
});
}
}
return (
<DynamicHeight transition="height 200ms ease">
{/** biome-ignore lint/a11y/noStaticElementInteractions: TODO */}
<div
className={cn(
"overflow-hidden rounded-2xl border border-border bg-card transition-colors",
isDragOver && "border-primary-foreground bg-primary/5",
props.className,
)}
onDragEnter={(e) => {
e.preventDefault();
setIsDragOver(true);
if (!props.allowImageUpload) {
return;
}
}}
onDragLeave={(e) => {
e.preventDefault();
if (!props.allowImageUpload) {
return;
}
// Only set drag over to false if we're leaving the container entirely
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
if (!props.allowImageUpload) {
return;
}
}}
onDrop={(e) => {
setIsDragOver(false);
e.preventDefault();
if (!props.allowImageUpload) {
showSigninToUploadImagesToast();
return;
}
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) handleImageUpload(files);
}}
>
{images.length > 0 && (
<ImagePreview
images={images}
isUploading={uploadImageMutation.isPending}
onRemove={(index) => {
setImages((prev) => prev.filter((_, i) => i !== index));
}}
/>
)}
<div className="p-2">
<div className="max-h-[200px] overflow-y-auto">
<AutoResizeTextarea
className="min-h-[60px] resize-none border-none bg-transparent pt-2 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={props.isChatStreaming}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
// ignore if shift key is pressed to allow entering new lines
if (e.shiftKey) {
return;
}
if (e.key === "Enter" && !props.isChatStreaming) {
e.preventDefault();
handleSubmit(message);
}
}}
onPaste={(e) => {
const files = Array.from(e.clipboardData.files);
if (files.length > 0) {
e.preventDefault();
if (!props.allowImageUpload) {
showSigninToUploadImagesToast();
return;
}
handleImageUpload(files);
}
}}
placeholder={props.placeholder}
value={message}
/>
</div>
<div className="flex items-end justify-between gap-3 px-2 pb-2">
{/* left */}
<div className="grow">
{props.showContextSelector && (
<div className="flex flex-wrap gap-2 [&>*]:w-auto">
{props.connectedWallets.length > 1 &&
!props.isConnectingWallet && (
<WalletSelector
client={props.client}
onClick={(walletMeta) => {
props.setActiveWallet(walletMeta);
props.setContext({
chainIds: props.context?.chainIds || [],
networks: props.context?.networks || null,
walletAddress: walletMeta.address,
});
}}
selectedAddress={
props.context?.walletAddress || undefined
}
wallets={props.connectedWallets}
/>
)}
{props.isConnectingWallet && (
<Badge
className="h-auto w-auto shrink-0 gap-1.5 px-2 py-1.5"
variant="outline"
>
<Spinner className="size-3" />
<span>Connecting Wallet</span>
</Badge>
)}
<MultiNetworkSelector
align="start"
client={props.client}
customTrigger={
<Button
className="h-auto w-full p-0 hover:bg-transparent "
variant="ghost"
>
{selectedChainIds.length > 0 && firstChainId && (
<ChainBadge
chainId={firstChainId}
client={props.client}
plusMore={selectedChainIds.length - 1}
/>
)}
{selectedChainIds.length === 0 && (
<Badge
className="flex h-auto gap-1 px-2 py-1.5 hover:bg-accent"
variant="outline"
>
Select Chains
<ChevronDownIcon className="size-3 text-muted-foreground" />
</Badge>
)}
</Button>
}
disableChainId
hideTestnets
onChange={(values) => {
props.setContext({
chainIds: values.map((x) => x.toString()),
networks: props.context?.networks || null,
walletAddress: props.context?.walletAddress || null,
});
}}
popoverContentClassName="!w-[calc(100vw-80px)] lg:!w-[320px]"
priorityChains={[
1, // ethereum
56, // bnb smart chain mainnet (bsc)
42161, // arbitrum one mainnet
8453, // base mainnet
43114, // avalanche mainnet
146, // sonic
137, // polygon
80094, // berachain mainnet
10, // optimism
]}
selectedChainIds={selectedChainIds}
showSelectedValuesInModal={true}
side="top"
/>
</div>
)}
</div>
{/* right */}
<div className="flex items-center gap-2">
{props.allowImageUpload ? (
<ImageUploadButton
accept={supportedFileTypes.join(",")}
className="!h-auto w-auto shrink-0 gap-2 p-2"
multiple
onChange={(files) => {
handleImageUpload(files);
}}
value={undefined}
variant="ghost"
>
<ToolTipLabel label="Attach Image">
<PaperclipIcon className="size-4" />
</ToolTipLabel>
</ImageUploadButton>
) : props.onLoginClick ? (
<Popover>
<PopoverTrigger asChild>
<Button
className="!h-auto w-auto shrink-0 gap-2 p-2"
variant="ghost"
>
<ToolTipLabel label="Attach Image">
<PaperclipIcon className="size-4" />
</ToolTipLabel>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<div>
<p className="mb-3 text-muted-foreground text-sm">
Get access to image uploads by signing in to Nebula
</p>
<Button
className="w-full"
onClick={props.onLoginClick}
variant="default"
>
Sign in
</Button>
</div>
</PopoverContent>
</Popover>
) : null}
{/* Send / Stop */}
{props.isChatStreaming ? (
<Button
className="!h-auto w-auto shrink-0 gap-2 p-2"
onClick={() => {
props.abortChatStream();
}}
variant="default"
>
<CircleStopIcon className="size-4" />
Stop
</Button>
) : (
<Button
aria-label="Send"
className="!h-auto w-auto border border-secondary p-2 disabled:opacity-100"
disabled={
(message.trim() === "" && images.length === 0) ||
props.isConnectingWallet
}
onClick={() => {
if (message.trim() === "" && images.length === 0) return;
handleSubmit(message);
}}
variant="primary"
>
<ArrowUpIcon className="size-4" />
</Button>
)}
</div>
</div>
</div>
</div>
</DynamicHeight>
);
}
async function toBase64(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file); // includes "data:<mime-type>;base64," prefix
reader.onload = () =>
resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = (error) => reject(error);
});
}
function ImagePreview(props: {
images: Array<{ file: File; b64: string | undefined }>;
isUploading: boolean;
onRemove: (index: number) => void;
}) {
return (
<div className="fade-in-0 flex animate-in flex-wrap items-center gap-2 border-b border-dashed bg-background p-3">
{props.images.map((image, index) => {
return (
<div
className="relative flex items-center gap-2 rounded-lg border bg-card p-1"
key={image.file.name}
>
<Img
className="size-8 rounded"
skeleton={
<div className="flex items-center justify-center rounded border">
<Spinner className="size-4 text-muted-foreground" />
</div>
}
src={image.b64}
/>
<div className="w-[100px] lg:w-[120px]">
<p className="truncate text-muted-foreground text-xs">
{props.isUploading ? "Uploading..." : image.file.name}
</p>
<p className="text-muted-foreground text-xs">
{Math.round(image.file.size / 1024)} kB
</p>
</div>
<ToolTipLabel label="Remove Image">
<Button
className="-top-1.5 -right-1.5 absolute h-auto w-auto rounded-full bg-card p-0.5"
onClick={() => props.onRemove(index)}
variant="outline"
>
<XIcon className="size-2.5" />
</Button>
</ToolTipLabel>
</div>
);
})}
</div>
);
}
function ChainBadge(props: {
chainId: number;
plusMore: number;
client: ThirdwebClient;
}) {
const { idToChain } = useAllChainsData();
const chain = idToChain.get(props.chainId);
return (
<Badge
className="flex h-auto gap-1.5 px-1.5 py-1.5 hover:bg-accent"
variant="outline"
>
<ChainIconClient
className="size-3.5"
client={props.client}
src={chain?.icon?.url}
/>
{chain?.name ? shortenChainName(chain.name) : `Chain #${props.chainId}`}
{props.plusMore > 0 && (
<span className="text-foreground"> +{props.plusMore}</span>
)}
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
</Badge>
);
}
function shortenChainName(chainName: string) {
return chainName.replace("Mainnet", "").trim();
}
function WalletSelector(props: {
wallets: WalletMeta[];
onClick: (wallet: WalletMeta) => void;
client: ThirdwebClient;
selectedAddress: string | undefined;
}) {
const [open, setOpen] = useState(false);
const activeWallet = useActiveWallet();
const accountBlobbie = <AccountBlobbie className="size-3 rounded-full" />;
const accountAvatarFallback = (
<WalletIcon
className="size-3 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
if (!props.selectedAddress || !activeWallet) {
return null;
}
// show smart account first
const sortedWallets = props.wallets.sort((a, b) => {
if (a.walletId === "smart") return -1;
if (b.walletId === "smart") return 1;
return 0;
});
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className="flex h-auto items-center gap-1 rounded-full px-2 py-1.5 text-xs"
variant="outline"
>
<WalletProvider id={activeWallet.id}>
<AccountProvider
address={props.selectedAddress}
client={props.client}
>
<AccountAvatar
className="size-3 rounded-full"
fallbackComponent={accountAvatarFallback}
loadingComponent={accountAvatarFallback}
/>
<AccountName
className="text-xs"
fallbackComponent={
<span className="text-xs">
{shortenAddress(props.selectedAddress)}
</span>
}
loadingComponent={
<span className="text-xs">
{shortenAddress(props.selectedAddress)}
</span>
}
/>
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
</AccountProvider>
</WalletProvider>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[350px] overflow-hidden border p-0 shadow-xl lg:w-[350px]"
sideOffset={10}
>
<div className="border-b p-3 py-4">
<p className="text-muted-foreground text-sm">
Select account to use for executing transactions
</p>
</div>
<div className="[&>*:not(:last-child)]:border-b">
{sortedWallets.map((wallet) => {
const accountBlobbie = (
<AccountBlobbie className="size-8 rounded-full" />
);
const accountAvatarFallback = (
<WalletIcon
className="size-8 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
return (
<div
className={cn(
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
props.selectedAddress === wallet.address && "bg-accent/50",
)}
key={wallet.address}
onClick={() => {
setOpen(false);
props.onClick(wallet);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setOpen(false);
props.onClick(wallet);
}
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<WalletProvider id={wallet.walletId}>
<AccountProvider
address={wallet.address}
client={props.client}
>
<WalletProvider id={wallet.walletId}>
<div className="flex items-center gap-2.5">
<AccountAvatar
className="size-8 rounded-full"
fallbackComponent={accountAvatarFallback}
loadingComponent={accountAvatarFallback}
/>
<div className="flex flex-col">
<div className="flex items-center gap-1">
<AccountName
className="text-sm"
fallbackComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
loadingComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
/>
<CopyButton address={wallet.address} />
{wallet.walletId === "smart" && (
<Badge
className="bg-card px-2"
variant="outline"
>
Gasless
</Badge>
)}
</div>
<div className="text-muted-foreground text-sm">
{wallet.walletId === "smart" ? (
"Smart Account"
) : (
<WalletName
fallbackComponent={
<span> Your Account </span>
}
loadingComponent={
<Skeleton className="h-3.5 w-40" />
}
/>
)}
</div>
</div>
</div>
</WalletProvider>
</AccountProvider>
</WalletProvider>
</div>
{props.selectedAddress === wallet.address && (
<CheckIcon className="size-4 text-foreground" />
)}
</div>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
function CopyButton(props: { address: string }) {
const [copied, setCopied] = useState(false);
return (
<Button
className="h-auto w-auto p-1.5 text-muted-foreground"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(props.address);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
}}
variant="ghost"
>
{copied ? (
<CheckIcon className="size-3 text-green-500" />
) : (
<CopyIcon className="size-3" />
)}
</Button>
);
}
@@ -0,0 +1,754 @@
"use client";
import { ArrowRightIcon, MessageSquareXIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import {
useActiveAccount,
useActiveWalletConnectionStatus,
useConnectedWallets,
useSetActiveWallet,
} from "thirdweb/react";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { promptNebula } from "../api/chat";
import { createSession, updateSession } from "../api/session";
import type {
NebulaContext,
NebulaSessionHistoryMessage,
NebulaUserMessage,
SessionInfo,
} from "../api/types";
import { examplePrompts } from "../data/examplePrompts";
import { newSessionsStore } from "../stores";
import { ChatBar, type WalletMeta } from "./ChatBar";
import { type ChatMessage, Chats } from "./Chats";
import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent";
export function ChatPageContent(props: {
project: Project;
session: SessionInfo | undefined;
accountAddress: string;
authToken: string;
client: ThirdwebClient;
type: "landing" | "new-chat";
initialParams:
| {
q: string | undefined;
chainIds: number[];
}
| undefined;
}) {
const address = useActiveAccount()?.address;
const connectionStatus = useActiveWalletConnectionStatus();
const connectedWallets = useConnectedWallets();
const setActiveWallet = useSetActiveWallet();
const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false);
const [messages, setMessages] = useState<Array<ChatMessage>>(() => {
if (props.session?.history) {
return parseHistoryToMessages(props.session.history);
}
return [];
});
const [_contextFilters, _setContextFilters] = useState<
NebulaContext | undefined
>(() => {
const contextRes = props.session?.context;
const value: NebulaContext = {
chainIds:
contextRes?.chain_ids ||
props.initialParams?.chainIds.map((x) => x.toString()) ||
[],
networks: "mainnet",
walletAddress: contextRes?.wallet_address || props.accountAddress || null,
};
return value;
});
const contextFilters = useMemo(() => {
return {
chainIds: _contextFilters?.chainIds || [],
networks: _contextFilters?.networks || null,
walletAddress: address || _contextFilters?.walletAddress || null,
} satisfies NebulaContext;
}, [_contextFilters, address]);
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
_setContextFilters(v);
saveLastUsedChainIds(v?.chainIds || undefined);
}, []);
const shouldRunEffect = useRef(true);
// if this is a new session,
// update chains to the last used chains in context filter
// we have to do this in effect to avoid hydration errors
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
// if viewing a session or context is set via params - do not update context
if (props.session || props.initialParams?.q || !shouldRunEffect.current) {
return;
}
shouldRunEffect.current = false;
_setContextFilters((_contextFilters) => {
try {
const lastUsedChainIds = getLastUsedChainIds();
if (lastUsedChainIds) {
return {
chainIds: lastUsedChainIds,
networks: _contextFilters?.networks || null,
walletAddress: _contextFilters?.walletAddress || null,
};
}
} catch {
// ignore local storage errors
}
return _contextFilters;
});
}, [props.session, props.initialParams?.q]);
const [sessionId, setSessionId] = useState<string | undefined>(
props.session?.id,
);
const [chatAbortController, setChatAbortController] = useState<
AbortController | undefined
>();
const [isChatStreaming, setIsChatStreaming] = useState(false);
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
const [showConnectModal, setShowConnectModal] = useState(false);
const initSession = useCallback(async () => {
const session = await createSession({
project: props.project,
context: contextFilters,
});
setSessionId(session.id);
return session;
}, [contextFilters, props.project]);
const handleSendMessage = useCallback(
async (message: NebulaUserMessage) => {
setUserHasSubmittedMessage(true);
setMessages((prev) => [
...prev,
{
content: message.content,
type: "user",
},
{
texts: [],
type: "presence",
},
]);
// handle hardcoded replies first
const lowerCaseMessage = message.content
.find((x) => x.type === "text")
?.text.toLowerCase();
const interceptedReply = examplePrompts.find(
(prompt) => prompt.message.toLowerCase() === lowerCaseMessage,
)?.interceptedReply;
if (interceptedReply) {
// slight delay to match other response times
await new Promise((resolve) => setTimeout(resolve, 1000));
setMessages((prev) => [
...prev.slice(0, -1),
{ request_id: undefined, text: interceptedReply, type: "assistant" },
]);
return;
}
setIsChatStreaming(true);
setEnableAutoScroll(true);
const abortController = new AbortController();
try {
// Ensure we have a session ID
let currentSessionId = sessionId;
if (!currentSessionId) {
const session = await initSession();
currentSessionId = session.id;
}
const firstTextMessage =
message.role === "user"
? message.content.find((x) => x.type === "text")?.text || ""
: "";
// add this session on sidebar
if (messages.length === 0 && firstTextMessage) {
const prevValue = newSessionsStore.getValue();
newSessionsStore.setValue([
{
created_at: new Date().toISOString(),
id: currentSessionId,
title: firstTextMessage,
updated_at: new Date().toISOString(),
},
...prevValue,
]);
}
setChatAbortController(abortController);
await handleNebulaPrompt({
abortController,
project: props.project,
authToken: props.authToken,
contextFilters: contextFilters,
message: message,
sessionId: currentSessionId,
setContextFilters,
setMessages,
});
} catch (error) {
if (abortController.signal.aborted) {
return;
}
handleNebulaPromptError({
error,
setMessages,
});
} finally {
setIsChatStreaming(false);
setEnableAutoScroll(false);
}
},
[
sessionId,
props.project,
contextFilters,
props.authToken,
messages.length,
initSession,
setContextFilters,
],
);
const hasDoneAutoPrompt = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (
props.initialParams?.q &&
messages.length === 0 &&
!hasDoneAutoPrompt.current
) {
hasDoneAutoPrompt.current = true;
handleSendMessage({
content: [
{
text: props.initialParams.q,
type: "text",
},
],
role: "user",
});
}
}, [props.initialParams?.q, messages.length, handleSendMessage]);
const showEmptyState =
!userHasSubmittedMessage &&
messages.length === 0 &&
!props.session &&
!props.initialParams?.q;
const sessionWithNoMessages = props.session && messages.length === 0;
const connectedWalletsMeta: WalletMeta[] = connectedWallets.map((x) => ({
address: x.getAccount()?.address || "",
walletId: x.id,
}));
const handleUpdateContextFilters = async (
values: NebulaContext | undefined,
) => {
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context
if (sessionId) {
await updateSession({
project: props.project,
contextFilters: values,
sessionId,
});
}
};
const handleSetActiveWallet = (walletMeta: WalletMeta) => {
const wallet = connectedWallets.find(
(x) => x.getAccount()?.address === walletMeta.address,
);
if (wallet) {
setActiveWallet(wallet);
}
};
return (
<div className="flex grow flex-col overflow-hidden">
<WalletDisconnectedDialog
onOpenChange={setShowConnectModal}
open={showConnectModal}
/>
<div className="flex grow overflow-hidden">
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-4">
{showEmptyState ? (
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
<EmptyStateChatPageContent
allowImageUpload={true}
connectedWallets={connectedWalletsMeta}
context={contextFilters}
isConnectingWallet={connectionStatus === "connecting"}
onLoginClick={undefined}
prefillMessage={props.initialParams?.q}
sendMessage={handleSendMessage}
setActiveWallet={handleSetActiveWallet}
setContext={setContextFilters}
showAurora={true}
client={props.client}
/>
</div>
) : (
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
{sessionWithNoMessages && (
<div className="container flex max-h-full max-w-[800px] flex-1 flex-col justify-center py-8">
<div className="flex flex-col items-center justify-center p-4">
<div className="mb-5 rounded-full border bg-card p-3">
<MessageSquareXIcon className="size-6 text-muted-foreground" />
</div>
<p className="mb-1 text-center text-foreground">
No messages found
</p>
<p className="text-balance text-center text-muted-foreground text-sm">
This session was aborted before receiving any messages
</p>
</div>
</div>
)}
{messages.length > 0 && (
<Chats
project={props.project}
authToken={props.authToken}
className="min-w-0 pt-6 pb-32"
client={props.client}
enableAutoScroll={enableAutoScroll}
isChatStreaming={isChatStreaming}
messages={messages}
sendMessage={handleSendMessage}
sessionId={sessionId}
setEnableAutoScroll={setEnableAutoScroll}
/>
)}
<div className="container max-w-[800px]">
<ChatBar
abortChatStream={() => {
chatAbortController?.abort();
setChatAbortController(undefined);
setIsChatStreaming(false);
}}
allowImageUpload={true}
client={props.client}
connectedWallets={connectedWalletsMeta}
context={contextFilters}
isChatStreaming={isChatStreaming}
isConnectingWallet={connectionStatus === "connecting"}
onLoginClick={undefined}
placeholder="Ask thirdweb AI"
prefillMessage={undefined}
sendMessage={handleSendMessage}
setActiveWallet={handleSetActiveWallet}
setContext={(v) => {
setContextFilters(v);
handleUpdateContextFilters(v);
}}
showContextSelector={true}
/>
</div>
</div>
)}
<p className="mt-4 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
thirdweb AI may make mistakes. Please use with discretion
</p>
</div>
</div>
</div>
);
}
function WalletDisconnectedDialog(props: {
open: boolean;
onOpenChange: (value: boolean) => void;
}) {
return (
<Dialog onOpenChange={props.onOpenChange} open={props.open}>
<DialogContent className="p-0">
<div className="p-6">
<DialogHeader>
<DialogTitle> Wallet Disconnected </DialogTitle>
<DialogDescription>
Connect your wallet to continue
</DialogDescription>
</DialogHeader>
</div>
<div className="flex justify-end gap-3 border-t bg-card p-6">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<Link className="gap-2" href="/login">
Connect Wallet
<ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
const AI_LAST_USED_CHAIN_IDS_KEY = "ai-last-used-chain-ids";
function saveLastUsedChainIds(chainIds: string[] | undefined) {
try {
if (chainIds && chainIds.length > 0) {
localStorage.setItem(
AI_LAST_USED_CHAIN_IDS_KEY,
JSON.stringify(chainIds),
);
} else {
localStorage.removeItem(AI_LAST_USED_CHAIN_IDS_KEY);
}
} catch {
// ignore local storage errors
}
}
function getLastUsedChainIds(): string[] | null {
try {
const lastUsedChainIdsStr = localStorage.getItem(
AI_LAST_USED_CHAIN_IDS_KEY,
);
return lastUsedChainIdsStr ? JSON.parse(lastUsedChainIdsStr) : null;
} catch {
return null;
}
}
async function handleNebulaPrompt(params: {
project: Project;
abortController: AbortController;
message: NebulaUserMessage;
sessionId: string;
authToken: string;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
contextFilters: NebulaContext | undefined;
setContextFilters: (v: NebulaContext | undefined) => void;
}) {
const {
project,
abortController,
message,
sessionId,
authToken,
setMessages,
contextFilters,
setContextFilters,
} = params;
let requestIdForMessage = "";
let hasReceivedResponse = false;
await promptNebula({
project,
abortController,
authToken,
context: contextFilters,
handleStream(res) {
if (abortController.signal.aborted) {
return;
}
switch (res.event) {
case "init": {
requestIdForMessage = res.data.request_id;
return;
}
case "image": {
hasReceivedResponse = true;
setMessages((prevMessages) => {
return [
...prevMessages,
{
data: res.data,
request_id: res.request_id,
type: "image",
},
];
});
return;
}
case "delta": {
// ignore empty string delta
if (!res.data.v) {
return;
}
hasReceivedResponse = true;
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
// append to previous assistant message
if (lastMessage?.type === "assistant") {
return [
...prev.slice(0, -1),
{
request_id: requestIdForMessage,
text: lastMessage.text + res.data.v,
type: "assistant",
},
];
}
// start a new assistant message
return [
...prev,
{
request_id: requestIdForMessage,
text: res.data.v,
type: "assistant",
},
];
});
return;
}
case "presence": {
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
// append to previous presence message
if (lastMessage?.type === "presence") {
return [
...prev.slice(0, -1),
{
texts: [...lastMessage.texts, res.data.data],
type: "presence",
},
];
}
// start a new presence message
return [...prev, { texts: [res.data.data], type: "presence" }];
});
return;
}
case "action": {
hasReceivedResponse = true;
switch (res.type) {
case "sign_transaction": {
setMessages((prevMessages) => {
return [
...prevMessages,
{
data: res.data,
request_id: res.request_id,
subtype: res.type,
type: "action",
},
];
});
return;
}
case "sign_swap": {
setMessages((prevMessages) => {
return [
...prevMessages,
{
data: res.data,
request_id: res.request_id,
subtype: res.type,
type: "action",
},
];
});
return;
}
}
return;
}
case "context": {
setContextFilters({
chainIds: res.data.chain_ids.map((x) => x.toString()),
networks: res.data.networks,
walletAddress: res.data.wallet_address,
});
return;
}
case "error": {
hasReceivedResponse = true;
setMessages((prev) => {
return [
...prev,
{
text: res.data.errorMessage,
type: "error",
},
];
});
return;
}
}
},
message,
sessionId,
});
// if the stream ends without any delta or tx events - we have nothing to show
// show an error message in that case
if (!hasReceivedResponse) {
setMessages((prev) => {
const newMessages = [...prev];
newMessages.push({
text: "No response received, please try again",
type: "error",
});
return newMessages;
});
}
}
function handleNebulaPromptError(params: {
error: unknown;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
}) {
const { error, setMessages } = params;
console.error(error);
setMessages((prev) => {
const newMessages = prev.slice(
0,
prev[prev.length - 1]?.type === "presence" ? -1 : undefined,
);
// add error message
newMessages.push({
text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`,
type: "error",
});
return newMessages;
});
}
function parseHistoryToMessages(history: NebulaSessionHistoryMessage[]) {
const messages: ChatMessage[] = [];
for (const message of history) {
switch (message.role) {
case "action": {
try {
const content = JSON.parse(message.content) as {
session_id: string;
data: string;
type: "sign_transaction" | "sign_swap";
request_id: string;
};
if (content.type === "sign_transaction") {
const txData = JSON.parse(content.data);
messages.push({
data: txData,
request_id: content.request_id,
subtype: "sign_transaction",
type: "action",
});
} else if (content.type === "sign_swap") {
const swapData = JSON.parse(content.data);
messages.push({
data: swapData,
request_id: content.request_id,
subtype: "sign_swap",
type: "action",
});
}
} catch (e) {
console.error("error processing message", e, { message });
}
break;
}
case "image": {
const content = JSON.parse(message.content) as {
type: "image";
request_id: string;
data: {
width: number;
height: number;
url: string;
};
};
messages.push({
data: content.data,
request_id: content.request_id,
type: "image",
});
break;
}
case "user": {
messages.push({
content:
typeof message.content === "string"
? [
{
text: message.content,
type: "text",
},
]
: message.content,
type: message.role,
});
break;
}
case "assistant": {
messages.push({
request_id: undefined,
text: message.content,
type: message.role,
});
}
}
}
return messages;
}
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ThirdwebProvider } from "thirdweb/react";
import { projectStub, randomLorem } from "@/storybook/stubs";
import { storybookThirdwebClient } from "@/storybook/utils";
import type { TruncatedSessionInfo } from "../api/types";
import { ChatPageLayout } from "./ChatPageLayout";
const meta = {
component: Variant,
decorators: [
(Story) => (
<ThirdwebProvider>
<Story />
</ThirdwebProvider>
),
],
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/ChatPageLayout",
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoRecentChats: Story = {
args: {
sessions: [],
},
};
export const ThreeChats: Story = {
args: {
sessions: generateRandomSessions(3),
},
};
export const ThirtyChats: Story = {
args: {
sessions: generateRandomSessions(30),
},
};
function generateRandomSessions(count: number): TruncatedSessionInfo[] {
return Array.from({ length: count }, (_, i) => ({
created_at: new Date().toISOString(),
id: i.toString(),
title: randomLorem(Math.floor(Math.random() * 10) + 1),
updated_at: new Date().toISOString(),
}));
}
function Variant(props: { sessions: TruncatedSessionInfo[] }) {
return (
<ChatPageLayout
accountAddress="0xC569B9FD77d132e10954cA5E6EF617414e314b11"
authToken="xxxxx"
sessions={props.sessions}
project={projectStub("xxxxx", "team-1")}
client={storybookThirdwebClient}
team_slug="team-1"
>
<div className="flex h-full w-full items-center justify-center">
CHILDREN
</div>
</ChatPageLayout>
);
}
@@ -0,0 +1,46 @@
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { cn } from "@/lib/utils";
import type { TruncatedSessionInfo } from "../api/types";
import { ChatSidebar } from "./ChatSidebar";
import { MobileNav } from "./NebulaMobileNav";
export function ChatPageLayout(props: {
team_slug: string;
authToken: string;
project: Project;
client: ThirdwebClient;
accountAddress: string;
sessions: TruncatedSessionInfo[];
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"h-[calc(100vh-125px)] lg:h-[calc(100vh-72px)] flex flex-col overflow-hidden bg-background lg:flex-row",
props.className,
)}
>
<MobileNav
project={props.project}
authToken={props.authToken}
sessions={props.sessions}
client={props.client}
team_slug={props.team_slug}
/>
{props.children}
<aside className="hidden w-[300px] shrink-0 border-border border-l bg-background lg:block">
<ChatSidebar
project={props.project}
team_slug={props.team_slug}
client={props.client}
sessions={props.sessions}
type="desktop"
/>
</aside>
</div>
);
}
@@ -0,0 +1,257 @@
"use client";
import {
ChartLineIcon,
ChevronDownIcon,
ChevronRightIcon,
FileCode2Icon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import {
AccountAvatar,
AccountBlobbie,
AccountProvider,
useActiveWallet,
WalletIcon,
WalletName,
WalletProvider,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import type { Project } from "@/api/project/projects";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { TabButtons } from "@/components/ui/tabs";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { TruncatedSessionInfo } from "../api/types";
import { useSessionsWithLocalOverrides } from "../hooks/useSessionsWithLocalOverrides";
import { AssetsSection } from "./AssetsSection/AssetsSection";
import { ChatSidebarLink } from "./ChatSidebarLink";
import { NebulaConnectWallet } from "./NebulaConnectButton";
import { TransactionsSection } from "./TransactionsSection/TransactionsSection";
export function ChatSidebar(props: {
sessions: TruncatedSessionInfo[];
team_slug: string;
project: Project;
client: ThirdwebClient;
type: "desktop" | "mobile";
}) {
const sessions = useSessionsWithLocalOverrides(props.sessions);
const sessionsToShow = sessions.slice(0, 10);
const newChatPage = `/team/${props.team_slug}/${props.project.slug}/ai`;
const router = useDashboardRouter();
const pathname = usePathname();
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-start gap-3 p-4 lg:justify-between">
<div className="flex items-center gap-2">
<NebulaIcon aria-label="AI" className="size-6 text-foreground" />
<span className="font-medium text-lg">thirdweb AI</span>
</div>
<Badge className="gap-1 py-1" variant="secondary">
Beta
</Badge>
</div>
<div className="h-1" />
<div className="flex flex-col gap-2 px-4">
<Button
className="w-full gap-2 rounded-lg"
onClick={() => {
if (pathname === newChatPage) {
// TODO - make it not reload the whole page, but just the React session state of the chat component
window.location.reload();
} else {
router.push(newChatPage);
}
}}
>
<PlusIcon className="size-4" />
New Chat
</Button>
</div>
<div className="h-5" />
<div className="flex flex-col gap-3 border-t border-dashed px-4 py-4">
<Link
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
href={`/team/${props.team_slug}/${props.project.slug}/ai/analytics`}
>
<div className="flex items-center gap-2">
<ChartLineIcon className="size-3.5 text-muted-foreground" />
Analytics
</div>
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
</Link>
<Link
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
href={"https://portal.thirdweb.com/ai/chat"}
target="_blank"
>
<div className="flex items-center gap-2">
<FileCode2Icon className="size-3.5 text-muted-foreground" />
Documentation
</div>
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
</Link>
</div>
{sessionsToShow.length > 0 && (
<div className="flex-1 overflow-auto border-t border-dashed p-2 pt-2">
<div className="flex items-center justify-between px-2 py-3">
<h3 className="text-muted-foreground text-xs">Recent Chats</h3>
{sessionsToShow.length < sessions.length && (
<Link
className="flex items-center gap-1 rounded-full text-foreground text-xs hover:underline"
href={`/team/${props.team_slug}/${props.project.slug}/ai/chat/history`}
>
View All
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
</Link>
)}
</div>
<div className="flex flex-col gap-1">
{sessionsToShow.map((session) => {
return (
<ChatSidebarLink
project={props.project}
team_slug={props.team_slug}
key={session.id}
sessionId={session.id}
title={session.title || "Untitled Chat"}
/>
);
})}
</div>
</div>
)}
<div className="mt-auto">
<WalletDetails client={props.client} />
</div>
</div>
);
}
function WalletDetails(props: { client: ThirdwebClient }) {
const [tab, setTab] = useState<"assets" | "transactions">("assets");
const [isExpanded, setIsExpanded] = useState(true);
return (
<DynamicHeight transition="height 220ms ease">
<div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
<CustomConnectButton client={props.client} />
<Button
className={cn(
"h-auto w-auto p-1.5 transition-transform duration-300",
isExpanded ? "rotate-180" : "",
)}
onClick={() => setIsExpanded(!isExpanded)}
size="sm"
variant="ghost"
>
<ChevronDownIcon className="size-4" />
</Button>
</div>
{isExpanded && (
<div className="border-t border-dashed">
<TabButtons
tabClassName="!text-sm py-1.5"
tabContainerClassName="px-2 pt-2"
tabs={[
{
isActive: tab === "assets",
name: "Assets",
onClick: () => setTab("assets"),
},
{
isActive: tab === "transactions",
name: "Recent Activity",
onClick: () => setTab("transactions"),
},
]}
/>
{isExpanded && (
<div className="h-[230px] overflow-y-auto py-4 pr-1 pl-2">
{tab === "assets" && <AssetsSection client={props.client} />}
{tab === "transactions" && (
<TransactionsSection client={props.client} />
)}
</div>
)}
</div>
)}
</DynamicHeight>
);
}
function CustomConnectButton(props: { client: ThirdwebClient }) {
const activeWallet = useActiveWallet();
const accountBlobbie = <AccountBlobbie className="size-8 rounded-full" />;
const accountAvatarFallback = (
<WalletIcon
className="size-8 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
return (
<div className="[&>*]:!w-full grow">
<NebulaConnectWallet
client={props.client}
customDetailsButton={
activeWallet
? (address) => {
return (
<WalletProvider id={activeWallet.id}>
<AccountProvider address={address} client={props.client}>
<Button
className="flex h-auto w-full items-center justify-start gap-2.5 rounded-lg px-2 hover:bg-accent"
variant="ghost"
>
<AccountAvatar
className="size-8 rounded-full"
fallbackComponent={accountAvatarFallback}
loadingComponent={accountAvatarFallback}
/>
<div className="flex flex-col items-start justify-start">
<div className="text-sm">
{shortenAddress(address)}
</div>
<div className="text-muted-foreground text-sm">
{activeWallet.id === "smart" ? (
<span> Smart Wallet </span>
) : (
<WalletName
fallbackComponent={<span>Wallet</span>}
loadingComponent={<span>Wallet</span>}
/>
)}
</div>
</div>
</Button>
</AccountProvider>
</WalletProvider>
);
}
: undefined
}
detailsButtonClassName="!bg-background hover:!border-active-border"
/>
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { Spinner } from "@workspace/ui/components/spinner";
import { EllipsisIcon, TrashIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import { NavLink } from "@/components/ui/NavLink";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { deleteSession } from "../api/session";
import { deletedSessionsStore } from "../stores";
// TODO - add delete chat confirmation dialog
export function ChatSidebarLink(props: {
sessionId: string;
title: string;
project: Project;
team_slug: string;
}) {
const router = useDashboardRouter();
const pathname = usePathname();
const isDeletingCurrentPage = pathname.includes(props.sessionId);
const newChatLink = `/team/${props.team_slug}/${props.project.slug}/ai`;
const deleteChat = useMutation({
mutationFn: () => {
return deleteSession({
project: props.project,
sessionId: props.sessionId,
});
},
onSuccess: () => {
const prev = deletedSessionsStore.getValue();
deletedSessionsStore.setValue([...prev, props.sessionId]);
if (isDeletingCurrentPage) {
router.replace(newChatLink);
}
},
});
return (
<div
className="group relative rounded-lg hover:bg-accent"
key={props.sessionId}
>
<Button
asChild
className="h-auto w-full items-center justify-between px-2 py-1.5 text-left text-sm hover:bg-transparent"
variant="ghost"
>
<NavLink
activeClassName="bg-muted"
href={`/team/${props.team_slug}/${props.project.slug}/ai/chat/${props.sessionId}`}
>
<span className="line-clamp-1 block truncate">{props.title}</span>
</NavLink>
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
className="-translate-y-1/2 absolute top-1/2 right-1 z-10 h-auto w-auto bg-accent p-1 opacity-0 hover:bg-zinc-300 group-hover:opacity-100 dark:hover:bg-zinc-700"
variant="ghost"
>
<EllipsisIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" side="right">
<Button
className="!text-destructive-text flex w-full justify-start gap-2.5 px-2"
onClick={() => {
const promise = deleteChat.mutateAsync();
toast.promise(promise, {
error: "Failed to delete chat",
success: "Chat deleted successfully",
});
}}
variant="ghost"
>
{deleteChat.isPending ? (
<Spinner className="size-4" />
) : (
<TrashIcon className="size-4" />
)}
Delete
</Button>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,195 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { ThirdwebProvider } from "thirdweb/react";
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
import type { NebulaContext } from "../api/types";
import { ChatBar, type WalletMeta } from "./ChatBar";
const meta = {
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/Chatbar",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
args: {},
};
const smartWalletAddress = "0x61230342D8D377cA437BdC7CD02C09e09A470500";
const userWalletAddress = "0x2d7B4e58bb163462cba2e705090a4EC56A958F2a";
function Story() {
return (
<ThirdwebProvider>
<div className="container flex max-w-[800px] flex-col gap-14 py-10">
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
isStreaming={false}
label="Not Streaming"
showContextSelector={true}
/>
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
isStreaming={true}
label="Streaming"
showContextSelector={true}
/>
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
context={{
chainIds: ["1"],
networks: null,
walletAddress: null,
}}
isStreaming={false}
label="1 Chain set in context"
showContextSelector={true}
/>
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
context={{
chainIds: ["1", "137", "10"],
networks: null,
walletAddress: null,
}}
isStreaming={false}
label="3 Chains set in context"
showContextSelector={true}
/>
<Variant
activeAccountAddress={userWalletAddress}
connectedWallets={[
{
address: userWalletAddress,
walletId: "io.metamask",
},
]}
context={{
chainIds: ["1", "137", "10", "146", "80094"],
networks: null,
walletAddress: null,
}}
isStreaming={false}
label="5 Chains set in context, 1 user connected wallet"
showContextSelector={true}
/>
<Variant
activeAccountAddress={smartWalletAddress}
connectedWallets={[
{
address: userWalletAddress,
walletId: "io.metamask",
},
{
address: smartWalletAddress,
walletId: "smart",
},
]}
context={{
chainIds: ["1", "137", "10", "146", "80094"],
networks: null,
walletAddress: null,
}}
isStreaming={false}
label="5 Chains set in context, 2 connected wallets - smart wallet active"
showContextSelector={true}
/>
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
isStreaming={false}
label="Prefilled Message"
prefillMessage="This is a prefilled message"
showContextSelector={true}
/>
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
isStreaming={false}
label="No Context Selector"
showContextSelector={false}
/>
<Variant
activeAccountAddress={undefined}
allowImageUpload={false}
connectedWallets={[]}
isStreaming={false}
label="No Image Upload"
showContextSelector={true}
/>
<Variant
activeAccountAddress={undefined}
connectedWallets={[]}
isConnectingWallet={true}
isStreaming={false}
label="Connecting Wallet"
showContextSelector={true}
/>
</div>
</ThirdwebProvider>
);
}
function Variant(props: {
label: string;
prefillMessage?: string;
context?: NebulaContext;
isStreaming: boolean;
showContextSelector: boolean;
connectedWallets: WalletMeta[];
activeAccountAddress: string | undefined;
isConnectingWallet?: boolean;
allowImageUpload?: boolean;
}) {
const [context, setContext] = useState<NebulaContext | undefined>(
props.context,
);
return (
<BadgeContainer label={props.label}>
<ChatBar
abortChatStream={() => {}}
allowImageUpload={
props.allowImageUpload === undefined ? true : props.allowImageUpload
}
client={storybookThirdwebClient}
connectedWallets={props.connectedWallets}
context={context}
isChatStreaming={props.isStreaming}
isConnectingWallet={props.isConnectingWallet || false}
onLoginClick={undefined}
placeholder={"Ask thirdweb AI"}
prefillMessage={props.prefillMessage}
sendMessage={() => {}}
setActiveWallet={(wallet) => {
setContext({
chainIds: context?.chainIds || [],
networks: context?.networks || null,
walletAddress: wallet.address,
});
}}
setContext={setContext}
showContextSelector={props.showContextSelector}
/>
</BadgeContainer>
);
}
@@ -0,0 +1,239 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
import { projectStub, randomLorem } from "@/storybook/stubs";
import { storybookThirdwebClient } from "@/storybook/utils";
import { type ChatMessage, Chats } from "./Chats";
const meta = {
component: Variant,
decorators: [
(Story) => (
<ThirdwebProvider>
<div className="container flex max-w-[800px] flex-col gap-14 py-10">
<div>
<ConnectButton client={storybookThirdwebClient} />
</div>
<Story />
</div>
</ThirdwebProvider>
),
],
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/Chats",
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
export const UserPresenceError: Story = {
args: {
messages: [
{
content: [
{
text: randomLorem(10),
type: "text",
},
],
type: "user",
},
{
texts: [randomLorem(20)],
type: "presence",
},
{
text: randomLorem(20),
type: "error",
},
],
},
};
export const SendTransaction: Story = {
args: {
messages: [
{
request_id: undefined,
text: randomLorem(40),
type: "assistant",
},
{
data: {
chain_id: 1,
data: "0x",
to: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
value: "0x16345785d8a0000",
},
request_id: "xxxxx",
subtype: "sign_transaction",
type: "action",
},
],
},
};
export const WithAndWithoutRequestId: Story = {
args: {
messages: [
{
request_id: "xxxxx",
text: randomLorem(40),
type: "assistant",
},
{
request_id: undefined,
text: randomLorem(50),
type: "assistant",
},
],
},
};
const markdownExample = `\
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
This a paragraph
This is another paragraph
This a very long paragraph lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula.
ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula.
## Empasis
*Italic text*
_Also italic text_
**Bold text**
__Also bold text__
***Bold and italic***
___Also bold and italic___
## Blockquote
> This is a blockquote.
## Lists
### Unordered list
- Item 1
- Item 2
- Nested Item 1
- Nested Item 2
### Ordered list
1. First item
2. Second item
1. Sub-item 1
2. Sub-item 2
### Mixed Nested lists
- Item 1
- Item 2
1. Sub-item 1
2. Sub-item 2
1. First item
2. Second item
- Sub-item 1
- Sub-item 2
### Code
This a a paragraph with some \`inlineCode()\`
This a \`const longerCodeSnippet = "Example. This should be able to handle line breaks as well, it should not be overflowing the page";\`
\`\`\`javascript
// Code block with syntax highlighting
function example() {
console.log("Hello, world!");
}
\`\`\`
### Links
[thirdweb](https://www.thirdweb.com)
### Images
![Alt text](https://picsum.photos/2000/500)
### Horizontal Rule
---
### Tables
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Row 1 | Data | More Data|
| Row 2 | Data | More Data|
| Column 1 | Column 2 | Column 3 | Column 4 | Column 5 |
|--------------|------------------|--------------|------------------|--------------|
| Row 1 Cell 1 | Row 1 Cell 2 | Row 1 Cell 3 | Row 1 Cell 4 | Row 1 Cell 5 |
| Row 2 Cell 1 | Row 2 Cell 2 | Row 2 Cell 3 | Row 2 Cell 4 | Row 2 Cell 5 |
| Row 3 Cell 1 | Row 3 Cell 2 | Row 3 Cell 3 | Row 3 Cell 4 | Row 3 Cell 5 |
| Row 4 Cell 1 | Row 4 Cell 2 | Row 4 Cell 3 | Row 4 Cell 4 | Row 4 Cell 5 |
| Row 5 Cell 1 | Row 5 Cell 2 | Row 5 Cell 3 | Row 5 Cell 4 | Row 5 Cell 5 |
`;
const responseWithCodeMarkdown = `
${randomLorem(20)}
${markdownExample}
`;
export const Markdown: Story = {
args: {
messages: [
{
request_id: undefined,
text: responseWithCodeMarkdown,
type: "assistant",
},
{
content: [
{
text: responseWithCodeMarkdown,
type: "text",
},
],
type: "user",
},
],
},
};
function Variant(props: { messages: ChatMessage[] }) {
return (
<Chats
project={projectStub("xxxxx", "team-1")}
authToken="xxxxx"
client={storybookThirdwebClient}
enableAutoScroll={false}
isChatStreaming={false}
messages={props.messages}
sendMessage={() => {}}
sessionId="xxxxx"
setEnableAutoScroll={() => {}}
/>
);
}
@@ -0,0 +1,423 @@
import { MarkdownRenderer } from "@workspace/ui/components/markdown-renderer";
import { ScrollShadow } from "@workspace/ui/components/scroll-shadow";
import { AlertCircleIcon } from "lucide-react";
import { useEffect, useRef } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { cn } from "@/lib/utils";
import type {
NebulaSwapData,
NebulaTxData,
NebulaUserMessage,
NebulaUserMessageContent,
} from "../api/types";
import { ExecuteTransactionCard } from "./ExecuteTransactionCard";
import { MessageActions } from "./MessageActions";
import { NebulaImage } from "./NebulaImage";
import { Reasoning } from "./Reasoning/Reasoning";
import { ApproveTransactionCard, SwapTransactionCard } from "./Swap/SwapCards";
export type ChatMessage =
| {
type: "user";
content: NebulaUserMessageContent;
}
| {
text: string;
type: "error";
}
| {
texts: string[];
type: "presence";
}
| {
// assistant type message loaded from history doesn't have request_id
request_id: string | undefined;
text: string;
type: "assistant";
}
| {
type: "action";
subtype: "sign_transaction";
request_id: string;
data: NebulaTxData;
}
| {
type: "action";
subtype: "sign_swap";
request_id: string;
data: NebulaSwapData;
}
| {
type: "image";
request_id: string;
data: {
width: number;
height: number;
url: string;
};
};
export function Chats(props: {
project: Project;
messages: Array<ChatMessage>;
isChatStreaming: boolean;
authToken: string;
sessionId: string | undefined;
className?: string;
client: ThirdwebClient;
setEnableAutoScroll: (enable: boolean) => void;
enableAutoScroll: boolean;
useSmallText?: boolean;
sendMessage: (message: NebulaUserMessage) => void;
}) {
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
const scrollAnchorRef = useRef<HTMLDivElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
// auto scroll to bottom when messages change
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!enableAutoScroll || messages.length === 0) {
return;
}
scrollAnchorRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, enableAutoScroll]);
// stop auto scrolling when user interacts with chat
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!enableAutoScroll) {
return;
}
const chatScrollContainer =
chatContainerRef.current?.querySelector("[data-scrollable]");
if (!chatScrollContainer) {
return;
}
const disableScroll = () => {
setEnableAutoScroll(false);
chatScrollContainer.removeEventListener("mousedown", disableScroll);
chatScrollContainer.removeEventListener("wheel", disableScroll);
};
chatScrollContainer.addEventListener("mousedown", disableScroll);
chatScrollContainer.addEventListener("wheel", disableScroll);
}, [setEnableAutoScroll, enableAutoScroll]);
return (
<div
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
ref={chatContainerRef}
>
<ScrollShadow
className="flex-1"
scrollableClassName="max-h-full overscroll-contain"
shadowClassName="z-[1]"
shadowColor="hsl(var(--background))"
>
<div className="container max-w-[800px]">
<div className={cn("flex flex-col gap-5 py-4", props.className)}>
{props.messages.map((message, index) => {
const isMessagePending =
props.isChatStreaming && index === props.messages.length - 1;
const shouldHideMessage =
message.type === "user" &&
message.content.every((msg) => msg.type === "transaction");
if (shouldHideMessage) {
return null;
}
return (
<div
className={cn(
"fade-in-0 min-w-0 animate-in pt-1 text-sm duration-300 lg:text-base",
props.useSmallText && "lg:text-sm",
)}
// biome-ignore lint/suspicious/noArrayIndexKey: index is the unique key
key={index}
>
<RenderMessage
project={props.project}
authToken={props.authToken}
client={props.client}
isMessagePending={isMessagePending}
message={message}
nextMessage={props.messages[index + 1]}
sendMessage={props.sendMessage}
sessionId={props.sessionId}
/>
</div>
);
})}
<div ref={scrollAnchorRef} />
</div>
</div>
</ScrollShadow>
</div>
);
}
function RenderMessage(props: {
message: ChatMessage;
isMessagePending: boolean;
client: ThirdwebClient;
sendMessage: (message: NebulaUserMessage) => void;
nextMessage: ChatMessage | undefined;
authToken: string;
sessionId: string | undefined;
project: Project;
}) {
const { message } = props;
if (props.message.type === "user") {
return (
<div className="mt-6 flex flex-col gap-4">
{props.message.content.map((msg, index) => {
if (msg.type === "text") {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO
<div className="flex justify-end" key={index}>
<div className="max-w-[80%] overflow-auto rounded-xl border bg-card px-4 py-2">
<StyledMarkdownRenderer
isMessagePending={props.isMessagePending}
text={msg.text}
type="user"
/>
</div>
</div>
);
}
if (msg.type === "image") {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO
<div className="flex justify-end" key={index}>
<NebulaImage
client={props.client}
project={props.project}
type="submitted"
url={
typeof msg.b64 === "string"
? msg.b64.startsWith("data:image")
? msg.b64
: `data:image/png;base64,${msg.b64}`
: (msg.image_url ?? "")
}
/>
</div>
);
}
if (msg.type === "transaction") {
return null;
}
return null;
})}
</div>
);
}
return (
<div className="flex gap-3">
{/* Left Icon */}
<div className="-translate-y-[2px] relative shrink-0">
<div
className={cn(
"flex size-9 items-center justify-center rounded-full",
message.type === "assistant" && "border bg-card",
message.type === "error" && "border",
message.type === "presence" && "border bg-card",
)}
>
{message.type === "presence" && (
<NebulaIcon className="size-5 text-muted-foreground" />
)}
{message.type === "assistant" && (
<NebulaIcon className="size-5 text-muted-foreground" />
)}
{message.type === "error" && (
<AlertCircleIcon className="size-5 text-destructive-text" />
)}
</div>
</div>
{/* Right Message */}
<div className="min-w-0 grow">
<ScrollShadow className="rounded-lg">
<RenderResponse
project={props.project}
authToken={props.authToken}
client={props.client}
isMessagePending={props.isMessagePending}
message={message}
nextMessage={props.nextMessage}
sendMessage={props.sendMessage}
sessionId={props.sessionId}
/>
</ScrollShadow>
{/* message feedback */}
{message.type === "assistant" &&
!props.isMessagePending &&
props.sessionId &&
message.request_id && (
<MessageActions
project={props.project}
className="mt-4"
messageText={message.text}
requestId={message.request_id}
sessionId={props.sessionId}
/>
)}
</div>
</div>
);
}
function RenderResponse(props: {
message: ChatMessage;
isMessagePending: boolean;
client: ThirdwebClient;
sendMessage: (message: NebulaUserMessage) => void;
nextMessage: ChatMessage | undefined;
sessionId: string | undefined;
authToken: string;
project: Project;
}) {
const { message, isMessagePending, client, sendMessage, nextMessage } = props;
switch (message.type) {
case "assistant":
return (
<StyledMarkdownRenderer
isMessagePending={isMessagePending}
text={message.text}
type="assistant"
/>
);
case "presence":
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
case "error":
return (
<div className="rounded-xl border bg-card px-4 py-2 text-destructive-text leading-normal">
{message.text}
</div>
);
case "image": {
return (
<NebulaImage
project={props.project}
client={client}
height={message.data.height}
requestId={message.request_id}
sessionId={props.sessionId}
type="response"
url={message.data.url}
width={message.data.width}
/>
);
}
case "action": {
if (message.subtype === "sign_transaction") {
return (
<ExecuteTransactionCard
client={client}
onTxSettled={(txHash) => {
// do not send automatic prompt if there is another transaction after this one
if (nextMessage?.type === "action") {
return;
}
sendMessage({
content: [
{
chain_id: message.data.chain_id,
transaction_hash: txHash,
type: "transaction",
},
],
role: "user",
});
}}
txData={message.data}
/>
);
}
if (message.subtype === "sign_swap") {
if (message.data.action === "approval") {
return (
<ApproveTransactionCard client={client} swapData={message.data} />
);
}
return (
<SwapTransactionCard
client={client}
onTxSettled={(txHash) => {
// do not send automatic prompt if there is another transaction after this one
if (nextMessage?.type === "action") {
return;
}
sendMessage({
content: [
{
chain_id: message.data.transaction.chain_id,
transaction_hash: txHash,
type: "transaction",
},
],
role: "user",
});
}}
swapData={message.data}
/>
);
}
return null;
}
case "user": {
return null;
}
}
return null;
}
function StyledMarkdownRenderer(props: {
text: string;
isMessagePending: boolean;
type: "assistant" | "user";
}) {
return (
<MarkdownRenderer
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
code={{
className: "bg-transparent",
ignoreFormattingErrors: true,
}}
inlineCode={{ className: "border-none" }}
markdownText={props.text}
p={{
className: props.type === "assistant" ? "" : "leading-normal",
}}
skipHtml
/>
);
}
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ThirdwebProvider } from "thirdweb/react";
import { storybookThirdwebClient } from "@/storybook/utils";
import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent";
const meta = {
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/EmptyStateChatPage",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
prefillMessage: undefined,
},
};
export const PrefilledMessage: Story = {
args: {
prefillMessage: "This is a prefilled message",
},
};
function Story(props: { prefillMessage: string | undefined }) {
return (
<ThirdwebProvider>
<div className="container flex max-w-[800px] grow flex-col justify-center overflow-hidden">
<EmptyStateChatPageContent
allowImageUpload={true}
connectedWallets={[]}
context={undefined}
isConnectingWallet={false}
onLoginClick={undefined}
prefillMessage={props.prefillMessage}
sendMessage={() => {}}
setActiveWallet={() => {}}
setContext={() => {}}
showAurora={false}
client={storybookThirdwebClient}
/>
</div>
</ThirdwebProvider>
);
}
@@ -0,0 +1,167 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { Button } from "@/components/ui/button";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { cn } from "@/lib/utils";
import type { NebulaContext, NebulaUserMessage } from "../api/types";
import { examplePrompts } from "../data/examplePrompts";
import { ChatBar, type WalletMeta } from "./ChatBar";
export function EmptyStateChatPageContent(props: {
sendMessage: (message: NebulaUserMessage) => void;
prefillMessage: string | undefined;
context: NebulaContext | undefined;
setContext: (context: NebulaContext | undefined) => void;
connectedWallets: WalletMeta[];
setActiveWallet: (wallet: WalletMeta) => void;
isConnectingWallet: boolean;
showAurora: boolean;
allowImageUpload: boolean;
onLoginClick: undefined | (() => void);
client: ThirdwebClient;
}) {
return (
<div className="overflow-hidden py-10 lg:py-16">
{props.showAurora && (
<Aurora className="top-0 left-1/2 h-[800px] w-[1000px] text-[hsl(var(--primary-foreground)/8%)] lg:w-[150%] dark:text-[hsl(var(--primary-foreground)/10%)]" />
)}
<div className="relative py-10">
<FancyBorders />
<div className="flex justify-center">
<div className="rounded-full border-[1.5px] border-primary-foreground/20 bg-[hsl(var(--primary-foreground)/5%)] p-1">
<div className="rounded-full border-[1.5px] border-primary-foreground/40 bg-[hsl(var(--primary-foreground)/5%)] p-2">
<NebulaIcon className="size-7 text-primary-foreground" />
</div>
</div>
</div>
<div className="h-5" />
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
How can I help you <br /> onchain today?
</h1>
<div className="h-5" />
<div className="mx-auto max-w-[600px]">
<ChatBar
abortChatStream={() => {
// the page will switch so, no need to handle abort here
}}
allowImageUpload={props.allowImageUpload}
client={props.client}
connectedWallets={props.connectedWallets}
context={props.context}
isChatStreaming={false}
isConnectingWallet={props.isConnectingWallet}
onLoginClick={props.onLoginClick}
placeholder={"Ask thirdweb AI"}
prefillMessage={props.prefillMessage}
sendMessage={props.sendMessage}
setActiveWallet={props.setActiveWallet}
setContext={props.setContext}
showContextSelector={true}
/>
<div className="h-5" />
<div className="flex flex-wrap justify-center gap-2.5">
{examplePrompts.map((prompt) => {
return (
<ExamplePrompt
key={prompt.title}
label={prompt.title}
onClick={() =>
props.sendMessage({
content: [{ text: prompt.message, type: "text" }],
role: "user",
})
}
/>
);
})}
</div>
</div>
</div>
</div>
);
}
function ExamplePrompt(props: { label: string; onClick: () => void }) {
return (
<Button
className="h-auto gap-1.5 rounded-full bg-card px-3 py-1 text-muted-foreground text-xs"
onClick={props.onClick}
variant="outline"
>
{props.label} <ArrowUpRightIcon className="size-3" />
</Button>
);
}
function FancyBorders() {
return (
<>
{/* fancy borders */}
<div className="">
{/* top */}
<DashedBgDiv
className="-translate-x-1/2 -translate-y-5 absolute top-0 right-0 left-1/2 hidden h-[1px] w-[calc(100%+200px)] lg:block"
type="horizontal"
/>
{/* bottom */}
<DashedBgDiv
className="-translate-x-1/2 absolute right-0 bottom-0 left-1/2 hidden h-[1px] w-[calc(100%+200px)] translate-y-5 lg:block"
type="horizontal"
/>
{/* left */}
<DashedBgDiv
className="-translate-y-1/2 absolute top-1/2 left-8 hidden h-[calc(100%+200px)] w-[1px] lg:block"
type="vertical"
/>
{/* right */}
<DashedBgDiv
className="-translate-y-1/2 absolute top-1/2 right-8 hidden h-[calc(100%+200px)] w-[1px] lg:block"
type="vertical"
/>
</div>
</>
);
}
function DashedBgDiv(props: {
className?: string;
type: "horizontal" | "vertical";
}) {
return (
<div
className={cn(
"text-primary-foreground/50 dark:text-active-border",
props.className,
)}
style={{
backgroundImage: `linear-gradient(${props.type === "horizontal" ? "90deg" : "180deg"}, currentColor 0 30%, transparent 0 100%)`,
backgroundRepeat: "repeat",
backgroundSize: "10px 10px",
maskImage: `linear-gradient(${
props.type === "horizontal" ? "to right" : "to bottom"
}, rgba(0,0,0,0.1), black 30%, black 70%, rgba(0,0,0,0.1))`,
}}
/>
);
}
type AuroraProps = {
className?: string;
};
const Aurora: React.FC<AuroraProps> = ({ className }) => {
return (
<div
className={cn(
"-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute",
className,
)}
style={{
backgroundImage:
"radial-gradient(ellipse at center, currentColor, transparent 60%)",
}}
/>
);
};
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
import {
BadgeContainer,
storybookLog,
storybookThirdwebClient,
} from "@/storybook/utils";
import { ExecuteTransactionCardLayout } from "./ExecuteTransactionCard";
import type { TxStatus } from "./Swap/common";
const meta = {
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/actions/ExecuteTransactionCard",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
args: {},
};
const exampleTxHash =
"0xbe81f5a6421625052214b41bb79d1d82751b29aa5639b54d120f00531bb8bcf";
function Story() {
return (
<ThirdwebProvider>
<div className="container flex max-w-[800px] flex-col gap-10 py-10">
<div>
<ConnectButton client={storybookThirdwebClient} />
</div>
<Variant label="Idle" status={{ type: "idle" }} />
<Variant label="Sending" status={{ type: "sending" }} />
<Variant
label="Confirming"
status={{ txHash: exampleTxHash, type: "confirming" }}
/>
<Variant
label="Confirmed"
status={{ txHash: exampleTxHash, type: "confirmed" }}
/>
<Variant
label="Failed"
status={{ txHash: exampleTxHash, type: "failed" }}
/>
</div>
</ThirdwebProvider>
);
}
function Variant(props: { label: string; status: TxStatus }) {
const [status, setStatus] = useState<TxStatus>(props.status);
return (
<BadgeContainer label={props.label}>
<ExecuteTransactionCardLayout
client={storybookThirdwebClient}
onTxSettled={(txHash) => {
storybookLog(`onTxSettled called with ${txHash}`);
}}
sendTx={async () => {}}
setStatus={setStatus}
status={status}
txData={{
chain_id: 1,
data: "0x", // thirdweb.eth
to: "0xEb0effdFB4dC5b3d5d3aC6ce29F3ED213E95d675",
value: "0x16345785d8a0000", // 0.1 eth
}}
/>
</BadgeContainer>
);
}
@@ -0,0 +1,164 @@
import { ArrowRightLeftIcon } from "lucide-react";
import {
type PreparedTransaction,
prepareTransaction,
type ThirdwebClient,
toEther,
} from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { TransactionButton } from "@/components/tx-button";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { ChainIconClient } from "@/icons/ChainIcon";
import type { NebulaTxData } from "../api/types";
import {
TxHashRow,
type TxStatus,
TxStatusRow,
useTxSetup,
} from "./Swap/common";
export function ExecuteTransactionCard(props: {
txData: NebulaTxData;
client: ThirdwebClient;
onTxSettled: (txHash: string) => void;
}) {
const { status, setStatus, sendTx } = useTxSetup();
return (
<ExecuteTransactionCardLayout
client={props.client}
onTxSettled={props.onTxSettled}
sendTx={sendTx}
setStatus={setStatus}
status={status}
txData={props.txData}
/>
);
}
export function ExecuteTransactionCardLayout(props: {
txData: NebulaTxData;
client: ThirdwebClient;
status: TxStatus;
setStatus: (status: TxStatus) => void;
onTxSettled: (txHash: string) => void;
sendTx: (
tx: PreparedTransaction,
onTxSettled: (txHash: string) => void,
) => Promise<void>;
}) {
const { txData } = props;
const chain = useV5DashboardChain(txData.chain_id);
const account = useActiveAccount();
return (
<div>
<div className="max-w-lg rounded-xl border bg-card">
{/* header */}
<h3 className="border-b p-4 py-4 font-semibold text-foreground text-lg tracking-tight lg:px-5 lg:text-xl">
Transaction
</h3>
{/* content */}
<div className="px-4 text-sm lg:px-5 [&>*:not(:last-child)]:border-b [&>*]:h-[52px]">
{/* From */}
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">From</span>
{account ? (
<WalletAddress
address={account.address}
className="h-auto py-0"
client={props.client}
iconClassName="size-5"
/>
) : (
<span className="text-muted-foreground">Your Wallet</span>
)}
</div>
{/* To */}
{txData.to && (
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">To</span>
<WalletAddress
address={txData.to}
className="h-auto py-0"
client={props.client}
iconClassName="size-5"
/>
</div>
)}
{/* Value */}
{txData.value !== undefined && (
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Value</span>
{toEther(BigInt(txData.value))} {chain.nativeCurrency?.symbol}
</div>
)}
{/* Network */}
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Network</span>
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5 rounded-full"
client={props.client}
src={chain.icon?.url}
/>
<span className="text-foreground">
{chain.name || `Chain ID: ${txData.chain_id}`}
</span>
</div>
</div>
{/* Status */}
{props.status.type !== "idle" && (
<TxStatusRow status={props.status} />
)}
{/* Transaction Hash */}
{"txHash" in props.status && props.status.txHash && (
<TxHashRow chainId={txData.chain_id} txHash={props.status.txHash} />
)}
</div>
{/* footer */}
{props.status.type !== "confirmed" && (
<div className="flex items-center justify-end border-t px-4 py-5 lg:px-5">
<TransactionButton
className="gap-2"
client={props.client}
disabled={
props.status.type === "sending" ||
props.status.type === "confirming"
}
isLoggedIn={true}
isPending={props.status.type === "sending"}
onClick={async () => {
const tx = prepareTransaction({
chain: chain,
client: props.client,
data: txData.data,
to: txData.to,
value: txData.value ? BigInt(txData.value) : undefined,
});
props.sendTx(tx, props.onTxSettled);
}}
size="sm"
transactionCount={undefined}
txChainID={txData.chain_id}
variant="default"
>
<ArrowRightLeftIcon className="size-4" />
Execute Transaction
</TransactionButton>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { useMutation } from "@tanstack/react-query";
import { Spinner } from "@workspace/ui/components/spinner";
import {
CheckIcon,
CopyIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { submitFeedback } from "../api/feedback";
export function MessageActions(props: {
project: Project;
requestId: string;
sessionId: string;
messageText: string | undefined;
className?: string;
buttonClassName?: string;
}) {
const [isCopied, setIsCopied] = useState(false);
function sendRating(rating: "good" | "bad") {
return submitFeedback({
project: props.project,
rating,
requestId: props.requestId,
sessionId: props.sessionId,
});
}
const sendPositiveRating = useMutation({
mutationFn: () => sendRating("good"),
onError() {
toast.error("Failed to send feedback", {
position: "top-right",
});
},
onSuccess() {
toast.info("Thanks for the feedback!", {
position: "top-right",
});
},
});
const sendBadRating = useMutation({
mutationFn: () => sendRating("bad"),
onError() {
toast.error("Failed to send feedback", {
position: "top-right",
});
},
onSuccess() {
toast.info("Thanks for the feedback!", {
position: "top-right",
});
},
});
const { messageText } = props;
return (
<div
className={cn("flex items-center gap-2 text-foreground", props.className)}
>
{messageText && (
<Button
className={cn("h-8 gap-2 rounded-lg text-sm", props.buttonClassName)}
onClick={() => {
navigator.clipboard.writeText(messageText);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
}}
size="sm"
variant="outline"
>
{isCopied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5" />
)}
Copy
</Button>
)}
<Button
className={cn(
"size-8 rounded-lg bg-background p-0",
props.buttonClassName,
)}
onClick={() => {
sendPositiveRating.mutate();
}}
size="sm"
variant="outline"
>
{sendPositiveRating.isPending ? (
<Spinner className="size-4" />
) : (
<ThumbsUpIcon className="size-4" />
)}
</Button>
<Button
className={cn(
"size-8 rounded-lg bg-background p-0",
props.buttonClassName,
)}
onClick={() => {
sendBadRating.mutate();
}}
size="sm"
variant="outline"
>
{sendBadRating.isPending ? (
<Spinner className="size-4" />
) : (
<ThumbsDownIcon className="size-4" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import type { ThirdwebClient } from "thirdweb";
import {
ConnectButton,
useActiveAccount,
useActiveWalletConnectionStatus,
} from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export const NebulaConnectWallet = (props: {
client: ThirdwebClient;
connectButtonClassName?: string;
signInLinkButtonClassName?: string;
detailsButtonClassName?: string;
customDetailsButton?: (address: string) => React.ReactElement;
}) => {
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
const { allChainsV5 } = useAllChainsData();
const pathname = usePathname();
const account = useActiveAccount();
const connectionStatus = useActiveWalletConnectionStatus();
if (connectionStatus === "connecting") {
return (
<Button
className={cn(
props.signInLinkButtonClassName,
"gap-2 disabled:opacity-100",
)}
disabled
size="lg"
variant="outline"
>
<Spinner className="size-4" /> Connecting Wallet
</Button>
);
}
if (!account) {
return (
<Button
asChild
className={props.signInLinkButtonClassName}
size="lg"
variant="default"
>
<Link
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
>
Connect Wallet
</Link>
</Button>
);
}
const { customDetailsButton } = props;
return (
<ConnectButton
appMetadata={{
logoUrl: "https://thirdweb.com/favicon.ico",
name: "thirdweb",
url: "https://thirdweb.com",
}}
autoConnect={false}
chains={allChainsV5}
client={props.client}
connectButton={{
className: props.connectButtonClassName,
}}
connectModal={{
privacyPolicyUrl: "/privacy-policy",
showThirdwebBranding: false,
termsOfServiceUrl: "/terms",
}}
detailsButton={{
className: props.detailsButtonClassName,
render: customDetailsButton
? () => customDetailsButton(account.address)
: undefined,
}}
// we have an AutoConnect already added in root layout with AA configuration
theme={getSDKTheme(t)}
/>
);
};
@@ -0,0 +1,126 @@
import { useMutation } from "@tanstack/react-query";
import { Spinner } from "@workspace/ui/components/spinner";
import { ArrowDownToLineIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { Img } from "@/components/blocks/Img";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { MessageActions } from "./MessageActions";
export function NebulaImage(
props:
| {
project: Project;
type: "response";
url: string;
width: number;
height: number;
client: ThirdwebClient;
requestId: string;
sessionId: string | undefined;
}
| {
project: Project;
type: "submitted";
url: string;
client: ThirdwebClient;
},
) {
const src = props.url.startsWith("ipfs://")
? resolveSchemeWithErrorHandler({
client: props.client,
uri: props.url,
})
: props.url;
const downloadMutation = useMutation({
mutationFn: () => downloadImage(src || ""),
});
if (!src) {
return null;
}
return (
<div className="group relative w-full max-w-[80%] lg:max-w-[50%]">
<Dialog>
<DialogTrigger asChild>
<Img
className="w-full rounded-lg border hover:border-active-border"
height={props.type === "response" ? props.height : undefined}
skeleton={
<div className="min-h-[300px] animate-skeleton bg-muted" />
}
src={src}
width={props.type === "response" ? props.width : undefined}
/>
</DialogTrigger>
<DialogContent
className="!w-full !max-w-[800px] p-2"
dialogCloseClassName="focus:ring-offset-0 focus:ring-transparent"
>
<DialogTitle className="sr-only">Image</DialogTitle>
<Img
className="h-full w-full object-contain"
skeleton={<div className="animate-skeleton bg-muted" />}
src={src}
/>
</DialogContent>
</Dialog>
<div className="absolute top-4 right-4 z-10 flex justify-between gap-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Button
className="size-8 gap-2 rounded-lg bg-background/50 p-0 "
onClick={async () => {
downloadMutation.mutate();
}}
size="sm"
variant="ghost"
>
{downloadMutation.isPending ? (
<Spinner className="size-4" />
) : (
<ArrowDownToLineIcon className="size-4" />
)}
</Button>
</div>
{props.type === "response" && props.sessionId && (
<div className="absolute right-4 bottom-4 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<MessageActions
project={props.project}
buttonClassName="bg-background/50 border-none"
messageText={undefined}
requestId={props.requestId}
sessionId={props.sessionId}
/>
</div>
)}
</div>
);
}
async function downloadImage(src: string) {
try {
const response = await fetch(src, { mode: "cors" });
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "image.png";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Download failed:", error);
}
}
@@ -0,0 +1,72 @@
"use client";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import type { TruncatedSessionInfo } from "../api/types";
import { ChatSidebar } from "./ChatSidebar";
export function MobileNav(props: {
sessions: TruncatedSessionInfo[];
authToken: string;
client: ThirdwebClient;
team_slug: string;
project: Project;
}) {
const [isOpen, setIsOpen] = useState(false);
const newChatPage = `/team/${props.team_slug}/${props.project.slug}/ai`;
return (
<nav className="flex justify-end border-b bg-background px-4 py-2 lg:hidden gap-4">
<div className="flex items-center gap-5">
<Button asChild className="h-auto w-auto rounded-md px-2.5 py-1.5">
<Link href={newChatPage}>New Chat</Link>
</Button>
</div>
<Sheet onOpenChange={setIsOpen} open={isOpen}>
<SheetTrigger asChild>
<Button
className="h-auto w-auto p-0.5"
onClick={() => setIsOpen(!isOpen)}
variant="ghost"
>
<MenuIcon className="size-6" />
</Button>
</SheetTrigger>
<SheetContent
className="w-[calc(100vw-80px)] max-w-[300px] p-0"
onClick={(e) => {
if (!(e.target instanceof HTMLElement)) {
return;
}
if (
e.target instanceof HTMLAnchorElement ||
e.target.closest("a")
) {
setIsOpen(false);
}
}}
side="right"
>
<SheetTitle className="sr-only"> Menu </SheetTitle>
<ChatSidebar
project={props.project}
team_slug={props.team_slug}
client={props.client}
sessions={props.sessions}
type="mobile"
/>
</SheetContent>
</Sheet>
</nav>
);
}
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { randomLorem } from "@/storybook/stubs";
import { Reasoning } from "./Reasoning";
const meta = {
component: Story,
decorators: [
(Story) => (
<div className="container max-w-[800px] py-10">
<Story />
</div>
),
],
title: "AI/Reasoning",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Pending: Story = {
args: {},
};
function Story() {
return (
<div className="flex flex-col gap-10">
<Reasoning isPending texts={[randomLorem(10), randomLorem(5)]} />
<Reasoning
isPending={false}
texts={[randomLorem(10), randomLorem(5), randomLorem(30)]}
/>
</div>
);
}
@@ -0,0 +1,62 @@
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>
);
}
@@ -0,0 +1,141 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
import type { NebulaSwapData } from "../../api/types";
import type { TxStatus } from "./common";
import {
ApproveTransactionCardLayout,
SwapTransactionCardLayout,
} from "./SwapCards";
const meta = {
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "AI/actions/Swap Cards",
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
const swapBaseData: Omit<NebulaSwapData, "action"> = {
from_token: {
address: "0x4200000000000000000000000000000000000006",
amount: "100000000000000000",
chain_id: 1,
decimals: 18,
symbol: "WETH",
},
intent: {
amount: "100000000000000000",
destination_chain_id: 8453,
destination_token_address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
origin_chain_id: 8453,
origin_token_address: "0x4200000000000000000000000000000000000006",
receiver: "0x1E68D1dB85f3F4e1cc8dd816709C529139f79290",
sender: "0x1E68D1dB85f3F4e1cc8dd816709C529139f79290",
},
to_token: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "176443798",
chain_id: 8453,
decimals: 6,
symbol: "USDC",
},
transaction: {
chain_id: 8453,
data: "0x095ea7b3000000000000000000000000f8ab2dbe6c43bf1a856471182290f91d621ba76d000000000000000000000000000000000000000000000000016345785d8a0000",
to: "0x4200000000000000000000000000000000000006",
},
};
export const Swap: Story = {
args: {
swapData: {
...swapBaseData,
action: "sell",
},
},
};
export const Approve: Story = {
args: {
swapData: {
...swapBaseData,
action: "approval",
},
},
};
const exampleTxHash =
"0xbe81f5a6421625052214b41bb79d1d82751b29aa5639b54d120f00531bb8bcf";
function Story(props: { swapData: NebulaSwapData }) {
return (
<ThirdwebProvider>
<div className="container flex max-w-[800px] flex-col gap-10 py-10">
<div>
<ConnectButton client={storybookThirdwebClient} />
</div>
<Variant
label="Idle"
status={{ type: "idle" }}
swapData={props.swapData}
/>
<Variant
label="Sending"
status={{ type: "sending" }}
swapData={props.swapData}
/>
<Variant
label="Confirming"
status={{ txHash: exampleTxHash, type: "confirming" }}
swapData={props.swapData}
/>
<Variant
label="Confirmed"
status={{ txHash: exampleTxHash, type: "confirmed" }}
swapData={props.swapData}
/>
<Variant
label="Failed"
status={{ txHash: exampleTxHash, type: "failed" }}
swapData={props.swapData}
/>
</div>
</ThirdwebProvider>
);
}
function Variant(props: {
label: string;
status: TxStatus;
swapData: NebulaSwapData;
}) {
const [status, setStatus] = useState<TxStatus>(props.status);
return (
<BadgeContainer label={props.label}>
{props.swapData.action === "approval" ? (
<ApproveTransactionCardLayout
client={storybookThirdwebClient}
sendTx={async () => {}}
setStatus={setStatus}
status={status}
swapData={props.swapData}
/>
) : (
<SwapTransactionCardLayout
client={storybookThirdwebClient}
sendTx={async () => {}}
setStatus={setStatus}
status={status}
swapData={props.swapData}
/>
)}
</BadgeContainer>
);
}
@@ -0,0 +1,280 @@
import { ArrowRightLeftIcon, CheckIcon } from "lucide-react";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type PreparedTransaction,
prepareTransaction,
type ThirdwebClient,
toTokens,
} from "thirdweb";
import { TransactionButton } from "@/components/tx-button";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { ChainIconClient } from "@/icons/ChainIcon";
import type { NebulaSwapData } from "../../api/types";
import { TxHashRow, type TxStatus, TxStatusRow, useTxSetup } from "./common";
export function SwapTransactionCard(props: {
swapData: NebulaSwapData;
client: ThirdwebClient;
onTxSettled: (txHash: string) => void;
}) {
const { status, setStatus, sendTx } = useTxSetup();
return (
<SwapTransactionCardLayout
client={props.client}
sendTx={(tx) => sendTx(tx, props.onTxSettled)}
setStatus={setStatus}
status={status}
swapData={props.swapData}
/>
);
}
export function SwapTransactionCardLayout(props: {
swapData: NebulaSwapData;
client: ThirdwebClient;
status: TxStatus;
setStatus: (status: TxStatus) => void;
sendTx: (tx: PreparedTransaction) => Promise<void>;
}) {
const { swapData } = props;
const txChain = useV5DashboardChain(swapData.transaction.chain_id);
const isSellingNativeToken =
getAddress(swapData.from_token.address) ===
getAddress(NATIVE_TOKEN_ADDRESS);
return (
<div className="max-w-lg">
<div className="rounded-xl border bg-card">
{/* header */}
<div className="border-b p-4 py-4 lg:px-5 lg:text-xl">
<h3 className="font-semibold text-foreground text-lg tracking-tight ">
Swap
</h3>
</div>
{/* content */}
<div className="px-4 text-sm lg:px-5 [&>*:not(:last-child)]:border-b [&>*]:min-h-14 [&>*]:py-3.5">
<TokenRow
amount={swapData.from_token.amount}
chainId={swapData.from_token.chain_id}
client={props.client}
decimals={swapData.from_token.decimals}
symbol={swapData.from_token.symbol}
title="Sell"
/>
<TokenRow
amount={swapData.to_token.amount}
chainId={swapData.to_token.chain_id}
client={props.client}
decimals={swapData.to_token.decimals}
symbol={swapData.to_token.symbol}
title="Buy"
/>
{/* Status */}
{props.status.type !== "idle" && (
<TxStatusRow status={props.status} />
)}
{/* Transaction Hash */}
{"txHash" in props.status && props.status.txHash && (
<TxHashRow
chainId={swapData.transaction.chain_id}
txHash={props.status.txHash}
/>
)}
</div>
{/* footer */}
{props.status.type !== "confirmed" && (
<div className="flex items-center justify-end border-t px-4 py-4 lg:px-5">
<TransactionButton
className="gap-2"
client={props.client}
disabled={
props.status.type === "sending" ||
props.status.type === "confirming"
}
isLoggedIn={true}
isPending={props.status.type === "sending"}
onClick={async () => {
const tx = prepareTransaction({
chain: txChain,
client: props.client,
data: swapData.transaction.data,
value: swapData.transaction.value
? BigInt(swapData.transaction.value)
: undefined,
extraGas: 50000n,
erc20Value: isSellingNativeToken
? undefined
: {
amountWei: BigInt(swapData.from_token.amount),
tokenAddress: swapData.from_token.address,
},
to: swapData.transaction.to,
});
props.sendTx(tx);
}}
size="sm"
transactionCount={undefined}
txChainID={swapData.transaction.chain_id}
variant="default"
>
<ArrowRightLeftIcon className="size-4" />
Swap Tokens
</TransactionButton>
</div>
)}
</div>
</div>
);
}
export function ApproveTransactionCard(props: {
swapData: NebulaSwapData;
client: ThirdwebClient;
}) {
const { status, setStatus, sendTx } = useTxSetup();
return (
<ApproveTransactionCardLayout
client={props.client}
sendTx={(tx) => sendTx(tx, undefined)}
setStatus={setStatus}
status={status}
swapData={props.swapData}
/>
);
}
export function ApproveTransactionCardLayout(props: {
swapData: NebulaSwapData;
client: ThirdwebClient;
status: TxStatus;
setStatus: (status: TxStatus) => void;
sendTx: (tx: PreparedTransaction) => Promise<void>;
}) {
const { swapData } = props;
const txChain = useV5DashboardChain(swapData.transaction.chain_id);
const isTransactionPending =
props.status.type === "sending" || props.status.type === "confirming";
return (
<div className="max-w-lg">
<div className="rounded-xl border bg-card">
{/* header */}
<div className="border-b p-4 py-4 lg:px-5 lg:text-xl">
<h3 className="font-semibold text-foreground text-lg tracking-tight ">
Approve
</h3>
<p className="text-muted-foreground text-sm">
Approve spending to swap tokens on your behalf
</p>
</div>
{/* content */}
<div className="px-4 text-sm lg:px-5 [&>*:not(:last-child)]:border-b [&>*]:min-h-14 [&>*]:py-3.5">
<TokenRow
amount={swapData.from_token.amount}
chainId={swapData.from_token.chain_id}
client={props.client}
decimals={swapData.from_token.decimals}
symbol={swapData.from_token.symbol}
title="Approve Spending"
/>
<TokenRow
amount={swapData.to_token.amount}
chainId={swapData.to_token.chain_id}
client={props.client}
decimals={swapData.to_token.decimals}
symbol={swapData.to_token.symbol}
title="To Buy"
/>
{/* Status */}
{props.status.type !== "idle" && (
<TxStatusRow status={props.status} />
)}
{/* Transaction Hash */}
{"txHash" in props.status && props.status.txHash && (
<TxHashRow
chainId={swapData.transaction.chain_id}
txHash={props.status.txHash}
/>
)}
</div>
{/* footer */}
{props.status.type !== "confirmed" && (
<div className="flex items-center justify-end border-t px-4 py-4 lg:px-5">
<TransactionButton
className="gap-2"
client={props.client}
disabled={isTransactionPending}
isLoggedIn={true}
isPending={isTransactionPending}
onClick={async () => {
const tx = prepareTransaction({
chain: txChain,
client: props.client,
data: swapData.transaction.data,
to: swapData.transaction.to,
});
props.sendTx(tx);
}}
size="sm"
transactionCount={undefined}
txChainID={swapData.transaction.chain_id}
variant="default"
>
<CheckIcon className="size-4" />
Approve
</TransactionButton>
</div>
)}
</div>
</div>
);
}
function TokenRow(props: {
amount: string;
symbol: string;
chainId: number;
client: ThirdwebClient;
title: string;
decimals: number;
}) {
const chain = useV5DashboardChain(props.chainId);
const tokenDisplayValue = toTokens(BigInt(props.amount), props.decimals);
return (
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col">
<div className="font-medium text-muted-foreground">{props.title}</div>
<div className="text-lg">
{tokenDisplayValue} {props.symbol}
</div>
</div>
<div>
<div className="flex items-center gap-1.5">
<ChainIconClient
className="size-4"
client={props.client}
src={chain.icon?.url || ""}
/>
<div>{chain.name}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
import { Spinner } from "@workspace/ui/components/spinner";
import { CircleCheckIcon, CircleXIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { useCallback, useState } from "react";
import { type PreparedTransaction, waitForReceipt } from "thirdweb";
import { useSendTransaction } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export type TxStatus =
| {
type: "idle";
}
| {
type: "sending";
}
| {
type: "confirming";
txHash: string;
}
| {
type: "confirmed";
txHash: string;
}
| {
type: "failed";
txHash: string | undefined;
};
export function TxStatusRow(props: { status: TxStatus }) {
return (
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Status</span>
<div className="flex items-center gap-1.5">
<span
className={cn(
"flex items-center gap-2 font-medium",
props.status.type === "sending" &&
"text-blue-700 dark:text-blue-500",
props.status.type === "confirming" &&
"text-yellow-600 dark:text-yellow-500",
props.status.type === "confirmed" &&
"text-green-700 dark:text-green-500",
props.status.type === "failed" && "text-red-700 dark:text-red-500",
)}
>
{/* icon */}
{(props.status.type === "sending" ||
props.status.type === "confirming") && (
<Spinner className="size-4" />
)}
{props.status.type === "confirmed" && (
<CircleCheckIcon className="size-4" />
)}
{props.status.type === "failed" && <CircleXIcon className="size-4" />}
{/* text */}
<span>
{props.status.type === "sending" && "Sending Transaction"}
{props.status.type === "confirming" && "Waiting for Confirmation"}
{props.status.type === "confirmed" && "Transaction Confirmed"}
{props.status.type === "failed" && "Transaction Failed"}
</span>
</span>
</div>
</div>
);
}
export function TxHashRow(props: { chainId: number; txHash: string }) {
const { idToChain } = useAllChainsData();
const chainMetadata = idToChain.get(props.chainId);
const explorer = chainMetadata?.explorers?.[0]?.url;
return (
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-muted-foreground">
Transaction Hash
</span>
<div className="flex justify-end gap-2.5">
{explorer ? (
<Button
asChild
className="gap-1.5 font-mono"
size="sm"
variant="ghost"
>
<Link href={`${explorer}/tx/${props.txHash}`} target="_blank">
{`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
<ExternalLinkIcon className="size-3" />
</Link>
</Button>
) : (
<CopyTextButton
className="font-mono"
copyIconPosition="right"
textToCopy={props.txHash}
textToShow={`${props.txHash.slice(0, 6)}...${props.txHash.slice(-4)}`}
tooltip="Copy Transaction Hash"
variant="ghost"
/>
)}
</div>
</div>
);
}
export function useTxSetup() {
const [status, setStatus] = useState<TxStatus>({ type: "idle" });
const { theme } = useTheme();
const sendTransaction = useSendTransaction({
payModal: {
theme: getSDKTheme(theme === "light" ? "light" : "dark"),
},
});
const sendTx = useCallback(
async (
tx: PreparedTransaction,
onTxSettled: ((txHash: string) => void) | undefined,
) => {
let txHash: string | undefined;
try {
// submit transaction
setStatus({ type: "sending" });
const submittedReceipt = await sendTransaction.mutateAsync(tx);
txHash = submittedReceipt.transactionHash;
// wait for receipt
setStatus({
txHash: submittedReceipt.transactionHash,
type: "confirming",
});
const confirmReceipt = await waitForReceipt(submittedReceipt);
txHash = confirmReceipt.transactionHash;
setStatus({
txHash: confirmReceipt.transactionHash,
type: "confirmed",
});
onTxSettled?.(txHash);
} catch (e) {
console.error(e);
if (txHash) {
onTxSettled?.(txHash);
}
setStatus({
txHash: txHash,
type: "failed",
});
}
},
[sendTransaction],
);
return {
sendTx,
setStatus,
status,
};
}
@@ -0,0 +1,153 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { storybookThirdwebClient } from "@/storybook/utils";
import {
TransactionSectionUI,
type WalletTransaction,
} from "./TransactionsSection";
const meta = {
component: TransactionSectionUI,
decorators: [
(Story) => (
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
<Story />
</div>
),
],
title: "AI/TransactionsSection",
} satisfies Meta<typeof TransactionSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const transactionsStub: WalletTransaction[] = [
{
chain_id: "8453",
decoded: {
inputs: {
to: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "10000",
},
name: "transfer",
signature: "transfer(address,uint256)",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0x2a098695dcfa32a67ec115af7c8da1ef6f443ea72baf7e49525204dd521a985e",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
},
{
chain_id: "8453",
decoded: {
inputs: null,
name: "",
signature: "",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0xc521bfa0ba3e68fa1a52c67f93a8e215d3ade0b45956ba215390bcc0576202f1",
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "1000000000000000",
},
{
chain_id: "8453",
decoded: {
inputs: null,
name: "",
signature: "",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0xf2d92059c9ea425ccf7568bfe2589b3c7e45b108b5af658ec79c2f2d3723e410",
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "1000000000000000",
},
{
chain_id: "8453",
decoded: {
inputs: {
to: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
value: "100000",
},
name: "transfer",
signature: "transfer(address,uint256)",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0xea3da430876c09acfa665450a91edb99fe8dc018864c5dfa3ac53bf265ce8d66",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
},
{
chain_id: "8453",
decoded: {
inputs: {
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "10000000",
},
name: "approve",
signature: "approve(address,uint256)",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0xad03e5b350645f2e4cdd066c30b2f6b708aa34bd4d1c5ca20fd81ecfe1656164",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
},
{
chain_id: "8453",
decoded: {
inputs: {},
name: "initiateTransaction",
signature:
"initiateTransaction((bytes32,address,uint256,address,address,uint256,address,uint256,bytes,bytes),bytes)",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0xd3106aaa4b7e530ac7530c8ea4984eec52670aabaf5969ae6bc8d8246e74c3c0",
to_address: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "0",
},
{
chain_id: "8453",
decoded: {
inputs: {
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "1000000",
},
name: "approve",
signature: "approve(address,uint256)",
},
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
hash: "0xd284b6e0dd938b4610ff1877c1d4692a8d10d83ec6be24789dc87e1ef4aa4756",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
},
];
export const MultipleAssets: Story = {
args: {
client: storybookThirdwebClient,
data: transactionsStub,
isPending: false,
},
};
export const SingleAsset: Story = {
args: {
client: storybookThirdwebClient,
data: transactionsStub.slice(0, 1),
isPending: false,
},
};
export const EmptyAssets: Story = {
args: {
client: storybookThirdwebClient,
data: [],
isPending: false,
},
};
export const Loading: Story = {
args: {
client: storybookThirdwebClient,
data: [],
isPending: true,
},
};

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