Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
4d39fb6ee3 Version Packages (#6620)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-03 12:28:27 -07:00
MananTank 1e092a7bff Dashboard: Fix timestamp ordering in various charts, migrate them to use ThirdwebBarChart (#6636)
<!-- start pr-codex -->

## PR-Codex overview
This PR primarily focuses on updating various components to improve chart functionalities and data handling in the dashboard, including the introduction of the `ThirdwebBarChart`. It also refines date handling and enhances the user interface for better data representation.

### Detailed summary
- Updated date handling in `stubbedData` from February to December.
- Introduced `BadgeContainer` for displaying time ranges in `TotalSponsoredChartCard`.
- Enhanced `searchParams` structure in the analytics page to include `from` and `to` dates.
- Added `customHeader`, `toolTipValueFormatter`, and `emptyChartState` to `ThirdwebBarChart`.
- Replaced `BarChart` with `ThirdwebBarChart` in multiple components for consistency.
- Improved data mapping and sorting in `TotalSponsoredChartCard`.
- Created new stubs for `EcosystemWalletUsersChartCard` to simulate user data over various days.
- Refactored empty state components to improve user guidance when no data is available.

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

<!-- end pr-codex -->
2025-04-03 18:36:21 +00:00
MananTank 80ee3d6670 [TOOL-3950] Dashboard: link to /explore page for contracts card on chain page (#6637)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates a link in the `products.ts` file for the dashboard component, changing the URL for a specific product.

### Detailed summary
- Updated the `link` property for the product from `https://thirdweb.com/contracts` to `https://thirdweb.com/explore`.

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

<!-- end pr-codex -->
2025-04-03 17:05:00 +00:00
MananTank a7fd4e51a3 [TOOL-3946] Dashboard: Fix in-app wallet users chart timestamp incorrect ordering (#6635) 2025-04-03 13:09:56 +00:00
MananTank 61583cf6f3 Dashboard: Fix project settings page max-width (#6634)
<!--

## 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 layout of the `settings` page in the project dashboard. It enhances the styling and structure of the header and the content area.

### Detailed summary
- Replaced the existing `<h1>` element with a new structure wrapped in a `<div>` with a class of `border-b py-10`.
- Changed the font size of the `<h1>` from `text-3xl` to `text-2xl` for better responsiveness.
- Added a container with a maximum width of `7xl` around the header.
- Adjusted the padding of the content area to `pt-6 pb-20`.

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

<!-- end pr-codex -->
2025-04-03 12:58:26 +00:00
iuwqyir cf02af06a4 fix insight blueprints navigation (#6633)
## [Dashboard] Feature: Update API path format and add token lookup endpoint

## Notes for the reviewer

This PR updates the API path format in `insightBlueprints.ts` to use curly braces for path parameters (e.g., changing `/v1/transactions/:contractAddress` to `/v1/transactions/{contractAddress}`). This standardizes our path format to match OpenAPI specifications.

## How to test

The changes can be verified by checking the updated paths in the playground interface to ensure they display correctly with the new format.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the URL path parameters in the `insightBlueprints` to use curly braces `{}` instead of colons `:` for better clarity and consistency in API endpoints.

### Detailed summary
- Changed path parameters from `:paramName` to `{paramName}` for various endpoints:
  - `/v1/transactions/:contractAddress` to `/v1/transactions/{contractAddress}`
  - `/v1/wallets/:wallet_address/transactions` to `/v1/wallets/{wallet_address}/transactions`
  - Updated multiple other endpoints similarly, including:
    - `/v1/events/:contractAddress` to `/v1/events/{contractAddress}`
    - `/v1/tokens/transfers/:contract_address` to `/v1/tokens/transfers/{contract_address}`
    - `/v1/nfts/:contract_address` to `/v1/nfts/{contract_address}`
    - And many more throughout the file.

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

<!-- end pr-codex -->
2025-04-03 12:15:39 +00:00
MananTank 6001f2487e [TOOL-3909] Dashboard: Add footer links to project pages & header style adjustments (#6627)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the layout and structure of various components in the application, introducing new footer sections, and improving the user interface for better organization and navigation.

### Detailed summary
- Removed the `SupportedPlatformLink.tsx` file.
- Updated `href` in `react-native/v5/page.mdx` from `/installation` to `/getting-started`.
- Enhanced layout in multiple components with added `container` classes for better styling.
- Introduced new `FooterCard` component in various pages for documentation and tutorial links.
- Replaced `AAFooterSection` with `AAFooter` in `AccountAbstractionPage.tsx`.
- Updated `InAppWalletFooterSection` to `InAppWalletsFooter` with a new structure.
- Modified headers and added spacing in several components for improved visual hierarchy.
- Introduced new `InsightFooter` component with relevant links.
- Adjusted various components to use consistent `container` class for layout consistency.

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

<!-- end pr-codex -->
2025-04-03 17:23:24 +05:30
Joaquim VergesandGitHub 3f21445369 [SDK] Emit accountChanged event when smart wallets switch chains (#6626) 2025-04-02 18:19:14 -07:00
MananTank 9e8fd89ec0 [TOOL-3942] Playground: Fix slow dev server - generate insight sidebar links with script (#6631)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new script for managing insight blueprints, updates the `getSidebarLinks` function to use pre-fetched blueprints, and modifies the `Page` component to display these blueprints. It also refines the utility functions for fetching blueprint data.

### Detailed summary
- Added `update-insight-blueprints` script in `package.json`.
- Created `insightBlueprints.ts` to store auto-generated blueprint data.
- Updated `getSidebarLinks` to use `insightBlueprints` instead of fetching blueprints dynamically.
- Modified `Page` component to render blueprints from `insightBlueprints`.
- Enhanced utility functions in `utils.ts` for fetching blueprint data.

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

<!-- end pr-codex -->
2025-04-02 23:28:15 +00:00
Joaquim VergesandGitHub 95a87530e3 [SDK] Fix updateListing date handling in marketplace direct listings (#6628) 2025-04-02 16:27:41 -07:00
0xFirekeeper 5faaf1be48 [Portal] Universal Bridge .NET (#6625)
<!--

## 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 the removal of several payment-related pages in the `.NET SDK` documentation and the addition of a new section for the `Universal Bridge`, including examples and detailed API usage.

### Detailed summary
- Deleted pages:
  - `quickstart/page.mdx`
  - `buywithfiat/page.mdx`
  - `buywithcrypto/page.mdx`
  - `getbuyhistory/page.mdx`
  - `getbuywithfiatquote/page.mdx`
  - `getbuywithfiatstatus/page.mdx`
  - `getbuywithcryptoquote/page.mdx`
  - `getbuywithcryptostatus/page.mdx`
  - `getbuywithfiatcurrencies/page.mdx`
- Updated `sidebar.tsx` to remove payment links and add a new `Bridge` section.
- Introduced `Universal Bridge` documentation with:
  - Metadata creation
  - Core API examples for buying, selling, and transferring tokens.
  - Code snippets for `C#` usage of the `ThirdwebBridge`.

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

<!-- end pr-codex -->
2025-04-02 18:26:19 +00:00
jnsdls 4847ada819 Add /brand-kit to framer rewrites (#6624)
Added a rewrite rule for the `/brand-kit` path in the Framer rewrites configuration.
2025-04-02 17:53:14 +00:00
MananTank 08eea95f9b [TOOL-3764] Dashboard: Add Nebula FTUX (#6616)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `ClientIDSection` component to streamline the display of the Client ID across various components. It also modifies the `WaitingForIntegrationCard` to utilize this new component, improving code organization and reducing redundancy.

### Detailed summary
- Added `ClientIDSection` component to display the Client ID.
- Updated `InsightFTUX` to include `ClientIDSection` instead of inline code.
- Modified `WaitingForIntegrationCard` to accept children for rendering.
- Created `NebulaFTUX` component to integrate Nebula features and display Client ID.
- Adjusted styles in various components for consistency.

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

<!-- end pr-codex -->
2025-04-02 09:12:32 +00:00
MananTank 737155ba21 [TOOL-3765] Dashboard: Add Insight FTUX (#6614)
<!--

## 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 `Insight` page by introducing new components for better integration and user experience, specifically around project insights and blueprints. It refactors existing code and adds new features for displaying API usage examples.

### Detailed summary
- Updated `isProjectActive` function to accept a structured parameter.
- Added `InsightFTUX` component for displaying integration instructions.
- Introduced `BlueprintCard` component with feature descriptions.
- Modified `WaitingForIntegrationCard` to display the client ID without a period.
- Enhanced `page.tsx` to conditionally render `InsightFTUX` or `BlueprintCard` based on project status.

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

<!-- end pr-codex -->
2025-04-02 09:12:31 +00:00
MananTank 73ae5817f2 [TOOL-3763] Dashboard: Update Contracts page (#6611)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the user interface and functionality of the contracts management section. It introduces new components and modifies existing ones to improve user experience, particularly around contract deployment and management.

### Detailed summary
- Removed the `GetStartedWithContractsDeploy` component.
- Added `DeployViaCLIOrImportCard` component for contract deployment options.
- Updated the `DeployedContractsPage` to include the new `DeployViaCLIOrImportCard`.
- Modified the `ContractTableUI` to display a message and button when no contracts are present.
- Increased the height of the no contracts message section.
- Added a link to discover contracts with an external icon.

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

<!-- end pr-codex -->
2025-04-02 09:12:31 +00:00
Joaquim VergesandGitHub 6a0704a19b Connect to current wallet chain when connecting other wallets in PayEmbed (#6621) 2025-04-02 02:09:39 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2ef8f2a371 chore(deps): bump biomejs/setup-biome from 2.3.0 to 2.5.0 (#6598)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 01:50:04 -07:00
d6373b2c67 [SDK] Feature: added ru_RU locale to thirdweb package (#6520)
Co-authored-by: Denis Vasilev <[email protected]>
Co-authored-by: Joaquim Verges <[email protected]>
2025-04-02 01:46:15 -07:00
MananTank 7eaf86a607 [TOOL-3762] Dashboard: Update In-app wallet analytics page (#6610)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on refactoring components related to in-app wallets and account abstraction analytics. It introduces new loading states, replaces certain components with `StatCard`, and enhances the handling of asynchronous data fetching.

### Detailed summary
- Removed `loading.tsx` files for in-app wallets and account abstraction.
- Updated `InAppWalletsHeader` to no longer accept props.
- Replaced `Stat` with `StatCard` in `EcosystemWalletsSummary` and `AccountAbstractionSummary`.
- Added `isPending` state to `StatCard` for loading indication.
- Refactored `InAppWalletsSummary` and `AccountAbstractionSummary` to utilize async data fetching with `Suspense`.
- Enhanced `InAppWalletAnalytics` to manage loading states and data fetching more effectively.

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

<!-- end pr-codex -->
2025-04-02 07:10:08 +00:00
gregGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
5788cbdbad [SDK] Feature: Adds action field to Bridge transactions (#6613)
Signed-off-by: greg <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-04-02 00:08:03 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
d9a39cde33 Version Packages (#6619)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 19:43:56 -07:00
Joaquim VergesandGitHub 62c8e7c0c4 [SDK] show transfer amounts with fees (#6618) 2025-04-01 19:20:15 -07:00
Joaquim VergesandGitHub f618c5308c [SDK] Handle entrypoint resolution inside createAndSignUserOp (#6615) 2025-04-01 18:17:17 -07:00
gregandGitHub 04943d730b [Dashboard] Feature: Token symbol search (#6617) 2025-04-01 15:06:52 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2606da6d87 Version Packages (#6606)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 13:02:59 -07:00
kumaryash90 9cf137660a Remove custom config for Ronin (#6612)
Create2 deployer restrictions are lifted by the chain. Enable deployments through default create2 factory.

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on removing the custom configuration for the `Ronin` blockchain within the `thirdweb` package.

### Detailed summary
- Removed the `Ronin` configuration from the gas price settings in the `create-2-factory.ts` file.
- Updated the gas price for `Ronin` from `110n * 10n ** 9n` to a static value of `110000n`.

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

<!-- end pr-codex -->
2025-04-01 19:06:06 +00:00
MananTank 58ab8c2ee7 [TOOL-3766] Dashboard: Account Abstraction page updates (#6609)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving the UI and functionality of various components in the dashboard by modifying styles, enhancing layout elements, and integrating new features related to account abstraction and user operation statistics.

### Detailed summary
- Removed `lg:gap-6` from grid classes in multiple components.
- Increased header sizes from `text-lg` to `text-xl` in `SponsoredTransactionsTableUI.tsx`.
- Adjusted margins and padding for improved spacing in various components.
- Added new `GasCreditAlert` component for displaying gas credit information.
- Integrated `AccountAbstractionSummary` into `AccountAbstractionAnalytics`.
- Updated `userOpStats` handling in `Page` component to use promises for better performance.
- Replaced certain text with more concise phrasing in descriptions.
- Removed unused imports and optimized component structure for clarity.

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

<!-- end pr-codex -->
2025-04-01 18:12:49 +00:00
MananTank 447a519ddc [TOOL-3768] Dashboard: Move Nebula from team to project layout (#6608)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the `Nebula` components in the dashboard application by improving layout styles, adding project ID handling in analytics, and adjusting waitlist checks.

### Detailed summary
- Updated class names for better styling in `nebula-analytics-filter.tsx` and `nebula-waitlist-page.tsx`.
- Added `projectId` parameter to `fetchNebulaAnalytics`.
- Modified `NebulaWaitListPageUI` layout.
- Integrated `showNebula` prop in `ProjectSidebarLayout`.
- Adjusted project layout and waitlist checks in `layout.tsx`.
- Enhanced `NebulaAnalyticsPage` to pass `projectId`.
- Updated page handling in `page.tsx` to include project fetching and redirects.

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

<!-- end pr-codex -->
2025-04-01 11:56:32 +00:00
Joaquim VergesandGitHub a9b7633d05 [SDK] Show past transactions in transactions modal screen (#6607) 2025-04-01 03:14:28 -07:00
Joaquim VergesandGitHub 38e089a5f3 [SDK] Only onramp for required transaction amount (#6605) 2025-04-01 02:01:11 -07:00
Joaquim VergesandGitHub ef2f86cff0 Version Packages (#6589) 2025-03-31 23:05:33 -07:00
gregfromstl 8fdef82147 [Playground] Fix: Filter UB Backend API for only GETs (#6604)
<!--

## 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 layout and functionality of the `Page` component in `apps/playground-web/src/app/connect/pay/backend/page.tsx`. It improves the presentation of the Universal Bridge REST API information and adds a link to view all endpoints.

### Detailed summary
- Wrapped the header and description in a `div` for better layout.
- Added a `Link` component with a button to "View all endpoints".
- Changed the title of the `BlueprintSection` from "Available endpoints" to "Available GET endpoints".
- Updated the filtering of `blueprints` to only include those with defined `get` properties.

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

<!-- end pr-codex -->
2025-04-01 01:02:26 +00:00
MananTank cbb15692a7 [TOOL-3880] Playground: Add utm_source on links (#6597)
<!-- start pr-codex -->

## PR-Codex overview
This PR primarily updates various `docsLink` URLs across multiple components in the `playground-web` application to include a query parameter (`?utm_source=playground`). This change likely aims to track traffic sources more effectively.

### Detailed summary
- Updated `docsLink` in `apps/playground-web/src/app/connect/auth/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/pay/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/blockchain-api/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/pay/commerce/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/pay/fund-wallet/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/pay/transactions/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/sign-in/embed/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/sign-in/button/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/ui/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/sign-in/headless/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/in-app-wallet/layout.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/insight/layout.tsx`
- Updated `docsLink` in `apps/playground-web/src/components/blocks/APIHeader.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/pay/backend/layout.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/account-abstraction/connect/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/account-abstraction/sponsor/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/ui/nft/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/otherLinks.ts`
- Updated `docsLink` in `apps/playground-web/src/app/connect/account-abstraction/native-aa/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/ui/chain/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/ui/token/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/social/page.tsx`
- Updated `docsLink` in `apps/playground-web/src/app/connect/ui/wallet/page.tsx`
- Updated `href` in `apps/playground-web/src/app/connect/pay/embed/LeftSection.tsx`
- Updated `href` in `apps/playground-web/src/app/insight/page.tsx`
- Updated `href` in `apps/playground-web/src/components/auth/gated-content.tsx`
- Updated `href` in `apps/playground-web/src/components/engine/minting/erc1155-mint-to.tsx`
- Updated `href` in `apps/playground-web/src/app/connect/sign-in/components/InAppWalletFormGroup.tsx`
- Updated `href` in `apps/playground-web/src/app/connect/sign-in/button/LeftSection.tsx`

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

<!-- end pr-codex -->
2025-03-31 19:37:09 +00:00
MananTank 1d23e5cd9c Dashboard: Fix AA page alert styles (#6596)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `SmartWalletsBillingAlert` component in the `Alerts.tsx` file. It updates the alert's UI and removes the dismissible functionality, while also changing the import paths for components used in the alert.

### Detailed summary
- Removed unused imports related to `Alert`, `AlertDescription`, `AlertIcon`, `AlertTitle`, `Flex`, and `IconButton`.
- Updated imports for `Alert`, `AlertDescription`, `AlertTitle`, `TrackedUnderlineLink`, and `CircleAlertIcon`.
- Removed the `dismissible` prop and related logic.
- Simplified the alert structure with a new layout.
- Changed the alert icon and styling.

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

<!-- end pr-codex -->
2025-03-31 19:07:57 +00:00
MananTank a17d893811 [TOOL-3873] Fix pageview tracking in dashboard, Add posthog to playground (#6595) 2025-03-31 18:38:58 +00:00
kumaryash90 0dcde077f5 [SDK] Fix bytecode caching (#6593)
<!--

## 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 bytecode caching mechanism in the `thirdweb` package by including additional metadata in the cache key, which improves cache differentiation and management.

### Detailed summary
- Updated the `cacheKey` in `deploy-metadata.ts` to include `compilerMetadata.publisher` and `compilerMetadata.version` along with `compilerMetadata.name` and `chain.id`.
- Removed the `version` and `publisher` fields from the deployment test case in `process-ref-deployments.test.ts`.

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

<!-- end pr-codex -->
2025-03-31 18:30:28 +00:00
jnsdls 05a041fac3 Add glossary slug route to framer rewrites (#6594)
### TL;DR

Added a rewrite rule for glossary detail pages in the dashboard app.

### What changed?

Added a new path `/learn/glossary/:glossary_slug` to the framer-rewrites.js file to support individual glossary term pages.

### How to test?

1. Navigate to a glossary term detail page (e.g., `/learn/glossary/some-term`)
2. Verify that the page loads correctly and the routing works as expected

### Why make this change?

To enable proper routing for individual glossary term pages, allowing users to access specific glossary entries directly through their unique URLs. This complements the existing glossary index page that was already configured.
2025-03-31 18:00:30 +00:00
MananTank 3d140eed97 [TOOL-3874] Portal: Fix pl.js script CSP error (#6591)
<!--

## 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 Content Security Policy in the `next.config.mjs` file to enhance security by modifying the `script-src` directive.

### Detailed summary
- Updated the `script-src` directive to include `thirdweb.com` and `thirdweb-dev.com` without a wildcard prefix.
- Retained existing sources for `self`, `unsafe-eval`, `unsafe-inline`, `wasm-unsafe-eval`, `inline-speculation-rules`, `vercel.live`, and `js.stripe.com`.

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

<!-- end pr-codex -->
2025-03-31 17:43:57 +00:00
jnsdls a5f005fff0 Remove tutorial routes from framer rewrites (#6592)
closes: CORE-802

Redirect `/learn/tutorials` to `/learn/guides`

This PR removes `/learn/tutorials` and `/learn/tutorials/:tutorial_slug` from the framer-rewrites list and adds redirects from these paths to their corresponding `/learn/guides` equivalents.
2025-03-31 17:14:04 +00:00
saminacodes fb70cab6d4 [portal] added module warning (#6590)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds a warning callout to the `page.mdx` file in the `cross-chain/agglayer` directory, informing users that the Agglayer module is in beta and has deployment limitations.

### Detailed summary
- Added a warning callout indicating that the Agglayer module is in beta.
- Specified that deployments are limited to `Sepolia` and `Cardona` due to requirements for Ethereum as L1 and zkEVM as L2.

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

<!-- end pr-codex -->
2025-03-31 14:53:39 +00:00
Joaquim VergesandGitHub cd9ea69c13 [SDK] Add erc20Value for makeOffer extension function (#6588) 2025-03-31 00:24:42 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
dc892bd136 Version Packages (#6585)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-30 16:14:40 -07:00
Joaquim VergesandGitHub 3d7c009d72 [CLI] Fix secret key handling (#6584) 2025-03-30 15:49:45 -07:00
YashandGitHub 6dbed0d4fc [SDK] fix: Use default contract params with optional overrides (#6581) 2025-03-30 15:45:13 -07:00
MananTank adee1903d5 [TOOL-3814] Dashboard: Show all active coupons in team billing page (#6568)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on refactoring the coupon management system within the billing section of the dashboard. It introduces a new structure for handling coupons, including a client component for API interactions and UI components for displaying and applying coupons.

### Detailed summary
- Deleted obsolete files: `CouponCard.tsx`, `CouponDetails.stories.tsx`, `ApplyCouponCard.stories.tsx`.
- Updated global styles import in `preview.tsx`.
- Replaced `CouponSection` with `Coupons` in `index.tsx`.
- Introduced `CouponsClient` for managing coupon API interactions.
- Created `AsyncCoupons` to fetch coupon data asynchronously.
- Developed `Coupons` component to handle loading states and display.
- Added `CouponsUI` for rendering coupon information and actions.
- Implemented coupon application and deletion logic with error handling.
- Created a story for `CouponsUI` to demonstrate different states and interactions.

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

<!-- end pr-codex -->
2025-03-30 22:18:56 +00:00
gregandGitHub a1d785b8ed Fix: Increase commerce price to avoid minimum purchase errors (#6582) 2025-03-29 20:50:25 -07:00
202 changed files with 4682 additions and 3284 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
uses: ./.github/composite-actions/install
- name: Setup Biome
uses: biomejs/setup-biome@c016c38f26f2c4a6eb3662679143614a254263fd # v2.3.0
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
with:
version: latest
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Preview } from "@storybook/react";
import "@/styles/globals.css";
import "../src/global.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MoonIcon, SunIcon } from "lucide-react";
import { ThemeProvider, useTheme } from "next-themes";
+3 -2
View File
@@ -49,12 +49,13 @@ module.exports = [
"/templates/:template_slug",
// -- learn --
"/learn",
"/learn/tutorials",
"/learn/tutorials/:tutorial_slug",
"/learn/guides",
"/learn/guides/:guide_slug",
"/learn/courses",
"/learn/glossary",
"/learn/glossary/:glossary_slug",
// -- faucets --
"/faucets",
// -- brand kit --
"/brand-kit",
];
+1 -1
View File
@@ -135,7 +135,7 @@
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"eslint-plugin-storybook": "0.11.6",
"eslint-plugin-storybook": "0.12.0",
"knip": "5.46.0",
"next-sitemap": "^4.2.3",
"postcss": "8.5.3",
+13
View File
@@ -361,6 +361,19 @@ async function redirects() {
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
permanent: false,
},
// all /learn/tutorials (and sub-routes) -> /learn/guides
{
source: "/learn/tutorials/:path*",
destination: "/learn/guides/:path*",
permanent: false,
},
{
source: "/learn/tutorials",
destination: "/learn/guides",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
}
+4 -3
View File
@@ -270,9 +270,10 @@ type ActiveStatus = {
ecosystemWallet: boolean;
};
export async function isProjectActive(
params: AnalyticsQueryParams,
): Promise<ActiveStatus> {
export async function isProjectActive(params: {
teamId: string;
projectId: string;
}): Promise<ActiveStatus> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/active-usage?${searchParams.toString()}`,
@@ -17,6 +17,7 @@ import { NavLink } from "../ui/NavLink";
import { Separator } from "../ui/separator";
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
import { CustomSidebar, type SidebarLink } from "./Sidebar";
import { AppFooter } from "./app-footer";
export function SidebarLayout(props: {
sidebarLinks: SidebarLink[];
@@ -54,7 +55,6 @@ export function FullWidthSidebarLayout(props: {
footerSidebarLinks?: SidebarLink[];
children: React.ReactNode;
className?: string;
footer?: React.ReactNode;
}) {
const { contentSidebarLinks, children, footerSidebarLinks } = props;
return (
@@ -65,7 +65,7 @@ export function FullWidthSidebarLayout(props: {
)}
>
{/* left - sidebar */}
<Sidebar collapsible="icon" side="left">
<Sidebar collapsible="icon" side="left" className="pt-2">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
@@ -95,10 +95,10 @@ export function FullWidthSidebarLayout(props: {
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
/>
<main className="container z-0 flex min-w-0 max-w-[1280px] grow flex-col pb-20 max-sm:w-full lg:pt-6">
<main className="z-0 flex min-w-0 grow flex-col max-sm:w-full">
{children}
</main>
{props.footer}
<AppFooter containerClassName="max-w-7xl" />
</div>
</div>
);
@@ -156,7 +156,7 @@ function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
const activeLink = useActiveSidebarLink(props.links);
return (
<div className="mb-4 flex items-center gap-3 border-b px-4 py-4 lg:hidden">
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
<SidebarTrigger className="size-4" />
<Separator orientation="vertical" className="h-4" />
{activeLink && <span className="text-sm">{activeLink.label}</span>}
@@ -12,17 +12,23 @@ import Link from "next/link";
type AppFooterProps = {
className?: string;
containerClassName?: string;
};
export function AppFooter(props: AppFooterProps) {
return (
<footer
className={cn(
"w-full border-border border-t bg-card py-6 md:py-8",
"w-full border-border border-t py-6 md:py-8",
props.className,
)}
>
<div className="container flex flex-col gap-4 md:gap-6">
<div
className={cn(
"container flex flex-col gap-4 md:gap-6",
props.containerClassName,
)}
>
{/* top row */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-row items-center gap-3">
@@ -32,6 +32,7 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
description?: string;
titleClassName?: string;
};
customHeader?: React.ReactNode;
// chart config
config: TConfig;
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
@@ -41,7 +42,9 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
chartClassName?: string;
isPending: boolean;
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
hideLabel?: boolean;
emptyChartState?: React.ReactElement;
};
export function ThirdwebBarChart<TConfig extends ChartConfig>(
@@ -65,12 +68,14 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
</CardHeader>
)}
{props.customHeader && props.customHeader}
<CardContent className={cn(!props.header && "pt-6")}>
<ChartContainer config={props.config} className={props.chartClassName}>
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState />
<EmptyChartState>{props.emptyChartState}</EmptyChartState>
) : (
<BarChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
@@ -88,6 +93,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
props.hideLabel !== undefined ? props.hideLabel : true
}
labelFormatter={props.toolTipLabelFormatter}
valueFormatter={props.toolTipValueFormatter}
/>
}
/>
@@ -96,25 +102,15 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
content={<ChartLegendContent className="pt-5" />}
/>
)}
{configKeys.map((key, idx) => (
{configKeys.map((key) => (
<Bar
key={key}
dataKey={key}
// if stacked then they should all be the same stackId
// if grouped then they should all be unique stackId (so the key works great)
stackId={variant === "stacked" ? "a" : key}
fill={`var(--color-${key})`}
// if stacked then we need to figure out the radius based on the index in the array
// if grouped then we can just use the same radius for all
radius={
variant === "stacked"
? idx === 0
? [0, 0, 4, 4]
: idx === configKeys.length - 1
? [4, 4, 0, 0]
: [0, 0, 0, 0]
: [4, 4, 4, 4]
}
fill={props.config[key]?.color}
radius={[4, 4, 4, 4]}
strokeWidth={1}
className="stroke-background"
/>
+6 -2
View File
@@ -20,6 +20,7 @@ export function TabLinks(props: {
className?: string;
tabContainerClassName?: string;
shadowColor?: string;
scrollableClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLAnchorElement>();
@@ -30,7 +31,7 @@ export function TabLinks(props: {
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
<ScrollShadow
scrollableClassName="pb-[8px] relative"
scrollableClassName={cn("pb-[8px] relative", props.scrollableClassName)}
shadowColor={props.shadowColor}
>
<div
@@ -159,9 +160,11 @@ function useUnderline<El extends HTMLElement>() {
const containerRect = containerRef.current.getBoundingClientRect();
const lineEl = lineRef.current;
const rect = activeTabEl.getBoundingClientRect();
const containerPaddingLeft =
containerRect.left - containerRef.current.offsetLeft;
lineEl.style.width = `${rect.width}px`;
lineEl.style.transform = `translateX(${
rect.left - containerRect.left
rect.left - containerPaddingLeft
}px)`;
setTimeout(() => {
lineEl.style.transition = "transform 0.3s, width 0.3s";
@@ -205,6 +208,7 @@ export function TabPathLinks(props: {
className?: string;
tabContainerClassName?: string;
shadowColor?: string;
scrollableClassName?: string;
}) {
const pathname = usePathname() || "";
const { links, ...restProps } = props;
@@ -47,7 +47,7 @@ export const SearchInput: React.FC = () => {
<div className="group relative w-full">
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
<Input
placeholder="Search by token address or chain ID"
placeholder="Search with anything!"
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
defaultValue={searchParams?.get("query") || ""}
onChange={(e) => handleSearch(e.target.value)}
@@ -7,6 +7,7 @@ import {
TableRow,
} from "@/components/ui/table";
import type { Address } from "thirdweb";
import { checksumAddress } from "thirdweb/utils";
import { getRoutes } from "../../../utils";
import { ChainlistPagination } from "../client/pagination";
import { RouteListCard } from "../server/routelist-card";
@@ -34,28 +35,83 @@ async function getRoutesToRender(params: SearchParams) {
originTokenAddress?: Address;
destinationChainId?: number;
destinationTokenAddress?: Address;
originTextQuery?: string;
destinationTextQuery?: string;
}> = {};
if (params.type === "origin" || typeof params.type === "undefined") {
if (params.query?.startsWith("0x")) {
filters.originTokenAddress = params.query as Address;
} else if (params.query) {
} else if (Number.isInteger(Number(params.query))) {
filters.originChainId = Number(params.query);
} else if (params.query) {
filters.originTextQuery = params.query;
}
} else if (params.type === "destination") {
if (params.query?.startsWith("0x")) {
filters.destinationTokenAddress = params.query as Address;
} else if (params.query) {
} else if (Number.isInteger(Number(params.query))) {
filters.destinationChainId = Number(params.query);
} else if (params.query) {
filters.destinationTextQuery = params.query;
}
}
// Temporary, will update this after the /routes endpoint
filters.limit = 50_000;
const routes = await getRoutes(filters);
let routes = await getRoutes({ limit: 100_000 });
const totalCount = routes.length;
if (filters.originChainId) {
routes = routes.filter(
(route) => route.originToken.chainId === filters.originChainId,
);
}
if (filters.originTokenAddress) {
const originTokenAddress = filters.originTokenAddress;
routes = routes.filter(
(route) =>
checksumAddress(route.originToken.address) ===
checksumAddress(originTokenAddress),
);
}
if (filters.destinationChainId) {
routes = routes.filter(
(route) => route.destinationToken.chainId === filters.destinationChainId,
);
}
if (filters.destinationTokenAddress) {
const destinationTokenAddress = filters.destinationTokenAddress;
routes = routes.filter(
(route) =>
checksumAddress(route.destinationToken.address) ===
checksumAddress(destinationTokenAddress),
);
}
if (filters.originTextQuery) {
const originTextQuery = filters.originTextQuery.toLowerCase();
routes = routes.filter((route) => {
return (
route.originToken.name.toLowerCase().includes(originTextQuery) ||
route.originToken.symbol.toLowerCase().includes(originTextQuery)
);
});
}
if (filters.destinationTextQuery) {
const destinationTextQuery = filters.destinationTextQuery.toLowerCase();
routes = routes.filter((route) => {
return (
route.destinationToken.name
.toLowerCase()
.includes(destinationTextQuery) ||
route.destinationToken.symbol
.toLowerCase()
.includes(destinationTextQuery)
);
});
}
return {
routesToRender: routes,
totalCount,
@@ -154,13 +210,21 @@ export async function RoutesData(props: {
out of{" "}
{filteredCount !== totalCount ? (
<>
<span className="text-accent-foreground">{filteredCount}</span>{" "}
<span className="text-accent-foreground">
{filteredCount.toLocaleString()}
</span>{" "}
routes that match filters. (Total:{" "}
<span className="text-accent-foreground">{totalCount}</span>)
<span className="text-accent-foreground">
{totalCount.toLocaleString()}
</span>
)
</>
) : (
<>
<span className="text-accent-foreground">{totalCount}</span> routes.
<span className="text-accent-foreground">
{totalCount.toLocaleString()}
</span>{" "}
routes.
</>
)}
</p>
@@ -2,43 +2,18 @@ import "server-only";
import { BRIDGE_URL, DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
import { getAuthToken } from "app/api/lib/getAuthToken";
import type { Address } from "thirdweb";
import type { Route } from "./types/route";
export async function getRoutes({
limit,
offset,
originChainId,
originTokenAddress,
destinationChainId,
destinationTokenAddress,
}: {
limit?: number;
offset?: number;
originChainId?: number;
originTokenAddress?: Address;
destinationChainId?: number;
destinationTokenAddress?: Address;
} = {}) {
const url = new URL(`${BRIDGE_URL}/v1/routes`);
if (limit) {
url.searchParams.set("limit", limit.toString());
}
if (offset) {
url.searchParams.set("offset", offset.toString());
}
if (originChainId) {
url.searchParams.set("originChainId", originChainId.toString());
}
if (originTokenAddress) {
url.searchParams.set("originTokenAddress", originTokenAddress);
}
if (destinationChainId) {
url.searchParams.set("destinationChainId", destinationChainId.toString());
}
if (destinationTokenAddress) {
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
}
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
const token = await getAuthToken();
const routesResponse = await fetch(url, {
headers: token
@@ -14,7 +14,7 @@ export const products = [
id: "contracts",
icon: ContractIcon,
description: "Pre-built contracts & deployment tools",
link: "https://thirdweb.com/contracts",
link: "https://thirdweb.com/explore",
},
{
name: "Connect SDK",
@@ -13,7 +13,7 @@ export function DeployedContractsPageHeader(props: {
const [importModalOpen, setImportModalOpen] = useState(false);
return (
<div>
<div className="border-b">
<ImportModal
isOpen={importModalOpen}
onClose={() => {
@@ -23,9 +23,11 @@ export function DeployedContractsPageHeader(props: {
projectId={props.projectId}
/>
<div className="flex flex-col gap-3 pb-6 lg:flex-row lg:items-center lg:justify-between">
<div className="container flex max-w-7xl flex-col gap-3 py-10 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
Contracts
</h1>
</div>
<div className="flex gap-3 [&>*]:grow">
<Button
@@ -0,0 +1,60 @@
"use client";
import { Button } from "@/components/ui/button";
import { ArrowUpRightIcon, DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ImportModal } from "../../../../components/contract-components/import-contract/modal";
export function DeployViaCLIOrImportCard(props: {
teamId: string;
projectId: string;
}) {
const [importModalOpen, setImportModalOpen] = useState(false);
return (
<div className="rounded-lg border bg-card p-4 lg:p-6">
<ImportModal
isOpen={importModalOpen}
onClose={() => {
setImportModalOpen(false);
}}
teamId={props.teamId}
projectId={props.projectId}
/>
<h2 className="mb-0.5 font-semibold text-lg">
Already have a smart contract?
</h2>
<p className="max-w-2xl text-muted-foreground text-sm lg:text-base">
Import an already deployed contract or deploy a contract from source
code to easily manage permissions, upload assets, and interact with
contract functions
</p>
<div className="mt-6 flex flex-col gap-3 lg:flex-row">
<Button
variant="outline"
className="gap-2 bg-background lg:px-10"
asChild
>
<Link
href="https://portal.thirdweb.com/contracts/deploy/overview"
target="_blank"
>
Deploy via CLI
<ArrowUpRightIcon className="size-4" />
</Link>
</Button>
<Button
variant="outline"
className="gap-2 bg-background"
onClick={() => setImportModalOpen(true)}
>
<DownloadIcon className="size-4" />
Import Contract
</Button>
</div>
</div>
);
}
@@ -3,7 +3,7 @@ import { ClientOnly } from "components/ClientOnly/ClientOnly";
import { Suspense } from "react";
import { ContractTable } from "../../../../components/contract-components/tables/contract-table";
import { DeployedContractsPageHeader } from "../DeployedContractsPageHeader";
import { GetStartedWithContractsDeploy } from "./GetStartedWithContractsDeploy";
import { DeployViaCLIOrImportCard } from "./DeployViaCLIOrImportCard";
import { getSortedDeployedContracts } from "./getSortedDeployedContracts";
export function DeployedContractsPage(props: {
@@ -17,10 +17,16 @@ export function DeployedContractsPage(props: {
teamId={props.teamId}
projectId={props.projectId}
/>
<div className="flex grow flex-col">
<div className="h-6" />
<div className="container flex max-w-7xl grow flex-col">
<Suspense fallback={<Loading />}>
<DeployedContractsPageAsync {...props} />
</Suspense>
<div className="h-8" />
<DeployViaCLIOrImportCard
teamId={props.teamId}
projectId={props.projectId}
/>
</div>
</div>
);
@@ -37,15 +43,6 @@ async function DeployedContractsPageAsync(props: {
authToken: props.authToken,
});
if (deployedContracts.length === 0) {
return (
<GetStartedWithContractsDeploy
teamId={props.teamId}
projectId={props.projectId}
/>
);
}
return (
<ClientOnly ssr={<Loading />}>
<ContractTable
@@ -1,139 +0,0 @@
"use client";
import { TabButtons } from "@/components/ui/tabs";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ImportModal } from "components/contract-components/import-contract/modal";
import { StepsCard } from "components/dashboard/StepsCard";
import { useTrack } from "hooks/analytics/useTrack";
import { useMemo, useState } from "react";
export function GetStartedWithContractsDeploy(props: {
teamId: string;
projectId: string;
}) {
const steps = useMemo(
() => [
{
title: "Build, deploy or import a contract",
description:
"Choose between deploying your own contract or import an existing one.",
children: (
<DeployOptions teamId={props.teamId} projectId={props.projectId} />
),
completed: false, // because we only show this component if the user does not have any contracts
},
],
[props.teamId, props.projectId],
);
return (
<StepsCard
title="Get started with deploying contracts"
description="This guide will help you to start deploying contracts on-chain in just a few minutes."
steps={steps}
/>
);
}
type ContentItem = {
title: string;
description: string;
href?: string;
onClick?: () => void;
};
type TabId = "explore" | "import" | "build" | "deploy";
const DeployOptions = (props: {
teamId: string;
projectId: string;
}) => {
const [showImportModal, setShowImportModal] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const content: Record<TabId, ContentItem> = useMemo(
() => ({
explore: {
title: "Ready-to-deploy",
description:
"Pick from our library of ready-to-deploy contracts and deploy to any EVM chain in just 1-click.",
href: "/explore",
},
import: {
title: "Import",
description:
"Import an already deployed contract to build apps on top of contract using thirdweb tools.",
onClick: () => setShowImportModal(true),
},
build: {
title: "Build your own",
description:
"Get started with the Solidity SDK to create custom contracts specific to your use case.",
href: "/build",
},
deploy: {
title: "Deploy from source",
description:
"Deploy your contract by using our interactive CLI. (Supports Hardhat, Forge, Truffle, and more)",
href: "https://portal.thirdweb.com/cli",
},
}),
[],
);
const [activeTab, setActiveTab] = useState<TabId>("explore");
const activeTabContent = content[activeTab];
return (
<div className="mt-3">
<ImportModal
isOpen={showImportModal}
onClose={() => {
setShowImportModal(false);
}}
teamId={props.teamId}
projectId={props.projectId}
/>
<TabButtons
tabs={Object.entries(content).map(([key, value]) => ({
key,
name: value.title,
isActive: activeTab === key,
onClick: () => setActiveTab(key as TabId),
}))}
tabClassName="font-medium"
tabContainerClassName="gap-1"
/>
<button
className="mt-3 flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border bg-card p-4 hover:border-active-border"
type="button"
onClick={() => {
activeTabContent.onClick?.();
if (activeTabContent.href) {
router.push(activeTabContent.href);
}
trackEvent({
category: "your_contracts",
action: "click",
label: "deploy_options",
type: activeTab,
href: null,
isExternal: false,
});
}}
>
<div>
<h4 className="text-start font-semibold text-lg">
{activeTabContent.title}
</h4>
<p className="text-start text-muted-foreground text-sm">
{activeTabContent.description}
</p>
</div>
</button>
</div>
);
};
@@ -1,22 +0,0 @@
// app/providers.tsx
"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
if (typeof window !== "undefined") {
posthog.init(
process.env.NEXT_PUBLIC_POSTHOG_API_KEY ||
"phc_hKK4bo8cHZrKuAVXfXGpfNSLSJuucUnguAgt2j6dgSV",
{
api_host: "https://a.thirdweb.com",
autocapture: true,
debug: false,
capture_pageview: true,
disable_session_recording: true,
},
);
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
return <PHProvider client={posthog}>{children}</PHProvider>;
}
+7 -3
View File
@@ -8,8 +8,10 @@ import NextTopLoader from "nextjs-toploader";
import { Suspense } from "react";
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
import { PHProvider } from "../lib/posthog/Posthog";
import { PosthogHeadSetup } from "../lib/posthog/PosthogHeadSetup";
import { PostHogPageView } from "../lib/posthog/PosthogPageView";
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
import { PostHogProvider } from "./components/root-providers";
import { AppRouterProviders } from "./providers";
const fontSans = Inter({
@@ -63,8 +65,10 @@ export default function RootLayout({
customDomain="https://pl.thirdweb.com"
selfHosted
/>
<PosthogHeadSetup />
</head>
<PostHogProvider>
<PHProvider>
<PostHogPageView />
<body
className={cn(
"bg-background font-sans antialiased",
@@ -91,7 +95,7 @@ export default function RootLayout({
showSpinner={false}
/>
</body>
</PostHogProvider>
</PHProvider>
</html>
);
}
@@ -88,10 +88,10 @@ export async function TotalSponsoredChartCardUI({
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
<div className={cn("rounded-lg border p-4 lg:p-6", className)}>
<h3 className="mb-1 font-semibold text-xl tracking-tight">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title || "Total Sponsored"}
</h3>
<p className="text-muted-foreground"> {description}</p>
<p className="text-muted-foreground text-sm"> {description}</p>
<BarChart
isCurrency
chartConfig={chartConfig}
@@ -1,5 +1,5 @@
import { getProjects } from "@/api/projects";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { getTeams } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { TabPathLinks } from "@/components/ui/tabs";
import { redirect } from "next/navigation";
@@ -39,9 +39,6 @@ export default async function TeamLayout(props: {
})),
);
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
?.onWaitlist;
return (
<div className="flex h-full grow flex-col">
<AnnouncementBanner />
@@ -74,14 +71,6 @@ export default async function TeamLayout(props: {
path: `/team/${params.team_slug}/~/ecosystem`,
name: "Ecosystems",
},
...(isOnNebulaWaitList
? [
{
path: `/team/${params.team_slug}/~/nebula`,
name: "Nebula",
},
]
: []),
{
path: `/team/${params.team_slug}/~/usage`,
name: "Usage",
@@ -0,0 +1,129 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { EcosystemWalletStats } from "types/analytics";
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
const meta = {
title: "Ecosystem/Analytics/EcosystemWalletUsersChartCard",
component: EcosystemWalletUsersChartCard,
decorators: [
(Story) => (
<div className="container max-w-7xl py-10">
<Story />
</div>
),
],
} satisfies Meta<typeof EcosystemWalletUsersChartCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const authMethods = [
"Email",
"Google",
"Apple",
"Discord",
"Twitter",
"GitHub",
"Facebook",
"Twitch",
"LinkedIn",
"TikTok",
"Coinbase",
"MetaMask",
];
function ecosystemWalletStatsStub(
length: number,
startDate = new Date(2024, 11, 1),
): EcosystemWalletStats[] {
const stats: EcosystemWalletStats[] = [];
for (let i = 0; i < length; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const formattedDate = date.toISOString().split("T")[0] || "";
// each day, we pick between 1 and 4 auth methods
const authMethodsToPick = Math.floor(Math.random() * 4) + 1;
for (let j = 0; j < authMethodsToPick; j++) {
const authMethod =
authMethods[Math.floor(Math.random() * authMethods.length)];
stats.push({
date: formattedDate,
authenticationMethod: authMethod || "MetaMask",
uniqueWalletsConnected: Math.floor(Math.random() * 1000) + 1,
});
}
}
return stats;
}
// Empty data state
export const EmptyData: Story = {
args: {
ecosystemWalletStats: [],
isPending: false,
},
};
// Loading state
export const Loading: Story = {
args: {
ecosystemWalletStats: [],
isPending: true,
},
};
// 30 days of data
export const ThirtyDaysData: Story = {
args: {
ecosystemWalletStats: ecosystemWalletStatsStub(30),
isPending: false,
},
};
// 60 days of data
export const SixtyDaysData: Story = {
args: {
ecosystemWalletStats: ecosystemWalletStatsStub(60),
isPending: false,
},
};
// 120 days of data
export const OneHundredTwentyDaysData: Story = {
args: {
ecosystemWalletStats: ecosystemWalletStatsStub(120),
isPending: false,
},
};
// Data with lots of authentication methods to test the "Others" category
export const ManyAuthMethods: Story = {
args: {
ecosystemWalletStats: (() => {
// Generate data with 15 different auth methods to test "Others" category
const basicData = ecosystemWalletStatsStub(30);
return basicData.map((item, index) => ({
...item,
authenticationMethod:
authMethods[index % authMethods.length] || "MetaMask",
}));
})(),
isPending: false,
},
};
// Zero values test
export const ZeroValues: Story = {
args: {
ecosystemWalletStats: ecosystemWalletStatsStub(30).map((stat) => ({
...stat,
uniqueWalletsConnected: 0,
})),
isPending: false,
},
};
@@ -1,26 +1,15 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { formatTickerNumber } from "lib/format-utils";
import { formatDate } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { EcosystemWalletStats } from "types/analytics";
import { formatTickerNumber } from "../../../../../../../../../../lib/format-utils";
type ChartData = Record<string, number> & {
time: string; // human readable date
@@ -47,7 +36,7 @@ export function EcosystemWalletUsersChartCard(props: {
// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
time: stat.date,
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
} as ChartData);
} else if (chartData) {
@@ -114,128 +103,85 @@ export function EcosystemWalletUsersChartCard(props: {
);
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Unique Users
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The total number of active users in your ecosystem for each period.
</p>
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Unique Users
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The total number of active users in your ecosystem for each period.
</p>
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
</div>
}
config={chartConfig}
data={chartData}
isPending={props.isPending}
emptyChartState={<EcosystemWalletUsersEmptyChartState />}
showLegend
hideLabel={false}
variant="stacked"
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
toolTipValueFormatter={(value) => formatTickerNumber(Number(value))}
/>
);
}
function EcosystemWalletUsersEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
label="Unity"
icon={UnityIcon}
/>
</div>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
uniqueAuthMethods.every((authMethod) =>
chartData.every((data) => data[authMethod] === 0),
) ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
label="Unity"
icon={UnityIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) =>
Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0)
}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueAuthMethods.map((authMethod) => {
return (
<Bar
key={authMethod}
dataKey={authMethod}
fill={chartConfig[authMethod]?.color}
radius={4}
stackId="a"
strokeWidth={1}
className="stroke-background"
/>
);
})}
</BarChart>
)}
</ChartContainer>
</div>
);
}
@@ -1,4 +1,4 @@
import { Stat } from "components/analytics/stat";
import { StatCard } from "components/analytics/stat";
import { ActivityIcon, UserIcon } from "lucide-react";
import type { EcosystemWalletStats } from "types/analytics";
@@ -28,15 +28,17 @@ export function EcosystemWalletsSummary(props: {
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
<StatCard
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
icon={ActivityIcon}
isPending={false}
/>
<Stat
<StatCard
label="Monthly Active Users"
value={monthlyStats?.uniqueWalletsConnected || 0}
icon={UserIcon}
isPending={false}
/>
</div>
);
@@ -1,4 +1,3 @@
import type { Range } from "components/analytics/date-range-selector";
import { redirect } from "next/navigation";
import { getTeamBySlug } from "../../../../../../../../../@/api/team";
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
@@ -12,7 +11,8 @@ export default async function Page(props: {
}>;
searchParams: Promise<{
interval?: "day" | "week";
range?: Range;
from?: string;
to?: string;
}>;
}) {
const [params, searchParams] = await Promise.all([
@@ -45,7 +45,15 @@ export default async function Page(props: {
ecosystemSlug={ecosystem.slug}
teamId={team.id}
interval={searchParams.interval || "week"}
range={searchParams.range}
range={
searchParams.from && searchParams.to
? {
from: new Date(searchParams.from),
to: new Date(searchParams.to),
type: "custom",
}
: undefined
}
/>
);
}
@@ -1,44 +0,0 @@
import { getTeamBySlug } from "@/api/team";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-page";
import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<{
from: string | undefined | string[];
to: string | undefined | string[];
interval: string | undefined | string[];
}>;
}) {
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
const [authToken, team] = await Promise.all([
getAuthToken(),
getTeamBySlug(params.team_slug),
]);
if (!team || !authToken) {
loginRedirect(`/team/${params.team_slug}/~/nebula`);
}
const hasNebulaAccess = team.enabledScopes.includes("nebula");
if (hasNebulaAccess) {
return (
<NebulaAnalyticsPage
teamId={team.id}
authToken={authToken}
searchParams={searchParams}
/>
);
}
return <NebulaWaitListPage team={team} />;
}
@@ -5,7 +5,6 @@ import { BillingPortalButton } from "@/components/billing";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { differenceInDays, isAfter } from "date-fns";
import { format } from "date-fns/format";
import { CircleAlertIcon } from "lucide-react";
@@ -37,7 +36,7 @@ export function PlanInfoCard(props: {
<div className="rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
<div>
<div className="flex flex-col items-start gap-1">
<div className="flex flex-col items-start gap-0.5">
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{validPlan} Plan
</h3>
@@ -56,6 +55,7 @@ export function PlanInfoCard(props: {
<div className="flex flex-row items-center gap-2">
{/* go to invoices page */}
<Button asChild variant="outline">
<Link href={`/team/${team.slug}/~/settings/invoices`}>
View Invoices
@@ -73,20 +73,6 @@ export function PlanInfoCard(props: {
>
Manage Billing
</BillingPortalButton>
{isActualFreePlan && (
<Button asChild variant="outline">
<TrackedLinkTW
category="account"
href="/pricing"
label="pricing-plans"
target="_blank"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
View Pricing
</TrackedLinkTW>
</Button>
)}
</div>
</div>
@@ -73,7 +73,7 @@ export function SponsoredTransactionsTableUI(
<div className="overflow-hidden rounded-lg border bg-card">
{/* header */}
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
<h2 className="font-semibold text-lg">Sponsored Transactions</h2>
<h2 className="font-semibold text-xl">Sponsored Transactions</h2>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{/* Filters */}
<div className="flex gap-2">
@@ -23,13 +23,13 @@ export const UsageCard: React.FC<UsageCardProps> = ({
totalUsage,
}) => {
return (
<div className="relative flex min-h-[190px] flex-col rounded-lg border border-border bg-card p-4 lg:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight">{name}</h3>
<p className="text-muted-foreground"> {description}</p>
<div className="relative flex flex-col rounded-lg border border-border bg-card p-4 lg:p-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">{name}</h3>
<p className="text-muted-foreground text-sm"> {description}</p>
<div className="h-6" />
<div className="mt-auto flex flex-col gap-1.5">
<div className="flex flex-col gap-1 text-sm">
{title && <p className="text-foreground">{title}</p>}
{total !== undefined && (
@@ -0,0 +1,22 @@
import { CopyTextButton } from "@/components/ui/CopyTextButton";
export function ClientIDSection(props: {
clientId: string;
}) {
return (
<div>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application
</p>
<CopyTextButton
textToCopy={props.clientId}
className="!h-auto w-full max-w-[400px] justify-between truncate bg-background px-3 py-3 font-mono"
textToShow={props.clientId}
copyIconPosition="right"
tooltip="Copy Client ID"
/>
</div>
);
}
@@ -29,7 +29,7 @@ export function IntegrateAPIKeyCodeTabs(props: {
isActive: tab === key,
}))}
/>
<div className="h-3" />
<div className="h-2" />
{props.tabs[tab]}
</div>
);
@@ -1,5 +1,4 @@
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CodeServer } from "@/components/ui/code/code.server";
@@ -19,6 +18,7 @@ import { TypeScriptIcon } from "../../../../../../components/icons/brand-icons/T
import { UnityIcon } from "../../../../../../components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "../../../../../../components/icons/brand-icons/UnrealIcon";
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
import { ClientIDSection } from "./ClientIDSection";
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
import { SecretKeySection } from "./SecretKeySection";
@@ -59,21 +59,7 @@ function IntegrateAPIKeySection({
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-6 ">
<div>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application.
</p>
<CopyTextButton
textToCopy={clientId}
className="!h-auto w-full max-w-[350px] justify-between truncate bg-background px-3 py-3 font-mono"
textToShow={clientId}
copyIconPosition="right"
tooltip="Copy Client ID"
/>
</div>
<ClientIDSection clientId={clientId} />
{secretKeyMasked && (
<SecretKeySection
secretKeyMasked={secretKeyMasked}
@@ -1,6 +1,5 @@
"use client";
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
import { AppFooter } from "@/components/blocks/app-footer";
import {
BookTextIcon,
BoxIcon,
@@ -12,12 +11,14 @@ import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
import { NebulaIcon } from "../../../../nebula-app/(app)/icons/NebulaIcon";
export function ProjectSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
showNebula: boolean;
}) {
const { layoutPath, children } = props;
const { layoutPath, children, showNebula } = props;
const tracking = (label: string) => ({
category: "project-sidebar",
@@ -27,7 +28,6 @@ export function ProjectSidebarLayout(props: {
return (
<FullWidthSidebarLayout
footer={<AppFooter />}
contentSidebarLinks={[
{
href: layoutPath,
@@ -60,6 +60,16 @@ export function ProjectSidebarLayout(props: {
icon: ContractIcon,
tracking: tracking("contracts"),
},
...(showNebula
? [
{
href: `${layoutPath}/nebula`,
label: "Nebula",
icon: NebulaIcon,
tracking: tracking("nebula"),
},
]
: []),
{
href: `${layoutPath}/insight`,
label: "Insight",
@@ -0,0 +1,75 @@
"use client";
import { Button } from "@/components/ui/button";
import { TabButtons } from "@/components/ui/tabs";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { ExternalLinkIcon } from "lucide-react";
import { useState } from "react";
export function WaitingForIntegrationCard(props: {
title: string;
codeTabs: {
code: React.ReactNode;
label: string;
}[];
ctas: {
label: string;
href: string;
trackingLabel: string;
category: string;
}[];
children?: React.ReactNode;
}) {
const [selectedTab, setSelectedTab] = useState(props.codeTabs[0]?.label);
return (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-4 lg:px-6">
<h2 className="font-semibold text-xl tracking-tight">{props.title}</h2>
</div>
<div className="px-4 py-6 lg:p-6">
{props.children}
{/* Code */}
<div>
<TabButtons
tabClassName="!text-sm"
tabs={props.codeTabs.map((tab) => ({
name: tab.label,
onClick: () => setSelectedTab(tab.label),
isActive: tab.label === selectedTab,
}))}
/>
<div className="h-2" />
{props.codeTabs.find((tab) => tab.label === selectedTab)?.code}
</div>
</div>
<div className="flex flex-col gap-3 border-t p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
<div className="flex gap-3">
{props.ctas.map((cta) => (
<Button asChild key={cta.label} variant="outline" size="sm">
<TrackedLinkTW
href={cta.href}
key={cta.label}
target="_blank"
className="gap-2"
category={cta.category}
label={cta.trackingLabel}
>
{cta.label}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</TrackedLinkTW>
</Button>
))}
</div>
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
<span className="!pointer-events-auto relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
Waiting for integration
</p>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { LinkIcon } from "lucide-react";
type FooterSectionProps = {
title: string;
links: {
href: string;
label: string;
}[];
};
type FooterCardProps = {
left: FooterSectionProps;
center: FooterSectionProps;
right: FooterSectionProps;
trackingCategory: string;
};
export function FooterLinksSection(props: FooterCardProps) {
return (
<div className="py-6">
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3 xl:gap-0 xl:divide-x xl:divide-border">
<div className="xl:pr-6">
<FooterSection
{...props.left}
trackingCategory={props.trackingCategory}
/>
</div>
<div className="xl:px-6">
<FooterSection
{...props.center}
trackingCategory={props.trackingCategory}
/>
</div>
<div className="xl:pl-6">
<FooterSection
{...props.right}
trackingCategory={props.trackingCategory}
/>
</div>
</div>
</div>
);
}
function FooterSection(
props: FooterSectionProps & { trackingCategory: string },
) {
return (
<div>
<h3 className="mb-2.5 font-medium">{props.title}</h3>
<div className="flex flex-col gap-2.5">
{props.links.map((link) => (
<FooterLink
key={link.label}
href={link.href}
trackingCategory={props.trackingCategory}
label={link.label}
/>
))}
</div>
</div>
);
}
function FooterLink({
href,
label,
trackingCategory,
}: {
href: string;
label: string;
trackingCategory: string;
}) {
return (
<TrackedLinkTW
href={href}
category={trackingCategory}
label={label.replace(/ /g, "-").toLowerCase()}
target="_blank"
className="flex items-start gap-2 text-balance text-muted-foreground text-sm hover:text-foreground"
>
<LinkIcon className="mt-1 size-3 shrink-0 opacity-70" />
{label}
</TrackedLinkTW>
);
}
@@ -1,108 +1,76 @@
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { FooterLinksSection } from "../../components/footer/FooterLinksSection";
type LinkInfo = { label: string; href: string; trackingLabel: string };
export function AAFooterSection(props: {
trackingCategory: string;
}) {
export function AAFooter() {
return (
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<FooterCard
title="Docs"
links={docLinks}
trackingCategory={props.trackingCategory}
/>
<FooterCard
title="Guides"
links={guideLinks}
trackingCategory={props.trackingCategory}
/>
<FooterCard
title="Templates"
links={templateLinks}
trackingCategory={props.trackingCategory}
/>
</section>
<FooterLinksSection
trackingCategory="account-abstraction"
left={{
title: "Documentation",
links: [
{
href: "https://portal.thirdweb.com/connect/account-abstraction/overview",
label: "Overview",
},
{
href: "https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started",
label: "Typescript",
},
{
href: "https://portal.thirdweb.com/react/v5/account-abstraction/get-started",
label: "React",
},
{
href: "https://portal.thirdweb.com/react/v5/account-abstraction/get-started",
label: "React Native",
},
{
href: "https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction",
label: ".NET",
},
{
href: "https://portal.thirdweb.com/unity/v5/wallets/account-abstraction",
label: "Unity",
},
{
href: "https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet",
label: "Unreal Engine",
},
],
}}
right={{
title: "Tutorials",
links: [
{
label: "Web3 onboarding with in-app wallets and AA",
href: "https://www.youtube.com/watch?v=HP8wSH7gnQA",
},
{
label: "Multi-chain experience with account abstraction",
href: "https://www.youtube.com/watch?v=2wUtq-drxAk",
},
{
label: "Add AA to a web3 unity game",
href: "https://www.youtube.com/watch?v=04G4DLhqwgI",
},
],
}}
center={{
title: "Demos",
links: [
{
label: "Connect Smart Accounts",
href: "https://playground.thirdweb.com/connect/account-abstraction/connect",
},
{
label: "Sponsor Gas Fees",
href: "https://playground.thirdweb.com/connect/account-abstraction/sponsor",
},
{
label: "Native Account Abstraction",
href: "https://playground.thirdweb.com/connect/account-abstraction/native-aa",
},
],
}}
/>
);
}
function FooterCard(props: {
title: string;
links: LinkInfo[];
trackingCategory: string;
}) {
return (
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
<h3 className="mb-5 font-semibold text-lg tracking-tight lg:text-xl">
{props.title}
</h3>
<div className="flex flex-col gap-2">
{props.links.map((link) => (
<TrackedLinkTW
key={link.href}
href={link.href}
category={props.trackingCategory}
label={link.trackingLabel}
target="_blank"
className="text-muted-foreground text-sm hover:text-foreground"
>
{link.label}
</TrackedLinkTW>
))}
</div>
</div>
);
}
const docLinks: LinkInfo[] = [
{
label: "Full Docs",
href: "https://portal.thirdweb.com/wallets/smart-wallet",
trackingLabel: "full-docs",
},
{
label: "Account Abstraction with React SDK",
href: "https://portal.thirdweb.com/wallets/smart-wallet/guides/react",
trackingLabel: "smart-wallet-react",
},
{
label: "Account Abstraction with TypeScript SDK",
href: "https://portal.thirdweb.com/wallets/smart-wallet/guides/typescript",
trackingLabel: "smart-wallet-typescript",
},
];
const guideLinks: LinkInfo[] = [
{
label: "Deploy a Smart Account (ERC-4337)",
href: "https://blog.thirdweb.com/guides/how-to-use-erc4337-smart-wallets/",
trackingLabel: "deploy-smart-wallet",
},
{
label:
"How to Extend the Base Account Abstraction Contracts Using the Solidity SDK",
href: "https://blog.thirdweb.com/guides/custom-smart-wallet-contracts/",
trackingLabel: "extend-base-smart-wallet",
},
{
label: "Batch Transactions with Account Abstraction",
href: "https://blog.thirdweb.com/guides/how-to-batch-transactions-with-the-thirdweb-sdk/",
trackingLabel: "batch-txns",
},
];
const templateLinks: LinkInfo[] = [
{
label: "Node.js template",
trackingLabel: "node-template",
href: "https://github.com/thirdweb-example/smart-wallet-script",
},
{
label: "React template",
trackingLabel: "react-template",
href: "https://github.com/thirdweb-example/smart-wallet-react",
},
];
@@ -1,100 +1,149 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import {
TrackedLinkTW,
TrackedUnderlineLink,
} from "@/components/ui/tracked-link";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { CircleAlertIcon } from "lucide-react";
import { useActiveWalletChain } from "thirdweb/react";
import type { UserOpStats } from "types/analytics";
import { AccountAbstractionSummary } from "../../../../../../components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { AAFooterSection } from "./AAFooterSection";
import { isOpChainId } from "./isOpChain";
import { useLocalStorage } from "hooks/useLocalStorage";
import { ArrowRightIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import { AAFooter } from "./AAFooterSection";
const TRACKING_CATEGORY = "smart-wallet";
export function AccountAbstractionLayout(props: {
projectSlug: string;
projectId: string;
teamSlug: string;
projectKey: string;
children: React.ReactNode;
hasSmartWalletsWithoutBilling: boolean;
userOpStats: UserOpStats;
}) {
const chain = useActiveWalletChain();
const isOpChain = chain?.id ? isOpChainId(chain.id) : false;
const smartWalletsLayoutSlug = `/team/${props.teamSlug}/${props.projectSlug}/connect/account-abstraction`;
return (
<div>
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
Account Abstraction
</h1>
<p className="text-muted-foreground text-sm">
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
into your apps.{" "}
<TrackedUnderlineLink
target="_blank"
label="docs-wallets"
category={TRACKING_CATEGORY}
href="https://portal.thirdweb.com/wallets/smart-wallet"
>
View Documentation
</TrackedUnderlineLink>
</p>
<div className="flex grow flex-col">
{/* top */}
<div className="pt-4 lg:pt-6">
{/* header */}
<div className="container flex max-w-7xl flex-col gap-4">
<div>
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Account Abstraction
</h1>
<p className="text-muted-foreground text-sm">
Integrate ERC-4337 compliant smart accounts for gasless
sponsorships and more. <br className="lg:hidden" />
<TrackedUnderlineLink
target="_blank"
label="docs-wallets"
category={TRACKING_CATEGORY}
href="https://portal.thirdweb.com/wallets/smart-wallet"
>
Learn more about Account Abstraction
</TrackedUnderlineLink>
</p>
</div>
{props.hasSmartWalletsWithoutBilling && <SmartWalletsBillingAlert />}
<GasCreditAlert
teamSlug={props.teamSlug}
projectId={props.projectId}
/>
</div>
<div className="h-4" />
{/* Nav */}
<TabPathLinks
scrollableClassName="container max-w-7xl"
links={[
{
name: "Analytics",
path: `${smartWalletsLayoutSlug}`,
exactMatch: true,
},
{
name: "Account Factories",
path: `${smartWalletsLayoutSlug}/factories`,
},
{
name: "Sponsorship Rules",
path: `${smartWalletsLayoutSlug}/settings`,
},
]}
/>
</div>
{/* content */}
<div className="h-6" />
<div className="flex flex-col gap-6">
{props.hasSmartWalletsWithoutBilling ? (
<SmartWalletsBillingAlert />
) : (
isOpChain && (
<Alert variant="info">
<CircleAlertIcon className="size-4" />
<AlertTitle>
Using the gas credits for OP chain paymaster
</AlertTitle>
<AlertDescription>
Credits will automatically be applied to cover gas fees for any
onchain activity across thirdweb services. <br />
Eligible chains: OP Mainnet, Base, Zora, Frax, Mode.
</AlertDescription>
</Alert>
)
)}
<div className="container flex max-w-7xl grow flex-col gap-6">
<div>{props.children}</div>
</div>
<div className="h-20" />
<div>
<AccountAbstractionSummary
aggregateUserOpUsageQuery={props.userOpStats}
/>
<div className="h-12" />
<TabPathLinks
links={[
{
name: "Analytics",
path: `${smartWalletsLayoutSlug}`,
exactMatch: true,
},
{
name: "Account Factories",
path: `${smartWalletsLayoutSlug}/factories`,
},
{
name: "Sponsorship Rules",
path: `${smartWalletsLayoutSlug}/settings`,
},
]}
/>
<div className="h-6" />
{props.children}
{/* footer */}
<div className="border-border border-t">
<div className="container max-w-7xl">
<AAFooter />
</div>
</div>
</div>
);
}
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">
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<TrackedUnderlineLink
target="_blank"
label="superchain-landing"
category={TRACKING_CATEGORY}
href="https://thirdweb.com/grant/superchain"
>
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"
>
Claim your credits <ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</div>
<div className="h-14" />
<AAFooterSection trackingCategory={TRACKING_CATEGORY} />
</div>
);
}
@@ -1,3 +0,0 @@
export function isOpChainId(chainId: number) {
return [10, 8453, 7777777, 252, 34443].includes(chainId);
}
@@ -1,4 +1,3 @@
import { getAggregateUserOpUsage } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import type { Metadata } from "next";
@@ -34,18 +33,13 @@ export default async function Page(props: {
team.billingStatus !== "validPayment" &&
team.billingStatus !== "pastDue";
const userOpStats = await getAggregateUserOpUsage({
teamId: team.id,
projectId: project.id,
});
return (
<AccountAbstractionLayout
userOpStats={userOpStats}
projectSlug={project.slug}
teamSlug={team_slug}
projectKey={project.publishableKey}
hasSmartWalletsWithoutBilling={hasSmartWalletsWithoutBilling}
projectId={project.id}
>
{props.children}
</AccountAbstractionLayout>
@@ -1,3 +0,0 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,5 +1,6 @@
import { getUserOpUsage } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import {
type Range,
@@ -8,6 +9,7 @@ import {
import { AccountAbstractionAnalytics } from "components/smart-wallets/AccountAbstractionAnalytics";
import { notFound, redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { AccountAbstractionSummary } from "../../../../../../components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { searchParamLoader } from "./search-params";
@@ -31,7 +33,14 @@ export default async function Page(props: {
notFound();
}
const project = await getProject(params.team_slug, params.project_slug);
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
@@ -61,12 +70,20 @@ export default async function Page(props: {
});
return (
<AccountAbstractionAnalytics
userOpStats={userOpStats}
client={getThirdwebClient(authToken)}
teamId={project.teamId}
projectId={project.id}
teamSlug={params.team_slug}
/>
<div>
<AccountAbstractionSummary
teamId={project.teamId}
projectId={project.id}
/>
<div className="h-10" />
<AccountAbstractionAnalytics
userOpStats={userOpStats}
client={getThirdwebClient(authToken)}
teamId={project.teamId}
projectId={project.id}
teamSlug={params.team_slug}
/>
</div>
);
}
@@ -1,176 +1,80 @@
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { ArrowRightIcon } from "lucide-react";
import { SupportedPlatformLink } from "../../../../../../../components/wallets/SupportedPlatformLink";
import { FooterLinksSection } from "../../../components/footer/FooterLinksSection";
export function InAppWalletFooterSection(props: {
trackingCategory: string;
}) {
export function InAppWalletsFooter() {
return (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<ViewDocs trackingCategory={props.trackingCategory} />
<Templates trackingCategory={props.trackingCategory} />
</div>
);
}
function ViewDocs(props: {
trackingCategory: string;
}) {
const TRACKING_CATEGORY = props.trackingCategory;
return (
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
<div className="flex items-center gap-2">
<h3 className="font-semibold">View Docs</h3>
<ArrowRightIcon className="size-4" />
</div>
<div className="h-4" />
<div className="grid grid-cols-2 gap-4">
<SupportedPlatformLink
trackingCategory={TRACKING_CATEGORY}
platform="React"
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
/>
<SupportedPlatformLink
trackingCategory={TRACKING_CATEGORY}
platform="Unity"
href="https://portal.thirdweb.com/unity/wallets/providers/embedded-wallet"
/>
<SupportedPlatformLink
trackingCategory={TRACKING_CATEGORY}
platform="React Native"
href="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
/>
<SupportedPlatformLink
trackingCategory={TRACKING_CATEGORY}
platform="TypeScript"
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
/>
</div>
<div className="h-6" />
<div className="flex items-center gap-2">
<h3 className="font-semibold">Relevant Guides</h3>
<ArrowRightIcon className="size-4" />
</div>
<div className="h-4" />
<div className="flex flex-col gap-3">
<GuideLink
href="https://blog.thirdweb.com/what-are-embedded-wallets/"
label="what-is-an-embedded-wallet"
trackingCategory={TRACKING_CATEGORY}
>
What is an in-app wallet?
</GuideLink>
<GuideLink
href="https://portal.thirdweb.com/connect/in-app-wallet/get-started"
label="sdks-get-started"
trackingCategory={TRACKING_CATEGORY}
>
Get started with In-App Wallets
</GuideLink>
<GuideLink
href="https://portal.thirdweb.com/connect/in-app-wallet/how-to/connect-users"
label="how-to-connect-your-users"
trackingCategory={TRACKING_CATEGORY}
>
Using In-App Wallets with Connect
</GuideLink>
<GuideLink
href="https://portal.thirdweb.com/connect/in-app-wallet/how-to/build-your-own-ui"
label="how-to-build-your-own-ui"
trackingCategory={TRACKING_CATEGORY}
>
How to Build Your Own UI
</GuideLink>
<GuideLink
href="https://portal.thirdweb.com/connect/in-app-wallet/custom-auth/custom-auth-server"
label="how-to-custom-auth-server"
trackingCategory={TRACKING_CATEGORY}
>
Create a custom auth server
</GuideLink>
</div>
</div>
);
}
function GuideLink(props: {
label: string;
children: React.ReactNode;
href: string;
trackingCategory: string;
}) {
return (
<TrackedLinkTW
category={props.trackingCategory}
label="guide"
trackingProps={{
guide: props.label,
<FooterLinksSection
trackingCategory="in-app-wallets"
left={{
title: "Documentation",
links: [
{
href: "https://portal.thirdweb.com/connect/wallet/overview",
label: "Overview",
},
{
href: "https://portal.thirdweb.com/typescript/v5/in-app-wallet",
label: "Typescript",
},
{
href: "https://portal.thirdweb.com/react/v5/in-app-wallet/get-started",
label: "React",
},
{
href: "https://portal.thirdweb.com/react/v5/in-app-wallet/get-started",
label: "React Native",
},
{
href: "https://portal.thirdweb.com/dotnet/wallets/providers/in-app-wallet",
label: ".NET",
},
{
href: "https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet",
label: "Unity",
},
{
href: "https://portal.thirdweb.com/unreal-engine/blueprints/in-app-wallet",
label: "Unreal Engine",
},
],
}}
href={props.href}
className="!text-muted-foreground hover:!text-foreground text-sm"
target="_blank"
>
{props.children}
</TrackedLinkTW>
);
}
function Templates(props: {
trackingCategory: string;
}) {
return (
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Relevant Templates</h3>
<ArrowRightIcon className="size-4" />
</div>
<div className="h-6" />
<div className="flex flex-col gap-3">
<GuideLink
href="https://github.com/thirdweb-example/embedded-smart-wallet"
label="embedded-smart-wallet"
trackingCategory={props.trackingCategory}
>
In-App Wallet + Account Abstraction Starter Kit
</GuideLink>
<GuideLink
href="https://github.com/thirdweb-example/catattacknft"
label="embedded-cat-attack"
trackingCategory={props.trackingCategory}
>
Cat Attack [Demo Web Game]
</GuideLink>
<GuideLink
href="https://github.com/thirdweb-example/embedded-wallet-custom-ui"
label="embedded-wallet-with-custom-ui-react"
trackingCategory={props.trackingCategory}
>
In-App Wallet With Custom UI [React]
</GuideLink>
<GuideLink
href="https://github.com/thirdweb-example/embedded-wallet-custom-ui-react-native"
label="embedded-wallet-with-custom-ui-react-native"
trackingCategory={props.trackingCategory}
>
In-App Wallet With Custom UI [ReactNative]
</GuideLink>
</div>
</div>
center={{
title: "Tutorials",
links: [
{
href: "https://www.youtube.com/watch?v=9s3Z-MvPv2I",
label: "Build your own custom connect component",
},
{
label: "Learn to create wallets inside a Unity game",
href: "https://www.youtube.com/watch?v=oUouNueoVFU",
},
{
href: "https://www.youtube.com/watch?v=xEp-fKba_cI",
label: "Pre-generate wallets for users",
},
{
label: "Sign-in with guest mode",
href: "https://portal.thirdweb.com/connect/wallet/sign-in-methods/guest",
},
],
}}
right={{
title: "Demos",
links: [
{
href: "https://playground.thirdweb.com/connect/in-app-wallet/ecosystem",
label: "Ecosystems",
},
{
href: "https://playground.thirdweb.com/connect/in-app-wallet/sponsor",
label: "Signless Sponsored Transactions",
},
{
href: "https://playground.thirdweb.com/connect/in-app-wallet",
label: "Customized UI",
},
],
}}
/>
);
}
@@ -1,43 +1,16 @@
import { getInAppWalletUsage } from "@/api/analytics";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { subDays } from "date-fns";
import { TRACKING_CATEGORY } from "../_constants";
export async function InAppWalletsHeader({
teamId,
projectId,
}: { teamId: string; projectId: string }) {
const allTimeStatsPromise = getInAppWalletUsage({
teamId,
projectId,
from: new Date(2022, 0, 1),
to: new Date(),
period: "all",
});
const monthlyStatsPromise = getInAppWalletUsage({
teamId,
projectId,
from: subDays(new Date(), 30),
to: new Date(),
period: "month",
});
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
]).catch(() => [null, null]);
export async function InAppWalletsHeader() {
return (
<div>
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
<div className="">
<h1 className="mb-2 font-semibold text-2xl tracking-tighter lg:text-3xl">
In-App Wallets
</h1>
<p className="mb-7 max-w-[700px] text-muted-foreground text-sm leading-relaxed">
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
A wallet infrastructure that enables apps to create, manage, and control
their users wallets. Email login, social login, and bring-your-own auth
supported.{" "}
their users wallets. <br /> Email login, social login, and
bring-your-own auth supported.{" "}
<TrackedUnderlineLink
target="_blank"
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
@@ -47,10 +20,6 @@ export async function InAppWalletsHeader({
Learn more
</TrackedUnderlineLink>
</p>
<InAppWalletsSummary
allTimeStats={allTimeStats || []}
monthlyStats={monthlyStats || []}
/>
</div>
);
}
@@ -1,9 +1,8 @@
import { getProject } from "@/api/projects";
import { TabPathLinks } from "@/components/ui/tabs";
import { redirect } from "next/navigation";
import { InAppWalletFooterSection } from "./_components/footer";
import { InAppWalletsFooter } from "./_components/footer";
import { InAppWalletsHeader } from "./_components/header";
import { TRACKING_CATEGORY } from "./_constants";
export default async function Layout(props: {
params: Promise<{
@@ -22,34 +21,48 @@ export default async function Layout(props: {
const { team_slug, project_slug } = params;
return (
<div>
<InAppWalletsHeader teamId={project.teamId} projectId={project.id} />
<div className="h-8" />
<div className="flex grow flex-col">
{/* header */}
<div className="pt-4 lg:pt-6">
<div className="container max-w-7xl">
<InAppWalletsHeader />
</div>
<div className="h-3" />
<TabPathLinks
scrollableClassName="container max-w-7xl"
links={[
{
name: "Analytics",
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
exactMatch: true,
},
{
name: "Users",
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/users`,
exactMatch: true,
},
{
name: "Settings",
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/settings`,
exactMatch: true,
},
]}
/>
</div>
<TabPathLinks
links={[
{
name: "Analytics",
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
exactMatch: true,
},
{
name: "Users",
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/users`,
exactMatch: true,
},
{
name: "Settings",
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/settings`,
exactMatch: true,
},
]}
/>
{/* content */}
<div className="container flex max-w-7xl grow flex-col">
<div className="h-6" />
{props.children}
<div className="h-20" />
</div>
<div className="h-8" />
{props.children}
<div className="h-8" />
<InAppWalletFooterSection trackingCategory={TRACKING_CATEGORY} />
{/* footer */}
<div className="border-t">
<div className="container max-w-7xl">
<InAppWalletsFooter />
</div>
</div>
</div>
);
}
@@ -1,3 +0,0 @@
"use client";
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
@@ -1,7 +1,8 @@
import { getProject } from "@/api/projects";
import type { Range } from "components/analytics/date-range-selector";
import { InAppWalletAnalytics } from "components/embedded-wallets/Analytics";
import { redirect } from "next/navigation";
import { getProject } from "../../../../../../@/api/projects";
import { InAppWalletsSummary } from "../../../../../../components/embedded-wallets/Analytics/Summary";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -39,11 +40,15 @@ export default async function Page(props: {
}
return (
<InAppWalletAnalytics
teamId={project.teamId}
projectId={project.id}
interval={interval}
range={range as Range}
/>
<div>
<InAppWalletsSummary teamId={project.teamId} projectId={project.id} />
<div className="h-10" />
<InAppWalletAnalytics
teamId={project.teamId}
projectId={project.id}
interval={interval}
range={range as Range}
/>
</div>
);
}
@@ -1,14 +0,0 @@
import { redirect } from "next/navigation";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
}) {
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/connect/in-app-wallets`,
);
}
@@ -1,7 +1,8 @@
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { TabPathLinks } from "@/components/ui/tabs";
import { redirect } from "next/navigation";
import { UnderlineLink } from "../../../../../../@/components/ui/UnderlineLink";
import { FooterLinksSection } from "../../components/footer/FooterLinksSection";
export default async function Layout(props: {
params: Promise<{
@@ -20,13 +21,13 @@ export default async function Layout(props: {
const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge`;
return (
<div className="flex w-full flex-col gap-6">
<div className="flex flex-col items-start justify-between gap-6 lg:flex-row">
<div className="max-w-[700px]">
<div className="flex grow flex-col">
<div className="pt-4 lg:pt-6">
<div className="container max-w-7xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
Universal Bridge
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="max-w-3xl text-muted-foreground text-sm leading-relaxed">
Universal Bridge allows your users to bridge, swap, and purchase
cryptocurrencies and execute transactions with any fiat options or
tokens via cross-chain routing.{" "}
@@ -38,28 +39,99 @@ export default async function Layout(props: {
</UnderlineLink>
</p>
</div>
<div className="h-4" />
<TabPathLinks
scrollableClassName="container max-w-7xl"
className="w-full"
links={[
{
name: "Analytics",
path: payLayoutPath,
exactMatch: true,
},
{
name: "Webhooks",
path: `${payLayoutPath}/webhooks`,
},
{
name: "Settings",
path: `${payLayoutPath}/settings`,
},
]}
/>
</div>
<TabPathLinks
className="w-full"
links={[
{
name: "Analytics",
path: payLayoutPath,
exactMatch: true,
},
{
name: "Webhooks",
path: `${payLayoutPath}/webhooks`,
},
{
name: "Settings",
path: `${payLayoutPath}/settings`,
},
]}
/>
<div className="h-6" />
<div className="container flex max-w-7xl grow flex-col">
{props.children}
</div>
{props.children}
<div className="h-20" />
<div className="border-border border-t">
<div className="container max-w-7xl">
<UBFooter />
</div>
</div>
</div>
);
}
function UBFooter() {
return (
<FooterLinksSection
trackingCategory="universal-bridge"
left={{
title: "Documentation",
links: [
{
href: "https://portal.thirdweb.com/connect/pay/overview",
label: "Overview",
},
{
href: "https://portal.thirdweb.com/typescript/v5/convertCryptoToFiat",
label: "TypeScript",
},
{
href: "https://portal.thirdweb.com/react/v5/pay/fund-wallets",
label: "React",
},
{
href: "https://portal.thirdweb.com/dotnet/universal-bridge/quickstart",
label: ".NET",
},
],
}}
right={{
title: "Tutorials",
links: [
{
label: "Implement cross-chain payments in any app",
href: "https://www.youtube.com/watch?v=aBu175-VsNY",
},
],
}}
center={{
title: "Demos",
links: [
{
label: "UI Component",
href: "https://playground.thirdweb.com/connect/pay",
},
{
label: "Buy Crypto",
href: "https://playground.thirdweb.com/connect/pay/fund-wallet",
},
{
label: "Commerce",
href: "https://playground.thirdweb.com/connect/pay/commerce",
},
{
label: "Transactions",
href: "https://playground.thirdweb.com/connect/pay/transactions",
},
],
}}
/>
);
}
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { DeployedContractsPage } from "../../../../account/contracts/_components/DeployedContractsPage";
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { FooterLinksSection } from "../components/footer/FooterLinksSection";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -29,10 +30,71 @@ export default async function Page(props: {
}
return (
<DeployedContractsPage
teamId={team.id}
projectId={project.id}
authToken={authToken}
/>
<div className="flex grow flex-col">
<DeployedContractsPage
teamId={team.id}
projectId={project.id}
authToken={authToken}
/>
<div className="h-20" />
<ContractsFooter />
</div>
);
}
function ContractsFooter() {
return (
<div className="border-t">
<div className="container max-w-7xl">
<FooterLinksSection
trackingCategory="contracts"
left={{
title: "Documentation",
links: [
{
label: "Deployment Tools",
href: "https://portal.thirdweb.com/contracts/deploy/overview",
},
{
label: "Modular Contracts",
href: "https://portal.thirdweb.com/contracts/modular-contracts/overview",
},
{
label: "Pre-built Contracts",
href: "https://portal.thirdweb.com/contracts/explore/overview",
},
],
}}
center={{
title: "Templates",
links: [
{
label: "Hardhat Starter",
href: "https://thirdweb.com/templates/hardhat-starter",
},
{
href: "https://thirdweb.com/templates/forge-starter",
label: "Forge Starter",
},
],
}}
right={{
title: "Tutorials",
links: [
{
label:
"Everything you need to know about upgradeable smart contracts",
href: "https://www.youtube.com/watch?v=cZt-CkzxrNM",
},
{
label:
"Modular Contracts SDK: Build Core & Modules from Scratch (Advanced Guide)",
href: "https://www.youtube.com/watch?v=ZoOk41y4f_k",
},
],
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { Button } from "@/components/ui/button";
import {
Code2Icon,
DatabaseIcon,
ExternalLinkIcon,
ZapIcon,
} from "lucide-react";
import Link from "next/link";
export function BlueprintCard() {
const features = [
{
icon: Code2Icon,
title: "Easy-to-Use API",
description: "RESTful endpoints for any application",
},
{
icon: DatabaseIcon,
title: "Managed Infrastructure",
description:
"No need to index blockchains yourself or manage infrastructure and RPC costs.",
},
{
icon: ZapIcon,
title: "Lightning-Fast Queries",
description: "Access any transaction, event or token API data",
},
];
return (
<div className="rounded-lg border bg-card">
{/* header */}
<div className="border-b p-4 lg:px-6 lg:py-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-2xl tracking-tight">Blueprints</h2>
<div className="flex items-center gap-2">
<Button asChild variant="outline" className="gap-2 bg-background">
<Link
href="https://portal.thirdweb.com/insight/blueprints"
target="_blank"
>
Docs <ExternalLinkIcon className="size-4" />
</Link>
</Button>
</div>
</div>
</div>
{/* Content */}
<div className="p-4 lg:p-6">
<p className="mb-2 font-semibold text-xl leading-tight tracking-tight lg:mb-1">
Simple endpoints for querying rich blockchain data
</p>
<p className="text-muted-foreground text-sm">
A blueprint is an API that provides access to on-chain data in a
user-friendly format. <br /> No need for ABIs, decoding, RPC, or web3
knowledge required to fetch blockchain data.
</p>
<div className="h-6" />
{/* Features */}
<div className="flex flex-col gap-6">
{features.map((feature) => (
<div key={feature.title} className="flex items-start gap-3">
<div className="rounded-full border p-2">
<feature.icon className="size-4 text-muted-foreground" />
</div>
<div>
<h4 className="font-medium">{feature.title}</h4>
<p className="text-muted-foreground text-sm">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
{/* Playground link */}
<div className="border-t p-4 lg:p-6">
<Button className="w-full gap-2" asChild>
<Link href="https://playground.thirdweb.com/insight" target="_blank">
Try Insight blueprints in the playground
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { isProd } from "@/constants/env";
import { ClientIDSection } from "../components/ProjectFTUX/ClientIDSection";
import { WaitingForIntegrationCard } from "../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function InsightFTUX(props: {
clientId: string;
}) {
return (
<WaitingForIntegrationCard
title="Integrate Insight"
codeTabs={[
{
label: "JavaScript",
code: (
<CodeServer
code={jsCode(props.clientId)}
className="bg-background"
lang="ts"
/>
),
},
{
label: "Python",
code: (
<CodeServer
code={pythonCode(props.clientId)}
className="bg-background"
lang="python"
/>
),
},
{
label: "Curl",
code: (
<CodeServer
code={curlCode(props.clientId)}
className="bg-background"
lang="bash"
/>
),
},
]}
ctas={[
{
label: "Try on Playground",
href: "https://playground.thirdweb.com/insight",
trackingLabel: "playground",
category: "insight-ftux",
},
{
label: "View Docs",
href: "https://portal.thirdweb.com/insight",
trackingLabel: "docs",
category: "insight-ftux",
},
]}
>
<ClientIDSection clientId={props.clientId} />
<div className="h-4" />
</WaitingForIntegrationCard>
);
}
const twDomain = isProd ? "thirdweb" : "thirdweb-dev";
const jsCode = (clientId: string) => `\
// Example: Get latest 10 transactions on Ethereum
const res = await fetch("https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}");
const data = await res.json();
`;
const curlCode = (clientId: string) => `\
# Example: Get latest 10 transactions on Ethereum
curl -X GET "https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}"
`;
const pythonCode = (clientId: string) => `\
# Example: Get latest 10 transactions on Ethereum
import requests
response = requests.get("https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}")
data = response.json()
`;
@@ -1,106 +1,110 @@
import { Button } from "@/components/ui/button";
import {
Code2Icon,
DatabaseIcon,
ExternalLinkIcon,
ZapIcon,
} from "lucide-react";
import Link from "next/link";
import { isProjectActive } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { notFound } from "next/navigation";
import { FooterLinksSection } from "../components/footer/FooterLinksSection";
import { BlueprintCard } from "./blueprint-card";
import { InsightFTUX } from "./insight-ftux";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
}) {
const params = await props.params;
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team || !project) {
notFound();
}
const activeResponse = await isProjectActive({
teamId: team.id,
projectId: project.id,
});
const showFTUX = !activeResponse.insight;
export default async function Page() {
return (
<div className="flex grow flex-col">
<h1 className="mb-6 font-semibold text-2xl tracking-tight sm:text-3xl">
Insight
</h1>
<div className="pb-20">
<BlueprintCard />
</div>
</div>
);
}
function BlueprintCard() {
const features = [
{
icon: Code2Icon,
title: "Easy-to-Use API",
description: "RESTful endpoints for any application",
},
{
icon: DatabaseIcon,
title: "Managed Infrastructure",
description:
"No need to index blockchains yourself or manage infrastructure and RPC costs.",
},
{
icon: ZapIcon,
title: "Lightning-Fast Queries",
description: "Access any transaction, event or token API data",
},
];
return (
<div className="rounded-lg border bg-card">
{/* header */}
<div className="border-b p-4 lg:px-6 lg:py-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-2xl tracking-tight">Blueprints</h2>
<div className="flex items-center gap-2">
<Button asChild variant="outline" className="gap-2 bg-background">
<Link
href="https://portal.thirdweb.com/insight/blueprints"
target="_blank"
>
Docs <ExternalLinkIcon className="size-4" />
</Link>
</Button>
</div>
<div className="border-b py-10">
<div className="container max-w-7xl">
<h1 className="mb-1 font-semibold text-3xl tracking-tight">
Insight
</h1>
<p className="text-muted-foreground text-sm">
APIs to retrieve blockchain data from any EVM chain, enrich it with
metadata, and transform it using custom logic
</p>
</div>
</div>
{/* Content */}
<div className="p-4 lg:p-6">
<p className="mb-2 font-semibold text-xl leading-tight tracking-tight lg:mb-1">
Simple endpoints for querying rich blockchain data
</p>
<p className="text-muted-foreground text-sm">
A blueprint is an API that provides access to on-chain data in a
user-friendly format. <br /> No need for ABIs, decoding, RPC, or web3
knowledge required to fetch blockchain data.
</p>
<div className="h-6" />
<div className="h-6" />
{/* Features */}
<div className="flex flex-col gap-6">
{features.map((feature) => (
<div key={feature.title} className="flex items-start gap-3">
<div className="rounded-full border p-2">
<feature.icon className="size-4 text-muted-foreground" />
</div>
<div>
<h4 className="font-medium">{feature.title}</h4>
<p className="text-muted-foreground text-sm">
{feature.description}
</p>
</div>
</div>
))}
</div>
<div className="container flex max-w-7xl grow flex-col">
{showFTUX ? (
<InsightFTUX clientId={project.publishableKey} />
) : (
<BlueprintCard />
)}
</div>
{/* Playground link */}
<div className="border-t p-4 lg:p-6">
<Button className="w-full gap-2" asChild>
<Link href="https://playground.thirdweb.com/insight" target="_blank">
Try Insight blueprints in the playground
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
<div className="h-20" />
<div className="border-t">
<div className="container max-w-7xl">
<InsightFooter />
</div>
</div>
</div>
);
}
function InsightFooter() {
return (
<FooterLinksSection
left={{
title: "Documentation",
links: [
{
label: "Overview",
href: "https://portal.thirdweb.com/insight",
},
{
label: "API Reference",
href: "https://insight-api.thirdweb.com/reference",
},
],
}}
center={{
title: "Tutorials",
links: [
{
label:
"Blockchain Data on Any EVM - Quick and Easy REST APIs for Onchain Data",
href: "https://www.youtube.com/watch?v=U2aW7YIUJVw",
},
{
label: "Build a Whale Alerts Telegram Bot with Insight",
href: "https://www.youtube.com/watch?v=HvqewXLVRig",
},
],
}}
right={{
title: "Demos",
links: [
{
label: "API Playground",
href: "https://playground.thirdweb.com/insight",
},
],
}}
trackingCategory="insight"
/>
);
}
@@ -1,5 +1,5 @@
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { SidebarProvider } from "@/components/ui/sidebar";
import { redirect } from "next/navigation";
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
@@ -9,7 +9,7 @@ import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logg
import { ProjectSidebarLayout } from "./components/ProjectSidebarLayout";
import { SaveLastUsedProject } from "./components/SaveLastUsedProject";
export default async function TeamLayout(props: {
export default async function ProjectLayout(props: {
children: React.ReactNode;
breadcrumbNav: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -49,6 +49,9 @@ export default async function TeamLayout(props: {
redirect(`/team/${params.team_slug}`);
}
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
?.onWaitlist;
const layoutPath = `/team/${params.team_slug}/${params.project_slug}`;
return (
@@ -64,7 +67,10 @@ export default async function TeamLayout(props: {
accountAddress={accountAddress}
/>
</div>
<ProjectSidebarLayout layoutPath={layoutPath}>
<ProjectSidebarLayout
layoutPath={layoutPath}
showNebula={!!isOnNebulaWaitList}
>
{props.children}
</ProjectSidebarLayout>
</div>
@@ -12,6 +12,7 @@ export type NebulaAnalyticsDataItem = {
export const fetchNebulaAnalytics = unstable_cache(
async (params: {
teamId: string;
projectId: string;
authToken: string;
from: string;
to: string;
@@ -20,6 +21,7 @@ export const fetchNebulaAnalytics = unstable_cache(
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
const url = new URL(`${analyticsEndpoint}/v2/nebula/usage`);
url.searchParams.set("teamId", params.teamId);
url.searchParams.set("projectId", params.projectId);
url.searchParams.set("from", params.from);
url.searchParams.set("to", params.to);
url.searchParams.set("period", params.period);
@@ -22,7 +22,7 @@ export function NebulaAnalyticsFilter() {
});
return (
<div className="flex items-center gap-3">
<div className="no-scrollbar flex items-center gap-3 overflow-auto">
<DateRangeSelector
range={range}
popoverAlign="end"
@@ -19,11 +19,12 @@ export function NebulaAnalyticsPage(props: {
};
teamId: string;
authToken: string;
projectId: string;
}) {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<header className="border-b">
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
<div className="container flex max-w-7xl flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
<div className="flex gap-3">
@@ -44,7 +45,7 @@ export function NebulaAnalyticsPage(props: {
</div>
</header>
<div className="container pt-8 pb-20">
<div className="container max-w-7xl pt-8">
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
<NebulaAnalyticsFilter />
@@ -56,6 +57,7 @@ export function NebulaAnalyticsPage(props: {
<NebulaAnalyticDashboard
searchParams={props.searchParams}
teamId={props.teamId}
projectId={props.projectId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
@@ -67,6 +69,7 @@ export function NebulaAnalyticsPage(props: {
async function NebulaAnalyticDashboard(props: {
teamId: string;
authToken: string;
projectId: string;
searchParams: {
from: string | undefined | string[];
to: string | undefined | string[];
@@ -80,6 +83,7 @@ async function NebulaAnalyticDashboard(props: {
const res = await fetchNebulaAnalytics({
teamId: props.teamId,
authToken: props.authToken,
projectId: props.projectId,
from: normalizeTimeISOString(range.from),
to: normalizeTimeISOString(range.to),
// internally renamed
@@ -14,7 +14,7 @@ export function NebulaWaitListPageUI(props: {
{/* Header */}
{!props.hideHeader && (
<div className="border-b py-10">
<div className="container flex items-center justify-between">
<div className="container flex max-w-7xl items-center justify-between">
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
<Button asChild variant="outline">
<Link href="/contact-us" target="_blank">
@@ -25,7 +25,7 @@ export function NebulaWaitListPageUI(props: {
</div>
)}
<div className="container flex grow flex-col overflow-hidden py-32">
<div className="container flex max-w-7xl grow flex-col overflow-hidden py-32">
<CenteredCard
title="You're on the waitlist"
description="You should receive access to Nebula soon!"
@@ -46,7 +46,7 @@ function UnexpectedErrorPage(props: {
message: string;
}) {
return (
<div className="container flex grow flex-col py-10">
<div className="flex grow flex-col py-10">
<div className="flex min-h-[300px] grow flex-col items-center justify-center rounded-lg border">
<div className="flex flex-col items-center gap-4">
<span className="text-destructive-text">{props.message}</span>
@@ -0,0 +1,72 @@
import { FooterLinksSection } from "../components/footer/FooterLinksSection";
export default function Layout(props: {
children: React.ReactNode;
}) {
return (
<div className="flex grow flex-col">
<div className="flex grow flex-col">{props.children}</div>
<div className="h-20" />
<div className="border-t">
<div className="container max-w-7xl">
<NebulaFooter />
</div>
</div>
</div>
);
}
function NebulaFooter() {
return (
<FooterLinksSection
trackingCategory="nebula"
left={{
title: "Documentation",
links: [
{
label: "API Reference",
href: "https://portal.thirdweb.com/nebula/get-started",
},
{
label: "Python SDK",
href: "https://portal.thirdweb.com/nebula/tools/python-sdk/installation",
},
{
label: "MCP Server",
href: "https://portal.thirdweb.com/nebula/mcp-server/get-started",
},
],
}}
center={{
title: "Tutorials",
links: [
{
href: "https://www.youtube.com/watch?v=xrm0emiRGMk",
label: "Build a Telegram Mini App ERC20 deployer, powered by AI",
},
{
label: "Build an AI Powered Blockchain Explorer",
href: "https://www.youtube.com/watch?v=vBooJytkXa4",
},
],
}}
right={{
title: "Templates",
links: [
{
label: "Bouncer Eliza Agent",
href: "https://github.com/thirdweb-example/bouncer-eliza-agent",
},
{
label: "Block Explorer",
href: "https://github.com/thirdweb-example/nebula-block-explorer",
},
{
label: "ERC20 Telegram Mini-app Token Deployer",
href: "https://github.com/thirdweb-example/erc20-token-deployer",
},
],
}}
/>
);
}
@@ -0,0 +1,104 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { SecretKeySection } from "../components/ProjectFTUX/SecretKeySection";
import { WaitingForIntegrationCard } from "../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function NebulaFTUX(props: {
secretKeyMasked: string;
projectId: string;
}) {
return (
<WaitingForIntegrationCard
title="Integrate Nebula"
codeTabs={[
{
label: "JavaScript",
code: (
<CodeServer code={jsCode} className="bg-background" lang="ts" />
),
},
{
label: "Python",
code: (
<CodeServer
code={pythonCode}
className="bg-background"
lang="python"
/>
),
},
{
label: "Curl",
code: (
<CodeServer code={curlCode} className="bg-background" lang="bash" />
),
},
]}
ctas={[
{
label: "Try on Playground",
href: "https://nebula.thirdweb.com/",
trackingLabel: "playground",
category: "nebula-ftux",
},
{
label: "View Docs",
href: "https://portal.thirdweb.com/nebula",
trackingLabel: "docs",
category: "nebula-ftux",
},
]}
>
<SecretKeySection
secretKeyMasked={props.secretKeyMasked}
projectId={props.projectId}
/>
<div className="h-4" />
</WaitingForIntegrationCard>
);
}
const jsCode = `\
// Example: Send message to Nebula
// Replace PROJECT_SECRET_KEY with your project's full secret key
const res = await fetch("https://nebula-api.thirdweb.com/chat", {
method: "POST",
headers: {
"x-secret-key": "PROJECT_SECRET_KEY",
},
body: {
message: "Hello",
stream: false,
},
});
const data = await res.json();
`;
const curlCode = `\
# Example: Send message to Nebula
# Replace PROJECT_SECRET_KEY with your project's full secret key
curl -X POST https://nebula-api.thirdweb.com/chat \
-H "x-secret-key:PROJECT_SECRET_KEY" \
-d '{
"message": "Hello",
"stream": false,
}'
`;
const pythonCode = `\
# Example: Send message to Nebula
# Replace PROJECT_SECRET_KEY with your project's full secret key
import requests
response = requests.post("https://nebula-api.thirdweb.com/chat", headers={
"x-secret-key": "PROJECT_SECRET_KEY"
}, json={
"message": "Hello",
"stream": False,
})
data = response.json()
`;
@@ -0,0 +1,85 @@
import { isProjectActive } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { NebulaAnalyticsPage } from "./components/analytics/nebula-analytics-page";
import { NebulaWaitListPage } from "./components/nebula-waitlist-page";
import { NebulaFTUX } from "./nebula-ftux";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
searchParams: Promise<{
from: string | undefined | string[];
to: string | undefined | string[];
interval: string | undefined | string[];
}>;
}) {
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
const [authToken, team, project] = await Promise.all([
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`);
}
const hasNebulaAccess = team.enabledScopes.includes("nebula");
const activeResponse = await isProjectActive({
teamId: team.id,
projectId: project.id,
});
const showFTUX = !activeResponse.nebula;
if (hasNebulaAccess) {
if (showFTUX) {
return (
<div className="flex grow flex-col">
<div className="border-b py-10">
<div className="container max-w-7xl">
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
</div>
</div>
<div className="container mt-6 max-w-7xl">
<NebulaFTUX
secretKeyMasked={project.secretKeys[0]?.masked || ""}
projectId={project.id}
/>
</div>
</div>
);
}
return (
<NebulaAnalyticsPage
teamId={team.id}
authToken={authToken}
searchParams={searchParams}
projectId={project.id}
/>
);
}
return <NebulaWaitListPage team={team} />;
}
@@ -76,26 +76,29 @@ export default async function ProjectOverviewPage(props: PageProps) {
projectId: project.id,
});
// is any analytics data active?
const isActive = Object.values(activeStatus).some((v) => !!v);
return (
<div className="flex grow flex-col">
<div className="w-full">
<Header
title={project.name}
interval={interval}
range={range}
showRangeSelector={isActive}
/>
<div className="w-full border-b py-10">
<div className="container max-w-7xl">
<Header
title={project.name}
interval={interval}
range={range}
showRangeSelector={isActive}
/>
</div>
</div>
<div className="h-6" />
{!isActive ? (
<div className="pt-6">
<div className="container max-w-7xl">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="flex grow flex-col pt-6">
<div className="container flex max-w-7xl grow flex-col">
<Suspense fallback={<GenericLoadingPage />}>
<ProjectAnalytics
project={project}
@@ -106,6 +109,8 @@ export default async function ProjectOverviewPage(props: PageProps) {
</Suspense>
</div>
)}
<div className="h-20" />
</div>
);
}
@@ -7,10 +7,14 @@ export default async function Layout(props: {
}) {
return (
<div className="flex grow flex-col">
<h1 className="font-semibold text-3xl tracking-tight">
Project Settings
</h1>
<div className="py-6">{props.children}</div>
<div className="border-b py-10">
<div className="container max-w-7xl">
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
Project Settings
</h1>
</div>
</div>
<div className="container max-w-7xl pt-6 pb-20">{props.children}</div>
</div>
);
}
@@ -1,22 +1,27 @@
export const Stat: React.FC<{
import { Skeleton } from "../../@/components/ui/skeleton";
export const StatCard: React.FC<{
label: string;
value?: number;
icon: React.FC<{ className?: string }>;
formatter?: (value: number) => string;
}> = ({ label, value, formatter, icon: Icon }) => {
isPending: boolean;
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
return (
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 lg:p-6">
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
<div>
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
{value !== undefined && formatter
? formatter(value)
: value?.toLocaleString()}
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
{isPending ? (
<Skeleton className="h-8 w-20" />
) : value !== undefined && formatter ? (
formatter(value)
) : (
value?.toLocaleString()
)}
</dd>
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
{label}
</dt>
<dt className="text-muted-foreground text-sm">{label}</dt>
</div>
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
<Icon className="hidden size-8 text-muted-foreground opacity-50 lg:block" />
</dl>
);
};
@@ -22,7 +22,8 @@ import {
import { useMutation } from "@tanstack/react-query";
import { ChainIcon } from "components/icons/ChainIcon";
import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown";
import { EllipsisVerticalIcon, XIcon } from "lucide-react";
import { EllipsisVerticalIcon, ExternalLinkIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import React from "react";
import { toast } from "sonner";
@@ -202,8 +203,16 @@ export function ContractTableUI(props: {
</Table>
{contracts.length === 0 && (
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
No Contracts
<div className="flex h-[350px] items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="mb-3">No contracts added to project</p>
<Button variant="outline" asChild className="bg-background">
<Link href="/explore" target="_blank">
Discover Contracts
<ExternalLinkIcon className="ml-2 size-4" />
</Link>
</Button>
</div>
</div>
)}
</TableContainer>
@@ -50,6 +50,15 @@ function Component() {
/>
</BadgeContainer>
<BadgeContainer label="120 days">
<InAppWalletUsersChartCardUI
inAppWalletStats={createInAppWalletStatsStub(120)}
isPending={false}
title={title}
description={description}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<InAppWalletUsersChartCardUI
inAppWalletStats={createInAppWalletStatsStub(10)}
@@ -117,7 +126,7 @@ function createInAppWalletStatsStub(days: number): InAppWalletStats[] {
while (d !== 0) {
const uniqueWallets = Math.floor(Math.random() * 100);
stubbedData.push({
date: new Date(2024, 1, d).toLocaleString(),
date: new Date(2024, 11, d).toLocaleString(),
uniqueWalletsConnected: uniqueWallets,
authenticationMethod: pickRandomAuthMethod(),
});
@@ -1,27 +1,15 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { formatDate } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { InAppWalletStats } from "types/analytics";
import { formatTickerNumber } from "../../../lib/format-utils";
type ChartData = Record<string, number> & {
time: string; // human readable date
@@ -49,7 +37,7 @@ export function InAppWalletUsersChartCardUI(props: {
// if no data for current day - create new entry
if (!chartData && stat.uniqueWalletsConnected > 0) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
time: stat.date,
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
} as ChartData);
} else if (chartData) {
@@ -114,128 +102,87 @@ export function InAppWalletUsersChartCardUI(props: {
chartData.every((data) => data.sponsoredUsd === 0);
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground">{props.description}</p>
<ThirdwebBarChart
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground text-sm">
{props.description}
</p>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
chartData.every((data) => data.sponsoredUsd === 0) ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/getting-started"
label="Unreal Engine"
icon={UnrealIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueAuthMethods];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
>
<CartesianGrid vertical={false} />
/>
</div>
}
data={chartData}
isPending={props.isPending}
config={chartConfig}
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
emptyChartState={<InAppWalletUsersEmptyChartState />}
variant="stacked"
showLegend
hideLabel={false}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
/>
);
}
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) =>
Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0)
}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
tickMargin={10}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueAuthMethods.map((authMethod) => {
return (
<Bar
key={authMethod}
dataKey={authMethod}
fill={chartConfig[authMethod]?.color}
radius={4}
stackId="a"
strokeWidth={1}
className="stroke-background"
/>
);
})}
</BarChart>
)}
</ChartContainer>
function InAppWalletUsersEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/getting-started"
label="Unreal Engine"
icon={UnrealIcon}
/>
</div>
</div>
);
}
@@ -1,10 +1,14 @@
import { Stat } from "components/analytics/stat";
import { getInAppWalletUsage } from "@/api/analytics";
import { StatCard } from "components/analytics/stat";
import { subDays } from "date-fns";
import { ActivityIcon, UserIcon } from "lucide-react";
import { Suspense } from "react";
import type { InAppWalletStats } from "types/analytics";
export function InAppWalletsSummary(props: {
function InAppWalletsSummaryInner(props: {
allTimeStats: InAppWalletStats[] | undefined;
monthlyStats: InAppWalletStats[] | undefined;
isPending: boolean;
}) {
const allTimeStats = props.allTimeStats?.reduce(
(acc, curr) => {
@@ -27,17 +31,76 @@ export function InAppWalletsSummary(props: {
);
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
icon={ActivityIcon}
isPending={props.isPending}
/>
<Stat
<StatCard
label="Monthly Active Users"
value={monthlyStats?.uniqueWalletsConnected || 0}
icon={UserIcon}
isPending={props.isPending}
/>
</div>
);
}
async function AsyncInAppWalletsSummary(props: {
teamId: string;
projectId: string;
}) {
const { teamId, projectId } = props;
const allTimeStatsPromise = getInAppWalletUsage({
teamId,
projectId,
from: new Date(2022, 0, 1),
to: new Date(),
period: "all",
});
const monthlyStatsPromise = getInAppWalletUsage({
teamId,
projectId,
from: subDays(new Date(), 30),
to: new Date(),
period: "month",
});
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
]).catch(() => [null, null]);
return (
<InAppWalletsSummaryInner
allTimeStats={allTimeStats || undefined}
monthlyStats={monthlyStats || undefined}
isPending={false}
/>
);
}
export function InAppWalletsSummary(props: {
teamId: string;
projectId: string;
}) {
return (
<Suspense
fallback={
<InAppWalletsSummaryInner
allTimeStats={undefined}
monthlyStats={undefined}
isPending={true}
/>
}
>
<AsyncInAppWalletsSummary
teamId={props.teamId}
projectId={props.projectId}
/>
</Suspense>
);
}
@@ -1,43 +1,34 @@
import { getInAppWalletUsage } from "@/api/analytics";
import {
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { getInAppWalletUsage } from "../../../@/api/analytics";
import { Suspense } from "react";
import type { InAppWalletStats } from "../../../types/analytics";
import { RangeSelector } from "../../analytics/range-selector";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
export async function InAppWalletAnalytics({
teamId,
projectId,
interval,
range = getLastNDaysRange("last-120"),
}: {
teamId: string;
projectId: string;
type InAppWalletAnalyticsProps = {
interval: "day" | "week";
range?: Range;
}) {
const stats = await getInAppWalletUsage({
teamId,
projectId,
from: range.from,
to: range.to,
period: interval,
}).catch((error) => {
console.error(error);
return [];
});
range: Range;
stats: InAppWalletStats[];
isPending: boolean;
};
function InAppWalletAnalyticsInner({
interval,
range,
stats,
isPending,
}: InAppWalletAnalyticsProps) {
return (
<div>
<RangeSelector range={range} interval={interval} />
<div className="h-6" />
<div className="flex flex-col gap-4 lg:gap-6">
<InAppWalletUsersChartCardUI
inAppWalletStats={stats || []}
isPending={false}
isPending={isPending}
title="Unique Users"
description="The total number of active in-app wallet users on your project."
/>
@@ -45,3 +36,48 @@ export async function InAppWalletAnalytics({
</div>
);
}
type AsyncInAppWalletAnalyticsProps = Omit<
InAppWalletAnalyticsProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
};
async function AsyncInAppWalletAnalytics(
props: AsyncInAppWalletAnalyticsProps,
) {
const range = props.range ?? getLastNDaysRange("last-120");
const stats = await getInAppWalletUsage({
teamId: props.teamId,
projectId: props.projectId,
from: range.from,
to: range.to,
period: props.interval,
}).catch((error) => {
console.error(error);
return [];
});
return (
<InAppWalletAnalyticsInner
{...props}
stats={stats}
range={range}
isPending={false}
/>
);
}
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
return (
<Suspense
fallback={
<InAppWalletAnalyticsInner {...props} stats={[]} isPending={true} />
}
>
<AsyncInAppWalletAnalytics {...props} />
</Suspense>
);
}
@@ -1,124 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Toaster } from "sonner";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import { type ActiveCouponResponse, ApplyCouponCardUI } from "./CouponCard";
const meta = {
title: "Billing/Coupons/ApplyCoupon",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function statusStub(status: number) {
return async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const data: ActiveCouponResponse | null =
status === 200
? {
id: "xyz",
start: 1727992716,
end: 1759528716,
coupon: {
id: "XYZTEST",
name: "TEST COUPON",
duration: "repeating",
duration_in_months: 12,
},
}
: null;
return {
status,
data,
};
};
}
function Story() {
return (
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
<BadgeContainer label="Prefill code - XYZ, Success - 200">
<ApplyCouponCardUI
submit={statusStub(200)}
onCouponApplied={undefined}
prefillPromoCode="XYZ"
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="No Valid Payment setup">
<ApplyCouponCardUI
submit={statusStub(200)}
onCouponApplied={undefined}
isPaymentSetup={false}
/>
</BadgeContainer>
<BadgeContainer label="Success - 200">
<ApplyCouponCardUI
submit={statusStub(200)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Invalid - 400">
<ApplyCouponCardUI
submit={statusStub(400)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Not Authorized - 401">
<ApplyCouponCardUI
submit={statusStub(401)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Already applied - 409">
<ApplyCouponCardUI
submit={statusStub(409)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Rate Limited - 429">
<ApplyCouponCardUI
submit={statusStub(429)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Other - 500">
<ApplyCouponCardUI
submit={statusStub(500)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<Toaster richColors />
</div>
);
}
@@ -1,360 +0,0 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format, fromUnixTime } from "date-fns";
import { TagIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useActiveAccount } from "thirdweb/react";
import { z } from "zod";
export type ActiveCouponResponse = {
id: string;
start: number;
end: number | null;
coupon: {
id: string;
name: string | null;
duration: "forever" | "once" | "repeating";
duration_in_months: number | null;
};
};
function ApplyCouponCard(props: {
teamId: string | undefined;
onCouponApplied: (data: ActiveCouponResponse) => void;
isPaymentSetup: boolean;
}) {
const searchParams = useSearchParams();
const couponCode = searchParams?.get("coupon");
return (
<ApplyCouponCardUI
onCouponApplied={props.onCouponApplied}
prefillPromoCode={couponCode || undefined}
scrollIntoView={!!couponCode}
isPaymentSetup={props.isPaymentSetup}
submit={async (promoCode: string) => {
const res = await apiServerProxy<{ data: ActiveCouponResponse }>({
method: "POST",
pathname: "/v1/coupons/redeem",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
promoCode,
teamId: props.teamId,
}),
});
if (res.ok) {
const json = res.data;
return {
status: 200,
data: json.data,
};
}
return {
status: res.status,
data: null,
};
}}
/>
);
}
const couponFormSchema = z.object({
promoCode: z.string().min(1, "Coupon code is required"),
});
export function ApplyCouponCardUI(props: {
submit: (promoCode: string) => Promise<{
status: number;
data: null | ActiveCouponResponse;
}>;
onCouponApplied: ((data: ActiveCouponResponse) => void) | undefined;
prefillPromoCode?: string;
scrollIntoView?: boolean;
isPaymentSetup: boolean;
}) {
const containerRef = useRef<HTMLFormElement | null>(null);
const form = useForm<z.infer<typeof couponFormSchema>>({
resolver: zodResolver(couponFormSchema),
defaultValues: {
promoCode: props.prefillPromoCode || "",
},
});
const scrolled = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (props.scrollIntoView && !scrolled.current) {
const el = containerRef.current;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.querySelector("input")?.focus();
scrolled.current = true;
}
}
}, [props.scrollIntoView]);
const applyCoupon = useMutation({
mutationFn: (promoCode: string) => props.submit(promoCode),
});
async function onSubmit(values: z.infer<typeof couponFormSchema>) {
if (!props.isPaymentSetup) {
return;
}
try {
const res = await applyCoupon.mutateAsync(values.promoCode);
switch (res.status) {
case 200: {
toast.success("Coupon applied successfully");
if (res.data) {
props.onCouponApplied?.(res.data);
}
break;
}
case 400: {
toast.error("Coupon code is invalid");
break;
}
case 401: {
toast.error("You are not authorized to apply coupons", {
description: "Login to dashboard and try again",
});
break;
}
case 409: {
toast.error("Coupon already applied");
break;
}
case 429: {
toast.error("Too many coupons applied in a short period", {
description: "Please try again after some time",
});
break;
}
default: {
toast.error("Failed to apply coupon");
}
}
} catch {
toast.error("Failed to apply coupon");
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} ref={containerRef}>
<SettingsCard
header={{
title: "Apply Coupon",
description:
"Enter your coupon code to apply discounts or free trials on thirdweb products",
}}
bottomText={
props.isPaymentSetup
? ""
: "A valid payment method must be added to apply a coupon."
}
saveButton={{
variant: "outline",
disabled: !props.isPaymentSetup,
isPending: applyCoupon.isPending,
label: "Apply Coupon",
type: "submit",
}}
noPermissionText={undefined}
errorText={
form.getFieldState("promoCode", form.formState).error?.message
}
>
<FormField
control={form.control}
name="promoCode"
render={({ field }) => (
<FormItem>
<FormLabel>Coupon Code</FormLabel>
<FormControl>
<Input
{...field}
className="lg:max-w-[450px]"
disabled={!props.isPaymentSetup}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsCard>
</form>
</Form>
);
}
export function CouponDetailsCardUI(props: {
activeCoupon: ActiveCouponResponse;
deleteCoupon: {
mutateAsync: () => Promise<void>;
isPending: boolean;
};
}) {
const { activeCoupon, deleteCoupon } = props;
return (
<DangerSettingCard
className="border-border"
footerClassName="!bg-transparent !border-border"
title="Coupon Applied"
description="To apply another coupon, you must remove this coupon"
buttonLabel="Remove Coupon"
buttonOnClick={() => {
const promise = deleteCoupon.mutateAsync();
toast.promise(promise, {
success: "Coupon Removed Successfully",
error: "Failed To Remove Coupon",
});
}}
confirmationDialog={{
title: `Delete Coupon "${activeCoupon.coupon.name}" ?`,
description: "Offers added by this coupon will no longer be available.",
}}
isPending={deleteCoupon.isPending}
>
<div className="mt-3 flex max-w-[600px] items-center gap-3 rounded-lg border border-border bg-background p-4">
<TagIcon className="hidden size-6 text-muted-foreground lg:block" />
<div>
<h4 className="font-medium text-foreground">
{activeCoupon.coupon.name || `Coupon #${activeCoupon.coupon.id}`}
</h4>
<p className="text-muted-foreground text-sm">
Valid from {format(fromUnixTime(activeCoupon.start), "MMM d, yyyy")}{" "}
{activeCoupon.end && (
<>to {format(fromUnixTime(activeCoupon.end), "MMMM d, yyyy")}</>
)}
</p>
</div>
</div>
</DangerSettingCard>
);
}
export function CouponSection(props: {
teamId: string | undefined;
isPaymentSetup: boolean;
}) {
const address = useActiveAccount()?.address;
const [optimisticCouponData, setOptimisticCouponData] = useState<
| {
type: "added";
data: ActiveCouponResponse;
}
| {
type: "deleted";
data: null;
}
>();
const activeCoupon = useQuery({
queryKey: ["active-coupon", address, props.teamId],
queryFn: async () => {
const res = await apiServerProxy<{ data: ActiveCouponResponse }>({
method: "GET",
pathname: "/v1/active-coupon",
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
});
if (!res.ok) {
return null;
}
const json = res.data;
return json.data;
},
});
const deleteActiveCoupon = useMutation({
mutationFn: async () => {
const res = await apiServerProxy({
method: "DELETE",
pathname: "/v1/active-coupon",
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
});
if (!res.ok) {
throw new Error("Failed to delete coupon");
}
},
onSuccess: () => {
setOptimisticCouponData({
type: "deleted",
data: null,
});
activeCoupon.refetch().then(() => {
setOptimisticCouponData(undefined);
});
},
});
if (activeCoupon.isPending) {
return <LoadingCouponSection />;
}
const couponData = optimisticCouponData
? optimisticCouponData.data
: activeCoupon.data;
return (
<>
{couponData ? (
<CouponDetailsCardUI
activeCoupon={couponData}
deleteCoupon={{
mutateAsync: deleteActiveCoupon.mutateAsync,
isPending: deleteActiveCoupon.isPending,
}}
/>
) : (
<Suspense fallback={<LoadingCouponSection />}>
<ApplyCouponCard
teamId={props.teamId}
onCouponApplied={(coupon) => {
setOptimisticCouponData({
type: "added",
data: coupon,
});
activeCoupon.refetch().then(() => {
setOptimisticCouponData(undefined);
});
}}
isPaymentSetup={props.isPaymentSetup}
/>
</Suspense>
)}
</>
);
}
function LoadingCouponSection() {
return (
<div className="flex h-[300px] items-center justify-center rounded-lg border border-border bg-card">
<Spinner className="size-6" />
</div>
);
}
@@ -1,60 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { mobileViewport } from "../../../../stories/utils";
import { CouponDetailsCardUI } from "./CouponCard";
const meta = {
title: "Billing/Coupons/CouponDetails",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Story() {
const [isPending, setIsPending] = useState(false);
return (
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
<CouponDetailsCardUI
activeCoupon={{
id: "xyz",
start: 1727992716,
end: 1759528716,
coupon: {
id: "XYZFOOBAR",
name: "TEST COUPON",
duration: "repeating",
duration_in_months: 12,
},
}}
deleteCoupon={{
mutateAsync: async () => {
setIsPending(true);
await new Promise((resolve) => setTimeout(resolve, 2000));
setIsPending(false);
},
isPending: isPending,
}}
/>
<Toaster richColors />
</div>
);
}
@@ -0,0 +1,235 @@
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import type { Meta, StoryObj } from "@storybook/react";
import { useMemo, useState } from "react";
import { type CouponData, CouponsUI } from "./CouponsUI";
const meta = {
title: "Billing/Coupons",
component: Story,
} satisfies Meta<typeof CouponsUI>;
export default meta;
type Story = StoryObj<typeof CouponsUI>;
const couponStubs: CouponData[] = [
// Forever
{
id: "di_1QAE7jCQUO4TBFqFQgnS4Qj4",
start: 1729011691,
end: null,
coupon: {
id: "forever_active",
name: "Example 1",
duration: "forever",
duration_in_months: null,
},
},
// Once
{
id: "once",
start: 1733391052,
end: null,
coupon: {
id: "once_active",
name: "Example 2",
duration: "once",
duration_in_months: null,
},
},
// Repeating - 3 months
{
id: "repeating-3",
start: 1733391629,
end: 1743391629, // Will end in the future
coupon: {
id: "repeating_active",
name: "Example 3",
duration: "repeating",
duration_in_months: 3,
},
},
// Repeating - 1 month
{
id: "repeating-1",
start: 1733391713,
end: 1736391713,
coupon: {
id: "repeating_single",
name: "Example 4",
duration: "repeating",
duration_in_months: 1,
},
},
];
export const Variants: Story = {
args: {},
};
function Story() {
const [status, setStatus] = useState<"pending" | "error" | "success">(
"success",
);
const [activeCouponsType, setActiveCouponsType] = useState<
"multiple" | "zero" | "one"
>("multiple");
const activeCoupons = useMemo(() => {
if (activeCouponsType === "multiple") {
return couponStubs;
}
if (activeCouponsType === "zero") {
return [];
}
if (activeCouponsType === "one") {
return couponStubs.slice(0, 1);
}
return [];
}, [activeCouponsType]);
const [hasAccountCoupon, setHasAccountCoupon] = useState(true);
const [isPaymentSetup, setIsPaymentSetup] = useState(true);
const [applyCouponStatus, setApplyCouponStatus] = useState<
200 | 400 | 401 | 409 | 429 | 500
>(200);
return (
<div className="container max-w-[1100px] py-10">
<div className="mb-8 space-y-6">
<div className="flex flex-col gap-6">
<div className="space-y-3">
<h3 className="font-semibold text-lg">Status</h3>
<RadioGroup
value={status}
onValueChange={(value) =>
setStatus(value as "pending" | "error" | "success")
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pending" id="status-pending" />
<Label htmlFor="status-pending">Pending</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="error" id="status-error" />
<Label htmlFor="status-error">Error</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="success" id="status-success" />
<Label htmlFor="status-success">Success</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-3">
<h3 className="font-semibold text-lg">Active Coupons</h3>
<RadioGroup
value={activeCouponsType}
onValueChange={(value) =>
setActiveCouponsType(value as "multiple" | "zero" | "one")
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="multiple" id="coupons-multiple" />
<Label htmlFor="coupons-multiple">Multiple</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="zero" id="coupons-zero" />
<Label htmlFor="coupons-zero">Zero</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="one" id="coupons-one" />
<Label htmlFor="coupons-one">One</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-3">
<h3 className="font-semibold text-lg">Apply Coupon Status</h3>
<RadioGroup
value={applyCouponStatus.toString()}
className="flex gap-4"
onValueChange={(value) =>
setApplyCouponStatus(
Number.parseInt(value) as 200 | 400 | 401 | 409 | 429 | 500,
)
}
>
{[200, 400, 401, 409, 429, 500].map((code) => (
<div key={code} className="flex items-center space-x-2">
<RadioGroupItem
value={code.toString()}
id={`status-${code}`}
/>
<Label htmlFor={`status-${code}`}>{code}</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<div className="space-y-3">
<h3 className="font-semibold text-lg">Other Settings</h3>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Switch
id="has-account-coupon"
checked={hasAccountCoupon}
onCheckedChange={setHasAccountCoupon}
/>
<Label htmlFor="has-account-coupon">Has Account Coupon</Label>
</div>
<div className="flex items-center gap-4">
<Switch
id="payment-setup"
checked={isPaymentSetup}
onCheckedChange={setIsPaymentSetup}
/>
<Label htmlFor="payment-setup">Payment Setup</Label>
</div>
</div>
</div>
</div>
<CouponsUI
status={status}
activeCoupons={activeCoupons}
accountCouponId={hasAccountCoupon ? activeCoupons[0]?.id : undefined}
isPaymentSetup={isPaymentSetup}
applyCoupon={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
status: applyCouponStatus,
data:
applyCouponStatus === 200
? {
id: "xyz",
start: 1727992716,
end: 1759528716,
coupon: {
id: "test",
name: "TEST COUPON",
duration: "repeating",
duration_in_months: 12,
},
}
: null,
};
}}
deleteCoupon={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
</div>
);
}
@@ -0,0 +1,92 @@
import { API_SERVER_URL } from "@/constants/env";
import { Suspense } from "react";
import { getAuthToken } from "../../../../../app/api/lib/getAuthToken";
import { CouponsClient } from "./CouponsClient";
import type { CouponData } from "./CouponsUI";
async function AsyncCoupons(props: {
teamId: string;
isPaymentSetup: boolean;
}) {
const authToken = await getAuthToken();
const allCouponsPromise = fetch(
`${API_SERVER_URL}/v1/active-coupons?teamId=${props.teamId}`,
{
headers: {
authorization: `Bearer ${authToken}`,
},
},
);
const accountCouponPromise = fetch(
`${API_SERVER_URL}/v1/active-coupon?teamId=${props.teamId}`,
{
headers: {
authorization: `Bearer ${authToken}`,
},
},
);
const [allCouponsRes, accountCouponRes] = await Promise.allSettled([
allCouponsPromise,
accountCouponPromise,
]);
if (
allCouponsRes.status === "rejected" ||
accountCouponRes.status === "rejected"
) {
return (
<CouponsClient
status="error"
activeCoupons={[]}
accountCouponId={undefined}
isPaymentSetup={props.isPaymentSetup}
teamId={props.teamId}
/>
);
}
const allCouponsData = (await allCouponsRes.value.json()) as {
data: CouponData[];
};
const accountCouponData = (await accountCouponRes.value.json()) as {
data: CouponData | null;
};
return (
<CouponsClient
status="success"
activeCoupons={allCouponsData.data}
accountCouponId={accountCouponData.data?.id}
isPaymentSetup={props.isPaymentSetup}
teamId={props.teamId}
/>
);
}
export function Coupons(props: {
teamId: string;
isPaymentSetup: boolean;
}) {
return (
<Suspense
fallback={
<CouponsClient
status="pending"
activeCoupons={[]}
accountCouponId={undefined}
isPaymentSetup={true}
teamId={props.teamId}
/>
}
>
<AsyncCoupons
teamId={props.teamId}
isPaymentSetup={props.isPaymentSetup}
/>
</Suspense>
);
}
@@ -0,0 +1,63 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { type CouponData, CouponsUI, type CouponsUIProps } from "./CouponsUI";
export function CouponsClient(
props: Omit<
CouponsUIProps,
"applyCoupon" | "onCouponApplied" | "deleteCoupon"
> & {
teamId: string;
},
) {
const router = useDashboardRouter();
return (
<CouponsUI
{...props}
deleteCoupon={async () => {
const res = await apiServerProxy({
method: "DELETE",
pathname: "/v1/active-coupon",
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
});
if (!res.ok) {
throw new Error("Failed to delete coupon");
}
router.refresh();
}}
applyCoupon={async (promoCode: string) => {
const res = await apiServerProxy<{ data: CouponData }>({
method: "POST",
pathname: "/v1/coupons/redeem",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
promoCode,
teamId: props.teamId,
}),
});
if (!res.ok) {
return {
status: res.status,
data: null,
};
}
const json = res.data;
router.refresh();
return {
status: 200,
data: json.data,
};
}}
/>
);
}
@@ -0,0 +1,391 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { format, fromUnixTime } from "date-fns";
import {
AlertCircleIcon,
CalendarIcon,
CalendarX2Icon,
ClockIcon,
InfinityIcon,
PlusIcon,
TicketCheckIcon,
Trash2Icon,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
type Coupon = {
id: string;
name: string;
duration: "forever" | "once" | "repeating";
duration_in_months: number | null;
};
export type CouponData = {
id: string;
start: number;
end: number | null;
coupon: Coupon;
};
type ApplyCouponFn = (promoCode: string) => Promise<{
status: number;
data: null | CouponData;
}>;
export type CouponsUIProps = {
activeCoupons: CouponData[];
status: "pending" | "error" | "success";
accountCouponId: string | undefined;
isPaymentSetup: boolean;
applyCoupon: ApplyCouponFn;
deleteCoupon: () => Promise<void>;
};
export function CouponsUI(props: CouponsUIProps) {
const formatUnixTime = (timestamp: number) => {
const date = fromUnixTime(timestamp);
return format(date, "MMM d, yyyy");
};
const renderDuration = (coupon: Coupon) => {
if (coupon.duration === "forever") {
return (
<div className="flex items-center gap-2">
<InfinityIcon className="size-4 text-muted-foreground" />
<span>Forever</span>
</div>
);
} else if (coupon.duration === "once") {
return (
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span>One-time discount</span>
</div>
);
} else if (coupon.duration === "repeating") {
return (
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span>
Repeats every {coupon.duration_in_months} month
{coupon.duration_in_months !== 1 ? "s" : ""}
</span>
</div>
);
} else {
return null;
}
};
const accountCoupon = props.activeCoupons.find(
(coupon) => coupon.id === props.accountCouponId,
);
return (
<div className="overflow-hidden rounded-lg border">
<div className="flex items-center justify-between border-b bg-card px-6 py-4">
<h3 className="font-semibold text-xl tracking-tight">Coupons</h3>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<PlusIcon className="size-4" />
Apply Coupon
</Button>
</DialogTrigger>
<DialogContent className="overflow-hidden p-0">
<ApplyCouponModalContent
isPaymentSetup={props.isPaymentSetup}
applyCoupon={props.applyCoupon}
accountCoupon={accountCoupon}
/>
</DialogContent>
</Dialog>
</div>
{props.status === "success" && props.activeCoupons.length > 0 ? (
<TableContainer className="rounded-t-none border-none">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Starts On</TableHead>
<TableHead>Ends On</TableHead>
<TableHead>Duration </TableHead>
{accountCoupon && (
<TableHead className="w-24">Actions</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{props.activeCoupons.map((item) => (
<TableRow key={item.id}>
{/* Name */}
<TableCell className="font-medium">
{item.coupon.name}
</TableCell>
{/* Start Date */}
<TableCell>
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
{formatUnixTime(item.start)}
</div>
</TableCell>
{/* End Date */}
<TableCell>
{item.end !== null ? (
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
{formatUnixTime(item.end)}
</div>
) : (
<div className="flex items-center gap-2">
<CalendarX2Icon className="size-4 text-muted-foreground" />
<span className="text-foreground">Never </span>
</div>
)}
</TableCell>
{/* Duration */}
<TableCell>{renderDuration(item.coupon)}</TableCell>
{accountCoupon && (
<TableCell>
<ToolTipLabel
label={
item.id === props.accountCouponId
? "Remove coupon"
: "This coupon cannot be removed"
}
>
<DeleteCouponButton
deleteCoupon={props.deleteCoupon}
disabled={item.id !== props.accountCouponId}
/>
</ToolTipLabel>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<div className="px-6 py-4">
<p className="flex min-h-[150px] items-center justify-center text-muted-foreground">
{props.status === "pending" ? (
<Spinner className="size-8" />
) : props.status === "error" ? (
<span className="text-destructive-text">
Failed to load coupons
</span>
) : (
<span>No coupons</span>
)}
</p>
</div>
)}
</div>
);
}
function DeleteCouponButton(props: {
deleteCoupon: () => Promise<void>;
disabled: boolean;
}) {
const deleteCoupon = useMutation({
mutationFn: props.deleteCoupon,
});
return (
<Button
variant="outline"
size="sm"
disabled={props.disabled}
onClick={() => {
const promise = deleteCoupon.mutateAsync();
toast.promise(promise, {
success: "Coupon removed successfully",
error: "Failed to remove coupon",
});
}}
aria-label="Remove coupon"
>
{deleteCoupon.isPending ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
);
}
const applyCouponFormSchema = z.object({
promoCode: z.string().min(1, "Coupon code is required"),
});
function ApplyCouponModalContent(props: {
isPaymentSetup: boolean;
applyCoupon: ApplyCouponFn;
accountCoupon: CouponData | undefined;
}) {
const applyCoupon = useMutation({
mutationFn: props.applyCoupon,
});
const form = useForm<z.infer<typeof applyCouponFormSchema>>({
resolver: zodResolver(applyCouponFormSchema),
defaultValues: {
promoCode: "",
},
});
async function onSubmit(values: z.infer<typeof applyCouponFormSchema>) {
try {
const res = await applyCoupon.mutateAsync(values.promoCode);
switch (res.status) {
case 200: {
toast.success("Coupon applied successfully");
break;
}
case 400: {
toast.error("Coupon code is invalid");
break;
}
case 401: {
toast.error("You are not authorized to apply coupons", {
description: "Login to dashboard and try again",
});
break;
}
case 409: {
toast.error("Coupon already applied");
break;
}
case 429: {
toast.error("Too many coupons applied in a short period", {
description: "Please try again after some time",
});
break;
}
default: {
toast.error("Failed to apply coupon");
}
}
} catch {
toast.error("Failed to apply coupon");
}
}
const couponEnabled = props.isPaymentSetup && !props.accountCoupon;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
<DialogHeader>
<DialogTitle>Apply Coupon</DialogTitle>
{couponEnabled && (
<DialogDescription>
Enter coupon code to apply discounts or free trials
</DialogDescription>
)}
</DialogHeader>
<div className="h-4" />
{couponEnabled && (
<FormField
control={form.control}
name="promoCode"
render={({ field }) => (
<FormItem>
<FormLabel>Coupon Code</FormLabel>
<FormControl>
<Input
{...field}
className=""
disabled={!props.isPaymentSetup}
/>
</FormControl>
</FormItem>
)}
/>
)}
{!props.isPaymentSetup && (
<Alert variant="destructive">
<AlertCircleIcon className="size-5" />
<AlertTitle>Payment method required</AlertTitle>
<AlertDescription>
A valid payment method must be added to apply a coupon.
</AlertDescription>
</Alert>
)}
{props.isPaymentSetup && props.accountCoupon && (
<Alert variant="destructive">
<AlertCircleIcon className="size-5" />
<AlertTitle>Coupon already applied</AlertTitle>
<AlertDescription>
Remove coupon {`"${props.accountCoupon.coupon.name}"`} to apply
a new coupon
</AlertDescription>
</Alert>
)}
</div>
<div className="mt-4 flex items-center justify-end border-t bg-card p-6">
<Button
type="submit"
disabled={applyCoupon.isPending || !couponEnabled}
className="gap-2"
>
{applyCoupon.isPending ? (
<Spinner className="size-4" />
) : (
<TicketCheckIcon className="size-4" />
)}
Redeem
</Button>
</div>
</form>
</Form>
);
}
@@ -6,9 +6,9 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { AlertCircleIcon } from "lucide-react";
import Link from "next/link";
import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard";
import { CouponSection } from "./CouponCard";
import { CreditsInfoCard } from "./PlanCard";
import { BillingPricing } from "./Pricing";
import { Coupons } from "./SubscriptionCoupons/Coupons";
// TODO - move this in app router folder in other pr
@@ -68,7 +68,7 @@ export const Billing: React.FC<BillingProps> = ({
/>
<CreditsInfoCard twAccount={twAccount} />
<CouponSection teamId={team.id} isPaymentSetup={validPayment} />
<Coupons teamId={team.id} isPaymentSetup={validPayment} />
</div>
);
};
@@ -1,81 +1,22 @@
"use client";
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Flex,
IconButton,
} from "@chakra-ui/react";
import { useLocalStorage } from "hooks/useLocalStorage";
import { XIcon } from "lucide-react";
import { Text, TrackedLink } from "tw-components";
export const SmartWalletsBillingAlert = ({
dismissible = false,
}: {
dismissible?: boolean;
}) => {
const [dismissed, setDismissed] = useLocalStorage(
"dismissed-smart-wallets-billing-alert",
false,
true,
);
if (dismissible && dismissed) {
return true;
}
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { CircleAlertIcon } from "lucide-react";
export const SmartWalletsBillingAlert = () => {
return (
<Alert
status="warning"
borderRadius="md"
as={Flex}
alignItems="start"
justifyContent="space-between"
mb={4}
variant="left-accent"
bg="backgroundCardHighlight"
>
<div className="flex flex-row">
<AlertIcon boxSize={4} mt={1} ml={1} />
<Flex flexDir="column" gap={1} pl={1}>
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
<AlertDescription>
<Text as="span" pr={1}>
You&apos;ve enabled Account Abstraction for one of your API keys.
<br />
To enable AA on mainnet chains,
</Text>{" "}
<TrackedLink
href="/team/~/~/settings/billing"
category="api_keys"
label="smart_wallets_missing_billing"
fontWeight="medium"
color="blue.500"
>
<Text as="span" color="blue.500">
subscribe to a billing plan.
</Text>
</TrackedLink>
</AlertDescription>
</Flex>
</div>
{dismissible && (
<IconButton
size="xs"
aria-label="Close notice"
icon={<XIcon className="size-4" />}
colorScheme="blackAlpha"
color="white"
variant="ghost"
opacity={0.6}
_hover={{ opacity: 1 }}
onClick={() => setDismissed(true)}
/>
)}
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
<AlertDescription>
To enable AA on mainnet chains,{" "}
<TrackedUnderlineLink
href="/team/~/~/settings/billing"
category="api_keys"
label="smart_wallets_missing_billing"
>
subscribe to a billing plan.
</TrackedUnderlineLink>
</AlertDescription>
</Alert>
);
};
@@ -1,18 +1,22 @@
import { Stat } from "components/analytics/stat";
import { getAggregateUserOpUsage } from "@/api/analytics";
import { StatCard } from "components/analytics/stat";
import { ActivityIcon, CoinsIcon } from "lucide-react";
import { Suspense } from "react";
import type { UserOpStats } from "types/analytics";
export function AccountAbstractionSummary(props: {
aggregateUserOpUsageQuery?: UserOpStats;
function AccountAbstractionSummaryInner(props: {
aggregateUserOpUsageQuery: UserOpStats | undefined;
isPending: boolean;
}) {
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Mainnet Sponsored Transactions"
value={props.aggregateUserOpUsageQuery?.successful || 0}
icon={ActivityIcon}
isPending={props.isPending}
/>
<Stat
<StatCard
label="Mainnet Gas Sponsored"
value={props.aggregateUserOpUsageQuery?.sponsoredUsd || 0}
formatter={(value: number) =>
@@ -22,7 +26,46 @@ export function AccountAbstractionSummary(props: {
}).format(value)
}
icon={CoinsIcon}
isPending={props.isPending}
/>
</div>
);
}
async function AsyncAccountAbstractionSummary(props: {
teamId: string;
projectId: string;
}) {
const aggregateUserOpStats = await getAggregateUserOpUsage({
teamId: props.teamId,
projectId: props.projectId,
});
return (
<AccountAbstractionSummaryInner
aggregateUserOpUsageQuery={aggregateUserOpStats}
isPending={false}
/>
);
}
export function AccountAbstractionSummary(props: {
teamId: string;
projectId: string;
}) {
return (
<Suspense
fallback={
<AccountAbstractionSummaryInner
aggregateUserOpUsageQuery={undefined}
isPending={true}
/>
}
>
<AsyncAccountAbstractionSummary
teamId={props.teamId}
projectId={props.projectId}
/>
</Suspense>
);
}
@@ -1,28 +1,17 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { formatDate } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { UserOpStats } from "types/analytics";
import { formatTickerNumber } from "../../../lib/format-utils";
@@ -48,22 +37,20 @@ export function SponsoredTransactionsChartCard(props: {
const { chainId } = stat;
const chain = chainsStore.idToChain.get(Number(chainId));
const chainName = chain?.name || chainId || "Unknown";
// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
[chain?.name || chainId || "Unknown"]: stat.successful,
time: stat.date,
[chainName]: stat.successful,
} as ChartData);
} else {
chartData[chain?.name || chainId || "Unknown"] =
(chartData[chain?.name || chainId || "Unknown"] || 0) +
stat.successful;
chartData[chainName] = (chartData[chainName] || 0) + stat.successful;
}
chainIdToVolumeMap.set(
chain?.name || chainId || "Unknown",
stat.successful +
(chainIdToVolumeMap.get(chain?.name || chainId || "Unknown") || 0),
chainName,
stat.successful + (chainIdToVolumeMap.get(chainName) || 0),
);
}
@@ -84,9 +71,9 @@ export function SponsoredTransactionsChartCard(props: {
}
}
chainsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
label: chainsToShow[i],
chainsToShow.forEach((chainName, i) => {
_chartConfig[chainName] = {
label: chainName,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
};
});
@@ -111,104 +98,59 @@ export function SponsoredTransactionsChartCard(props: {
chartData.every((data) => data.transactions === 0);
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Sponsored Transactions
</h3>
<p className="mb-3 text-muted-foreground text-sm">
Total number of sponsored transactions.
</p>
<ThirdwebBarChart
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Sponsored Transactions
</h3>
<p className="text-muted-foreground text-sm">
Total number of sponsored transactions
</p>
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Sponsored Transactions"
disabled={disableActions}
getData={async () => {
const header = ["Date", ...uniqueChainIds];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
{/* Chart */}
<ChartContainer config={chartConfig} className="h-[400px] w-full">
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
chartData.every((data) => data.transactions === 0) ? (
<EmptyChartState>
<EmptyAccountAbstractionChartContent />
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) => {
return Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0);
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Sponsored Transactions"
disabled={disableActions}
getData={async () => {
const header = ["Date", ...uniqueChainIds];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueChainIds.map((chainId) => {
return (
<Bar
key={chainId}
dataKey={chainId}
fill={chartConfig[chainId]?.color}
radius={4}
stackId="a"
strokeWidth={1}
className="stroke-background"
/>
);
})}
</BarChart>
)}
</ChartContainer>
</div>
</div>
</div>
}
config={chartConfig}
data={chartData}
isPending={props.isPending}
chartClassName="aspect-[1.5] lg:aspect-[3]"
showLegend
hideLabel={false}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
toolTipValueFormatter={(value) => formatTickerNumber(Number(value))}
emptyChartState={<EmptyAccountAbstractionChartContent />}
/>
);
}
export function EmptyAccountAbstractionChartContent() {
return (
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
<div className="flex flex-col items-center justify-center px-4">
<span className="mb-6 text-center text-lg">
Send your first sponsored transaction
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
@@ -42,6 +42,13 @@ function Component() {
/>
</BadgeContainer>
<BadgeContainer label="120 days">
<TotalSponsoredChartCard
userOpStats={createUserOpStatsStub(120)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<TotalSponsoredChartCard
userOpStats={createUserOpStatsStub(10)}
@@ -1,27 +1,16 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { formatDate } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { UserOpStats } from "types/analytics";
import { useAllChainsData } from "../../../hooks/chains/allChains";
import { toUSD } from "../../../utils/number";
@@ -51,7 +40,7 @@ export function TotalSponsoredChartCard(props: {
// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
time: stat.date,
[chain?.name || chainId || "Unknown"]: stat.sponsoredUsd,
} as ChartData);
} else {
@@ -98,7 +87,9 @@ export function TotalSponsoredChartCard(props: {
};
return {
chartData: Array.from(_chartDataMap.values()),
chartData: Array.from(_chartDataMap.values()).sort((a, b) => {
return new Date(a.time).getTime() - new Date(b.time).getTime();
}),
chartConfig: _chartConfig,
};
}, [userOpStats, chainsStore]);
@@ -110,141 +101,99 @@ export function TotalSponsoredChartCard(props: {
chartData.every((data) => data.sponsoredUsd === 0);
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Gas Sponsored
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The total amount of gas sponsored in USD.
</p>
<ThirdwebBarChart
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Gas Sponsored
</h3>
<p className="text-muted-foreground text-sm">
The total amount of gas sponsored in USD
</p>
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Gas Sponsored"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueChainIds];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Gas Sponsored"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueChainIds];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
</div>
}
config={chartConfig}
data={chartData}
isPending={props.isPending}
emptyChartState={<TotalSponsoredChartCardEmptyChartState />}
variant="stacked"
showLegend
hideLabel={false}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
toolTipValueFormatter={(value) => {
if (typeof value !== "number") {
return "";
}
return toUSD(value);
}}
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
/>
);
}
function TotalSponsoredChartCardEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">Sponsor gas for your users</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
label="Unreal Engine"
icon={UnrealIcon}
/>
<DocLink
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
label=".NET"
icon={DotNetIcon}
/>
</div>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
chartData.every((data) => data.sponsoredUsd === 0) ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">Sponsor gas for your users</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
label="Unreal Engine"
icon={UnrealIcon}
/>
<DocLink
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
label=".NET"
icon={DotNetIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) => {
return Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0);
}}
tickLine={false}
axisLine={false}
tickFormatter={(value) => toUSD(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => {
// typeguard
if (typeof value !== "number") {
return "";
}
return toUSD(value);
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueChainIds.map((chainId) => {
return (
<Bar
key={chainId}
dataKey={chainId}
fill={chartConfig[chainId]?.color}
radius={4}
stackId="a"
strokeWidth={1}
className="stroke-background"
/>
);
})}
</BarChart>
)}
</ChartContainer>
</div>
);
}
@@ -11,7 +11,7 @@ export function createUserOpStatsStub(days: number): UserOpStats[] {
const sponsoredUsd = Math.random() * 100;
stubbedData.push({
date: new Date(2024, 1, d).toLocaleString(),
date: new Date(2024, 11, d).toLocaleString(),
successful,
failed,
sponsoredUsd,
@@ -1,7 +1,7 @@
"use client";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import {
useActiveAccount,
@@ -32,48 +32,49 @@ export const PosthogIdentifierClient: React.FC<{
client,
});
const wallet = useActiveWallet();
const posthog = usePostHog();
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (wallet) {
if (wallet && posthog && posthog.__loaded) {
const connector = walletIdToPHName[wallet.id] || wallet.id;
posthog.register({ connector });
posthog.capture("wallet_connected", { connector });
}
}, [wallet]);
}, [wallet, posthog]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (accountAddress) {
if (accountAddress && posthog && posthog.__loaded) {
posthog.identify(accountAddress);
}
}, [accountAddress]);
}, [accountAddress, posthog]);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (accountId) {
if (accountId && posthog && posthog.__loaded) {
posthog.identify(accountId);
}
}, [accountId]);
}, [accountId, posthog]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (chain?.id) {
if (chain?.id && posthog && posthog.__loaded) {
posthog.unregister("network");
posthog.register({ chain_id: chain?.id, ecosystem: "evm" });
}
}, [chain?.id]);
}, [chain?.id, posthog]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (balance?.data?.displayValue) {
if (balance?.data?.displayValue && posthog && posthog.__loaded) {
posthog.register({ balance: balance.data.displayValue });
}
}, [balance]);
}, [balance, posthog]);
return null;
};
@@ -1,35 +0,0 @@
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { cn } from "@/lib/utils";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
export function SupportedPlatformLink(props: {
platform: "React" | "React Native" | "Unity" | "TypeScript";
href: string;
trackingCategory: string;
className?: string;
}) {
let icon = ReactIcon;
if (props.platform === "Unity") {
icon = UnityIcon;
} else if (props.platform === "TypeScript") {
icon = TypeScriptIcon;
}
return (
<TrackedLinkTW
category={props.trackingCategory}
label={`platform-${props.platform.replace(" ", "-").toLowerCase()}`}
href={props.href}
target="_blank"
className={cn(
"flex items-center gap-2 text-muted-foreground text-sm hover:text-foreground",
props.className,
)}
>
{icon({ className: "size-4" })}
{props.platform}
</TrackedLinkTW>
);
}
@@ -0,0 +1,28 @@
"use client";
import { isProd } from "@/constants/env";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
const NEXT_PUBLIC_POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
export function PHProvider({
children,
}: {
children: React.ReactNode;
}) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (NEXT_PUBLIC_POSTHOG_API_KEY && isProd) {
posthog.init(NEXT_PUBLIC_POSTHOG_API_KEY, {
api_host: "https://a.thirdweb.com",
capture_pageview: false,
debug: false,
disable_session_recording: true,
});
}
}, []);
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}
@@ -0,0 +1,10 @@
const posthogHost = "https://a.thirdweb.com";
export function PosthogHeadSetup() {
return (
<>
<link rel="preconnect" href={posthogHost} />
<link rel="dns-prefetch" href={posthogHost} />
</>
);
}
@@ -0,0 +1,34 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { usePostHog } from "posthog-js/react";
import { Suspense, useEffect } from "react";
function PostHogPageViewInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const posthog = usePostHog();
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (pathname && posthog) {
let url = window.origin + pathname;
if (searchParams.toString()) {
url = `${url}?${searchParams.toString()}`;
}
posthog.capture("$pageview", {
$current_url: url,
});
}
}, [pathname, searchParams, posthog]);
return null;
}
export function PostHogPageView() {
return (
<Suspense fallback={null}>
<PostHogPageViewInner />
</Suspense>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ const ContentSecurityPolicy = `
style-src 'self' 'unsafe-inline';
font-src 'self';
frame-src * data:;
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com;
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com;
connect-src * data: blob:;
worker-src 'self' blob:;
block-all-mixed-content;
+4 -2
View File
@@ -11,10 +11,11 @@
"lint": "eslint ./src",
"prefix": "biome check ./src --fix",
"fix": "eslint ./src --fix",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"update-insight-blueprints": "bun scripts/updateInsightBlueprints.ts"
},
"dependencies": {
"@abstract-foundation/agw-client": "^1.4.2",
"@abstract-foundation/agw-client": "^1.6.2",
"@abstract-foundation/agw-react": "^1.5.4",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.3",
@@ -41,6 +42,7 @@
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
"posthog-js": "1.67.1",
"prettier": "3.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -0,0 +1,20 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fetchAllBlueprints } from "./utils";
const blueprints = await fetchAllBlueprints();
const fileContent = `\
// This file is auto-generated by the update-insight-blueprints script
import type { MinimalBlueprintSpec } from "./utils";
export const insightBlueprints: MinimalBlueprintSpec[] = ${JSON.stringify(blueprints, null, 2)}`;
fs.writeFileSync(
path.join(__dirname, "../src/app/insight/insightBlueprints.ts"),
fileContent,
);
console.log(
"Updated insightBlueprints.ts - Please format this file and commit it",
);
+56
View File
@@ -0,0 +1,56 @@
import {
type BlueprintListItem,
type MinimalBlueprintSpec,
fetchBlueprintSpec,
} from "../src/app/insight/utils";
async function fetchBlueprintList() {
const res = await fetch("https://insight.thirdweb.com/v1/blueprints");
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch blueprints: ${text}`);
}
const json = (await res.json()) as { data: BlueprintListItem[] };
return json.data;
}
export const fetchAllBlueprints = async (): Promise<MinimalBlueprintSpec[]> => {
try {
// fetch list
const blueprintSpecs = await fetchBlueprintList();
// fetch all blueprints
const blueprints = await Promise.all(
blueprintSpecs.map((spec) =>
fetchBlueprintSpec({
blueprintId: spec.id,
}),
),
);
return blueprints.map((blueprint) => {
const paths = Object.keys(blueprint.openapiJson.paths);
return {
id: blueprint.id,
name: blueprint.name,
paths: paths.map((pathName) => {
const pathObj = blueprint.openapiJson.paths[pathName];
if (!pathObj) {
throw new Error(`Path not found: ${pathName}`);
}
return {
name: pathObj.get?.summary || "Unknown",
path: pathName,
};
}),
} satisfies MinimalBlueprintSpec;
});
} catch (error) {
console.error(error);
return [];
}
};
@@ -28,7 +28,7 @@ export default function Page() {
full wallet programmability.
</>
}
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
heroLink="/account-abstraction.png"
/>
@@ -25,7 +25,7 @@ export default function Page() {
full wallet programmability.
</>
}
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
heroLink="/account-abstraction.png"
/>
@@ -25,7 +25,7 @@ export default function Page() {
full wallet programmability.
</>
}
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
heroLink="/account-abstraction.png"
/>
@@ -28,7 +28,7 @@ export default function Page() {
them to create an additional account.
</>
}
docsLink="https://portal.thirdweb.com/typescript/v5/auth"
docsLink="https://portal.thirdweb.com/typescript/v5/auth?utm_source=playground"
heroLink="/auth.png"
/>
@@ -28,7 +28,7 @@ export default function Page() {
contract on any EVM chain through our RPC Edge endpoints.
</>
}
docsLink="https://portal.thirdweb.com/typescript/v5"
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
heroLink="/blockchain-api.png"
/>
@@ -20,7 +20,7 @@ export default function Page(props: { children: React.ReactNode }) {
and smart account integration.
</>
}
docsLink="https://portal.thirdweb.com/connect/in-app-wallet/overview"
docsLink="https://portal.thirdweb.com/connect/in-app-wallet/overview?utm_source=playground"
heroLink="/in-app-wallet.png"
/>
@@ -11,7 +11,7 @@ export default function Layout(props: {
description={
<>HTTP API to bridge, swap and onramp to and from any currency</>
}
docsLink="https://portal.thirdweb.com/connect/pay/overview"
docsLink="https://portal.thirdweb.com/connect/pay/overview?utm_source=playground"
heroLink="/ub.png"
/>

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