Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
d0c2ac63a9 Version Packages (#7042)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-16 05:06:17 -07:00
Joaquim VergesandGitHub 170f14fb54 [Dashboard] Add pagination to server wallets page (#7070) 2025-05-16 05:04:26 -07:00
Joaquim VergesandGitHub da2a2a0e86 [SDK] Fix buyWithCrypto false not respected when returning from quote (#7069) 2025-05-16 04:57:48 -07:00
saminaandGitHub e62fd3fe40 [Docs] Engine Update (#7052)
Signed-off-by: samina <[email protected]>
2025-05-16 04:09:33 -07:00
Joaquim VergesandGitHub 99a7cce624 [Engine] Add all testnets to test transaction options (#7066) 2025-05-16 04:08:17 -07:00
Joaquim VergesandGitHub 61152dd098 [Engine] Add FAILED status to execution result and update error handling (#7065) 2025-05-16 03:34:27 -07:00
Maximilian HubertandGitHub 05519fcdde [DOCS] fix link in contributing.md (#7063)
Signed-off-by: Maximilian Hubert <[email protected]>
2025-05-16 00:11:57 -07:00
saminaandGitHub 7257747520 [Docs] Engine (#7057)
Signed-off-by: samina <[email protected]>
2025-05-16 00:10:35 -07:00
Prithvish BaidyaandGitHub e71e7d70a6 [Vault] default to engine-cloud vault proxy (#7058) 2025-05-15 16:44:03 -07:00
Joaquim VergesandGitHub 8d47864967 [SDK] Export prepareUserOp and add support for new wallets (#7061) 2025-05-15 16:43:11 -07:00
kumaryash90 4a8adc143f [Docs] Stylus dev workflow (#7060)
<!--

## 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 documentation and assets for integrating thirdweb tools into the Stylus contract development workflow, specifically for Rust. It includes setup instructions, publishing and deployment commands, and images to enhance the documentation.

### Detailed summary
- Added new sections in `page.mdx` for Arbitrum Stylus.
- Included prerequisites for Rust and Solidity installations.
- Provided setup commands for creating a Stylus project.
- Added commands for publishing and deploying Stylus contracts.
- Included images (`stylus-publish-page.png`, `stylus-deploy-page.png`) to illustrate the process.

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

<!-- end pr-codex -->
2025-05-15 21:30:32 +00:00
MananTank db538473cf [NEB-248] Show native token balances in assets, show popular tokens on top (#7056)
<!--

## 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 `AssetsSection` component by integrating wallet balance retrieval, modifying how assets are displayed, and implementing new logic for sorting tokens. It also introduces a new constant for native tokens and adjusts rendering based on whether an asset is native.

### Detailed summary
- Added `NATIVE_TOKEN_ADDRESS` and `getWalletBalance` import from `thirdweb`.
- Updated `key` for `AssetItem` to include `chain_id`.
- Introduced `isNativeToken` to conditionally render the `ChainIconClient`.
- Modified the `href` for `Link` based on whether the asset is a native token.
- Added a new set `tokensToShowOnTop` for sorting specific tokens.
- Implemented `nativeBalances` query to fetch wallet balances for various chains.
- Combined `nativeBalances` and `assetsQuery` data for rendering in `AssetsSectionUI`.

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

<!-- end pr-codex -->
2025-05-15 20:52:49 +00:00
Manan TankandGitHub 2057546934 Nebula: Recent Activity improvements in sidebar (#7055) 2025-05-15 13:52:15 -07:00
gregandGitHub ba52e68eab [Dashboard] Fix: Trend markers and UB volume type dropdown (#7059) 2025-05-15 12:05:40 -07:00
0eff64824b [Dashboard] Feature: Add Universal Bridge to analytics dashboard (#7051)
Co-authored-by: Claude <[email protected]>
2025-05-14 18:07:07 -07:00
saminacodes ae7407dca4 [Docs] engine and ub changes (#7049)
<!--

## 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 documentation and user guidance for the `thirdweb` platform, specifically around the Engine v3 features, onramp providers, and pricing details. It introduces new FAQs, clarifies migration steps, and adds visual content.

### Detailed summary
- Added new section for "Onramp Providers" in `sidebar.tsx`.
- Updated Engine FAQs in `page.mdx` with pricing and transaction details.
- Revised migration guide in `page.mdx` with clearer instructions and a callout for wallet migration.
- Enhanced `faqs/page.mdx` with additional questions about Universal Bridge and supported payment methods.
- Updated "Buy With Fiat" availability to 160+ countries.
- Included images for creating a vault and sending transactions in the `get-started/page.mdx`.
- Revised onramp providers section to clarify provider preferences and fees in `onramp-providers/page.mdx`.

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

<!-- end pr-codex -->
2025-05-14 22:53:11 +00:00
Joe-Thirdweb a443b309dc Adding NeoX custom chain info. (#7045)
Adding Custom chain info to

- NeoX Mainnet 47763
- NeoX Testnt 12227332

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and metadata for the `Neo X` blockchain, enhancing the dashboard with images and information about its features and offerings.

### Detailed summary
- Added imports for `NeoXBanner` and `NeoXCTA` images.
- Introduced metadata for `Neo X` (ID: 47763) including:
  - `headerImgUrl`
  - `about` description
  - `cta` object with `backgroundImageUrl`, `title`, `buttonLink`, and `buttonText`.
- Duplicated metadata for `Neo X Testnet` (ID: 12227332) with similar structure.

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

<!-- end pr-codex -->
2025-05-14 22:44:28 +00:00
Joaquim VergesGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
ae2ff743c0 [Engine] Update OpenAPI spec and configuration (#7050)
Signed-off-by: Joaquim Verges <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-05-14 15:43:30 -07:00
MananTank dfa8603593 [NEB-213] Nebula: Show wallet Assets and Activity in sidebar (#7046)
<!--

## 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 enhancing the `AssetsSection` and `TransactionsSection` components by adding new features and improving the UI. It also modifies several existing components for better functionality and user experience.

### Detailed summary
- Changed `mergedSessions.push(session)` to `mergedSessions.unshift(session)` in `useSessionsWithLocalOverrides.ts`.
- Updated the width of the sidebar in `ChatPageLayout.tsx` from `280px` to `300px`.
- Added `customDetailsButton` prop to `NebulaConnectWallet` in `NebulaConnectButton.tsx`.
- Implemented conditional rendering for `customDetailsButton` in `NebulaConnectWallet`.
- Created storybook stories for `AssetsSection` and `TransactionsSection` with stubs for tokens and transactions.
- Enhanced `AssetsSection` and `TransactionsSection` components to handle loading states and display messages when there are no assets or transactions.
- Introduced `WalletDetails` component in `ChatSidebar` to manage assets and transactions tabs.
- Refactored `WalletSelector` to integrate `useActiveWallet` for better wallet management and user feedback.
- Improved button components with tooltips for better accessibility and UX.

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

<!-- end pr-codex -->
2025-05-14 21:47:42 +00:00
saminacodes 9f1ca54c26 Update page.mdx (#7048)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `preferredProvider` options in the `detailsModal` configuration for the `connectbutton` component, enhancing payment provider flexibility.

### Detailed summary
- Modified `preferredProvider` options in `detailsModal`:
  - Removed `"KADO"`
  - Added `"COINBASE"`

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

<!-- end pr-codex -->
2025-05-14 17:44:23 +00:00
MananTank 7df81b7323 [TOOL-4493] Dashboard: Add starter legacy discontinuation banner (#7047)
<!--

## 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 `StarterLegacyDiscontinuedBanner` component to the `RootTeamLayout` in `layout.tsx`. It displays a notification for users on the "starter_legacy" billing plan, informing them of the discontinuation and providing a link to select a new plan.

### Detailed summary
- Added a conditional rendering for `StarterLegacyDiscontinuedBanner` based on the team's billing plan.
- Created the `StarterLegacyDiscontinuedBanner` component to inform users about the discontinuation of starter legacy plans.
- Included a button with a link to the billing settings for selecting a new plan, featuring an `ArrowRightIcon`.

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

<!-- end pr-codex -->
2025-05-14 16:25:35 +00:00
GiselleNessi ab74fcdaf8 add new apis tab to nav (#7044)
<!--

## 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 adds a new section to the `Header` component in the `Header.tsx` file, introducing `apisLinks` for API documentation links and integrating a `DropdownLinks` component for displaying these links.

### Detailed summary
- Added a new constant `apisLinks` containing links to API documentation.
- Introduced a `DropdownLinks` component to display `apisLinks` in the header.
- Adjusted padding in the header's layout from `xl:px-2` to `xl:px-1` for the new dropdown.

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

<!-- end pr-codex -->
2025-05-14 15:09:12 +00:00
Joaquim VergesandGitHub 1191d5dad9 [Engine] Add EOA read/create permissions for server wallets (#7043) 2025-05-14 22:32:35 +12:00
Joaquim VergesandGitHub 20b5ba943a [SDK] Propagate 401 errors when connecting in-app wallet (#7040) 2025-05-14 02:03:41 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
f0f8da8fab Version Packages (#7032)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 19:50:56 -07:00
Joaquim VergesandGitHub da3fc99c0b [SDK] Default to in-memory storage for inAppWallet outside browser (#7038) 2025-05-13 18:32:37 -07:00
fb4c190eb3 [Dashboard] Feature: Default analytics range to 30 days and save preference (#7039)
Co-authored-by: Claude <[email protected]>
2025-05-13 15:25:09 -07:00
FirekeeperandGitHub 1c31a1e4df [Portal] .NET ThirdwebBridge Onramping & InAppWallet 7702 Account Abstraction (#7017) 2025-05-13 14:14:45 -07:00
MananTank 04c10f82fd [TOOL-4471] GatedSwitch updates, Make In-app wallet branding gated by starter+ (#7021)
<!--

## 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 billing and plan management features in the dashboard. It introduces new props for handling plan highlights and open plan sheets, modifies tooltip styles, and adjusts billing card components to improve user experience.

### Detailed summary
- Updated `tooltip.tsx` to change padding in tooltip content.
- Changed `requiredPlan` from `authRequiredPlan` to `brandingRequiredPlan` in `Configure/index.tsx`.
- Added `openPlanSheetButtonByDefault` and `highlightPlan` props in various components.
- Modified `PlanInfoCardClient` and `Billing` components to utilize new props.
- Updated `TeamPlanBadge` to include an optional postfix.
- Enhanced `BillingPricing` logic to handle plan highlights.
- Adjusted `GatedSwitch` tooltip content for better user guidance.

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

<!-- end pr-codex -->
2025-05-13 20:22:10 +00:00
MananTank ab173886ac [TOOL-4483] Move changelog to team landing page from notifications (#7037)
<!--

## 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 notification handling in the application, removing unused functions, and enhancing the UI components related to notifications and alerts.

### Detailed summary
- Removed `getChangelogNotifications` and related props from several components.
- Introduced `DismissibleAlert` component for better alert management.
- Updated `NotificationButtonUI` to streamline notification handling.
- Adjusted UI elements for consistency and clarity.

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

<!-- end pr-codex -->
2025-05-13 19:19:55 +00:00
Joe-Thirdweb df06bec699 [Dashboard]Adding zkCandy + Onyx chain updates (#7035)
Adding custom chain info to

- Onyx Mainnet
- zkCandy Mainnet

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and updates the `chainMetaRecord` object to include information for the `Onyx` and `zkCandy` blockchains, enhancing the dashboard with additional details and visuals.

### Detailed summary
- Added imports for `onyxBanner`, `onyxCTA`, and `zkCandyBanner` images.
- Introduced `Onyx` blockchain entry in `chainMetaRecord` with header image, description, and CTA.
- Added `zkCandy` blockchain entry in `chainMetaRecord` with header image, description, and CTA.
- Updated comment for `zkCandy` entry to specify it as "zkCandy Testnet".

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

<!-- end pr-codex -->
2025-05-13 15:42:08 +00:00
Joaquim VergesandGitHub 6000dacfbb [Docs] Add Engine v2 to v3 migration guide (#7036) 2025-05-13 04:12:27 -07:00
Joaquim VergesandGitHub fc6535069b [SDK] Preload wallet balances on pay embed (#7034) 2025-05-13 17:08:26 +12:00
Joaquim VergesandGitHub b15c3c0747 Add contract availability documentation to ERC methods (#7033) 2025-05-12 17:47:28 -07:00
Joaquim VergesandGitHub 1985de89aa [SDK] Expose inMemoryStorage for inAppWallet backend usage (#7016) 2025-05-12 16:04:22 -07:00
MananTank 2b87b7bb75 [NEB-246] Nebula: handle aborted session with no messages (#7025)
<!--

## 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 `ChatSidebar`, `useSessionsWithLocalOverrides`, and `ChatPageContent` components. It improves session handling, updates the chat sidebar title, and adds a user-friendly message for sessions without messages.

### Detailed summary
- Updated `title` prop in `<ChatSidebarLink>` to default to "Untitled Chat".
- Merged new sessions with existing ones in `useSessionsWithLocalOverrides`.
- Introduced a message and icon for sessions with no messages in `ChatPageContent`.
- Conditional rendering of `<Chats>` based on message availability.

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

<!-- end pr-codex -->
2025-05-12 20:54:35 +00:00
MananTank d99a985ab4 [NEB-238] Show ENS name in selected account badge (#7024)
<!--

## 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 rendering of the `AccountName` component in the `ChatBar.tsx` file to improve the styling and structure of the address display.

### Detailed summary
- Replaced `span` with `AccountName` component for displaying the selected address.
- Added `className="text-xs"` to the `AccountName` component.
- Updated `loadingComponent` and `fallbackComponent` to use a consistent `span` with `className="text-xs"`.
- Changed `className` of `span` in `WalletSelector` from `font-mono text-sm` to `text-sm` for a consistent style.

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

<!-- end pr-codex -->
2025-05-12 20:54:35 +00:00
MananTank e74574ccf5 [NEB-239] Fix chain search not working properly for chain name starting with number (#7023)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the code by replacing `Number.parseInt` with `Number` for checking if a value is an integer across multiple files, enhancing readability and consistency.

### Detailed summary
- In `TokenSelector.tsx`, changed `Number.parseInt(searchValue)` to `Number(searchValue)`.
- In `single-network-selector.tsx`, modified the same integer check.
- In `ConfigureCustomChain.tsx`, updated integer check for `chainSlug`.
- In `NetworkSelectors.tsx`, replaced the integer check similarly.

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

<!-- end pr-codex -->
2025-05-12 20:54:35 +00:00
MananTank 98d05d6fb6 [NEB-247] Show error event in chat UI (#7022)
<!--

## 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 error handling for chat responses in the `ChatPageContent` and `chat.ts` files. It adds functionality to display error messages when an error event is received.

### Detailed summary
- Added handling for `"error"` case in `ChatPageContent.tsx` to update messages with error details.
- Implemented parsing of error data in `chat.ts` to extract error code and message.
- Updated `ChatStreamedEvent` type to include an `"error"` event with relevant data structure.

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

<!-- end pr-codex -->
2025-05-13 02:09:03 +05:30
MananTank f4ac3c2a3a [NEB-245] Limit image format types for upload (#7019)
<!--

## 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 `ImageUploadButton` component by allowing it to accept specific image file types and updating the `ChatBar` component to utilize this feature.

### Detailed summary
- In `ChatBar.tsx`, added `accept` attribute with value `"image/jpeg,image/png,image/webp"` to the file input.
- In `image-upload-button.tsx`, changed `accept` property from a static value `"image/*"` to a dynamic value using `props.accept`.
- Updated `ImageUploadButton` to include `accept` in its props definition.

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

<!-- end pr-codex -->
2025-05-12 20:26:53 +00:00
MananTank a3a4bf4361 Nebula: Fix User Image message type (#7018)
<!--

## 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 image handling in the `types.ts` and related components by modifying the structure of image objects, allowing for optional `image_url` and `b64` properties.

### Detailed summary
- In `types.ts`, changed the `image` type to include `image_url: string | null` and `b64: string | null`.
- In `ChatBar.tsx`, added `image_url: null` when pushing the image object to `userMessage.content`.
- In `Chats.tsx`, updated the condition to check if `msg.b64` is a string and used `msg.image_url ?? ""` for the URL.
- In `NebulaImage.tsx`, adjusted the skeleton div to have a minimum height of 300px.

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

<!-- end pr-codex -->
2025-05-12 17:15:52 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e8ec54e6e2 Version Packages (#7014)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-12 01:43:18 -07:00
Joaquim VergesandGitHub 58e343c10c [SDK] Improve EIP-5792 wallet capabilities support (#7003) 2025-05-12 01:25:30 -07:00
Prithvish BaidyaandGitHub bad9db9760 postfix slash to engine url (#7013) 2025-05-12 00:41:45 -07:00
309 changed files with 5085 additions and 1649 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ We use [Turborepo](https://turbo.build/repo/docs) to manage the repository, and
We use [pnpm](https://pnpm.io) for package management across the repo. `pnpm` is similar to `npm` or `yarn` but with more efficient disk space usage.
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](./packages/thirdweb). You can still find the legacy packages at [/legacy_packages](./legacy_packages).**
**With the v5 SDK, we've consolidated everything into a single project at [/packages/thirdweb](../packages/thirdweb). You can still find the legacy packages at [/legacy_packages](../legacy_packages).**
This single package provides a performant & lightweight SDK to interact with any EVM chain across Node, React, and React Native. Learn more about how to use the thirdweb SDK in our [documentation](https://portal.thirdweb.com/typescript/v5).
+8 -10
View File
@@ -48,15 +48,15 @@ async function fetchAnalytics(
);
}
// client id DEBUG OVERRIDE
// ANALYTICS_SERVICE_URL.searchParams.delete("projectId");
// ANALYTICS_SERVICE_URL.searchParams.delete("teamId");
// ANALYTICS_SERVICE_URL.searchParams.append(
// analyticsServiceUrl.searchParams.delete("projectId");
// analyticsServiceUrl.searchParams.delete("teamId");
// analyticsServiceUrl.searchParams.append(
// "teamId",
// "team_clmb33q9w00gn1x0u2ri8z0k0",
// "team_cm0lde33r02344w129k5hm2xz",
// );
// ANALYTICS_SERVICE_URL.searchParams.append(
// analyticsServiceUrl.searchParams.append(
// "projectId",
// "prj_clyqwud5y00u1na7nzxnzlz7o",
// "prj_cm4rqwx9b002qrnsnr37wqpo6",
// );
return fetch(analyticsServiceUrl, {
@@ -377,7 +377,7 @@ export async function getEcosystemWalletUsage(args: {
export async function getUniversalBridgeUsage(args: {
teamId: string;
projectId: string;
projectId?: string;
from?: Date;
to?: Date;
period?: "day" | "week" | "month" | "year" | "all";
@@ -395,11 +395,10 @@ export async function getUniversalBridgeUsage(args: {
console.error(
`Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`,
);
return null;
return [];
}
const json = await res.json();
return json.data as UniversalBridgeStats[];
}
@@ -430,6 +429,5 @@ export async function getUniversalBridgeWalletUsage(args: {
}
const json = await res.json();
return json.data as UniversalBridgeWalletStats[];
}
@@ -71,7 +71,7 @@ export function MultiNetworkSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
@@ -181,7 +181,7 @@ export function SingleNetworkSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
@@ -137,7 +137,7 @@ function RenderSidebarGroup(props: {
}
if ("separator" in link) {
return <SidebarSeparator className="my-1" />;
return <SidebarSeparator key={Math.random()} className="my-1" />;
}
return (
@@ -46,7 +46,7 @@ export function TokenSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(token.chainId).startsWith(searchValue);
}
return (
@@ -0,0 +1,36 @@
"use client";
import { XIcon } from "lucide-react";
import { useLocalStorage } from "../../../hooks/useLocalStorage";
import { Button } from "../ui/button";
export function DismissibleAlert(props: {
title: React.ReactNode;
description: React.ReactNode;
localStorageId: string;
}) {
const [isVisible, setIsVisible] = useLocalStorage(
props.localStorageId,
true,
false,
);
if (!isVisible) return null;
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
onClick={() => setIsVisible(false)}
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
aria-label="Close alert"
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
<div className="text-muted-foreground text-sm">{props.description}</div>
</div>
</div>
);
}
@@ -12,6 +12,7 @@ export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
isCurrency?: boolean;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
@@ -11,6 +11,7 @@ interface ImageUploadProps {
variant?: React.ComponentProps<typeof Button>["variant"];
className?: string;
multiple?: boolean;
accept: string;
}
export function ImageUploadButton(props: ImageUploadProps) {
@@ -34,7 +35,7 @@ export function ImageUploadButton(props: ImageUploadProps) {
ref={fileInputRef}
type="file"
multiple={props.multiple}
accept="image/*"
accept={props.accept}
onChange={handleFileChange}
className="hidden"
aria-label="Upload image"
@@ -54,11 +54,11 @@ export function ToolTipLabel(props: {
align={props.align}
sideOffset={10}
className={cn(
"max-w-[400px] whitespace-normal leading-relaxed",
"max-w-[400px] whitespace-normal p-0 leading-relaxed",
props.contentClassName,
)}
>
<div className="flex items-center gap-1.5 p-2 text-sm">
<div className="flex items-center gap-1.5 p-4 text-sm">
{props.leftIcon}
{props.label}
{props.rightIcon}
+5 -4
View File
@@ -39,9 +39,10 @@ export function getFiltersFromSearchParams(params: {
};
const defaultInterval =
differenceInCalendarDays(range.to, range.from) > 30
? "week"
: ("day" as const);
params.interval ??
(differenceInCalendarDays(range.to, range.from) > 30
? ("week" as const)
: ("day" as const));
return {
range,
@@ -50,6 +51,6 @@ export function getFiltersFromSearchParams(params: {
? ("day" as const)
: params.interval === "week"
? ("week" as const)
: defaultInterval,
: (defaultInterval as "day" | "week"),
};
}
@@ -11,7 +11,7 @@ export function ConfigureCustomChain(props: {
chainSlug: string;
}) {
const { chainSlug } = props;
const isSlugNumber = Number.isInteger(Number.parseInt(chainSlug));
const isSlugNumber = Number.isInteger(Number(chainSlug));
const [isConfigured, setIsConfigured] = useState(false);
if (isConfigured) {
@@ -29,7 +29,7 @@ export function SingleNetworkSelector(props: {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
if (Number.isInteger(Number(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -41,10 +41,14 @@ import creatorCTA from "./temp-assets/creatorCTA.png";
import lumiaBanner from "./temp-assets/lumiaBanner.png";
import somniaBanner from "./temp-assets/somniaBanner.png";
import NeoXBanner from "./temp-assets/NeoXBanner.jpg";
import NeoXCTA from "./temp-assets/NeoXCTA.jpg";
import flowBanner from "./temp-assets/flowBanner.png";
import flowCTA from "./temp-assets/flowCTA.png";
import mantleBanner from "./temp-assets/mantle.png";
import metalBanner from "./temp-assets/metalBanner.png";
import onyxBanner from "./temp-assets/onyxBanner.png";
import onyxCTA from "./temp-assets/onyxCTA.png";
import plumeBanner from "./temp-assets/plumeBanner.png";
import plumeCTA from "./temp-assets/plumeCTA.png";
@@ -73,6 +77,7 @@ import worldChainCTA from "./temp-assets/worldChainCTA.jpg";
import xaiBanner from "./temp-assets/xai-banner.jpg";
import zetachainBanner from "./temp-assets/zetachainBanner.png";
import zetachainCTA from "./temp-assets/zetachainCTA.png";
import zkCandyBanner from "./temp-assets/zkCandyBanner.jpg";
import zytronBanner from "./temp-assets/zytronBanner.png";
import zytronCTA from "./temp-assets/zytronCTA.jpg";
// END TEMPORARY
@@ -542,6 +547,30 @@ const chainMetaRecord = {
about:
"Metal L2 is a banking-focused Layer 2 blockchain built on the Optimism Superchain, designed to enable direct on-chain fiat deposits through its connection to The Digital Banking Network—an open-source blockchain banking protocol developed by Metallicus",
},
//NEO X
47763: {
headerImgUrl: NeoXBanner.src,
about:
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
cta: {
backgroundImageUrl: NeoXCTA.src,
title: "Learn more about Neo X",
buttonLink: "https://x.neo.org/",
buttonText: "Learn More",
},
},
//NEO X Testnet
12227332: {
headerImgUrl: NeoXBanner.src,
about:
"Neo X is an EVM-compatible sidechain built by Neo, incorporating Neo's distinctive dBFT consensus mechanism. Serving as a bridge between Neo N3 and the widely used EVM network, Neo X will play a crucial role in expanding the Neo ecosystem and offering developers more opportunities for innovation.",
cta: {
backgroundImageUrl: NeoXCTA.src,
title: "Learn more about Neo X",
buttonLink: "https://x.neo.org/",
buttonText: "Learn More",
},
},
//0G-Newton-Testnet
16600: {
headerImgUrl: zeroGBanner.src,
@@ -554,6 +583,18 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//Onyx Mainnet
80888: {
headerImgUrl: onyxBanner.src,
about:
"Onyx is a layer 3 blockchain secured by Ethereum and Base providing developers and institutions a secure financial-purposed decentralized blockchain for banking and securities based applications. Powered by XCN.",
cta: {
backgroundImageUrl: onyxCTA.src,
title: "Unlock the potential of your application with Onyx Connect",
buttonLink: "https://onyx.org/",
buttonText: "Learn More",
},
},
//Plume Testnet
98864: {
headerImgUrl: plumeBanner.src,
@@ -718,7 +759,7 @@ const chainMetaRecord = {
buttonText: "Learn More",
},
},
//zkCandy
//zkCandy Testnet
302: {
headerImgUrl: thirdwebBanner.src,
about:
@@ -730,6 +771,19 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//zkCandy Mainnet
320: {
headerImgUrl: zkCandyBanner.src,
about:
"ZKcandy is an AI-powered Layer-2 aiming to redefine what’s possible in gaming and entertainment. As a ZK Chain built on ZKsync’s Elastic Network, it focuses on building immersive gaming ecosystems where players make the most of today’s AI capabilities. Emerged from a groundbreaking collaboration between ZKsync and the award-winning game studio iCandy Interactive, It's the first L2 ZK chain for gaming in the ZKsync ecosystem.",
cta: {
backgroundImageUrl: zkCandyBanner.src,
title: " ",
buttonLink: "https://litepaper.zkcandy.io/dev-guide/",
buttonText: "Learn More",
},
},
//zkSync
324: {
headerImgUrl: thirdwebBanner.src,
@@ -11,7 +11,6 @@ import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../../team/components/NotificationButton/fetch-notifications";
@@ -59,7 +58,6 @@ export function AccountHeader(props: {
account: props.account,
client,
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
@@ -64,7 +64,6 @@ function Variants(props: {
email: "[email protected]",
}}
client={storybookThirdwebClient}
getChangelogNotifications={() => Promise.resolve([])}
getInboxNotifications={() => Promise.resolve([])}
markNotificationAsRead={() => Promise.resolve()}
/>
@@ -24,7 +24,6 @@ export type AccountHeaderCompProps = {
account: Pick<Account, "email" | "id" | "image">;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
@@ -78,7 +77,6 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -126,7 +124,6 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -15,7 +15,6 @@ export function SecondaryNav(props: {
connectButton: React.ReactNode;
client: ThirdwebClient;
accountAddress: string;
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
@@ -24,7 +23,6 @@ export function SecondaryNav(props: {
<SecondaryNavLinks />
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogs}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -8,8 +8,8 @@ const teamPlanToBadgeVariant: Record<
> = {
// gray
free: "secondary",
starter: "secondary",
// yellow
starter: "warning",
starter_legacy: "warning",
growth_legacy: "warning",
// green
@@ -20,7 +20,7 @@ const teamPlanToBadgeVariant: Record<
pro: "default",
};
function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
if (plan === "growth_legacy") {
return "Growth - Legacy";
}
@@ -33,13 +33,14 @@ function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
export function TeamPlanBadge(props: {
plan: Team["billingPlan"];
className?: string;
postfix?: string;
}) {
return (
<Badge
variant={teamPlanToBadgeVariant[props.plan]}
className={cn("px-1.5 capitalize", props.className)}
>
{getTeamPlanBadgeLabel(props.plan)}
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
</Badge>
);
}
@@ -0,0 +1,64 @@
import { formatDistance } from "date-fns";
import { ArrowRightIcon } from "lucide-react";
import { unstable_cache } from "next/cache";
import Link from "next/link";
type ChangelogItem = {
published_at: string;
title: string;
url: string;
};
export async function Changelog() {
const changelog = await getChangelog();
return (
<div className="relative flex flex-col gap-6 border-border border-l py-2">
{changelog.map((item) => (
<div className="flex flex-row gap-2" key={item.title}>
<div className="-translate-x-1/2 size-2.5 shrink-0 translate-y-1/2 rounded-full bg-border" />
<div className="flex flex-col">
<Link
target="_blank"
href={`${item.url}?utm_source=thirdweb&utm_campaign=changelog`}
role="group"
className="line-clamp-2 text-foreground text-sm hover:underline"
>
{item.title}
</Link>
<div className="mt-1 text-muted-foreground text-xs">
{formatDistance(new Date(item.published_at), Date.now(), {
addSuffix: true,
})}
</div>
</div>
</div>
))}
<Link
href="https://blog.thirdweb.com/changelog?utm_source=thirdweb&utm_campaign=changelog"
target="_blank"
className="flex items-center gap-2 pl-5 text-foreground text-sm hover:underline"
>
View More <ArrowRightIcon className="size-4" />
</Link>
</div>
);
}
const getChangelog = unstable_cache(
async () => {
const res = await fetch(
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=10",
);
if (!res.ok) {
return [];
}
const json = await res.json();
return json.posts as ChangelogItem[];
},
["changelog"],
{
revalidate: 60 * 60, // 1 hour
},
);
@@ -1,13 +1,13 @@
import { getWalletConnections } from "@/api/analytics";
import { type Project, getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { subDays } from "date-fns";
import { CircleAlertIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import { Changelog } from "./_components/Changelog";
import { InviteTeamMembersButton } from "./_components/invite-team-members-button";
import {
type ProjectWithAnalytics,
@@ -52,20 +52,30 @@ export default async function Page(props: {
</div>
</div>
<div className="container flex grow flex-col gap-6 pt-8 pb-20">
<Alert variant={"info"}>
<CircleAlertIcon className="h-4 w-4" />
<AlertTitle>Looking for Engines?</AlertTitle>
<AlertDescription>
Engines, contracts, project settings, and more are now managed
within projects. Open or create a project to access them.
</AlertDescription>
</Alert>
<TeamProjectsPage
projects={projectsWithTotalWallets}
team={team}
client={client}
/>
<div className="container flex grow flex-col gap-4 lg:flex-row">
{/* left */}
<div className="flex grow flex-col gap-6 pt-8 lg:pb-20">
<DismissibleAlert
title="Looking for Engines?"
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`}
/>
<TeamProjectsPage
projects={projectsWithTotalWallets}
team={team}
client={client}
/>
</div>
{/* right */}
<div className="w-full px-4 py-8 lg:w-[300px]">
<h2 className="mb-3 font-semibold text-2xl tracking-tight">
Changelog
</h2>
<Changelog />
</div>
</div>
</div>
);
@@ -1,6 +1,7 @@
import {
getClientTransactions,
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
getWalletUsers,
@@ -14,6 +15,7 @@ import { redirect } from "next/navigation";
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import type {
InAppWalletStats,
UniversalBridgeStats,
WalletStats,
WalletUserStats,
} from "types/analytics";
@@ -24,7 +26,10 @@ import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
import { getTeamBySlug } from "@/api/team";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { Suspense } from "react";
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
@@ -100,6 +105,7 @@ async function OverviewPageContent(props: {
userOpUsage,
clientTransactionsTimeSeries,
clientTransactions,
universalBridgeUsage,
] = await Promise.all([
// Aggregated wallet connections
getWalletConnections({
@@ -148,6 +154,13 @@ async function OverviewPageContent(props: {
to: range.to,
period: "all",
}),
// Universal Bridge
getUniversalBridgeUsage({
teamId: teamId,
from: range.from,
to: range.to,
period: interval,
}),
]);
const isEmpty =
@@ -164,8 +177,9 @@ async function OverviewPageContent(props: {
<div className="flex grow flex-col gap-6">
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
<div className="">
<UsersChartCard
<AppHighlightsCard
userStats={walletUserStatsTimeSeries}
volumeStats={universalBridgeUsage}
searchParams={searchParams}
/>
</div>
@@ -218,70 +232,96 @@ async function OverviewPageContent(props: {
);
}
type UserMetrics = {
totalUsers: number;
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
returningUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = UserMetrics & {
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
let cumulativeUsers = 0;
for (const stat of userStats) {
cumulativeUsers += stat.newUsers ?? 0;
const volume = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
date: stat.date,
activeUsers: stat.totalUsers ?? 0,
returningUsers: stat.returningUsers ?? 0,
newUsers: stat.newUsers ?? 0,
totalUsers: cumulativeUsers,
totalVolume: volume,
feesCollected: fees,
});
}
return metrics;
}
function UsersChartCard({
function AppHighlightsCard({
userStats,
volumeStats,
searchParams,
}: {
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats);
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
returningUsers: {
label: "Returning Users",
color: "hsl(var(--chart-4))",
totalVolume: {
label: "Total Volume",
color: "hsl(var(--chart-2))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Payments"
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
link="https://portal.thirdweb.com/connect/pay/overview"
/>
),
},
feesCollected: {
label: "Fee Revenue",
color: "hsl(var(--chart-4))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Fees"
description="Your apps haven't collected any fees yet."
link={"https://portal.thirdweb.com/connect/pay/fees"}
/>
),
},
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
} as const;
return (
<CombinedBarChartCard
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
title="Users"
title="App Highlights"
chartConfig={chartConfig}
activeChart={
(searchParams?.usersChart as keyof UserMetrics) ?? "activeUsers"
(searchParams?.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
data={timeSeriesData}
aggregateFn={(_data, key) =>
// If there is only one data point, use that one, otherwise use the previous
timeSeriesData.filter((d) => (d[key] as number) > 0).length >= 2
? timeSeriesData[timeSeriesData.length - 2]?.[key]
: timeSeriesData[timeSeriesData.length - 1]?.[key]
timeSeriesData.reduce((acc, curr) => acc + curr[key], 0)
}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
@@ -291,7 +331,7 @@ function UsersChartCard({
1
: undefined
}
queryKey="usersChart"
queryKey="appHighlights"
existingQueryParams={searchParams}
/>
);
@@ -26,7 +26,7 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { formatDate } from "date-fns";
import { PlusIcon, SearchIcon } from "lucide-react";
import { ArrowDownNarrowWideIcon, PlusIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
@@ -251,10 +251,10 @@ function SearchInput(props: {
return (
<div className="relative grow">
<Input
placeholder="Search Projects by name or Client ID"
placeholder="Project name or Client ID"
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="bg-background pl-9 lg:w-[400px]"
className="bg-background pl-9 lg:w-[320px]"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
@@ -298,8 +298,8 @@ function SelectBy(props: {
}}
>
<SelectTrigger className="min-w-[200px] bg-background capitalize">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Sort by</span>
<div className="flex items-center gap-2">
<ArrowDownNarrowWideIcon className="size-4 text-muted-foreground" />
{valueToLabel[props.value]}
</div>
</SelectTrigger>
@@ -7,9 +7,12 @@ import { PlanInfoCardUI } from "./PlanInfoCard";
export function PlanInfoCardClient(props: {
subscriptions: TeamSubscription[];
team: Team;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}) {
return (
<PlanInfoCardUI
openPlanSheetButtonByDefault={props.openPlanSheetButtonByDefault}
team={props.team}
subscriptions={props.subscriptions}
getTeam={async () => {
@@ -26,6 +29,7 @@ export function PlanInfoCardClient(props: {
return res.data.result;
}}
highlightPlan={props.highlightPlan}
/>
);
}
@@ -118,6 +118,8 @@ function Story(props: {
team={team}
subscriptions={zeroUsageOnDemandSubs}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -129,6 +131,8 @@ function Story(props: {
}}
subscriptions={zeroUsageOnDemandSubs}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -137,6 +141,8 @@ function Story(props: {
team={team}
subscriptions={trialPlanZeroUsageOnDemandSubs}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -145,6 +151,8 @@ function Story(props: {
team={team}
subscriptions={subsWith1Usage}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
@@ -153,6 +161,8 @@ function Story(props: {
team={team}
subscriptions={subsWith4Usage}
getTeam={teamTeamStub}
highlightPlan={undefined}
openPlanSheetButtonByDefault={false}
/>
</BadgeContainer>
</div>
@@ -28,11 +28,15 @@ export function PlanInfoCardUI(props: {
subscriptions: TeamSubscription[];
team: Team;
getTeam: () => Promise<Team>;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}) {
const { subscriptions, team } = props;
const { subscriptions, team, openPlanSheetButtonByDefault } = props;
const validPlan = getValidTeamPlan(team);
const isActualFreePlan = team.billingPlan === "free";
const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(false);
const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(
openPlanSheetButtonByDefault,
);
const planSub = subscriptions.find(
(subscription) => subscription.type === "PLAN",
@@ -54,6 +58,7 @@ export function PlanInfoCardUI(props: {
isOpen={isPlanSheetOpen}
onOpenChange={setIsPlanSheetOpen}
getTeam={props.getTeam}
highlightPlan={props.highlightPlan}
/>
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
@@ -298,6 +303,7 @@ function ViewPlansSheet(props: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
getTeam: () => Promise<Team>;
highlightPlan: Team["billingPlan"] | undefined;
}) {
return (
<Sheet open={props.isOpen} onOpenChange={props.onOpenChange}>
@@ -309,6 +315,7 @@ function ViewPlansSheet(props: {
team={props.team}
trialPeriodEndedAt={props.trialPeriodEndedAt}
getTeam={props.getTeam}
highlightPlan={props.highlightPlan}
/>
</SheetContent>
</Sheet>
@@ -1,4 +1,4 @@
import { getTeamBySlug } from "@/api/team";
import { type Team, getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { Billing } from "components/settings/Account/Billing";
@@ -10,8 +10,13 @@ export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<{
showPlans?: string | string[];
highlight?: string | string[];
}>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const pagePath = `/team/${params.team_slug}/settings/billing`;
const [account, team, authToken] = await Promise.all([
@@ -41,6 +46,12 @@ export default async function Page(props: {
return (
<Billing
highlightPlan={
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
: undefined
}
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
team={team}
subscriptions={subscriptions}
twAccount={account}
@@ -50,9 +50,13 @@ export function ProjectSidebarLayout(props: {
tracking: tracking("account-abstraction"),
},
{
label: "Universal Bridge",
href: `${layoutPath}/connect/universal-bridge`,
icon: PayIcon,
label: (
<span className="flex items-center gap-2">
Universal Bridge <Badge>New</Badge>
</span>
),
tracking: tracking("universal-bridge"),
},
{
@@ -1,5 +1,6 @@
"use client";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import {
@@ -7,9 +8,7 @@ import {
TrackedUnderlineLink,
} from "@/components/ui/tracked-link";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { useLocalStorage } from "hooks/useLocalStorage";
import { ArrowRightIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import { AAFooter } from "./AAFooterSection";
const TRACKING_CATEGORY = "smart-wallet";
@@ -100,27 +99,12 @@ function GasCreditAlert(props: {
teamSlug: string;
projectId: string;
}) {
const [isVisible, setIsVisible] = useLocalStorage(
`gas-credit-${props.projectId}`,
true,
false,
);
if (!isVisible) return null;
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
onClick={() => setIsVisible(false)}
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
aria-label="Close alert"
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
<h2 className="mb-0.5 font-semibold">OP Gas Credit Program</h2>
<p className="text-muted-foreground text-sm">
<DismissibleAlert
localStorageId={`${props.projectId}-gas-credit-alert`}
title="OP Gas Credit Program"
description={
<>
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<TrackedUnderlineLink
target="_blank"
@@ -130,22 +114,26 @@ function GasCreditAlert(props: {
>
Learn More
</TrackedUnderlineLink>
</p>
<div className="mt-4 flex items-center gap-4">
<Button asChild variant="outline" size="sm" className="bg-background">
<TrackedLinkTW
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
className="gap-2"
category={TRACKING_CATEGORY}
label="claim-credits"
<div className="mt-4 flex items-center gap-4">
<Button
asChild
variant="outline"
size="sm"
className="bg-background"
>
Claim your credits <ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</div>
</div>
<TrackedLinkTW
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
className="gap-2"
category={TRACKING_CATEGORY}
label="claim-credits"
>
Claim your credits <ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</>
}
/>
);
}
@@ -14,11 +14,11 @@ export default async function Page(props: {
team_slug: string;
project_slug: string;
}>;
searchParams: {
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
}>;
}) {
const params = await props.params;
const project = await getProject(params.team_slug, params.project_slug);
@@ -32,10 +32,11 @@ export default async function Page(props: {
from: searchParams.from,
to: searchParams.to,
interval: searchParams.interval,
defaultRange: "last-30",
});
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<ResponsiveSearchParamsProvider value={searchParams}>
<div>
<div className="mb-4 flex justify-start">
<PayAnalyticsFilter />
@@ -19,15 +19,10 @@ import { Loader2Icon, LockIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
arbitrumSepolia,
baseSepolia,
optimismSepolia,
sepolia,
} from "thirdweb/chains";
import * as z from "zod";
import { CopyTextButton } from "../../../../../../../../@/components/ui/CopyTextButton";
import { useTrack } from "../../../../../../../../hooks/analytics/useTrack";
import { useAllChainsData } from "../../../../../../../../hooks/chains/allChains";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
import { deleteUserAccessToken, getUserAccessToken } from "./utils";
@@ -53,6 +48,7 @@ export function SendTestTransaction(props: {
const [hasSentTx, setHasSentTx] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const chainsQuery = useAllChainsData();
const userAccessToken =
props.userAccessToken ?? getUserAccessToken(props.project.id) ?? "";
@@ -227,32 +223,13 @@ export function SendTestTransaction(props: {
<p className="text-sm">Network</p>
<SingleNetworkSelector
className="bg-background"
chainIds={[
baseSepolia.id,
optimismSepolia.id,
arbitrumSepolia.id,
sepolia.id,
1301,
1946,
1993,
919,
6342,
10143,
44787,
80002,
80069,
98985,
128123,
167009,
168587773,
37714555429,
1952959480,
97,
296,
1740,
4202,
10200,
]}
chainIds={chainsQuery.allChains
.filter(
(chain) =>
chain.testnet === true &&
chain.stackType !== "zksync_stack",
)
.map((chain) => chain.chainId)}
client={thirdwebClient}
chainId={form.watch("chainId")}
onChange={(chainId) => {
@@ -32,6 +32,10 @@ type ExecutionResult4337Serialized =
| {
status: "QUEUED";
}
| {
status: "FAILED";
error: string;
}
| {
status: "SUBMITTED";
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
@@ -183,6 +183,52 @@ export async function createWalletAccessToken(props: {
},
],
},
{
type: "eoa:read",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:create",
requiredMetadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
],
metadata: {
projectId: props.project.id,
@@ -289,7 +335,6 @@ export async function createManagementAccessToken(props: {
});
if (res.success) {
const data = res.data;
// store the management access token in the project
await updateProjectClient(
{
projectId: props.project.id,
@@ -308,8 +353,9 @@ export async function createManagementAccessToken(props: {
],
},
);
return res;
}
return res;
throw new Error(`Failed to create management access token: ${res.error}`);
}
export function maskSecret(secret: string) {
@@ -8,12 +8,14 @@ import { ServerWalletsTable } from "./wallet-table/wallet-table";
export default async function TransactionsServerWalletsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const vaultClient = await createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
});
const { team_slug, project_slug } = await props.params;
const { page } = await props.searchParams;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
@@ -30,6 +32,8 @@ export default async function TransactionsServerWalletsPage(props: {
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const pageSize = 10;
const currentPage = Number.parseInt(page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
@@ -37,10 +41,14 @@ export default async function TransactionsServerWalletsPage(props: {
auth: {
accessToken: managementAccessToken,
},
options: {},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [] }, error: null, success: true };
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
return (
<>
@@ -50,6 +58,9 @@ export default async function TransactionsServerWalletsPage(props: {
<div className="flex flex-col gap-8">
<ServerWalletsTable
wallets={eoas.data.items as Wallet[]}
totalRecords={eoas.data.totalRecords}
currentPage={currentPage}
totalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
project={project}
teamSlug={team_slug}
managementAccessToken={managementAccessToken ?? undefined}
@@ -19,12 +19,21 @@ import { useQuery } from "@tanstack/react-query";
import { formatDistanceToNowStrict } from "date-fns";
import { format } from "date-fns/format";
import { SendIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import { Button } from "../../../../../../../../../@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../../../../../../../../../@/components/ui/pagination";
import { useThirdwebClient } from "../../../../../../../../../@/constants/thirdweb.client";
import { useDashboardRouter } from "../../../../../../../../../@/lib/DashboardRouter";
import { useV5DashboardChain } from "../../../../../../../../../lib/v5-adapter";
@@ -36,11 +45,17 @@ export function ServerWalletsTableUI({
project,
teamSlug,
managementAccessToken,
totalRecords,
currentPage,
totalPages,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
}) {
const [showSigners, setShowSigners] = useState(false);
return (
@@ -121,6 +136,60 @@ export function ServerWalletsTableUI({
</TableBody>
</Table>
</TableContainer>
<div className="flex flex-col items-center p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} server wallets
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${
currentPage > 1 ? currentPage - 1 : 1
}`}
passHref
legacyBehavior
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${project.slug}/engine/cloud/server-wallets?page=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
);
}
@@ -6,16 +6,25 @@ export function ServerWalletsTable({
wallets,
project,
teamSlug,
currentPage,
totalPages,
totalRecords,
managementAccessToken,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
totalRecords: number;
currentPage: number;
totalPages: number;
}) {
return (
<ServerWalletsTableUI
wallets={wallets}
totalRecords={totalRecords}
currentPage={currentPage}
totalPages={totalPages}
project={project}
teamSlug={teamSlug}
managementAccessToken={managementAccessToken}
@@ -36,12 +36,17 @@ export function TransactionDetailsUI({
transactionHash,
confirmedAt,
createdAt,
errorMessage,
executionParams,
executionResult,
} = transaction;
const status = executionResult?.status as keyof typeof statusDetails;
const errorMessage =
executionResult && "error" in executionResult
? executionResult.error
: executionResult && "revertData" in executionResult
? executionResult.revertData?.revertReason
: null;
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
@@ -45,5 +45,9 @@ export async function getEngineInstance(params: {
result: EngineInstance;
};
if (!json.result.url.endsWith("/")) {
json.result.url += "/";
}
return json.result;
}
@@ -9,6 +9,7 @@ import {
} from "components/analytics/date-range-selector";
import type {
InAppWalletStats,
UniversalBridgeStats,
UserOpStats,
WalletStats,
WalletUserStats,
@@ -16,12 +17,16 @@ import type {
import {
getInAppWalletUsage,
getUniversalBridgeUsage,
getUserOpUsage,
getWalletConnections,
getWalletUsers,
isProjectActive,
} from "@/api/analytics";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { RangeSelector } from "components/analytics/range-selector";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
@@ -116,6 +121,7 @@ export default async function ProjectOverviewPage(props: PageProps) {
<div className="container flex max-w-7xl grow flex-col">
<Suspense fallback={<GenericLoadingPage />}>
<ProjectAnalytics
params={params}
project={project}
range={range}
interval={interval}
@@ -133,12 +139,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
async function ProjectAnalytics(props: {
project: Project;
params: PageParams;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
}) {
const { project, range, interval, searchParams, client } = props;
const { project, params, range, interval, searchParams, client } = props;
// Fetch all analytics data in parallel
const [
@@ -147,6 +154,7 @@ async function ProjectAnalytics(props: {
inAppWalletUsage,
userOpUsageTimeSeries,
userOpUsage,
universalBridgeUsage,
] = await Promise.allSettled([
// Aggregated wallet connections
getWalletConnections({
@@ -187,22 +195,30 @@ async function ProjectAnalytics(props: {
to: range.to,
period: "all",
}),
// Universal Bridge
getUniversalBridgeUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
to: range.to,
period: interval,
}),
]);
return (
<div className="flex grow flex-col gap-6">
{walletUserStatsTimeSeries.status === "fulfilled" &&
universalBridgeUsage.status === "fulfilled" &&
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0) ? (
<div className="">
<UsersChartCard
<AppHighlightsCard
chartKey={
(searchParams.usersChart as
| "totalUsers"
| "activeUsers"
| "newUsers"
| "returningUsers") ?? "activeUsers"
(searchParams.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
params={params}
userStats={walletUserStatsTimeSeries.value}
volumeStats={universalBridgeUsage.value}
searchParams={searchParams}
/>
</div>
@@ -267,14 +283,14 @@ async function ProjectAnalytics(props: {
);
}
type UserMetrics = {
totalUsers: number;
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
returningUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = UserMetrics & {
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
@@ -283,52 +299,82 @@ type TimeSeriesMetrics = UserMetrics & {
*/
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
let cumulativeUsers = 0;
for (const stat of userStats) {
cumulativeUsers += stat.newUsers ?? 0;
const volume = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter((v) => v.date === stat.date && v.status === "completed")
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
date: stat.date,
activeUsers: stat.totalUsers ?? 0,
returningUsers: stat.returningUsers ?? 0,
newUsers: stat.newUsers ?? 0,
totalUsers: cumulativeUsers,
totalVolume: volume,
feesCollected: fees,
});
}
return metrics;
}
function UsersChartCard({
function AppHighlightsCard({
chartKey,
userStats,
volumeStats,
params,
searchParams,
}: {
chartKey: keyof UserMetrics;
chartKey: keyof AggregatedMetrics;
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
params: PageParams;
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats);
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
returningUsers: {
label: "Returning Users",
color: "hsl(var(--chart-4))",
totalVolume: {
label: "Total Volume",
color: "hsl(var(--chart-2))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Payments"
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
link="https://portal.thirdweb.com/connect/pay/overview"
/>
),
},
feesCollected: {
label: "Fee Revenue",
color: "hsl(var(--chart-4))",
isCurrency: true,
emptyContent: (
<EmptyStateContent
metric="Fees"
description="Your app hasn't collected any fees yet."
link={`/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge/settings`}
/>
),
},
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" },
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" },
} as const;
return (
<CombinedBarChartCard
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
title="Users"
title="App Highlights"
chartConfig={chartConfig}
activeChart={chartKey}
queryKey="usersChart"
queryKey="appHighlights"
data={timeSeriesData}
aggregateFn={(_data, key) =>
// If there is only one data point, use that one, otherwise use the previous
@@ -1,5 +1,8 @@
import { getTeamBySlug } from "@/api/team";
import { Button } from "@/components/ui/button";
import { PosthogIdentifierServer } from "components/wallets/PosthogIdentifierServer";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { EnsureValidConnectedWalletLoginServer } from "../../components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
@@ -28,6 +31,10 @@ export default async function RootTeamLayout(props: {
return (
<div className="flex min-h-dvh flex-col">
<div className="flex grow flex-col">
{team.billingPlan === "starter_legacy" && (
<StarterLegacyDiscontinuedBanner teamSlug={team_slug} />
)}
{team.billingStatus === "pastDue" && (
<PastDueBanner teamSlug={team_slug} />
)}
@@ -50,3 +57,31 @@ export default async function RootTeamLayout(props: {
</div>
);
}
function StarterLegacyDiscontinuedBanner(props: {
teamSlug: string;
}) {
return (
<div className="border-red-600 border-b bg-red-50 px-4 py-6 text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-100">
<div className="text-center">
<p>Starter legacy plans are being discontinued on May 31, 2025</p>
<p>
To prevent service interruptions and losing access to your current
features select a new plan
</p>
<Button
asChild
size="sm"
className="mt-3 gap-2 border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800"
>
<Link
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true`}
>
Select a new plan
<ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
</div>
);
}
@@ -41,7 +41,7 @@ export function BarChart({
}}
className="aspect-auto h-[250px] w-full pt-6"
>
{data.length === 0 ? (
{data.length === 0 || data.every((d) => d[activeKey] === 0) ? (
<EmptyChartState> {emptyChartContent} </EmptyChartState>
) : (
<RechartsBarChart
@@ -87,7 +87,7 @@ export function BarChart({
});
}}
valueFormatter={(v: unknown) =>
isCurrency
isCurrency || chartConfig[activeKey]?.isCurrency
? toUSD(v as number)
: formatTickerNumber(v as number)
}
@@ -5,7 +5,12 @@ import { BarChart } from "./BarChart";
import { Stat } from "./Stat";
type CombinedBarChartConfig<K extends string> = {
[key in K]: { label: string; color: string };
[key in K]: {
label: string;
color: string;
isCurrency?: boolean;
emptyContent?: React.ReactNode;
};
};
// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client
@@ -72,7 +77,7 @@ export function CombinedBarChartCard<
<Stat
label={chartConfig[key].label}
value={
isCurrency
isCurrency || chartConfig[key].isCurrency
? toUSD(aggregateFn(data, key) ?? 0)
: (aggregateFn(data, key) ?? 0)
}
@@ -95,6 +100,7 @@ export function CombinedBarChartCard<
chartConfig={chartConfig}
data={data}
activeKey={activeChart}
emptyChartContent={chartConfig[activeChart].emptyContent}
/>
</CardContent>
</Card>
@@ -6,25 +6,46 @@ import Link from "next/link";
export function EmptyStateCard({
metric,
link,
}: { metric: string; link?: string }) {
description,
}: { metric: string; link?: string; description?: string }) {
return (
<Card className="container h-[300px] p-2 md:h-[400px]">
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-md border text-center">
<div className="flex size-8 items-center justify-center rounded-md border bg-card text-muted-foreground">
<PlugIcon className="size-4" />
</div>
<div className="font-semibold text-lg">No data available</div>
<div className="text-muted-foreground text-sm">
Your app may not be configured to use {metric}.
</div>
{link && (
<Button asChild variant="primary" size="sm" className="mt-4">
<Link href={link} target="_blank" className="text-sm">
Configure {metric}
</Link>
</Button>
)}
<EmptyStateContent
metric={metric}
link={link}
description={description}
/>
</div>
</Card>
);
}
export function EmptyStateContent({
metric,
description,
link,
}: {
metric: string;
description?: string;
link?: string;
}) {
return (
<div className="flex w-full flex-col items-center justify-center gap-2 text-foreground">
<div className="flex size-8 items-center justify-center rounded-md border bg-card">
<PlugIcon className="size-4" />
</div>
<div className="font-semibold text-lg">No data available</div>
<div className="text-muted-foreground text-sm">
{description ?? `Your app may not be configured to use ${metric}.`}
</div>
{link && (
<Button asChild variant="primary" size="sm" className="mt-4">
<Link href={link} target="_blank" className="text-sm">
Configure {metric}
</Link>
</Button>
)}
</div>
);
}
@@ -67,114 +67,46 @@ function randomNotifications(count: number): NotificationMetadata[] {
export const AllUnread: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30),
isPending: false,
},
},
notifications: randomNotifications(10),
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const Loading: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: true,
},
changelogs: {
notifications: [],
isPending: true,
},
},
notifications: [],
isPending: true,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: false,
},
changelogs: {
notifications: [],
isPending: false,
},
},
notifications: [],
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const AllRead: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
},
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
export const MixedNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoUnreadInboxNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(5).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
notifications: randomNotifications(10).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
markNotificationAsRead: () => Promise.resolve(),
},
};
@@ -8,12 +8,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TabButtons } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { formatDistance } from "date-fns";
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
import { BellIcon, InboxIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { cleanupReadNotifications } from "./fetch-notifications";
export type NotificationMetadata = {
@@ -24,19 +24,7 @@ export type NotificationMetadata = {
createdAt: string;
};
type NotificationTabs = {
changelogs: {
notifications: NotificationMetadata[];
isPending: boolean;
};
inbox: {
notifications: NotificationMetadata[];
isPending: boolean;
};
};
export function NotificationButtonUI(props: {
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
@@ -45,22 +33,10 @@ export function NotificationButtonUI(props: {
queryFn: props.getInboxNotifications,
});
const changelogsQuery = useQuery({
queryKey: ["changelogs"],
queryFn: props.getChangelogs,
});
const [readNotifications, setReadNotifications] = useState<Set<string>>(
new Set(),
);
const changelogNotifications = useMemo(() => {
return (changelogsQuery.data || []).map((notification) => ({
...notification,
isRead: notification.isRead || readNotifications.has(notification.id),
}));
}, [changelogsQuery.data, readNotifications]);
const inboxNotifications = useMemo(() => {
return (inboxNotificationsQuery.data || []).map((notification) => ({
...notification,
@@ -68,58 +44,30 @@ export function NotificationButtonUI(props: {
}));
}, [inboxNotificationsQuery.data, readNotifications]);
// Ensure we don't keep adding things to the IndexedDB storage by removing notification ids that are no longer being displayed
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (changelogNotifications.length > 0 && inboxNotifications.length > 0) {
cleanupReadNotifications({
changelogs: changelogNotifications,
updates: inboxNotifications,
});
}
}, [changelogNotifications, inboxNotifications]);
const markAsRead = useCallback(
async (id: string) => {
setReadNotifications((prev) => new Set([...prev, id]));
await props.markNotificationAsRead(id);
await cleanupReadNotifications(inboxNotifications);
},
[props.markNotificationAsRead],
[props.markNotificationAsRead, inboxNotifications],
);
const notificationTabs: NotificationTabs = {
inbox: {
notifications: inboxNotifications,
isPending: inboxNotificationsQuery.isPending,
},
changelogs: {
notifications: changelogNotifications,
isPending: changelogsQuery.isPending,
},
};
return (
<NotificationButtonInner
notificationTabs={notificationTabs}
notifications={inboxNotifications}
isPending={inboxNotificationsQuery.isPending}
markNotificationAsRead={markAsRead}
/>
);
}
export function NotificationButtonInner(props: {
notificationTabs: NotificationTabs;
notifications: NotificationMetadata[];
isPending: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const hasUnreadInboxNotifications =
props.notificationTabs.inbox.notifications.some((x) => !x.isRead);
const [tab, setTab] = useState<keyof NotificationTabs>("inbox");
const unreadNotifications = {
inbox: props.notificationTabs.inbox.notifications.filter((x) => !x.isRead),
changelogs: props.notificationTabs.changelogs.notifications.filter(
(x) => !x.isRead,
),
};
const unreadCount = props.notifications.filter((x) => !x.isRead).length;
return (
<Popover>
@@ -128,9 +76,16 @@ export function NotificationButtonInner(props: {
className="relative size-10 rounded-full bg-background p-0"
variant="outline"
>
<BellIcon className="size-4 text-muted-foreground" />
{hasUnreadInboxNotifications && (
<div className="absolute top-0 right-0 flex size-2.5 items-center justify-center rounded-full bg-red-500" />
<BellIcon
className={cn(
"size-4 text-muted-foreground",
unreadCount > 0 && "text-foreground",
)}
/>
{unreadCount > 0 && (
<div className="-top-1.5 -right-1.5 absolute flex items-center justify-center rounded-full bg-[#e1002d] px-1 py-0.5 text-white text-xs">
{unreadCount > 9 ? "9+" : unreadCount}
</div>
)}
</Button>
</PopoverTrigger>
@@ -140,64 +95,28 @@ export function NotificationButtonInner(props: {
sideOffset={10}
side="bottom"
>
<TabButtons
tabClassName="!text-sm"
tabContainerClassName="px-2 pt-1.5"
tabs={[
{
isActive: tab === "inbox",
name: (
<BadgeTab
name="Inbox"
unreadCount={unreadNotifications.inbox.length}
/>
),
onClick: () => setTab("inbox"),
},
{
isActive: tab === "changelogs",
name: (
<BadgeTab
name="Changelog"
unreadCount={unreadNotifications.changelogs.length}
/>
),
onClick: () => setTab("changelogs"),
},
]}
<div className="flex gap-2 border-b px-4 py-4">
<span className="font-semibold">Notifications</span>
<BadgeTab unreadCount={unreadCount} />
</div>
<NotificationContent
notifications={props.notifications}
isPending={props.isPending}
icon={InboxIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
{tab === "inbox" && (
<NotificationContent
notifications={props.notificationTabs.inbox.notifications}
isPending={props.notificationTabs.inbox.isPending}
icon={InboxIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
{tab === "changelogs" && (
<NotificationContent
notifications={props.notificationTabs.changelogs.notifications}
isPending={props.notificationTabs.changelogs.isPending}
icon={ActivityIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
</PopoverContent>
</Popover>
);
}
function BadgeTab(props: {
name: string;
unreadCount: number;
}) {
return (
<span className="flex items-center gap-2">
{props.name}
{props.unreadCount > 0 && (
<span className="rounded-lg bg-accent px-1.5 py-1 text-xs">
<span className="rounded-xl border bg-card px-1.5 py-0.5 text-xs">
{props.unreadCount}
</span>
)}
@@ -30,18 +30,13 @@ async function fetchGhostPosts(tag: string) {
// Clean up the indexedDB storage of read notification IDs
// This is to prevent the storage from growing indefinitely
// Remove notification IDs from storage that are no longer shown to the user
export async function cleanupReadNotifications(params: {
changelogs: NotificationMetadata[];
updates: NotificationMetadata[];
}) {
const { changelogs, updates } = params;
export async function cleanupReadNotifications(
notifications: NotificationMetadata[],
) {
const readIds = await getReadNotificationIds();
// Get all current notification IDs
const currentIds = new Set([
...changelogs.map((item) => item.id),
...updates.map((item) => item.id),
]);
const currentIds = new Set([...notifications.map((item) => item.id)]);
// remove ids that are no longer being displayed
const usedIds = new Set(
@@ -59,23 +54,6 @@ export async function markNotificationAsRead(id: string) {
await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
}
export async function getChangelogNotifications(): Promise<
NotificationMetadata[]
> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("changelog"),
getReadNotificationIds(),
]);
return changelogs.map((item) => ({
id: item.id,
title: item.title,
href: item.url,
isRead: readIds.has(item.id),
createdAt: item.published_at,
}));
}
export async function getInboxNotifications(): Promise<NotificationMetadata[]> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("update"),
@@ -147,7 +147,6 @@ function Variant(props: {
const Comp =
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
const getChangelogsStub = () => Promise.resolve([]);
const getInboxNotificationsStub = () => Promise.resolve([]);
const markNotificationAsReadStub = () => Promise.resolve();
@@ -166,7 +165,6 @@ function Variant(props: {
connectButton={<ConnectButtonStub />}
createProject={() => {}}
client={storybookThirdwebClient}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
@@ -31,7 +31,6 @@ export type TeamHeaderCompProps = {
createProject: (team: Team) => void;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
@@ -117,7 +116,6 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -202,7 +200,6 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
@@ -11,7 +11,6 @@ import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../NotificationButton/fetch-notifications";
@@ -64,7 +63,6 @@ export function TeamHeaderLoggedIn(props: {
},
client: props.client,
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
@@ -152,6 +152,24 @@ export async function promptNebula(params: {
break;
}
case "error": {
const data = JSON.parse(event.data) as {
code: number;
error: {
message: string;
};
};
params.handleStream({
event: "error",
data: {
code: data.code,
errorMessage: data.error.message,
},
});
break;
}
case "init": {
const data = JSON.parse(event.data);
params.handleStream({
@@ -242,6 +260,13 @@ type ChatStreamedResponse =
chain_ids: number[];
networks: NebulaContext["networks"];
};
}
| {
event: "error";
data: {
code: number;
errorMessage: string;
};
};
type ChatStreamedEvent =
@@ -269,4 +294,8 @@ type ChatStreamedEvent =
| {
event: "context";
data: string;
}
| {
event: "error";
data: string;
};
@@ -6,11 +6,8 @@ type SessionContextFilter = {
type NebulaUserMessageContentItem =
| {
type: "image";
image_url: string;
}
| {
type: "image";
b64: string;
image_url: string | null;
b64: string | null;
}
| {
type: "text";
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookThirdwebClient } from "../../../../../stories/utils";
import { type AssetBalance, AssetsSectionUI } from "./AssetsSection";
const meta = {
title: "Nebula/AssetsSection",
component: AssetsSectionUI,
decorators: [
(Story) => (
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
<Story />
</div>
),
],
} satisfies Meta<typeof AssetsSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const tokensStub: AssetBalance[] = [
{
chain_id: 8453,
token_address: "0xe8e55a847bb446d967ef92f4580162fb8f2d3f38",
name: "Broge",
symbol: "BROGE",
decimals: 18,
balance: "10000000000000000000000",
},
{
chain_id: 8453,
token_address: "0x8d2757ea27aabf172da4cca4e5474c76016e3dc5",
name: "clBTC",
symbol: "clBTC",
decimals: 18,
balance: "2",
},
{
chain_id: 8453,
token_address: "0xb56d0839998fd79efcd15c27cf966250aa58d6d3",
name: "BASED USA",
symbol: "USA",
decimals: 18,
balance: "1000000000000000000",
},
{
chain_id: 8453,
token_address: "0x600c9b69a65fb6d2551623a53ddef17b050233cd",
name: "BearPaw",
symbol: "PAW",
decimals: 18,
balance: "48888800000000000000",
},
{
chain_id: 8453,
token_address: "0x4c96a67b0577358894407af7bc3158fc1dffbeb5",
name: "Degen Point Of View",
symbol: "POV",
decimals: 18,
balance: "69000000000000000000",
},
{
chain_id: 8453,
token_address: "0x4200000000000000000000000000000000000006",
name: "Wrapped Ether",
symbol: "WETH",
decimals: 18,
balance: "6237535850425",
},
];
export const MultipleAssets: Story = {
args: {
data: tokensStub,
isPending: false,
client: storybookThirdwebClient,
},
};
export const SingleAsset: Story = {
args: {
data: tokensStub.slice(0, 1),
isPending: false,
client: storybookThirdwebClient,
},
};
export const EmptyAssets: Story = {
args: {
data: [],
isPending: false,
client: storybookThirdwebClient,
},
};
export const Loading: Story = {
args: {
data: [],
isPending: true,
client: storybookThirdwebClient,
},
};
@@ -0,0 +1,257 @@
import { Skeleton } from "@/components/ui/skeleton";
import { isProd } from "@/constants/env-utils";
import { useQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react";
import Link from "next/link";
import {
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
defineChain,
getAddress,
toTokens,
} from "thirdweb";
import {
Blobbie,
TokenIcon,
TokenProvider,
useActiveAccount,
useActiveWalletChain,
} from "thirdweb/react";
import { getWalletBalance } from "thirdweb/wallets";
import { ChainIconClient } from "../../../../../components/icons/ChainIcon";
import { useAllChainsData } from "../../../../../hooks/chains/allChains";
import { nebulaAppThirdwebClient } from "../../utils/nebulaThirdwebClient";
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
key={`${asset.chain_id}-${asset.token_address}`}
asset={asset}
client={props.client}
/>
))}
{props.isPending &&
new Array(10).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<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;
return (
<TokenProvider
address={props.asset.token_address}
client={props.client}
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.asset.chain_id)}
>
<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"
loadingComponent={
<Blobbie
address={props.asset.token_address}
className="size-8 rounded-full"
/>
}
fallbackComponent={
<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
client={props.client}
className="size-3.5"
src={chainMeta?.icon?.url || ""}
/>
</div>
)}
</div>
<div className="flex min-w-0 flex-col text-sm">
<Link
href={
isNativeToken
? `https://thirdweb.com/${props.asset.chain_id}`
: `https://thirdweb.com/${props.asset.chain_id}/${props.asset.token_address}`
}
target="_blank"
className="truncate font-medium before:absolute before:inset-0"
>
{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({
queryKey: ["v1/tokens/erc20", account?.address, activeChain?.id],
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", nebulaAppThirdwebClient.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;
});
},
enabled: !!account && !!activeChain,
});
const nativeBalances = useQuery({
queryKey: ["getWalletBalance", account?.address, activeChain?.id],
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({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(chain),
client: props.client,
address: account.address,
}),
),
);
return result
.filter((r) => r.status === "fulfilled")
.map((r) => ({
chain_id: r.value.chainId,
token_address: r.value.tokenAddress,
balance: r.value.value.toString(),
name: r.value.name,
symbol: r.value.symbol,
decimals: r.value.decimals,
}))
.filter((x) => x.balance !== "0") as AssetBalance[];
},
});
const isPending = assetsQuery.isPending || nativeBalances.isPending;
const data = [...(nativeBalances.data ?? []), ...(assetsQuery.data ?? [])];
return (
<AssetsSectionUI data={data} isPending={isPending} client={props.client} />
);
}
@@ -36,8 +36,10 @@ import {
AccountBlobbie,
AccountName,
AccountProvider,
WalletIcon,
WalletName,
WalletProvider,
useActiveWallet,
} from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import type { Wallet } from "thirdweb/wallets";
@@ -82,7 +84,11 @@ export function ChatBar(props: {
if (images.length > 0) {
for (const image of images) {
if (image.b64) {
userMessage.content.push({ type: "image", b64: image.b64 });
userMessage.content.push({
type: "image",
b64: image.b64,
image_url: null,
});
}
}
}
@@ -250,6 +256,7 @@ export function ChatBar(props: {
<ImageUploadButton
multiple
value={undefined}
accept="image/jpeg,image/png,image/webp"
onChange={(files) => {
const totalFiles = files.length + images.length;
@@ -446,8 +453,18 @@ function WalletSelector(props: {
selectedAddress: string | undefined;
}) {
const [open, setOpen] = useState(false);
const activeWallet = useActiveWallet();
if (!props.selectedAddress) {
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;
}
@@ -466,22 +483,32 @@ function WalletSelector(props: {
aria-expanded={open}
className="flex h-auto items-center gap-1 rounded-full px-2 py-1.5 text-xs"
>
<AccountProvider
address={props.selectedAddress}
client={props.client}
>
<AccountAvatar
className="size-4 rounded-full"
loadingComponent={
<Skeleton className="size-3 rounded-full border" />
}
fallbackComponent={
<AccountBlobbie className="size-3 rounded-full" />
}
/>
{shortenAddress(props.selectedAddress)}
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
</AccountProvider>
<WalletProvider id={activeWallet.id}>
<AccountProvider
address={props.selectedAddress}
client={props.client}
>
<AccountAvatar
className="size-3 rounded-full"
loadingComponent={accountAvatarFallback}
fallbackComponent={accountAvatarFallback}
/>
<AccountName
className="text-xs"
loadingComponent={
<span className="text-xs">
{shortenAddress(props.selectedAddress)}
</span>
}
fallbackComponent={
<span className="text-xs">
{shortenAddress(props.selectedAddress)}
</span>
}
/>
<ChevronDownIcon className="size-3 text-muted-foreground/70" />
</AccountProvider>
</WalletProvider>
</Button>
</PopoverTrigger>
@@ -496,81 +523,107 @@ function WalletSelector(props: {
</div>
<div className="[&>*:not(:last-child)]:border-b">
{sortedWallets.map((wallet) => (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
key={wallet.address}
className={cn(
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
props.selectedAddress === wallet.address && "bg-accent/50",
)}
onClick={() => {
setOpen(false);
props.onClick(wallet);
}}
>
<div className="flex items-center gap-2">
<AccountProvider address={wallet.address} client={props.client}>
<WalletProvider id={wallet.walletId}>
<div className="flex items-center gap-2">
<AccountAvatar
className="size-8 rounded-full"
loadingComponent={
<Skeleton className="size-8 rounded-full border" />
}
fallbackComponent={
<AccountBlobbie className="size-8 rounded-full" />
}
/>
{sortedWallets.map((wallet) => {
const accountBlobbie = (
<AccountBlobbie className="size-8 rounded-full" />
);
const accountAvatarFallback = (
<WalletIcon
className="size-8 rounded-lg"
fallbackComponent={accountBlobbie}
loadingComponent={accountBlobbie}
/>
);
<div>
<div className="flex items-center gap-1">
<AccountName
className="text-sm"
loadingComponent={
<span className="font-mono text-sm">
{shortenAddress(wallet.address)}
</span>
}
fallbackComponent={
<span className="font-mono text-sm">
{shortenAddress(wallet.address)}
</span>
}
return (
<div
role="button"
tabIndex={0}
key={wallet.address}
className={cn(
"flex cursor-pointer items-center justify-between px-3 py-4 hover:bg-accent/50",
props.selectedAddress === wallet.address && "bg-accent/50",
)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setOpen(false);
props.onClick(wallet);
}
}}
onClick={() => {
setOpen(false);
props.onClick(wallet);
}}
>
<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"
loadingComponent={accountAvatarFallback}
fallbackComponent={accountAvatarFallback}
/>
<CopyButton address={wallet.address} />
<div className="flex flex-col">
<div className="flex items-center gap-1">
<AccountName
className="text-sm"
loadingComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
fallbackComponent={
<span className="text-sm">
{shortenAddress(wallet.address)}
</span>
}
/>
{wallet.walletId === "smart" && (
<Badge variant="outline" className="bg-card px-2">
Gasless
</Badge>
)}
</div>
<CopyButton address={wallet.address} />
<div className="text-muted-foreground text-xs">
{wallet.walletId === "smart" ? (
"Smart Account"
) : (
<WalletName
fallbackComponent={<span> Your Account </span>}
loadingComponent={
<Skeleton className="h-3.5 w-40" />
}
/>
)}
{wallet.walletId === "smart" && (
<Badge
variant="outline"
className="bg-card px-2"
>
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>
</div>
</div>
</WalletProvider>
</AccountProvider>
</WalletProvider>
</AccountProvider>
</div>
</div>
{props.selectedAddress === wallet.address && (
<CheckIcon className="size-4 text-foreground" />
)}
</div>
))}
{props.selectedAddress === wallet.address && (
<CheckIcon className="size-4 text-foreground" />
)}
</div>
);
})}
</div>
</PopoverContent>
</Popover>
@@ -9,7 +9,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { ArrowRightIcon } from "lucide-react";
import { ArrowRightIcon, MessageSquareXIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -263,8 +263,11 @@ export function ChatPageContent(props: {
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,
@@ -318,17 +321,35 @@ export function ChatPageContent(props: {
</div>
) : (
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
<Chats
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-6 pb-32"
client={client}
enableAutoScroll={enableAutoScroll}
setEnableAutoScroll={setEnableAutoScroll}
sendMessage={handleSendMessage}
/>
{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
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-6 pb-32"
client={client}
enableAutoScroll={enableAutoScroll}
setEnableAutoScroll={setEnableAutoScroll}
sendMessage={handleSendMessage}
/>
)}
<div className="container max-w-[800px]">
<ChatBar
@@ -578,6 +599,20 @@ export async function handleNebulaPrompt(params: {
});
return;
}
case "error": {
hasReceivedResponse = true;
setMessages((prev) => {
return [
...prev,
{
text: res.data.errorMessage,
type: "error",
},
];
});
return;
}
}
},
context: contextFilters,
@@ -17,7 +17,7 @@ export function ChatPageLayout(props: {
props.className,
)}
>
<aside className="hidden w-[280px] shrink-0 border-border border-r bg-card lg:block">
<aside className="hidden w-[300px] shrink-0 border-border border-r bg-card lg:block">
<ChatSidebar
sessions={props.sessions}
authToken={props.authToken}
@@ -1,10 +1,15 @@
"use client";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { TabButtons } from "@/components/ui/tabs";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import {
ChevronDownIcon,
ChevronRightIcon,
FileCode2Icon,
LogOutIcon,
@@ -14,15 +19,27 @@ import {
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import {
AccountAvatar,
AccountBlobbie,
AccountProvider,
useActiveWallet,
} from "thirdweb/react";
import { WalletIcon, WalletName, WalletProvider } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import { useIsClientMounted } from "../../../../components/ClientOnly/ClientOnly";
import { doNebulaLogout } from "../../login/auth-actions";
import type { TruncatedSessionInfo } from "../api/types";
import { useNewChatPageLink } from "../hooks/useNewChatPageLink";
import { useSessionsWithLocalOverrides } from "../hooks/useSessionsWithLocalOverrides";
import { NebulaIcon } from "../icons/NebulaIcon";
import { nebulaAppThirdwebClient } from "../utils/nebulaThirdwebClient";
import { AssetsSection } from "./AssetsSection/AssetsSection";
import { ChatSidebarLink } from "./ChatSidebarLink";
import { NebulaConnectWallet } from "./NebulaConnectButton";
import { TransactionsSection } from "./TransactionsSection/TransactionsSection";
export function ChatSidebar(props: {
sessions: TruncatedSessionInfo[];
@@ -87,7 +104,7 @@ export function ChatSidebar(props: {
return (
<ChatSidebarLink
sessionId={session.id}
title={session.title || session.id}
title={session.title || "Untitled Chat"}
key={session.id}
authToken={props.authToken}
/>
@@ -97,7 +114,11 @@ export function ChatSidebar(props: {
</div>
)}
<div className="mt-auto space-y-1 border-y border-dashed px-2 py-4">
<div className="mt-auto">
<WalletDetails />
</div>
<div className="flex items-center justify-center gap-2 border-t px-4 py-3">
<SidebarIconLink
href="https://portal.thirdweb.com/nebula"
icon={FileCode2Icon}
@@ -120,10 +141,122 @@ export function ChatSidebar(props: {
label="Log Out"
/>
</div>
</div>
);
}
<div className="[&>*]:!w-full p-4">
<NebulaConnectWallet detailsButtonClassName="!bg-background hover:!border-active-border" />
function WalletDetails() {
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 />
<Button
variant="ghost"
size="sm"
className={cn(
"h-auto w-auto p-1.5 transition-transform duration-300",
isExpanded ? "rotate-180" : "",
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronDownIcon className="size-4" />
</Button>
</div>
{isExpanded && (
<div className="border-t border-dashed">
<TabButtons
tabContainerClassName="px-2 pt-2"
tabClassName="!text-sm py-1.5"
tabs={[
{
name: "Assets",
onClick: () => setTab("assets"),
isActive: tab === "assets",
},
{
name: "Recent Activity",
onClick: () => setTab("transactions"),
isActive: tab === "transactions",
},
]}
/>
{isExpanded && (
<div className="h-[230px] overflow-y-auto py-4 pr-1 pl-2">
{tab === "assets" && (
<AssetsSection client={nebulaAppThirdwebClient} />
)}
{tab === "transactions" && (
<TransactionsSection client={nebulaAppThirdwebClient} />
)}
</div>
)}
</div>
)}
</DynamicHeight>
);
}
function CustomConnectButton() {
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
detailsButtonClassName="!bg-background hover:!border-active-border"
customDetailsButton={
activeWallet
? (address) => {
return (
<WalletProvider id={activeWallet.id}>
<AccountProvider
address={address}
client={nebulaAppThirdwebClient}
>
<Button
variant="ghost"
className="flex h-auto w-full items-center justify-start gap-2.5 rounded-lg px-2 hover:bg-accent"
>
<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
}
/>
</div>
);
}
@@ -140,7 +273,7 @@ function ToggleThemeButton() {
icon={
isClientMounted ? (theme === "light" ? SunIcon : MoonIcon) : Spinner
}
label="Theme"
label="Toggle Theme"
/>
);
}
@@ -152,16 +285,22 @@ function SidebarIconLink(props: {
href: string;
}) {
return (
<Button
asChild
variant="ghost"
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm"
>
<Link href={props.href} target={props.target} prefetch={false}>
<props.icon className="size-4" />
{props.label}
</Link>
</Button>
<ToolTipLabel label={props.label}>
<Button
asChild
variant="ghost"
className="size-10 rounded-full p-0 text-muted-foreground hover:text-foreground"
>
<Link
href={props.href}
target={props.target}
prefetch={false}
aria-label={props.label}
>
<props.icon className="size-5" />
</Link>
</Button>
</ToolTipLabel>
);
}
@@ -171,13 +310,15 @@ function SidebarIconButton(props: {
onClick: () => void;
}) {
return (
<Button
variant="ghost"
className="h-auto w-full justify-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm"
onClick={props.onClick}
>
<props.icon className="size-4" />
{props.label}
</Button>
<ToolTipLabel label={props.label}>
<Button
variant="ghost"
onClick={props.onClick}
aria-label={props.label}
className="size-10 rounded-full p-0 text-muted-foreground hover:text-foreground"
>
<props.icon className="size-5" />
</Button>
</ToolTipLabel>
);
}
@@ -202,11 +202,11 @@ function RenderMessage(props: {
<NebulaImage
type="submitted"
url={
"b64" in msg
typeof msg.b64 === "string"
? msg.b64.startsWith("data:image")
? msg.b64
: `data:image/png;base64,${msg.b64}`
: msg.image_url
: (msg.image_url ?? "")
}
client={props.client}
/>
@@ -21,6 +21,7 @@ export const NebulaConnectWallet = (props: {
connectButtonClassName?: string;
signInLinkButtonClassName?: string;
detailsButtonClassName?: string;
customDetailsButton?: (address: string) => React.ReactElement;
}) => {
const router = useDashboardRouter();
const { theme } = useTheme();
@@ -63,6 +64,7 @@ export const NebulaConnectWallet = (props: {
);
}
const { customDetailsButton } = props;
return (
<ConnectButton
theme={getSDKTheme(t)}
@@ -90,6 +92,9 @@ export const NebulaConnectWallet = (props: {
}}
detailsButton={{
className: props.detailsButtonClassName,
render: customDetailsButton
? () => customDetailsButton(account.address)
: undefined,
}}
chains={allChainsV5}
// we have an AutoConnect already added in root layout with AA configuration
@@ -55,7 +55,9 @@ export function NebulaImage(
height={props.type === "response" ? props.height : undefined}
src={src}
className="w-full rounded-lg border hover:border-active-border"
skeleton={<div className="animate-skeleton bg-muted" />}
skeleton={
<div className="min-h-[300px] animate-skeleton bg-muted" />
}
/>
</DialogTrigger>
@@ -0,0 +1,153 @@
import type { Meta, StoryObj } from "@storybook/react";
import { storybookThirdwebClient } from "../../../../../stories/utils";
import {
TransactionSectionUI,
type WalletTransaction,
} from "./TransactionsSection";
const meta = {
title: "Nebula/TransactionsSection",
component: TransactionSectionUI,
decorators: [
(Story) => (
<div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2">
<Story />
</div>
),
],
} satisfies Meta<typeof TransactionSectionUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const transactionsStub: WalletTransaction[] = [
{
chain_id: "8453",
hash: "0x2a098695dcfa32a67ec115af7c8da1ef6f443ea72baf7e49525204dd521a985e",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "transfer",
signature: "transfer(address,uint256)",
inputs: {
to: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "10000",
},
},
},
{
chain_id: "8453",
hash: "0xc521bfa0ba3e68fa1a52c67f93a8e215d3ade0b45956ba215390bcc0576202f1",
value: "1000000000000000",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
decoded: {
name: "",
signature: "",
inputs: null,
},
},
{
chain_id: "8453",
hash: "0xf2d92059c9ea425ccf7568bfe2589b3c7e45b108b5af658ec79c2f2d3723e410",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425",
value: "1000000000000000",
decoded: {
name: "",
signature: "",
inputs: null,
},
},
{
chain_id: "8453",
hash: "0xea3da430876c09acfa665450a91edb99fe8dc018864c5dfa3ac53bf265ce8d66",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "transfer",
signature: "transfer(address,uint256)",
inputs: {
to: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
value: "100000",
},
},
},
{
chain_id: "8453",
hash: "0xad03e5b350645f2e4cdd066c30b2f6b708aa34bd4d1c5ca20fd81ecfe1656164",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "approve",
signature: "approve(address,uint256)",
inputs: {
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "10000000",
},
},
},
{
chain_id: "8453",
hash: "0xd3106aaa4b7e530ac7530c8ea4984eec52670aabaf5969ae6bc8d8246e74c3c0",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "0",
decoded: {
name: "initiateTransaction",
signature:
"initiateTransaction((bytes32,address,uint256,address,address,uint256,address,uint256,bytes,bytes),bytes)",
inputs: {},
},
},
{
chain_id: "8453",
hash: "0xd284b6e0dd938b4610ff1877c1d4692a8d10d83ec6be24789dc87e1ef4aa4756",
from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37",
to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
value: "0",
decoded: {
name: "approve",
signature: "approve(address,uint256)",
inputs: {
spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d",
value: "1000000",
},
},
},
];
export const MultipleAssets: Story = {
args: {
data: transactionsStub,
isPending: false,
client: storybookThirdwebClient,
},
};
export const SingleAsset: Story = {
args: {
data: transactionsStub.slice(0, 1),
isPending: false,
client: storybookThirdwebClient,
},
};
export const EmptyAssets: Story = {
args: {
data: [],
isPending: false,
client: storybookThirdwebClient,
},
};
export const Loading: Story = {
args: {
data: [],
isPending: true,
client: storybookThirdwebClient,
},
};
@@ -0,0 +1,205 @@
import { Skeleton } from "@/components/ui/skeleton";
import { isProd } from "@/constants/env-utils";
import { useQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { shortenAddress } from "thirdweb/utils";
import { ChainIconClient } from "../../../../../components/icons/ChainIcon";
import { useAllChainsData } from "../../../../../hooks/chains/allChains";
import { nebulaAppThirdwebClient } from "../../utils/nebulaThirdwebClient";
// Note: this is not the full object type returned from insight API, it only includes fields we care about
export type WalletTransaction = {
chain_id: string;
value: string;
hash: string;
from_address: string;
to_address: string;
decoded: {
name: string;
signature: string;
inputs: null | object;
};
};
export function TransactionSectionUI(props: {
data: WalletTransaction[];
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 Recent Activity</div>
</div>
);
}
return (
<div className="flex flex-col gap-1">
{!props.isPending &&
props.data.map((asset) => (
<TransactionInfo
key={asset.hash}
transaction={asset}
client={props.client}
/>
))}
{props.isPending &&
new Array(10).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<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 TransactionInfo(props: {
transaction: WalletTransaction;
client: ThirdwebClient;
}) {
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(Number(props.transaction.chain_id));
const title = getTransactionTitle(props.transaction);
const description = getTransactionDescription(props.transaction);
const explorer =
chainMeta?.explorers?.[0]?.url ||
`https://thirdweb.com/${props.transaction.chain_id}`;
return (
<div className="relative flex h-[48px] items-center gap-2.5 rounded-lg px-2 py-1 hover:bg-accent">
<ChainIconClient
client={props.client}
className="size-8 rounded-full border"
src={chainMeta?.icon?.url || ""}
/>
<div className="flex min-w-0 flex-col text-sm">
<Link
href={`${explorer}/tx/${props.transaction.hash}`}
target="_blank"
className="truncate font-medium capitalize before:absolute before:inset-0"
>
{title}
</Link>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</div>
);
}
function getTransactionTitle(transaction: WalletTransaction) {
if (transaction.decoded.name) {
return transaction.decoded.name;
}
if (transaction.decoded.signature) {
const nameFromSignature = transaction.decoded.signature.split("(")[0];
if (nameFromSignature) {
return nameFromSignature;
}
}
if (transaction.value !== "0") {
return "Transfer";
}
return "Transaction Sent";
}
function getTransactionDescription(transaction: WalletTransaction) {
if (
typeof transaction.decoded.inputs === "object" &&
transaction.decoded.inputs !== null
) {
if (
"to" in transaction.decoded.inputs &&
typeof transaction.decoded.inputs.to === "string"
) {
return `To: ${shortenAddress(transaction.decoded.inputs.to)}`;
}
if (
"spender" in transaction.decoded.inputs &&
typeof transaction.decoded.inputs.spender === "string"
) {
return `Spender: ${shortenAddress(transaction.decoded.inputs.spender)}`;
}
}
return `To: ${shortenAddress(transaction.to_address)}`;
}
export function TransactionsSection(props: {
client: ThirdwebClient;
}) {
const account = useActiveAccount();
const activeChain = useActiveWalletChain();
const txQuery = useQuery({
queryKey: ["v1/wallets/transactions", account?.address, activeChain?.id],
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/wallets/${account.address}/transactions`,
);
url.searchParams.set("limit", "20");
url.searchParams.set("decode", "true");
url.searchParams.set("clientId", nebulaAppThirdwebClient.clientId);
const threeMonthsAgoUnixTime = Math.floor(
(Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000,
);
url.searchParams.set(
"filter_block_timestamp_gte",
`${threeMonthsAgoUnixTime}`,
);
for (const chain of chains) {
url.searchParams.append("chain", chain.toString());
}
const response = await fetch(url.toString());
const json = (await response.json()) as {
data?: WalletTransaction[];
};
return json.data ?? [];
},
retry: false,
enabled: !!account && !!activeChain,
});
return (
<TransactionSectionUI
data={txQuery.data ?? []}
isPending={txQuery.isPending}
client={props.client}
/>
);
}
@@ -7,7 +7,19 @@ export function useSessionsWithLocalOverrides(
) {
const newAddedSessions = useStore(newSessionsStore);
const deletedSessions = useStore(deletedSessionsStore);
return [...newAddedSessions, ..._sessions].filter((s) => {
const mergedSessions = [..._sessions];
for (const session of newAddedSessions) {
// if adding a new session that has same id as existing session, update the existing session
const index = mergedSessions.findIndex((s) => s.id === session.id);
if (index !== -1) {
mergedSessions[index] = session;
} else {
mergedSessions.unshift(session);
}
}
return mergedSessions.filter((s) => {
return !deletedSessions.some((d) => d === s.id);
});
}
@@ -80,7 +80,7 @@ export function DateRangeSelector(props: {
export function getLastNDaysRange(id: DurationId) {
const durationInfo = durationPresets.find((preset) => preset.id === id);
if (!durationInfo) {
throw new Error("Invalid duration id");
throw new Error(`Invalid duration id: ${id}`);
}
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
@@ -179,6 +179,7 @@ export const InAppWalletSettingsUI: React.FC<
!!config.applicationImageUrl?.length || !!config.applicationName?.length;
const authRequiredPlan = "accelerate";
const brandingRequiredPlan = "starter";
// accelerate or higher plan required
const canEditSmsCountries =
@@ -275,7 +276,7 @@ export const InAppWalletSettingsUI: React.FC<
form={form}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
requiredPlan={authRequiredPlan}
requiredPlan={brandingRequiredPlan}
/>
<NativeAppsFieldset form={form} />
@@ -1,7 +1,11 @@
"use client";
import { normalizeTimeISOString } from "@/lib/time";
import { DateRangeSelector } from "components/analytics/date-range-selector";
import { useQuery } from "@tanstack/react-query";
import {
DateRangeSelector,
type DurationId,
} from "components/analytics/date-range-selector";
import { IntervalSelector } from "components/analytics/interval-selector";
import { getUniversalBridgeFiltersFromSearchParams } from "lib/time";
import {
@@ -9,28 +13,93 @@ import {
useSetResponsiveSearchParams,
} from "responsive-rsc";
const STORAGE_KEY = "thirdweb:ub-analytics-range";
type SavedRange = {
rangeType: "custom" | DurationId | undefined;
interval: "day" | "week";
};
type SearchParams = {
from?: string;
to?: string;
interval?: "day" | "week";
};
export function PayAnalyticsFilter() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
// Load saved range from localStorage using useQuery
useQuery<SavedRange | null>({
queryKey: [
"savedRange",
responsiveSearchParams.from,
responsiveSearchParams.to,
],
queryFn: () => {
const savedRangeString = localStorage.getItem(STORAGE_KEY);
if (savedRangeString) {
try {
const savedRange = JSON.parse(savedRangeString) as SavedRange;
// Get the current range based on the saved range type
const { range } = getUniversalBridgeFiltersFromSearchParams({
from: undefined,
to: undefined,
interval: savedRange.interval,
defaultRange:
savedRange.rangeType === "custom"
? "last-30"
: savedRange.rangeType || "last-30",
});
setResponsiveSearchParams((v) => ({
...v,
from: normalizeTimeISOString(range.from),
to: normalizeTimeISOString(range.to),
interval: savedRange.interval,
}));
} catch (e) {
console.error("Failed to parse saved range:", e);
}
}
return null;
},
enabled: !responsiveSearchParams.from && !responsiveSearchParams.to,
});
const { range, interval } = getUniversalBridgeFiltersFromSearchParams({
from: responsiveSearchParams.from,
to: responsiveSearchParams.to,
interval: responsiveSearchParams.interval,
defaultRange: "last-30",
});
const saveToLocalStorage = (params: {
rangeType: string;
interval: "day" | "week";
}) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(params));
};
return (
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
range={range}
popoverAlign="end"
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
return {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
// Save to localStorage
saveToLocalStorage({
rangeType: newRange.type || "last-30",
interval: newParams.interval || "day",
});
return newParams;
});
}}
/>
@@ -38,11 +107,17 @@ export function PayAnalyticsFilter() {
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
return {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
interval: newInterval,
};
// Save to localStorage
saveToLocalStorage({
rangeType: range.type || "last-30",
interval: newInterval,
});
return newParams;
});
}}
/>
@@ -3,7 +3,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton";
import { useId, useMemo } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import type { UniversalBridgeWalletStats } from "types/analytics";
import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common";
import { CardHeading, NoDataOverlay, chartHeight } from "./common";
type GraphDataItem = {
date: string;
@@ -20,7 +20,7 @@ export function PayNewCustomers(props: {
/**
* For each date, compute the total number of wallets that have never existed before in the time series
*/
const { graphData, trend } = useMemo(() => {
const { graphData } = useMemo(() => {
const dates = new Set<string>();
for (const item of props.data) {
if (!dates.has(item.date)) {
@@ -47,16 +47,7 @@ export function PayNewCustomers(props: {
value: newUsers,
});
}
const lastPeriod = newUsersData[newUsersData.length - 2];
const currentPeriod = newUsersData[newUsersData.length - 1];
// Calculate the percent change from last period to current period
const trend =
lastPeriod && currentPeriod && lastPeriod.value > 0
? (currentPeriod.value - lastPeriod.value) / lastPeriod.value
: lastPeriod?.value === 0
? 100
: 0;
return { graphData: newUsersData, trend };
return { graphData: newUsersData };
}, [props.data, props.dateFormat]);
const isEmpty = useMemo(
() => graphData.length === 0 || graphData.every((x) => x.value === 0),
@@ -86,17 +77,6 @@ export function PayNewCustomers(props: {
);
}}
/>
{!isEmpty && (
<SkeletonContainer
loadedData={trend}
className="rounded-2xl"
skeletonData={1}
render={(v) => {
return <ChangeBadge percent={v} />;
}}
/>
)}
</div>
</div>
</div>
@@ -122,7 +102,7 @@ export function PayNewCustomers(props: {
{payload?.date}
</p>
<p className="text-base text-medium">
Customers: {payload?.value}
New Customers: {payload?.value}
</p>
</div>
);
@@ -43,6 +43,7 @@ export function PaymentHistory(props: {
});
return res;
},
refetchInterval: 10_000,
});
const isEmpty = useMemo(
() => !payPurchaseData?.data.length,
@@ -68,8 +69,8 @@ export function PaymentHistory(props: {
<table className="w-full selection:bg-inverted selection:text-inverted-foreground ">
<thead>
<TableHeadingRow>
<TableHeading> Paid </TableHeading>
<TableHeading> Bought </TableHeading>
<TableHeading> Sent </TableHeading>
<TableHeading> Received </TableHeading>
<TableHeading>Type</TableHeading>
<TableHeading>Status</TableHeading>
<TableHeading>Recipient</TableHeading>
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import type { UniversalBridgeStats } from "types/analytics";
import { toUSD } from "../../../../utils/number";
import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common";
import { CardHeading, NoDataOverlay, chartHeight } from "./common";
type GraphData = {
date: string;
@@ -24,7 +24,7 @@ export function Payouts(props: {
props.data.every((x) => x.developerFeeUsdCents === 0);
const barColor = isEmpty ? "hsl(var(--accent))" : "hsl(var(--chart-1))";
const { graphData, totalPayoutsUSD, trend } = useMemo(() => {
const { graphData, totalPayoutsUSD } = useMemo(() => {
const dates = new Set<string>();
for (const item of props.data) {
if (!dates.has(item.date)) {
@@ -49,18 +49,9 @@ export function Payouts(props: {
value: total / 100,
});
}
const lastPeriod = cleanedData[cleanedData.length - 2];
const currentPeriod = cleanedData[cleanedData.length - 1];
const trend =
lastPeriod && currentPeriod && lastPeriod.value > 0
? (currentPeriod.value - lastPeriod.value) / lastPeriod.value
: lastPeriod?.value === 0
? 100
: 0;
return {
graphData: cleanedData,
totalPayoutsUSD: totalPayouts / 100,
trend,
};
}, [props.data, props.dateFormat]);
@@ -90,17 +81,6 @@ export function Payouts(props: {
);
}}
/>
{!isEmpty && (
<SkeletonContainer
className="rounded-2xl"
loadedData={trend}
skeletonData={1}
render={(percent) => {
return <ChangeBadge percent={percent} />;
}}
/>
)}
</div>
<div className="relative flex w-full justify-center">
@@ -24,11 +24,10 @@ export function TotalPayVolume(props: {
};
}) {
const uniqueId = useId();
const [successType, setSuccessType] = useState<"success" | "fail">("success");
const [type, setType] = useState<"all" | "crypto" | "fiat">("all");
const graphData: GraphData[] | undefined = useMemo(() => {
let data = (() => {
const data = (() => {
switch (type) {
case "crypto": {
return props.data?.filter((x) => x.type === "onchain");
@@ -45,13 +44,6 @@ export function TotalPayVolume(props: {
}
})();
data = (() => {
if (successType === "fail") {
return data.filter((x) => x.status === "failed");
}
return data.filter((x) => x.status === "completed");
})();
const dates = new Set<string>();
for (const item of data) {
if (!dates.has(item.date)) {
@@ -72,15 +64,13 @@ export function TotalPayVolume(props: {
});
}
return cleanedData;
}, [props.data, type, successType, props.dateFormat]);
}, [props.data, type, props.dateFormat]);
const isEmpty =
graphData.length === 0 || graphData.every((x) => x.value === 0);
const chartColor = isEmpty
? "hsl(var(--muted-foreground))"
: successType === "success"
? "hsl(var(--chart-1))"
: "hsl(var(--chart-3))";
: "hsl(var(--chart-1))";
return (
<div className="flex flex-1 flex-col">
@@ -104,21 +94,6 @@ export function TotalPayVolume(props: {
<SelectItem value="fiat">Fiat</SelectItem>
</SelectContent>
</Select>
<Select
value={successType}
onValueChange={(value: "success" | "fail") => {
setSuccessType(value);
}}
>
<SelectTrigger className="bg-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="success">Successful</SelectItem>
<SelectItem value="fail">Failed</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
@@ -1,7 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
export function NoDataOverlay() {
return (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-card/50 text-muted-foreground text-sm backdrop-blur-sm">
@@ -14,39 +10,6 @@ export function CardHeading(props: { children: React.ReactNode }) {
return <h3 className="font-medium text-base">{props.children}</h3>;
}
export function ChangeBadge(props: { percent: number }) {
const percentValue = `${props.percent.toFixed(0)}%`;
let label = "No change compared to prior range";
if (props.percent !== 0) {
label = `
${props.percent >= 0 ? "Increase" : "Decrease"} of ${percentValue} compared to prior range
`;
}
return (
<ToolTipLabel label={label}>
<div>
<Badge
variant={props.percent >= 0 ? "success" : "destructive"}
className="gap-1 px-2 py-1.5 text-sm"
>
{props.percent >= 0 ? (
<ArrowUpIcon className="size-4 " />
) : (
<ArrowDownIcon className="size-4" />
)}
{new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
signDisplay: "never",
}).format(props.percent)}
</Badge>
</div>
</ToolTipLabel>
);
}
export function TableData({ children }: { children: React.ReactNode }) {
return <td className="px-3 py-2 text-sm">{children}</td>;
}
@@ -1,9 +1,14 @@
import type { Team } from "@/api/team";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { cn } from "@/lib/utils";
import { TeamPlanBadge } from "../../../../app/(app)/components/TeamPlanBadge";
import { ExternalLinkIcon } from "lucide-react";
import {
TeamPlanBadge,
getTeamPlanBadgeLabel,
} from "../../../../app/(app)/components/TeamPlanBadge";
import { planToTierRecordForGating } from "./planToTierRecord";
type SwitchProps = React.ComponentProps<typeof Switch>;
@@ -26,26 +31,40 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
return (
<ToolTipLabel
hoverable
contentClassName="max-w-[300px]"
label={
isUpgradeRequired ? (
<span>
To access this feature, <br /> Upgrade to the{" "}
<TrackedLinkTW
target="_blank"
href={`/team/${props.teamSlug}/~/settings/billing`}
category="advancedFeature"
label={props.trackingLabel}
className="text-link-foreground capitalize hover:text-foreground"
>
{props.requiredPlan} plan
</TrackedLinkTW>
</span>
<div className="w-full min-w-[280px]">
<h3 className="font-medium text-base">
<span className="capitalize">
{getTeamPlanBadgeLabel(props.requiredPlan)}+
</span>{" "}
plan required
</h3>
<p className="mb-3.5 text-muted-foreground">
Upgrade your plan to use this feature
</p>
<div className="flex w-full flex-col gap-2">
<Button asChild size="sm" className="justify-start gap-2">
<TrackedLinkTW
target="_blank"
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
category="advancedFeature"
label="checkout"
>
{`Upgrade to ${getTeamPlanBadgeLabel(props.requiredPlan)} plan`}
<ExternalLinkIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</div>
) : undefined
}
>
<div className="inline-flex items-center gap-2">
{isUpgradeRequired && <TeamPlanBadge plan={props.requiredPlan} />}
{isUpgradeRequired && (
<TeamPlanBadge plan={props.requiredPlan} postfix="+" />
)}
<Switch
{...props.switchProps}
checked={props.switchProps?.checked && !isUpgradeRequired}
@@ -29,6 +29,7 @@ interface BillingPricingProps {
team: Team;
trialPeriodEndedAt: string | undefined;
getTeam: () => Promise<Team>;
highlightPlan: Team["billingPlan"] | undefined;
}
type CtaLink =
@@ -49,6 +50,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
team,
trialPeriodEndedAt,
getTeam,
highlightPlan,
}) => {
const validTeamPlan = getValidTeamPlan(team);
const [isPending, startTransition] = useTransition();
@@ -65,17 +67,28 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
const isCurrentPlanScheduledToCancel = team.planCancellationDate !== null;
const highlightGrowthPlan =
!isCurrentPlanScheduledToCancel &&
(validTeamPlan === "free" ||
validTeamPlan === "starter" ||
validTeamPlan === "growth_legacy");
highlightPlan === "growth" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
(validTeamPlan === "free" ||
validTeamPlan === "starter" ||
validTeamPlan === "growth_legacy"));
const highlightStarterPlan =
!isCurrentPlanScheduledToCancel && validTeamPlan === "starter_legacy";
highlightPlan === "starter" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
validTeamPlan === "starter_legacy");
const highlightAcceleratePlan =
!isCurrentPlanScheduledToCancel && validTeamPlan === "growth";
highlightPlan === "accelerate" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
validTeamPlan === "growth");
const highlightScalePlan =
!isCurrentPlanScheduledToCancel && validTeamPlan === "accelerate";
highlightPlan === "scale" ||
(!highlightPlan &&
!isCurrentPlanScheduledToCancel &&
validTeamPlan === "accelerate");
return (
<div>
@@ -13,6 +13,8 @@ interface BillingProps {
subscriptions: TeamSubscription[];
twAccount: Account;
client: ThirdwebClient;
openPlanSheetButtonByDefault: boolean;
highlightPlan: Team["billingPlan"] | undefined;
}
export const Billing: React.FC<BillingProps> = ({
@@ -20,6 +22,8 @@ export const Billing: React.FC<BillingProps> = ({
subscriptions,
twAccount,
client,
openPlanSheetButtonByDefault,
highlightPlan,
}) => {
const validPayment =
team.billingStatus === "validPayment" || team.billingStatus === "pastDue";
@@ -27,7 +31,12 @@ export const Billing: React.FC<BillingProps> = ({
return (
<div className="flex flex-col gap-12">
<div>
<PlanInfoCardClient team={team} subscriptions={subscriptions} />
<PlanInfoCardClient
team={team}
subscriptions={subscriptions}
openPlanSheetButtonByDefault={openPlanSheetButtonByDefault}
highlightPlan={highlightPlan}
/>
</div>
<CreditsInfoCard
+3 -1
View File
@@ -1,4 +1,5 @@
import { getFiltersFromSearchParams } from "@/lib/time";
import type { DurationId } from "../components/analytics/date-range-selector";
export function getNebulaFiltersFromSearchParams(params: {
from: string | undefined | string[];
@@ -17,11 +18,12 @@ export function getUniversalBridgeFiltersFromSearchParams(params: {
from: string | undefined | string[];
to: string | undefined | string[];
interval: string | undefined | string[];
defaultRange: DurationId;
}) {
return getFiltersFromSearchParams({
from: params.from,
to: params.to,
interval: params.interval,
defaultRange: "last-120",
defaultRange: params.defaultRange,
});
}
+1 -2
View File
@@ -3,7 +3,7 @@
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "rm -rf .next && next dev --turbopack",
"build": "next build",
"start": "next start",
"format": "biome format ./src --write",
@@ -28,7 +28,6 @@
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tooltip": "1.2.3",
"@tanstack/react-query": "5.74.4",
"@thirdweb-dev/engine": "0.0.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
@@ -0,0 +1,104 @@
import ThirdwebProvider from "@/components/thirdweb-provider";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
import { Eip5792GetCapabilitiesPreview } from "../../../../components/account-abstraction/5792-get-capabilities";
import { Eip5792SendCallsPreview } from "../../../../components/account-abstraction/5792-send-calls";
import { PageLayout } from "../../../../components/blocks/APIHeader";
import { CodeExample } from "../../../../components/code/code-example";
export const metadata: Metadata = {
metadataBase,
title: "EIP-5792 Wallet Capabilities | thirdweb Connect",
description:
"EIP-5792 capabilities allow you to view the capabilities of the connected wallet",
};
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
title="EIP-5792 Wallet Capabilities"
description={
<>
EIP-5792 capabilities allow you to view the capabilities of the
connected wallet.
</>
}
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
>
<div className="flex flex-col gap-14">
<Eip5792GetCapabilities />
<Eip5792SendCalls />
</div>
</PageLayout>
</ThirdwebProvider>
);
}
function Eip5792GetCapabilities() {
return (
<>
<CodeExample
header={{
title: "Getting the wallet capabilities",
description:
"Get the capabilities of the connected wallet using the useCapabilities hook",
}}
preview={<Eip5792GetCapabilitiesPreview />}
code={`\
import { useCapabilities } from "thirdweb/react";
function App() {
// requires a connected wallet
// try metamask or coinbase wallet to view their capabilities
// works with in-app wallets too!
const capabilities = useCapabilities();
console.log(capabilities);
return <div>Capabilities: {JSON.stringify(capabilities)}</div>;
}
`}
lang="tsx"
/>
</>
);
}
function Eip5792SendCalls() {
return (
<>
<CodeExample
header={{
title: "Sending calls to the wallet",
description:
"Send batched calls to the connected wallet using the useSendCalls hook",
}}
preview={<Eip5792SendCallsPreview />}
lang="tsx"
code={`\
import { useSendCalls, useWaitForCallsReceipt } from "thirdweb/react";
function App() {
const { mutate: sendCalls, isPending, data } = useSendCalls();
const { data: receipt, isLoading: isConfirming } = useWaitForCallsReceipt(data);
const handleClick = async () => {
sendCalls({
calls: [firstTransaction, secondTransaction],
});
};
return (
<>
<button onClick={handleClick}>Send calls</button>
{isPending && <p>Sending...</p>}
{isConfirming && <p>Confirming...</p>}
{receipt && <p>Confirmed! {receipt?.receipts?.[0]?.transactionHash}</p>}
</>
);
}
`}
/>
</>
);
}
@@ -35,6 +35,11 @@ function Eip7702SmartAccount() {
return (
<>
<CodeExample
header={{
title: "Turning in-app wallets into EIP-7702 smart accounts",
description:
"In-app wallets can be turned into EIP-7702 smart accounts by changing the execution mode",
}}
preview={<Eip7702SmartAccountPreview />}
code={`\
import { claimTo } from "thirdweb/extensions/erc1155";
@@ -1,9 +1,11 @@
import ThirdwebProvider from "@/components/thirdweb-provider";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
import { ConnectSmartAccountCustomPreview } from "../../../../components/account-abstraction/connect-smart-account";
import { SponsoredTxPreview } from "../../../../components/account-abstraction/sponsored-tx";
import { PageLayout } from "../../../../components/blocks/APIHeader";
import { CodeExample } from "../../../../components/code/code-example";
import { SponsoredInAppTxPreview } from "../../../../components/in-app-wallet/sponsored-tx";
export const metadata: Metadata = {
metadataBase,
@@ -21,7 +23,11 @@ export default function Page() {
}
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
>
<SponsoredTx />
<div className="flex flex-col gap-14">
<SponsoredTx />
<SponsoredInAppTx />
<ConnectSmartAccount />
</div>
</PageLayout>
</ThirdwebProvider>
);
@@ -30,9 +36,15 @@ export default function Page() {
function SponsoredTx() {
return (
<CodeExample
header={{
title: "Usage with any external wallet",
description:
"Use the accountAbstraction flag on the ConnectButton or useConnect hook to automatically convert any external wallet to a EIP-4337 smart contract wallet.",
}}
preview={<SponsoredTxPreview />}
code={`import { claimTo } from "thirdweb/extensions/erc1155";
import { TransactionButton } from "thirdweb/react";
code={`\
import { useConnect } from "thirdweb/react";
import { inAppWallet } from "thirdweb/wallets";
function App(){
return (<>
@@ -54,3 +66,93 @@ function SponsoredTx() {
/>
);
}
function SponsoredInAppTx() {
return (
<CodeExample
header={{
title: "Usage with in-app wallets",
description:
"Set the executionMode when creating your in-app wallet to turn it into a EIP-4337 smart contract wallet. Note that when set, the returned address will be the smart contract wallet address.",
}}
preview={<SponsoredInAppTxPreview />}
code={`
import { inAppWallet } from "thirdweb/wallets";
import { claimTo } from "thirdweb/extensions/erc1155";
import {
ConnectButton,
TransactionButton,
} from "thirdweb/react";
import { baseSepolia } from "thirdweb/chains";
const wallets = [
inAppWallet(
{
// turn on gas sponsorship for in-app wallets
// Can use EIP4337 or EIP7702 on supported chains
executionMode: {
mode: "EIP4337",
smartAccount: { chain: baseSepolia, sponsorGas: true },
},
}),
];
function App() {
return (
<>
<ConnectButton client={client} wallets={wallets} />
{/* signless, sponsored transactions */}
<TransactionButton
transaction={() =>
claimTo({
contract,
to: "0x123...",
tokenId: 0n,
quantity: 1n,
})
}
>
Mint
</TransactionButton>
</>
);
}`}
lang="tsx"
/>
);
}
function ConnectSmartAccount() {
return (
<CodeExample
header={{
title: "Build custom UI",
description: "Build your own UI to connect to 4337 smart accounts",
}}
preview={<ConnectSmartAccountCustomPreview />}
code={`\
import { useConnect } from "thirdweb/react";
import { inAppWallet } from "thirdweb/wallets";
const wallet = inAppWallet({
executionMode: {
mode: "EIP4337",
smartAccount: { chain: baseSepolia, sponsorGas: true },
},
});
function App(){
const { connect } = useConnect();
return (<>
<button onClick={() => connect(async () => {
await wallet.connect({ client, strategy: "google" });
return adminWallet;
})}>Connect with Google</button>
</>);
};`}
lang="tsx"
/>
);
}
@@ -53,6 +53,7 @@ function UIIntegration() {
const wallets = [
inAppWallet(
// built-in auth methods
// or bring your own auth endpoint
{ auth: {
options: [
"google",
@@ -70,8 +71,12 @@ function UIIntegration() {
"guest",
]
}
},
// optional execution mode, defaults to "EOA"
executionMode: {
mode: "EIP7702", // or "EIP4337" or "EOA"
sponsorGas: true, // sponsor gas for all transactions
}
// or bring your own auth endpoint
)
];
@@ -9,16 +9,64 @@ export function useEngineTxStatus(queueId: string | undefined) {
if (!queueId) throw new Error("No queue ID provided");
const res = await get_engine_tx_status(queueId);
const txStatus: EngineTxStatus = {
queueId: queueId,
status: res.result.status,
chainId: res.result.chainId,
transactionHash: res.result.transactionHash,
queuedAt: res.result.queuedAt,
sentAt: res.result.sentAt,
minedAt: res.result.minedAt,
cancelledAt: res.result.cancelledAt,
};
let txStatus: EngineTxStatus;
switch (res.status) {
case "QUEUED": {
txStatus = {
queueId: queueId,
status: "queued",
chainId: res.chain.id.toString(),
transactionHash: null,
queuedAt: res.createdAt,
sentAt: null,
minedAt: null,
cancelledAt: null,
};
break;
}
case "SUBMITTED": {
txStatus = {
queueId: queueId,
status: "sent",
chainId: res.chain.id.toString(),
transactionHash: null,
queuedAt: res.createdAt,
sentAt: res.createdAt,
minedAt: null,
cancelledAt: null,
};
break;
}
case "CONFIRMED": {
txStatus = {
queueId: queueId,
status: "mined",
chainId: res.chain.id.toString(),
transactionHash: res.transactionHash,
queuedAt: res.createdAt,
sentAt: res.confirmedAt,
minedAt: res.confirmedAt,
cancelledAt: null,
};
break;
}
case "FAILED": {
txStatus = {
queueId: queueId,
status: "errored",
chainId: res.chain.id.toString(),
transactionHash: null,
queuedAt: res.createdAt,
sentAt: null,
minedAt: null,
cancelledAt: res.cancelledAt,
};
break;
}
default: {
throw new Error(`Unknown engine tx status: ${res}`);
}
}
return txStatus;
},
+61 -35
View File
@@ -1,12 +1,18 @@
"use server";
import { Engine } from "@thirdweb-dev/engine";
import { Engine, defineChain, encode, getContract } from "thirdweb";
import { multicall } from "thirdweb/extensions/common";
import * as ERC20 from "thirdweb/extensions/erc20";
import * as ERC1155 from "thirdweb/extensions/erc1155";
import { THIRDWEB_CLIENT } from "../../lib/client";
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
const ENGINE_VAULT_ACCESS_TOKEN = process.env
.ENGINE_VAULT_ACCESS_TOKEN as string;
const engine = new Engine({
url: process.env.ENGINE_URL as string,
accessToken: process.env.ENGINE_ACCESS_TOKEN as string,
const serverWallet = Engine.serverWallet({
address: BACKEND_WALLET_ADDRESS,
client: THIRDWEB_CLIENT,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
export async function airdrop_tokens_with_engine(params: {
@@ -17,20 +23,37 @@ export async function airdrop_tokens_with_engine(params: {
amount: string;
}[];
}) {
const res = await engine.erc20.mintBatchTo(
params.chainId.toString(),
params.contractAddress,
BACKEND_WALLET_ADDRESS,
{
data: params.receivers,
},
const contract = getContract({
address: params.contractAddress,
chain: defineChain(params.chainId),
client: THIRDWEB_CLIENT,
});
const data = await Promise.all(
params.receivers.map((receiver) =>
encode(
ERC20.mintTo({
contract,
to: receiver.toAddress,
amount: receiver.amount,
}),
),
),
);
const tx = multicall({
contract,
data,
});
return res.result;
const res = await serverWallet.enqueueTransaction({ transaction: tx });
return res.transactionId;
}
export async function get_engine_tx_status(queueId: string) {
const status = await engine.transaction.status(queueId);
const status = await Engine.getTransactionStatus({
client: THIRDWEB_CLIENT,
transactionId: queueId,
});
return status;
}
@@ -49,17 +72,19 @@ type MintNFTParams = {
};
export async function mint_erc1155_nft_with_engine(params: MintNFTParams) {
const res = await engine.erc1155.mintTo(
params.chainId.toString(),
params.contractAddress,
BACKEND_WALLET_ADDRESS,
{
receiver: params.toAddress,
metadataWithSupply: params.metadataWithSupply,
},
);
const tx = ERC1155.mintTo({
contract: getContract({
address: params.contractAddress,
chain: defineChain(params.chainId),
client: THIRDWEB_CLIENT,
}),
nft: params.metadataWithSupply.metadata,
to: params.toAddress,
supply: BigInt(params.metadataWithSupply.supply),
});
const res = await serverWallet.enqueueTransaction({ transaction: tx });
return res.result;
return res.transactionId;
}
type ClaimNFTParams = {
@@ -71,16 +96,17 @@ type ClaimNFTParams = {
};
export async function claim_erc1155_nft_with_engine(params: ClaimNFTParams) {
const res = await engine.erc1155.claimTo(
params.chainId.toString(),
params.contractAddress,
BACKEND_WALLET_ADDRESS,
{
receiver: params.receiverAddress,
quantity: params.quantity.toString(),
tokenId: params.tokenId,
},
);
const tx = ERC1155.claimTo({
contract: getContract({
address: params.contractAddress,
chain: defineChain(params.chainId),
client: THIRDWEB_CLIENT,
}),
to: params.receiverAddress,
tokenId: BigInt(params.tokenId),
quantity: BigInt(params.quantity),
});
const res = await serverWallet.enqueueTransaction({ transaction: tx });
return res.result;
return res.transactionId;
}
@@ -34,49 +34,49 @@ export function AirdropCode() {
}
const engineAirdropSendCode = `\
const chainId = ${airdropExample.chainId};
const contractAddress = "${airdropExample.contractAddress}";
const addresses = ${JSON.stringify(
airdropExample.receivers.map((x) => ({
address: x.toAddress,
quantity: x.amount,
recipient: x.toAddress,
amount: x.amount,
})),
null,
2,
)};
const url = \`\${YOUR_ENGINE_URL}\/contract/\${chainId}/\${contractAddress}\/erc1155\/airdrop\`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
"Content-Type": "application/json",
"X-Backend-Wallet-Address": "YOUR_BACKEND_WALLET_ADDRESS",
},
body: JSON.stringify({ addresses }),
const contract = getContract({
address: ${airdropExample.contractAddress},
chain: defineChain(${airdropExample.chainId}),
client: THIRDWEB_CLIENT,
});
const data = await response.json();
const queueId = data.queueId;
const transaction = airdropERC20({
contract,
tokenAddress: ${airdropExample.contractAddress},
contents: addresses,
});
const serverWallet = Engine.serverWallet({
address: BACKEND_WALLET_ADDRESS,
client: THIRDWEB_CLIENT,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
const { transactionId } = await serverWallet.enqueueTransaction({ transaction });
`;
const engineAirdropGetStatus = `\
function getEngineTxStatus(queueId: string) {
const url = \`\${YOUR_ENGINE_URL}\/transaction/\${queueId}\`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
},
});
const data = await response.json();
return data.result;
}
// you can keep polling for the status until you get a status of either "mined" or "errored" or "cancelled"
const result = await getEngineTxStatus(queueId);
const result = await Engine.getTransactionStatus({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.status);
// or wait for the transaction to be mined (polls status until it's mined)
const result = await Engine.waitForTransactionHash({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.transactionHash);
`;
@@ -32,10 +32,10 @@ export function EngineAirdropPreview() {
const res = await airdropMutation.mutateAsync();
updateEngineTxStatus({
chainId: airdropExample.chainId,
queueId: res.queueId,
queueId: res,
});
setQueueId(res.queueId);
setQueueId(res);
};
return (
@@ -1,5 +1,5 @@
export const airdropExample = {
contractAddress: "0xcB30dB8FB977e8b27ae34c86aF16C4F5E428c0bE",
contractAddress: "0x6E238275023A2575136CF60f655B6B2C0C58b4ac",
chainId: 84532,
chainName: "Base Sepolia",
chainExplorer: "https://base-sepolia.blockscout.com",
@@ -13,7 +13,7 @@ export function MintCode() {
<div className="h-4" />
<h3 className="mb-2 font-semibold text-xl tracking-tight">
Send Transaction Request to Mint NFTs
Send Transaction Request to Mint Dynamic NFTs
</h3>
<Code code={engineMintCode} lang="typescript" />
@@ -36,48 +36,45 @@ export function MintCode() {
const engineMintCode = `\
const chainId = ${mintExample.chainId};
const contractAddress = "${mintExample.contractAddress}";
const url = \`\${YOUR_ENGINE_URL}\/contract/\${chainId}/\${contractAddress}\/erc1155\/mint-to\`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
"Content-Type": "application/json",
"X-Backend-Wallet-Address": "YOUR_BACKEND_WALLET_ADDRESS",
},
body: JSON.stringify({
receiver: "0x....",
metadataWithSupply: {
metadata: {
name: "...",
description: "...",
image: "...", // ipfs or https link to your asset
},
supply: "1",
},
})
const contract = getContract({
address: ${mintExample.contractAddress},
chain: defineChain(${mintExample.chainId}),
client: THIRDWEB_CLIENT,
});
const data = await response.json();
console.log(data.queueId);
const transaction = mintTo({
contract,
to: "0x....",
nft: {
name: "...",
description: "...",
image: "...", // ipfs or https link to your asset
},
supply: "1",
});
const serverWallet = Engine.serverWallet({
address: BACKEND_WALLET_ADDRESS,
client: THIRDWEB_CLIENT,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});
const { transactionId } = await serverWallet.enqueueTransaction({ transaction });
`;
const getEngineStatusCode = `\
function getEngineTxStatus(queueId: string) {
const url = \`\${YOUR_ENGINE_URL}\/transaction/\${queueId}\`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_SECRET_TOKEN",
},
});
const data = await response.json();
return data.result;
}
// you can keep polling for the status until you get a status of either "mined" or "errored" or "cancelled"
const result = await getEngineTxStatus(queueId);
const result = await Engine.getTransactionStatus({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.status);
// or wait for the transaction to be mined (polls status until it's mined)
const result = await Engine.waitForTransactionHash({
client: THIRDWEB_CLIENT,
transactionId: transactionId,
});
console.log(result.transactionHash);
`;
@@ -231,10 +231,10 @@ export function EngineMintPreview() {
});
// optimistic update
queryClient.setQueryData(["engineTxStatus", result.queueId], {
queryClient.setQueryData(["engineTxStatus", result], {
status: "queued",
chainId: mintExample.chainId.toString(),
queueId: result.queueId,
queueId: result,
transactionHash: null,
queuedAt: new Date().toISOString(),
sentAt: null,
@@ -242,7 +242,7 @@ export function EngineMintPreview() {
cancelledAt: null,
} satisfies EngineTxStatus);
setQueueId(result.queueId);
setQueueId(result);
};
return (
@@ -1,5 +1,5 @@
export const mintExample = {
contractAddress: "0x8CD193648f5D4E8CD9fD0f8d3865052790A680f6",
contractAddress: "0x8a4E14591088bBce4a4Dcfa677C1af78d336d6FB",
chainId: 84532,
chainName: "Base Sepolia",
chainExplorer: "https://base-sepolia.blockscout.com",
@@ -7,7 +7,7 @@ export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
title="Mint NFTs"
title="Mint Dynamic NFTs"
description={
<>
Allow your users to mint new tokens into any given contract. You
@@ -71,7 +71,7 @@ export function EngineWebhooksPreview() {
async function onSubmit(values: z.infer<typeof formSchema>) {
const res = await claimMutation.mutateAsync(values.receiverAddress);
setQueueId(res.queueId);
setQueueId(res);
}
return (
+31 -53
View File
@@ -7,62 +7,40 @@ const staticSidebarLinks: SidebarLink[] = [
isCollapsible: false,
links: [
{
name: "Connect Wallet",
expanded: false,
links: [
{
name: "Button",
href: "/connect/sign-in/button",
},
{
name: "Embed",
href: "/connect/sign-in/embed",
},
{
name: "Headless",
href: "/connect/sign-in/headless",
},
],
name: "ConnectButton",
href: "/connect/sign-in/button",
},
{
name: "In-App Wallet",
expanded: false,
links: [
{
name: "Any Auth",
href: "/connect/in-app-wallet",
},
{
name: "Ecosystems",
href: "/connect/in-app-wallet/ecosystem",
},
{
name: "Sponsor Gas",
href: "/connect/in-app-wallet/sponsor",
},
],
name: "ConnectEmbed",
href: "/connect/sign-in/embed",
},
{
name: "Account Abstraction",
expanded: false,
links: [
{
name: "Connect",
href: "/connect/account-abstraction/connect",
},
{
name: "EIP-4337",
href: "/connect/account-abstraction/sponsor",
},
{
name: "EIP-7702",
href: "/connect/account-abstraction/7702",
},
{
name: "Native (zkSync)",
href: "/connect/account-abstraction/native-aa",
},
],
name: "Headless Connect",
href: "/connect/sign-in/headless",
},
{
name: "In-App Wallets",
href: "/connect/in-app-wallet",
},
{
name: "Ecosystem Wallets",
href: "/connect/in-app-wallet/ecosystem",
},
{
name: "EIP-4337",
href: "/connect/account-abstraction/sponsor",
},
{
name: "EIP-7702",
href: "/connect/account-abstraction/7702",
},
{
name: "EIP-5792",
href: "/connect/account-abstraction/5792",
},
{
name: "Native AA (zkSync)",
href: "/connect/account-abstraction/native-aa",
},
{
name: "Auth",
@@ -77,7 +55,7 @@ const staticSidebarLinks: SidebarLink[] = [
href: "/connect/blockchain-api",
},
{
name: "Headless UI",
name: "Headless Components",
expanded: false,
links: [
{
@@ -0,0 +1,55 @@
"use client";
import { sepolia } from "thirdweb/chains";
import {} from "thirdweb/extensions/erc1155";
import {
ConnectButton,
useActiveWallet,
useCapabilities,
useWalletInfo,
} from "thirdweb/react";
import { createWallet } from "thirdweb/wallets";
import { THIRDWEB_CLIENT } from "../../lib/client";
import CodeClient from "../code/code.client";
export function Eip5792GetCapabilitiesPreview() {
const capabilities = useCapabilities();
const activeWallet = useActiveWallet();
const walletInfo = useWalletInfo(activeWallet?.id);
return (
<div className="flex flex-col items-center justify-center gap-4">
{capabilities.isLoading ? (
<div className="mt-24 w-full">Loading...</div>
) : (
<>
<div className="flex flex-col justify-center gap-2 p-2">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={sepolia}
wallets={[
createWallet("io.metamask"),
createWallet("com.coinbase.wallet"),
]}
connectButton={{
label: "Login to view wallet capabilities",
}}
/>
</div>
<p className="text-lg">
{walletInfo.data?.name || "Wallet"} Capabilities
</p>
{capabilities.data ? (
<CodeClient
code={JSON.stringify(capabilities.data, null, 2)}
lang="json"
loader={<div className="mt-24 w-full">Loading...</div>}
className="max-h-[500px] w-[400px] overflow-y-auto"
scrollableContainerClassName="h-full"
scrollableClassName="h-full"
/>
) : null}
</>
)}
</div>
);
}
@@ -0,0 +1,258 @@
"use client";
import { getContract } from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { claimTo } from "thirdweb/extensions/erc1155";
import { getNFT, getOwnedNFTs } from "thirdweb/extensions/erc1155";
import {
ConnectButton,
MediaRenderer,
useActiveAccount,
useReadContract,
useSendCalls,
useWaitForCallsReceipt,
} from "thirdweb/react";
import { shortenHex } from "thirdweb/utils";
import { createWallet } from "thirdweb/wallets";
import { THIRDWEB_CLIENT } from "../../lib/client";
import { Spinner } from "../ui/Spinner/Spinner";
import { Button } from "../ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "../ui/card";
const chain = sepolia;
const editionDropContract = getContract({
address: "0x7B3e0B8353Ad5cD6C60355B50550F63335752f9F",
chain,
client: THIRDWEB_CLIENT,
});
const editionDropContract2 = getContract({
address: "0xe2cb0eb5147b42095c2FfA6F7ec953bb0bE347D8",
chain,
client: THIRDWEB_CLIENT,
});
export function Eip5792SendCallsPreview() {
const activeEOA = useActiveAccount();
const { data: nft, isLoading: isNftLoading } = useReadContract(getNFT, {
contract: editionDropContract2,
tokenId: 0n,
});
const { data: nft2, isLoading: isNft2Loading } = useReadContract(getNFT, {
contract: editionDropContract,
tokenId: 1n,
});
const { data: ownedNfts } = useReadContract(getOwnedNFTs, {
contract: editionDropContract,
useIndexer: false,
// biome-ignore lint/style/noNonNullAssertion: handled by queryOptions
address: activeEOA?.address!,
queryOptions: { enabled: !!activeEOA },
});
const { data: ownedNfts2 } = useReadContract(getOwnedNFTs, {
contract: editionDropContract2,
useIndexer: false,
// biome-ignore lint/style/noNonNullAssertion: handled by queryOptions
address: activeEOA?.address!,
queryOptions: { enabled: !!activeEOA },
});
const sendCalls = useSendCalls();
const results = useWaitForCallsReceipt(sendCalls.data);
const txHash = results.data?.receipts?.[0]?.transactionHash;
return (
<div className="mx-auto flex w-full max-w-md flex-col items-center justify-center gap-4">
{isNftLoading || isNft2Loading ? (
<Card className="w-full">
<CardContent className="flex items-center justify-center pt-6">
<Spinner className="h-8 w-8 text-primary" />
</CardContent>
</Card>
) : (
<>
<div className="w-full">
{!activeEOA && (
<Card className="mb-4">
<CardContent className="pt-6">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={sepolia}
wallets={[
createWallet("io.metamask"),
createWallet("com.coinbase.wallet"),
]}
connectButton={{
label: "Login to mint!",
}}
/>
</CardContent>
</Card>
)}
<Card className="w-full">
<CardHeader className="text-center">
<CardTitle>Mint 2 NFTs at once</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-row items-center justify-center gap-6">
<div className="flex flex-col items-center">
{nft && (
<>
<MediaRenderer
client={THIRDWEB_CLIENT}
src={nft.metadata.image}
className="h-32 w-32 rounded-lg"
alt={nft.metadata.name || "NFT Image"}
/>
<p className="mt-2 font-medium text-sm">
{nft.metadata.name}
</p>
<p className="text-muted-foreground text-xs">
Owned:{" "}
{ownedNfts2?.[0]?.quantityOwned.toString() || "0"}
</p>
</>
)}
</div>
<div className="flex h-8 w-8 items-center justify-center">
<span className="font-bold text-3xl">+</span>
</div>
<div className="flex flex-col items-center">
{nft2 && (
<>
<MediaRenderer
client={THIRDWEB_CLIENT}
src={nft2.metadata.image}
className="h-32 w-32 rounded-lg"
alt={nft2.metadata.name || "NFT Image"}
/>
<p className="mt-2 font-medium text-sm">
{nft2.metadata.name}
</p>
<p className="text-muted-foreground text-xs">
Owned:{" "}
{ownedNfts?.[0]?.quantityOwned.toString() || "0"}
</p>
</>
)}
</div>
</div>
</CardContent>
<CardFooter className="flex justify-center">
{activeEOA && (
<Button
onClick={async () => {
await sendCalls.mutateAsync({
calls: [
claimTo({
contract: editionDropContract2,
tokenId: 0n,
to: activeEOA.address,
quantity: 1n,
}),
claimTo({
contract: editionDropContract,
tokenId: 1n,
to: activeEOA.address,
quantity: 1n,
}),
],
});
}}
disabled={sendCalls.isPending || results.isLoading}
>
{results.isLoading ? (
"Confirming..."
) : sendCalls.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4 text-current" />
Minting...
</>
) : (
"Batch Mint with EIP-5792"
)}
</Button>
)}
</CardFooter>
</Card>
</div>
{sendCalls.data && results.isLoading && (
<Card className="mt-4 w-full">
<CardContent className="pt-6">
<div className="flex items-center justify-center gap-3">
<Spinner className="h-5 w-5 text-primary" />
<p className="text-center text-sm">
Waiting for transaction to be confirmed...
</p>
</div>
</CardContent>
</Card>
)}
{txHash && (
<Card className="mt-4 w-full">
<CardContent className="pt-6">
<div className="flex flex-col items-center gap-2">
<p className="font-medium text-green-500">
Transaction Confirmed!
</p>
<a
href={`${chain.blockExplorers?.[0]?.url}/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary text-sm hover:underline"
>
{shortenHex(txHash)}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="ml-1"
role="img"
aria-label="Open in Explorer"
>
<path
d="M18 13V19C18 19.5304 17.7893 20.0391 17.4142 20.4142C17.0391 20.7893 16.5304 21 16 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V8C3 7.46957 3.21071 6.96086 3.58579 6.58579C3.96086 6.21071 4.46957 6 5 6H11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15 3H21V9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 14L21 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
@@ -2,7 +2,7 @@
import { useState } from "react";
import { getContract } from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { baseSepolia } from "thirdweb/chains";
import { claimTo } from "thirdweb/extensions/erc1155";
import { getNFT, getOwnedNFTs } from "thirdweb/extensions/erc1155";
import {
@@ -10,15 +10,18 @@ import {
MediaRenderer,
TransactionButton,
useActiveAccount,
useActiveWallet,
useDisconnect,
useReadContract,
} from "thirdweb/react";
import { shortenHex } from "thirdweb/utils";
import { inAppWallet } from "thirdweb/wallets/in-app";
import { THIRDWEB_CLIENT } from "../../lib/client";
import { Button } from "../ui/button";
const chain = sepolia;
const editionDropAddress = "0x7B3e0B8353Ad5cD6C60355B50550F63335752f9F";
const editionDropTokenId = 1n;
const chain = baseSepolia;
const editionDropAddress = "0x638263e3eAa3917a53630e61B1fBa685308024fa";
const editionDropTokenId = 2n;
const editionDropContract = getContract({
address: editionDropAddress,
@@ -36,6 +39,8 @@ const iaw = inAppWallet({
export function Eip7702SmartAccountPreview() {
const [txHash, setTxHash] = useState<string | null>(null);
const activeEOA = useActiveAccount();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
const { data: nft, isLoading: isNftLoading } = useReadContract(getNFT, {
contract: editionDropContract,
tokenId: editionDropTokenId,
@@ -48,6 +53,21 @@ export function Eip7702SmartAccountPreview() {
queryOptions: { enabled: !!activeEOA },
});
if (activeEOA && activeWallet && activeWallet?.id !== iaw.id) {
return (
<div className="flex flex-col items-center justify-center gap-4">
Please connect with an in-app wallet for this example
<Button
onClick={() => {
disconnect(activeWallet);
}}
>
Disconnect current wallet
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4">
{isNftLoading ? (
@@ -57,7 +77,7 @@ export function Eip7702SmartAccountPreview() {
<div className="flex flex-col justify-center gap-2 p-2">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={sepolia}
chain={chain}
wallets={[iaw]}
connectButton={{
label: "Login to mint!",
+24 -1
View File
@@ -117,6 +117,21 @@ export const connectLinks: Array<{
},
] as const;
const apisLinks = [
{
name: "Insight",
href: "https://insight-api.thirdweb.com/reference",
},
{
name: "Engine Cloud",
href: "https://engine.thirdweb.com/reference#tag/write",
},
{
name: "Universal Bridge",
href: "https://bridge.thirdweb.com/reference",
},
];
const supportLinks = [
{
name: "Get thirdweb support",
@@ -221,7 +236,15 @@ export function Header() {
<DocSearch variant="search" />
</div>
<div className="xl:px-2">
<div className="xl:px-1">
<DropdownLinks
links={apisLinks}
onLinkClick={() => setShowBurgerMenu(false)}
category="APIs"
/>
</div>
<div className="xl:px-1">
<DropdownLinks
links={supportLinks}
onLinkClick={() => setShowBurgerMenu(false)}
@@ -95,7 +95,7 @@ You can specify which onramp provider to present to your users. By default, we c
client={client}
detailsModal={{
payOptions: {
preferredProvider: "STRIPE" | "KADO" | "TRANSAK",
preferredProvider: "STRIPE" | "TRANSAK" | "COINBASE",
},
}}
/>

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