Compare commits

..
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
de2a7ba6f5 Version Packages (#5503)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-23 17:24:16 +13:00
gregfromstl ac42c4538e [SDK] Feature: Export contract deployment utilities (#5501)
CNCT-2441

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new utility functions and enhances existing ones to improve the management of infrastructure contracts and initialization processes within the `thirdweb` framework.

### Detailed summary
- Added `getInitializeTransaction` for preparing contract initialization transactions.
- Added `getOrDeployInfraForPublishedContract` to manage infrastructure for published contracts.
- Documented various functions with detailed JSDoc comments.
- Enhanced `getDeployedCloneFactoryContract` and `getDeployedInfraContract` with clearer descriptions.

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

<!-- end pr-codex -->
2024-11-23 02:51:19 +00:00
MananTank adda60c1e2 [Dashboard] Migrate MarkdownRenderer to shadcn+tailwind (#5502)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `MarkdownRenderer` component by updating its styling and structure, as well as adding comprehensive Storybook stories for better documentation and testing.

### Detailed summary
- Replaced padding props with a `className` in `MarkdownRenderer`.
- Updated styles for `inline-code` component.
- Added Storybook stories for `MarkdownRenderer`, including examples and parameters for desktop and mobile views.
- Improved the structure of the `MarkdownRenderer` component, including class-based styling and code highlighting features.

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

<!-- end pr-codex -->
2024-11-23 01:49:57 +00:00
MananTank eb728869eb Update Free plan button copy and styles in onboarding plan selection screen (#5500)
<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new component called `TextDivider` and integrates it into the `OnboardingChoosePlan` component. It also makes adjustments to the layout and button styles within `OnboardingChoosePlan`.

### Detailed summary
- Added `TextDivider` component in `TextDivider.tsx` for displaying a horizontal divider with text.
- Updated `OnboardingChoosePlan` to include `TextDivider` with the text "OR".
- Changed the grid gap from `md:gap-4` to `md:gap-5`.
- Replaced the previous button with a new styled button in `OnboardingChoosePlan`.

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

<!-- end pr-codex -->
2024-11-23 00:21:17 +00:00
YashandGitHub 7456422936 change text for trusted forwarder form (#5494) 2024-11-22 13:17:26 -08:00
saminaandGitHub aaa2dda48a added migration guide (#5496) 2024-11-22 13:15:35 -08:00
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
be3570b2bf Version Packages (#5490)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-22 13:14:24 -08:00
Nacho IacovinoandGitHub 8f98a08bb6 thirdweb should be lowercase (#5497)
Signed-off-by: Nacho Iacovino <[email protected]>
2024-11-22 13:13:56 -08:00
ElasticBottle 6b6f61705d Winston/cnct 2363 export private key not working with connectbutton (#5498)
https://linear.app/thirdweb/issue/CNCT-2363/export-private-key-not-working-with-connectbutton

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the ability to export private keys for enclave wallets upon initial login, enhancing wallet management functionality.

### Detailed summary
- Added `connectLocale` prop to the `PrivateKey` component.
- Implemented `useEffect` to handle postMessage for private key export.
- Changed base domain retrieval to use `getThirdwebBaseUrl`.
- Updated `ModalHeader` title to use localized string.
- Set iframe ID dynamically based on wallet ID.

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

<!-- end pr-codex -->
2024-11-22 20:18:24 +00:00
MananTank 91cea2d7b7 [Dashboard] Fix stripe redirect for onboarding flow (#5499)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on adding a `redirectPath` prop across various components in the onboarding and billing sections of the application, enhancing navigation after specific actions.

### Detailed summary
- Added `redirectPath` prop to `LazyOnboardingUI` in `LoginPage.tsx`.
- Introduced `redirectPath` in `OnboardingChoosePlan` component.
- Passed `redirectPath` from `OnboardingUI` to `OnboardingChoosePlan`.
- Updated `PricingCard` to accept and utilize `redirectPath`.
- Implemented `redirectPath` in the `BillingPricing` component for `PricingCard` instances.

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

<!-- end pr-codex -->
2024-11-22 19:59:22 +00:00
kien-ngo d9a63a6d35 [SDK] Fix props for TokenIcon component (#5493)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on fixing the issue where properties were not being passed correctly to the `TokenIcon` component.

### Detailed summary
- Updated the return statement in the `Token/icon.tsx` file.
- Changed `return <img src={iconQuery.data} alt={restProps.alt} />;` to `return <img src={iconQuery.data} {...restProps} alt={restProps.alt} />;` to ensure all `restProps` are included in the rendered `img` element.

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

<!-- end pr-codex -->
2024-11-22 15:52:19 +00:00
kien-ngo 24f4e5acf5 [Dashboard] Fix JSON donwload in upload-step.tsx (#5491)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `UploadStep` component in `upload-step.tsx` to replace `Heading` and `Link` components with standard HTML elements like `p` and `a`, enhancing simplicity and readability.

### Detailed summary
- Replaced `Heading` components with `p` elements for text display.
- Changed `Link` components to `a` elements for file downloads.
- Updated class names for styling consistency.
- Simplified the conditional rendering structure.

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

<!-- end pr-codex -->
2024-11-22 14:09:40 +00:00
joaquim-verges b5baeae821 feat: Expose ABI decoding utilities (#5482)
## Problem solved

Fixes CNCT-2431

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `thirdweb` package by exposing utilities to decode errors and function data, improving the overall functionality and usability of the library.

### Detailed summary
- Added `decodeError`, `decodeFunctionData`, and `decodeFunctionResult` utilities in `packages/thirdweb/src/utils/abi/`.
- Updated `packages/thirdweb/src/exports/utils.ts` to export new decoding utilities.
- Skipped the test for getting events by `blockHash` in `packages/thirdweb/src/event/actions/get-events.test.ts`.

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

<!-- end pr-codex -->
2024-11-22 08:22:44 +00:00
MananTank c02104d843 [Portal] Fix reference page static generation (#5484)
NOTE:

This will not build - Recently the sidebar categories have been updated in the reference pages and this is creating issues with static build. Need to fix those errors

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on making adjustments to various files, including changing limits on data retrieval, modifying the structure of TypeScript types, and cleaning up the formatting of documentation.

### Detailed summary
- Changed `-limit` from `50` to `20` in `apps/portal/src/app/changelog/ghost.ts`.
- Removed `classes`, `variables`, `types`, and `enums` from `subgroups` in `apps/portal/src/app/references/components/TDoc/utils/subgroups.ts`.
- Cleaned up documentation text in `apps/portal/src/app/typescript/v5/chain/page.mdx`.
- Reformatted `PageProps` type definition in `apps/portal/src/app/references/components/TDoc/PageLayout.tsx` for better readability.
- Updated return type of `generateStaticParams` function to be more explicit in `apps/portal/src/app/references/components/TDoc/PageLayout.tsx`.

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

<!-- end pr-codex -->
2024-11-22 08:18:17 +00:00
MananTank c0ab87dd5e [Dashboard] Feature: Send UTM params when logging into dashboard (#5400)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on collecting and managing `utm_` cookies for tracking purposes during user login and middleware processing. It enhances the handling of cookies to ensure `utm_` parameters are captured and forwarded appropriately in API requests and redirects.

### Detailed summary
- Added collection of `utm_` cookies in `auth-actions.ts`.
- Modified the `doLogin` function to include `utmCookies` in the request body.
- Implemented logic in `middleware.ts` to capture `utm_` parameters from URL and set them as cookies.
- Updated redirect and rewrite functions to include `cookiesToSet` for managing cookies in responses.
- Ensured cookies are set in the response for various redirect scenarios.

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

<!-- end pr-codex -->
2024-11-22 08:03:42 +00:00
MananTank f0c077c259 Move onboarding components to app router folder (#5481)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR reorganizes the onboarding-related components and types in the `apps/dashboard/src/app/login/onboarding` directory, improving the structure and imports. It also updates several import paths and refactors components for better clarity.

### Detailed summary
- Moved onboarding-related files to `apps/dashboard/src/app/login/onboarding`.
- Updated import paths for `isOnboardingComplete` to reflect the new structure.
- Refactored `PricingCard` import in `AccountForm.tsx` and `ChoosePlan.tsx`.
- Added `FeatureItem` component in `PricingCard`.
- Updated `useSkipOnboarding` and `ConfirmEmail` for new import paths.

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

<!-- end pr-codex -->
2024-11-22 07:49:25 +00:00
MananTank 6e6d90756a [Dashboard] Onboarding UI adjustments + use doLogout server action instead of fetch (#5480)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring the logout functionality across various components by replacing direct API calls with a centralized `doLogout` function. It also includes some UI adjustments and enhancements.

### Detailed summary
- Removed direct API call to `/api/auth/logout` in favor of `doLogout()` in multiple components.
- Updated styles for various UI elements, including `AccountForm` and `OnboardingChoosePlan`.
- Adjusted layout and class names for better responsiveness and appearance.
- Added `Aurora` component for visual effects in the `LoginAndOnboardingPage`.

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

<!-- end pr-codex -->
2024-11-22 07:49:24 +00:00
jnsdls e9ea47630f [Dashboard] Feature: Integrate ecosystem creation with Stripe billing (#5488)
### TL;DR
Refactored ecosystem creation flow to use server actions and removed the confirmation dialog.

### What changed?
- Created a new server action `createEcosystem` to handle ecosystem creation
- Extracted `BASE_URL` constant for reusability
- Removed the confirmation dialog from the creation form
- Simplified form submission to directly create ecosystems
- Added more specific error handling with user-friendly toast messages
- Changed component props to use `teamSlug` instead of `ecosystemLayoutPath`

### How to test?
1. Navigate to the ecosystem creation page
2. Fill out the ecosystem form with a name and logo
3. Select permission type (PARTNER_WHITELIST or ANYONE)
4. Submit the form
5. Verify redirect to Stripe billing portal
6. Verify appropriate error messages display for various error scenarios

### Why make this change?
To streamline the ecosystem creation process by implementing server-side actions and providing better error handling. This change improves the user experience by removing the extra confirmation step while maintaining secure server-side processing of ecosystem creation.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `EcosystemCreatePage` and related components to streamline the creation of ecosystems, updating function signatures, and improving the handling of the creation process.

### Detailed summary
- Removed `ecosystemLayoutPath` prop from `EcosystemCreatePage`.
- Added `teamSlug` prop to `EcosystemCreatePage` and `CreateEcosystemForm`.
- Updated `createEcosystem` function to handle authentication and API calls.
- Enhanced error handling in the `CreateEcosystemForm`.
- Removed unused imports and state management related to confirmation dialogs.

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

<!-- end pr-codex -->
2024-11-22 07:44:14 +00:00
jnsdls d98c133604 [Dashboard] Redirect /solutions/chains to /solutions/ecosystem (#5486) 2024-11-22 00:12:04 +00:00
MananTank 63c5277629 Update Nebula Waitlist Page UI and invite link (#5485)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `NebulaWaitListPageUI` and related components by adding a `teamId` prop to improve link sharing functionality. It modifies how the invite link is generated and displayed, allowing users to share a personalized link tied to their team.

### Detailed summary
- Updated `Story` to pass `teamId="foo"` to `NebulaWaitListPageUI`.
- Modified `NebulaWaitListPageUI` to accept `teamId` as a prop.
- Enhanced `ShareButton` to accept `teamId` and generate a personalized invite link.
- Changed tooltip label for the share button to "Copy Invite Link".
- Updated footer in `NebulaWaitListPageUI` to include the personalized `ShareButton` and a message encouraging sharing.

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

<!-- end pr-codex -->
2024-11-21 23:40:49 +00:00
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
d66568467d Version Packages (#5465)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-21 10:05:59 -08:00
arcoraven 6bd968362a chore: Allow optional includeUsage (default: true) (#5477)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces an optional `includeUsage` parameter to the `CoreServiceConfig` type and updates API URL construction to utilize this parameter, defaulting to `true`. This allows for more flexible API calls regarding usage data.

### Detailed summary
- Added optional `includeUsage` parameter to `CoreServiceConfig`.
- Updated `fetchKeyMetadataFromApi` to use `includeUsage` in the API URL.
- Updated `fetchAccountFromApi` to use `includeUsage` in the API URL for both wallet and account endpoints.

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

<!-- end pr-codex -->
2024-11-21 04:50:27 +00:00
Joaquim VergesandGitHub 17488b8139 [Documentation] Refactor: React guides (#5375) 2024-11-21 15:37:20 +13:00
MananTank 776f5aaeda [Dashboard] User Onboarding Revamp (#5471)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the onboarding process and account management in the application. It introduces better handling of team retrieval, login redirection, and email confirmation, while also refining the UI components related to these features.

### Detailed summary
- Added optional chaining for `firstTeam` retrieval.
- Introduced `loginRedirect` function for consistent login handling.
- Created `getValidAccount` to enforce login and onboarding checks.
- Improved `Onboarding` components for better user experience.
- Enhanced email confirmation flow with clearer messaging.
- Updated various components to use new account and team management functions.

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

<!-- end pr-codex -->
2024-11-21 01:18:01 +00:00
DustinandGitHub ecaa304a81 Update contact-us form (#5474) 2024-11-20 15:37:31 -08:00
Jonas DanielsandGitHub 377f46c303 cleanup unused (#5476) 2024-11-20 15:22:11 -08:00
MananTank b52049c20a [Dashboard] Improve team overview page load perf + UI adjustments (#5475)
Move the chart data fetching inside suspense boundary with loading fallback to speed up page load

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the layout and structure of several components in the dashboard, enhancing the user interface by adjusting styles and adding new features like loading states. It also refines the `TeamOverviewPage` to better handle data and display.

### Detailed summary
- Updated styles in `range-selector.tsx` for improved layout.
- Added `w-full` class in `EmptyState.tsx` for full-width section.
- Adjusted gap sizes in `AnalyticsHeader.tsx`.
- Modified `TeamOverviewPage` to include a loading state using `Suspense`.
- Introduced `OverviewPageContent` function to manage content rendering.
- Enhanced conditionals to simplify rendering logic in `TeamOverviewPage`.

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

<!-- end pr-codex -->
2024-11-20 22:29:19 +00:00
MananTank a67e980ac3 [Dashboard] Feature: Add account settings page with Name and Email fields (#5461)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the account settings functionality, including email confirmation and account updates, while improving error handling and user interactions.

### Detailed summary
- Added a new route for `/account/settings`.
- Improved onboarding logic for email confirmation.
- Updated waitlist joining logic to handle responses better.
- Enhanced `joinTeamWaitlist` and `updateAccount` actions with better error handling.
- Implemented email confirmation with OTP in `confirmEmailWithOTP`.
- Improved `AccountSettingsPage` to include email update functionalities.
- Added UI components for email verification and account updates.
- Refined error handling in various components for better user feedback.

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

<!-- end pr-codex -->
2024-11-20 21:00:48 +00:00
AmineAfia 4c8333d4a3 [Dashboard] Feature: Add initial insight UI (#5435)
## Problem solved
BLOCK-436

This PR adds an initial UI for insight to give visibility to insight in the platform. The first three blueprints are Data APIs available for all users. The next iteration will add the ability to create a new blueprint.

![CleanShot 2024-11-15 at [email protected]](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/dGj3ureqJk84bcvq1s10/8d0fb6d9-f80b-414a-925d-e6ed68aa6bfb.png)

---

## PR-Codex overview
This PR introduces a new `Insight` page under the project structure, adding components for displaying blueprints, including a header and an explorer. It also implements authentication redirection and a loading state for better user experience.

### Detailed summary
- Added a new route for `Insight` in `layout.tsx`.
- Created `BlueprintsPageHeader` component for the `Insight` page.
- Implemented `Page` component with authentication check and redirection logic.
- Developed `BlueprintsPage` component to display blueprints with a loading state.
- Created `getProjectBlueprints` function to fetch blueprint data.
- Added `BlueprintsExplorer` component to list blueprints with a fallback message.
- Implemented `BlueprintCard` for individual blueprint display.
- Included loading spinner in `Loading` component.

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

<!-- end pr-codex -->

Linear Ticket: https://linear.app/thirdweb/issue/BLOCK-436/implement-initial-insight-ui

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds a new `Insight` page to the dashboard for a project, featuring a `BlueprintsPage` that displays blueprints and a header. It includes authentication checks and a redirect to the login page if the user is not authenticated.

### Detailed summary
- Added a new route for `Insight` in `layout.tsx`.
- Created `Page` component in `page.tsx` with authentication check.
- Added `BlueprintsPageHeader` component for the `Insight` page.
- Implemented `BlueprintsPage` to display billing alerts and blueprints.
- Created `getProjectBlueprints` function to fetch blueprint data.
- Developed `BlueprintsExplorer` component to list blueprints.
- Added `BlueprintCard` component to display individual blueprints.

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

<!-- end pr-codex -->

Linear Ticket: https://linear.app/thirdweb/issue/BLOCK-436/implement-initial-insight-ui

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `Insight` feature to the dashboard, which includes a dedicated page for insights with blueprints and a header component. It also implements authentication checks and displays billing alerts.

### Detailed summary
- Added a new route for `Insight`.
- Created `Page` component with authentication check and redirection.
- Introduced `BlueprintsPage` component displaying billing alerts and blueprints.
- Implemented `BlueprintsPageHeader` with a disabled button.
- Added `BlueprintsExplorer` for listing blueprints.
- Defined `BlueprintCard` for individual blueprint display.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
2024-11-20 14:34:39 +01:00
e95ddcdd3e [Dashboard] Feature: Integrate billing v2 init (#5432)
Co-authored-by: Manan Tank <[email protected]>
2024-11-20 00:14:37 -08:00
kien-ngo 7973d6450c [React] Feature: Headless UI Token components (#5433)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new components related to token management in a React application, including `TokenProvider`, `TokenName`, `TokenSymbol`, and `TokenIcon`. These components allow users to fetch and display token-related data such as name, symbol, and icon.

### Detailed summary
- Added `Token` section in the sidebar with links to token components.
- Created `TokenProvider` for managing token data context.
- Implemented `TokenName`, `TokenSymbol`, and `TokenIcon` components for fetching and displaying token information.
- Exported new token-related types and components from `react.ts`.

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

<!-- end pr-codex -->
2024-11-20 06:40:01 +00:00
kien-ngo 017b13f444 [SDK] Feature: ConnectedWalletDetails to use headless UI (#5429)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the wallet connection components in the `thirdweb` package, specifically enhancing the `ConnectButton` and `ConnectedWalletDetails` components by integrating `AccountProvider` and improving the handling of wallet details.

### Detailed summary
- Updated JSDoc comment in `useConnectedWalletDetails` to indicate its limited usage.
- Wrapped `ConnectedWalletDetails` with `AccountProvider` in `ConnectButtonInner`.
- Removed `useConnectedWalletDetails` from `ConnectedWalletDetails`.
- Enhanced `ConnectedWalletDetails` to use `AccountAvatar`, `AccountName`, and `AccountBalance` components.
- Updated props handling for connected account avatar and name in `ConnectedWalletDetails`.
- Refactored balance display logic in `ConnectedWalletDetails` to use `AccountBalance`.
- Modified `DetailsModal` to utilize `AccountProvider` and improved wallet avatar handling.

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

<!-- end pr-codex -->
2024-11-20 05:37:00 +00:00
kien-ngo 901c3a102b [SDK] Feature: Account components (#5388)
cnct-2137

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `Account` component suite to the `thirdweb` library, enhancing user account management with features like account name, address, avatar, and balance display. It also marks existing `NFT` components as `@beta`.

### Detailed summary
- Added `Account` components: `AccountName`, `AccountAddress`, `AccountAvatar`, `AccountBalance`, and `AccountBlobbie`.
- Updated existing `NFT` components to `@beta`.
- Introduced tests for new `Account` components.
- Enhanced `AccountProvider` for context management.
- Updated documentation for new components and props.

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

<!-- end pr-codex -->
2024-11-20 05:37:00 +00:00
saminacodes 3253a532f8 added 7 new guides for contracts (#5467)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on restructuring and enhancing the documentation for various modular contract types in the `pre-built-modular` section, including updates to navigation, descriptions, and deployment steps.

### Detailed summary
- Renamed `Pre-built Contracts` to `Pre-built Modular` in the sidebar.
- Added detailed documentation for:
  - `NFT Drop`
  - `Token`
  - `Token Drop`
  - `Edition Drop`
  - `Open Edition`
  - `Edition`
  - `NFT Collection`
- Included images and steps for deploying each contract type.
- Updated links and metadata for better navigation and user experience.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
https://linear.app/thirdweb/issue/DEVX-1828/7-guides-for-contracts
<!-- end pr-codex -->
2024-11-20 01:36:57 +00:00
joaquim-verges 0e5d120909 [Documentation] Refactor: Update tsdoc references (#5374)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring the `NFT` component and its related functionalities to improve performance, enhance control over fetching logic, and simplify usage by removing the need for `React.Suspense`. It also introduces new props for better loading and error handling.

### Detailed summary
- Deleted several test files and an unused `NFT.tsx` file.
- Introduced `NFTProvider`, `NFTMedia`, `NFTName`, and `NFTDescription` components.
- Added `loadingComponent` and `fallbackComponent` props for better handling of loading states and errors.
- Updated references in tests to use new component structure.
- Renamed and adjusted documentation comments to reflect new functionality.
- Improved sidebar organization by updating links and names for consistency.
- Enhanced utility functions and hooks for better NFT data fetching and context management.

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

<!-- end pr-codex -->
2024-11-20 00:11:28 +00:00
gregfromstl f368793375 [SDK] Fix: PayEmbed Error State (#5463)
CNCT-2385

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving error handling and user experience in the `BuyScreen` and `TransactionModeScreen` components of the `thirdweb` package. It adds error states for loading issues and modifies error messages for better clarity.

### Detailed summary
- Updated error message for missing account in `useBuyTxStates.ts`.
- Introduced an error state in `BuyScreen` for `supportedDestinationsQuery` failures.
- Added loading and error handling in `TransactionModeScreen` for transaction cost and chain data.
- Enhanced user feedback with `ErrorState` and `LoadingScreen` components.

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

<!-- end pr-codex -->
2024-11-19 23:15:21 +00:00
gregfromstl b5227c9a8f [SDK] Fix: Adds overrides to createNewPack (#5464)
CNCT-2230

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces the ability to override transaction values in the `createNewPack` function within the `thirdweb` package.

### Detailed summary
- Added `WithOverrides` type to the `options` parameter in `createNewPack` and `getCreatePackParams` functions.
- Updated the function signatures to accept overrides for transaction options.
- Included `overrides` in the parameters for creating pack details.

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

<!-- end pr-codex -->
2024-11-19 23:02:02 +00:00
ElasticBottle 0998fc737b [Portal] Docs: Winston/cnct 2314 consolidate documentation for in app wallets and ecosystem (#5443)
https://linear.app/thirdweb/issue/CNCT-2314/consolidate-documentation-for-in-app-wallets-and-ecosystem

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on restructuring and updating the documentation and components related to `thirdweb` wallets, including `In-App`, `Ecosystem`, and `External Wallets`. It includes deletions of outdated files and additions of new guides and features.

### Detailed summary
- Deleted multiple outdated `.mdx` files related to `In-App Wallet` and `Ecosystem` documentation.
- Added new images and updated asset paths for wallet-related components.
- Introduced a new `AuthList` component to display various authentication methods.
- Updated wallet security documentation to reflect changes in architecture.
- Enhanced the sidebar for better navigation of wallet-related topics.
- Revised various guides to improve clarity on wallet integration and user management.
- Added detailed sections on wallet pregeneration and linking multiple identities.
- Updated the `sign-in methods` documentation with clearer instructions and links.

> The following files were skipped due to too many changes: `apps/portal/src/app/connect/sidebar.tsx`, `apps/portal/src/app/connect/wallet/faq/page.mdx`, `apps/portal/src/app/connect/wallet/user-management/get-user-profiles/page.mdx`, `apps/portal/redirects.mjs`, `apps/portal/src/app/connect/wallet/security/assets/ecosystem-scoped.svg`

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

<!-- end pr-codex -->
2024-11-19 22:30:55 +00:00
3df0541582 Update webauthn to v2 (#5458)
Co-authored-by: Joaquim Verges <[email protected]>
2024-11-20 11:29:40 +13:00
395 changed files with 12547 additions and 11060 deletions
+2
View File
@@ -2,6 +2,7 @@
module.exports = [
// -- home
"/",
"/pricing",
// -- product landing pages --
// -- connect
"/connect/sign-in",
@@ -16,6 +17,7 @@ module.exports = [
"/solutions/gaming",
"/solutions/consumer-apps",
"/solutions/defi",
"/solutions/ecosystem",
// -- campaigns --
"/unlimited-wallets",
];
+1
View File
@@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
};
const baseNextConfig: NextConfig = {
serverExternalPackages: ["pino-pretty"],
async headers() {
return [
{
-2
View File
@@ -48,8 +48,6 @@
"@radix-ui/react-tooltip": "1.1.4",
"@sentry/nextjs": "8.38.0",
"@shazow/whatsabi": "^0.16.0",
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^3.5.0",
"@tanstack/react-query": "5.60.2",
"@tanstack/react-table": "^8.17.3",
"@thirdweb-dev/service-utils": "workspace:*",
+6
View File
@@ -265,6 +265,12 @@ async function redirects() {
destination: "/account-abstraction",
permanent: false,
},
// redirect /solutions/chains to /solutions/ecosystem
{
source: "/solutions/chains",
destination: "/solutions/ecosystem",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
}
+117
View File
@@ -0,0 +1,117 @@
"use server";
import "server-only";
import { API_SERVER_URL, getAbsoluteUrlFromPath } from "@/constants/env";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
export type RedirectCheckoutOptions = {
teamSlug: string;
sku: ProductSKU;
redirectPath?: string;
metadata?: Record<string, string>;
};
export async function redirectToCheckout(
options: RedirectCheckoutOptions,
): Promise<{ status: number }> {
if (!options.teamSlug) {
return {
status: 400,
};
}
const token = await getAuthToken();
if (!token) {
return {
status: 401,
};
}
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`,
{
method: "POST",
body: JSON.stringify({
sku: options.sku,
redirectTo: getAbsoluteUrlFromPath(
options.redirectPath ||
`/team/${options.teamSlug}/~/settings/billing`,
).toString(),
metadata: options.metadata || {},
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return {
status: res.status,
};
}
const json = await res.json();
if (!json.result) {
return {
status: 500,
};
}
// redirect to the stripe checkout session
redirect(json.result);
}
export type BillingPortalOptions = {
teamSlug: string | undefined;
redirectPath?: string;
};
export async function redirectToBillingPortal(
options: BillingPortalOptions,
): Promise<{ status: number }> {
if (!options.teamSlug) {
return {
status: 400,
};
}
const token = await getAuthToken();
if (!token) {
return {
status: 401,
};
}
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`,
{
method: "POST",
body: JSON.stringify({
redirectTo: getAbsoluteUrlFromPath(
options.redirectPath ||
`/team/${options.teamSlug}/~/settings/billing`,
).toString(),
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return {
status: res.status,
};
}
const json = await res.json();
if (!json.result) {
return {
status: 500,
};
}
// redirect to the stripe billing portal
redirect(json.result);
}
@@ -0,0 +1,39 @@
"use server";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import { API_SERVER_URL } from "../constants/env";
export async function confirmEmailWithOTP(otp: string) {
const token = await getAuthToken();
if (!token) {
return {
errorMessage: "You are not authorized to perform this action",
};
}
const res = await fetch(`${API_SERVER_URL}/v1/account/confirmEmail`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
confirmationToken: otp,
}),
});
if (!res.ok) {
const json = await res.json();
if (json.error) {
return {
errorMessage: json.error.message,
};
}
return {
errorMessage: "Failed to confirm email",
};
}
}
@@ -0,0 +1,7 @@
"use server";
import { getRawAccount } from "../../app/account/settings/getAccount";
export async function getRawAccountAction() {
return getRawAccount();
}
+9 -3
View File
@@ -12,7 +12,9 @@ export async function joinTeamWaitlist(options: {
const token = await getAuthToken();
if (!token) {
throw new Error("No Auth token");
return {
errorMessage: "You are not authorized to perform this action",
};
}
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, {
@@ -27,8 +29,12 @@ export async function joinTeamWaitlist(options: {
});
if (!res.ok) {
throw new Error("Failed to join waitlist");
return {
errorMessage: "Failed to join waitlist",
};
}
return true;
return {
success: true,
};
}
@@ -0,0 +1,37 @@
"use server";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import { API_SERVER_URL } from "../constants/env";
export async function updateAccount(values: {
name?: string;
email?: string;
}) {
const token = await getAuthToken();
if (!token) {
throw new Error("No Auth token");
}
const res = await fetch(`${API_SERVER_URL}/v1/account`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
});
if (!res.ok) {
const json = await res.json();
if (json.error) {
return {
errorMessage: json.error.message,
};
}
return {
errorMessage: "Failed To Update Account",
};
}
}
@@ -0,0 +1,62 @@
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import { API_SERVER_URL } from "../constants/env";
import type { ProductSKU } from "../lib/billing";
type InvoiceLine = {
// amount for this line item
amount: number;
// statement descriptor
description: string | null;
// the thirdweb product sku or null if it is not recognized
thirdwebSku: ProductSKU | null;
};
type Invoice = {
// total amount excluding tax
amount: number | null;
// the ISO currency code (e.g. USD)
currency: string;
// the line items on the invoice
lines: InvoiceLine[];
};
export type TeamSubscription = {
id: string;
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
status:
| "incomplete"
| "incomplete_expired"
| "trialing"
| "active"
| "past_due"
| "canceled"
| "unpaid"
| "paused";
currentPeriodStart: string;
currentPeriodEnd: string;
trialStart: string | null;
trialEnd: string | null;
upcomingInvoice: Invoice;
};
export async function getTeamSubscriptions(slug: string) {
const token = await getAuthToken();
if (!token) {
return null;
}
const teamRes = await fetch(
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (teamRes.ok) {
return (await teamRes.json())?.result as TeamSubscription[];
}
return null;
}
+5 -11
View File
@@ -1,7 +1,5 @@
import "server-only";
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { API_SERVER_URL } from "@/constants/env";
import { cookies } from "next/headers";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
export type Team = {
@@ -13,9 +11,10 @@ export type Team = {
deletedAt?: string;
bannedAt?: string;
image?: string;
billingPlan: "pro" | "growth" | "free";
billingPlan: "pro" | "growth" | "free" | "starter";
billingStatus: "validPayment" | (string & {}) | null;
billingEmail: string | null;
growthTrialEligible: boolean | null;
};
export async function getTeamBySlug(slug: string) {
@@ -37,14 +36,9 @@ export async function getTeamBySlug(slug: string) {
}
export async function getTeams() {
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
const token = await getAuthToken();
if (!token) {
return [];
return null;
}
const teamsRes = await fetch(`${API_SERVER_URL}/v1/teams`, {
@@ -55,7 +49,7 @@ export async function getTeams() {
if (teamsRes.ok) {
return (await teamsRes.json())?.result as Team[];
}
return [];
return null;
}
type TeamNebulWaitList = {
@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
export function TextDivider(props: {
text: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center text-muted-foreground text-sm",
props.className,
)}
>
<span className="h-[1px] flex-1 bg-border" />
<span className="mx-4">{props.text}</span>
<span className="h-[1px] flex-1 bg-border" />
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
import {
type BillingPortalOptions,
type RedirectCheckoutOptions,
redirectToBillingPortal,
redirectToCheckout,
} from "../actions/billing";
import { Button, type ButtonProps } from "./ui/button";
type CheckoutButtonProps = RedirectCheckoutOptions & ButtonProps;
export function CheckoutButton({
onClick,
teamSlug,
sku,
metadata,
redirectPath,
children,
...restProps
}: CheckoutButtonProps) {
return (
<Button
{...restProps}
onClick={async (e) => {
onClick?.(e);
await redirectToCheckout({
teamSlug,
sku,
metadata,
redirectPath,
});
}}
>
{children}
</Button>
);
}
type BillingPortalButtonProps = BillingPortalOptions & ButtonProps;
export function BillingPortalButton({
onClick,
teamSlug,
redirectPath,
children,
...restProps
}: BillingPortalButtonProps) {
return (
<Button
{...restProps}
onClick={async (e) => {
onClick?.(e);
await redirectToBillingPortal({
teamSlug,
redirectPath,
});
}}
>
{children}
</Button>
);
}
@@ -0,0 +1,187 @@
import type { Team } from "@/api/team";
import { CheckoutButton } from "@/components/billing";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { cn } from "@/lib/utils";
import { CheckIcon, CircleDollarSignIcon } from "lucide-react";
import type React from "react";
import { TEAM_PLANS } from "utils/pricing";
import { remainingDays } from "../../../utils/date-utils";
type ButtonProps = React.ComponentProps<typeof Button>;
const PRO_CONTACT_US_URL =
"https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro";
type PricingCardProps = {
teamSlug: string;
billingPlan: Exclude<Team["billingPlan"], "free">;
cta?: {
hint?: string;
title: string;
tracking: {
category: string;
label?: string;
};
variant?: ButtonProps["variant"];
};
ctaHint?: string;
highlighted?: boolean;
current?: boolean;
canTrialGrowth?: boolean;
activeTrialEndsAt?: string;
redirectPath: string;
};
export const PricingCard: React.FC<PricingCardProps> = ({
teamSlug,
billingPlan,
cta,
highlighted = false,
current = false,
canTrialGrowth = false,
activeTrialEndsAt,
redirectPath,
}) => {
const plan = TEAM_PLANS[billingPlan];
const isCustomPrice = typeof plan.price === "string";
const remainingTrialDays =
(activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0;
return (
<div
className={cn(
"z-[999] flex w-full flex-col gap-6 rounded-xl border border-border bg-muted/50 p-4 md:p-6",
current && "border-blue-500",
)}
style={
highlighted
? {
backgroundImage:
"linear-gradient(to top, hsl(var(--muted)) 30%, transparent)",
}
: undefined
}
>
<div className="flex flex-col gap-5">
{/* Title + Desc */}
<div>
<div className="mb-2 flex flex-row items-center gap-2">
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{plan.title}
</h3>
{current && <Badge className="capitalize">Current plan</Badge>}
</div>
<p className="max-w-[320px] text-muted-foreground">
{plan.description}
</p>
</div>
{/* Price */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-3xl text-foreground tracking-tight">
{isCustomPrice ? (
plan.price
) : canTrialGrowth ? (
<>
<span className="text-muted-foreground line-through">
${plan.price}
</span>{" "}
$0
</>
) : (
`$${plan.price}`
)}
</span>
{!isCustomPrice && (
<span className="text-muted-foreground">/ month</span>
)}
</div>
{remainingTrialDays > 0 && (
<p className="text-muted-foreground text-sm">
Your free trial will{" "}
{remainingTrialDays > 1
? `end in ${remainingTrialDays} days.`
: "end today."}
</p>
)}
</div>
</div>
<div className="flex grow flex-col items-start gap-2.5 text-foreground">
{plan.subTitle && (
<p className="font-medium text-foreground">{plan.subTitle}</p>
)}
{plan.features.map((f) => (
<FeatureItem key={Array.isArray(f) ? f[0] : f} text={f} />
))}
</div>
{cta && (
<div className="flex flex-col gap-3">
{billingPlan !== "pro" ? (
<CheckoutButton
variant={cta.variant || "outline"}
teamSlug={teamSlug}
sku={billingPlan === "starter" ? "plan:starter" : "plan:growth"}
redirectPath={redirectPath}
>
{cta.title}
</CheckoutButton>
) : (
<Button variant={cta.variant || "outline"} asChild>
<TrackedLinkTW
href={PRO_CONTACT_US_URL}
label={cta.tracking?.label}
category={cta.tracking?.category}
target="_blank"
>
{cta.title}
</TrackedLinkTW>
</Button>
)}
{cta.hint && (
<p className="text-center text-muted-foreground text-sm">
{cta.hint}
</p>
)}
</div>
)}
</div>
);
};
type FeatureItemProps = {
text: string | string[];
};
function FeatureItem({ text }: FeatureItemProps) {
const titleStr = Array.isArray(text) ? text[0] : text;
return (
<div className="flex items-center gap-2">
<CheckIcon className="size-4 shrink-0 text-green-500" />
{Array.isArray(text) ? (
<div className="flex items-center gap-2">
<p className="text-muted-foreground">
{titleStr}{" "}
<span className="text-muted-foreground md:hidden">{text[1]}</span>
</p>
<ToolTipLabel label={text[1]}>
<CircleDollarSignIcon className="hidden size-4 text-muted-foreground md:block" />
</ToolTipLabel>
</div>
) : (
<p className="text-muted-foreground">{titleStr}</p>
)}
</div>
);
}
@@ -7,7 +7,7 @@ export function InlineCode({
return (
<code
className={cn(
"inline-block rounded bg-muted px-2 font-mono text-sm",
"mx-0.5 inline rounded-lg border border-border px-1.5 py-[3px] font-mono text-[0.85em] text-foreground",
className,
)}
>
+13
View File
@@ -33,3 +33,16 @@ export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
// Comma-separated list of chain IDs to disable faucet for.
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
export const BASE_URL = isProd
? "https://thirdweb.com"
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
: "http://localhost:3000") || "https://thirdweb-dev.com";
export function getAbsoluteUrlFromPath(path: string) {
const url = new URL(BASE_URL);
url.pathname = path;
return url;
}
+13
View File
@@ -0,0 +1,13 @@
// keep in line with product SKUs in the backend
export type ProductSKU =
| "plan:starter"
| "plan:growth"
| "plan:custom"
| "product:ecosystem_wallets"
| "product:engine_standard"
| "product:engine_premium"
| "usage:storage"
| "usage:in_app_wallet"
| "usage:aa_sponsorship"
| "usage:aa_sponsorship_op_grant"
| null;
@@ -4,6 +4,7 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useStore } from "@/lib/reactive";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "app/components/sdk-component-theme";
import { CustomChainRenderer } from "components/selects/CustomChainRenderer";
import { mapV4ChainToV5Chain } from "contexts/map-chains";
@@ -20,6 +21,7 @@ import {
useConnectModal,
} from "thirdweb/react";
import { useFavoriteChainIds } from "../../../../app/(dashboard)/(chain)/components/client/star-button";
import { doLogout } from "../../../../app/login/auth-actions";
import { LazyConfigureNetworkModal } from "../../../../components/configure-networks/LazyConfigureNetworkModal";
import { useAllChainsData } from "../../../../hooks/chains/allChains";
import {
@@ -35,6 +37,7 @@ export const CustomConnectWallet = (props: {
connectButtonClassName?: string;
signInLinkButtonClassName?: string;
detailsButtonClassName?: string;
loadingButtonClassName?: string;
chain?: Chain;
}) => {
const thirdwebClient = useThirdwebClient();
@@ -123,7 +126,12 @@ export const CustomConnectWallet = (props: {
if (isPending) {
return (
<>
<div className="flex h-[48px] w-[144px] items-center justify-center rounded-lg border border-border bg-muted">
<div
className={cn(
"flex h-[48px] w-[144px] items-center justify-center rounded-lg border border-border bg-muted",
props.loadingButtonClassName,
)}
>
<Spinner className="size-4" />
</div>
</>
@@ -167,10 +175,7 @@ export const CustomConnectWallet = (props: {
}}
onDisconnect={async () => {
try {
// log out the user
await fetch("/api/auth/logout", {
method: "POST",
});
await doLogout();
} catch (err) {
console.error("Failed to log out", err);
}
@@ -1,4 +1,5 @@
import type { UserOpStats } from "@/api/analytics";
import type { Team } from "@/api/team";
import {
type Query,
useMutation,
@@ -29,9 +30,6 @@ export const accountPlan = {
enterprise: "enterprise",
} as const;
type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
export type AuthorizedWallet = {
id: string;
accountId: string;
@@ -47,30 +45,17 @@ export type Account = {
id: string;
isStaff: boolean;
creatorWalletAddress: string;
status: AccountStatus;
plan: AccountPlan;
name?: string;
email?: string;
advancedEnabled: boolean;
currentBillingPeriodStartsAt: string;
currentBillingPeriodEndsAt: string;
emailConfirmedAt?: string;
unconfirmedEmail?: string;
trialPeriodEndedAt?: string;
emailConfirmationWalletAddress?: string;
stripePaymentActionUrl?: string;
onboardSkipped?: boolean;
paymentAttemptCount?: number;
notificationPreferences?: {
billing: "email" | "none";
updates: "email" | "none";
};
recurringPaymentFailures: {
subscriptionId: string;
subscriptionDescription: string;
paymentFailureCode: string;
serviceCutoffDate: string;
}[];
// TODO - add image URL
};
@@ -194,33 +179,16 @@ export interface UpdateKeyInput {
redirectUrls: string[];
}
interface UsageBundler {
chainId: number;
sumTransactionFee: string;
}
interface UsageStorage {
sumFileSizeBytes: number;
}
interface UsageEmbeddedWallets {
countWalletAddresses: number;
}
export interface UsageBillableByService {
usage: {
bundler: UsageBundler[];
storage: UsageStorage;
embeddedWallets: UsageEmbeddedWallets;
};
billableUsd: {
bundler: number;
storage: number;
embeddedWallets: number;
};
limits: {
storage: number;
embeddedWallets: number;
};
rateLimits: {
storage: number;
@@ -288,30 +256,6 @@ export function useAccount({ refetchInterval }: UseAccountInput = {}) {
});
}
export function useAccountUsage() {
const { user, isLoggedIn } = useLoggedInUser();
return useQuery({
queryKey: accountKeys.usage(user?.address as string),
queryFn: async () => {
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/usage`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data as UsageBillableByService;
},
enabled: !!user?.address && isLoggedIn,
});
}
export function useAccountCredits() {
const { user, isLoggedIn } = useLoggedInUser();
@@ -498,51 +442,6 @@ export function useUpdateAccount() {
});
}
export function useUpdateAccountPlan(waitForWebhook?: boolean) {
const { user } = useLoggedInUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { plan: string; feedback?: string }) => {
invariant(user?.address, "walletAddress is required");
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/plan`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
// Wait for account plan to update via stripe webhook
// TODO: find a better way to notify the client that the plan has been updated
if (waitForWebhook) {
await new Promise((resolve) => setTimeout(resolve, 1000 * 10));
}
return json.data;
},
onSuccess: async () => {
return Promise.all([
// invalidate usage data as limits are different
queryClient.invalidateQueries({
queryKey: accountKeys.me(user?.address as string),
}),
queryClient.invalidateQueries({
queryKey: accountKeys.usage(user?.address as string),
}),
]);
},
});
}
export function useUpdateNotifications() {
const { user } = useLoggedInUser();
const queryClient = useQueryClient();
@@ -575,36 +474,6 @@ export function useUpdateNotifications() {
});
}
export function useCreateBillingSession(enabled = false) {
const { user } = useLoggedInUser();
return useQuery({
queryKey: accountKeys.billingSession(user?.address as string),
queryFn: async () => {
invariant(user?.address, "walletAddress is required");
const res = await fetch(
`${THIRDWEB_API_HOST}/v1/account/billingSession`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
enabled,
});
}
export function useConfirmEmail() {
const { user } = useLoggedInUser();
const queryClient = useQueryClient();
@@ -627,7 +496,7 @@ export function useConfirmEmail() {
throw new Error(json.error.message);
}
return json.data;
return json.data as { team: Team; account: Account };
},
onSuccess: async () => {
// invalidate related cache, since could be relinking account
@@ -681,40 +550,6 @@ export function useResendEmailConfirmation() {
});
}
export function useCreatePaymentMethod() {
const { user } = useLoggedInUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (paymentMethodId: string) => {
invariant(user?.address, "walletAddress is required");
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/paymentMethod`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentMethodId,
}),
});
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: accountKeys.me(user?.address as string),
});
},
});
}
export function useApiKeys() {
const { user, isLoggedIn } = useLoggedInUser();
return useQuery({
@@ -13,16 +13,17 @@ import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { Turnstile } from "@marsidev/react-turnstile";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
import { Onboarding } from "components/onboarding";
import { mapV4ChainToV5Chain } from "contexts/map-chains";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { toUnits } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
import { z } from "zod";
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
function formatTime(seconds: number) {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
@@ -52,6 +53,7 @@ export function FaucetButton({
chain: ChainMetadata;
amount: number;
}) {
const pathname = usePathname();
const client = useThirdwebClient();
const address = useActiveAccount()?.address;
const chainId = chain.chainId;
@@ -118,7 +120,6 @@ export function FaucetButton({
const accountQuery = useAccount();
const userQuery = useLoggedInUser();
const [showOnboarding, setShowOnBoarding] = useState(false);
const canClaimFaucetQuery = useQuery({
queryKey: ["testnet-faucet-can-claim", chainId],
@@ -145,7 +146,8 @@ export function FaucetButton({
return (
<CustomConnectWallet
loginRequired={true}
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
loadingButtonClassName="!w-full"
signInLinkButtonClassName="!w-full !h-auto !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm hover:!bg-primary/80"
/>
);
}
@@ -201,23 +203,17 @@ export function FaucetButton({
);
}
// Email verification is required to claim from the faucet
if (
!accountQuery.data.emailConfirmedAt &&
!accountQuery.data.unconfirmedEmail
) {
if (!isOnboardingComplete(accountQuery.data)) {
return (
<>
<Button
variant="outline"
className="!opacity-100 w-full"
onClick={() => setShowOnBoarding(true)}
<Button asChild className="w-full">
<Link
href={
pathname ? `/login?next=${encodeURIComponent(pathname)}` : "/login"
}
>
Verify your Email
</Button>
{/* We will show the modal only if the user click on it, because this is a public page */}
{showOnboarding && <Onboarding onOpenChange={setShowOnBoarding} />}
</>
</Link>
</Button>
);
}
@@ -9,6 +9,7 @@ import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateAPIKeyDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { doLogout } from "../../login/auth-actions";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -27,11 +28,8 @@ export function AccountHeader(props: {
const { disconnect } = useDisconnect();
const logout = useCallback(async () => {
// log out the user
try {
await fetch("/api/auth/logout", {
method: "POST",
});
await doLogout();
if (wallet) {
disconnect(wallet);
}
@@ -1,6 +1,5 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ClientOnly } from "components/ClientOnly/ClientOnly";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { Suspense } from "react";
import { DeployedContractsPageHeader } from "../DeployedContractsPageHeader";
import { DeployedContractsTable } from "./DeployedContractsTable";
@@ -13,7 +12,6 @@ export function DeployedContractsPage(props: {
}) {
return (
<div className={props.className}>
<BillingAlerts className="mb-10" />
<DeployedContractsPageHeader />
<div className="h-6" />
<Suspense fallback={<Loading />}>
@@ -2,7 +2,6 @@
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { useAuthorizedWallets } from "@3rdweb-sdk/react/hooks/useApi";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { AuthorizedWalletsTable } from "components/settings/AuthorizedWallets/AuthorizedWalletsTable";
// TODO - remove ChakraProviderSetup after migrating AuthorizedWalletsTable
@@ -13,7 +12,6 @@ export default function Page() {
return (
<div className="container py-8">
<BillingAlerts className="mb-10" />
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<div className="flex flex-col justify-between gap-4">
+16 -10
View File
@@ -1,18 +1,24 @@
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { type Team, getTeams } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import type React from "react";
import { TabPathLinks } from "../../@/components/ui/tabs";
import { TWAutoConnect } from "../components/autoconnect";
import { loginRedirect } from "../login/loginRedirect";
import { AccountHeader } from "./components/AccountHeader";
export default async function AccountLayout(props: {
children: React.ReactNode;
}) {
const teams = await getTeams();
if (!teams) {
loginRedirect("/account");
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="flex grow flex-col">
<HeaderAndNav />
<HeaderAndNav teams={teams} />
{props.children}
</div>
<TWAutoConnect />
@@ -21,11 +27,11 @@ export default async function AccountLayout(props: {
);
}
async function HeaderAndNav() {
const teams = await getTeams();
async function HeaderAndNav(props: {
teams: Team[];
}) {
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
props.teams.map(async (team) => ({
team,
projects: await getProjects(team.slug),
})),
@@ -47,12 +53,12 @@ async function HeaderAndNav() {
name: "Contracts",
exactMatch: true,
},
{
path: "/account/settings",
name: "Settings",
},
// TODO - enable these links after they are functional
// {
// path: "/account/settings",
// name: "Settings",
// },
// {
// path: "/account/wallets",
// name: "Wallets",
// },
+6 -10
View File
@@ -1,19 +1,16 @@
import { getTeams } from "@/api/team";
import { getMembers } from "@/api/team-members";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { redirect } from "next/navigation";
import { loginRedirect } from "../login/loginRedirect";
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
import { getAccount } from "./settings/getAccount";
import { getValidAccount } from "./settings/getAccount";
export default async function Page() {
const account = await getAccount();
if (!account) {
redirect("/login?next=/account");
}
const account = await getValidAccount("/account");
const teams = await getTeams();
if (!teams) {
loginRedirect("/account");
}
const teamsWithRole = (
await Promise.all(
@@ -40,7 +37,6 @@ export default async function Page() {
return (
<div className="container grow py-8">
<BillingAlerts className="mb-10" />
<AccountTeamsUI
teamsWithRole={teamsWithRole}
client={getThirdwebClient()}
@@ -1,37 +1,56 @@
"use client";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { updateAccount } from "@/actions/updateAccount";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
export function AccountSettingsPage(props: {
account: Account;
client: ThirdwebClient;
}) {
const router = useDashboardRouter();
return (
<div>
<BillingAlerts className="mb-10" />
<AccountSettingsPageUI
account={props.account}
updateAccountImage={async (file) => {
if (file) {
// upload to IPFS
const ipfsUri = await upload({
client: props.client,
files: [file],
});
// TODO - Implement updating the account image with uri
console.log(ipfsUri);
} else {
// TODO - Implement deleting the account image
}
throw new Error("Not implemented");
}}
/>
<div className="border-border border-b py-10">
<div className="container max-w-[950px]">
<h1 className="font-semibold text-3xl tracking-tight">
Account Settings
</h1>
</div>
</div>
<div className="container max-w-[950px] grow pt-8 pb-20">
<AccountSettingsPageUI
// TODO - remove hide props these when these fields are functional
hideAvatar
hideDeleteAccount
account={props.account}
updateEmailWithOTP={async (otp) => {
const res = await confirmEmailWithOTP(otp);
if (res?.errorMessage) {
throw new Error(res.errorMessage);
}
router.refresh();
}}
updateName={async (name) => {
const res = await updateAccount({ name });
if (res?.errorMessage) {
throw new Error(res.errorMessage);
}
router.refresh();
}}
// yes, this is weird -
// to send OTP to email, we use updateAccount
sendEmail={async (email) => {
const res = await updateAccount({ email });
if (res?.errorMessage) {
throw new Error(res.errorMessage);
}
}}
/>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { ThirdwebProvider } from "thirdweb/react";
import { mobileViewport } from "../../../stories/utils";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
@@ -33,20 +34,63 @@ export const Mobile: Story = {
};
function Variants() {
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
const [sendEmailFails, setSendEmailFails] = useState(false);
const [emailConfirmationFails, setEmailConfirmationFails] = useState(false);
return (
<ThirdwebProvider>
<div className="container mx-auto flex w-full max-w-[1132px] flex-col gap-10 py-10">
<AccountSettingsPageUI
account={{
name: "John Doe",
email: "[email protected]",
}}
updateAccountImage={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
<div className="container flex max-w-[1132px] flex-col gap-10 py-10">
<div className="flex flex-col gap-2">
<CheckboxWithLabel>
<Checkbox
checked={isVerifiedEmail}
onCheckedChange={(v) => setIsVerifiedEmail(!!v)}
/>
is Verified Email
</CheckboxWithLabel>
<CheckboxWithLabel>
<Checkbox
checked={sendEmailFails}
onCheckedChange={(v) => setSendEmailFails(!!v)}
/>
Sending Email Fails
</CheckboxWithLabel>
<CheckboxWithLabel>
<Checkbox
checked={emailConfirmationFails}
onCheckedChange={(v) => setEmailConfirmationFails(!!v)}
/>
Email Confirmation Fails
</CheckboxWithLabel>
</div>
<AccountSettingsPageUI
account={{
name: "John Doe",
email: "[email protected]",
emailConfirmedAt: isVerifiedEmail
? new Date().toISOString()
: undefined,
}}
updateEmailWithOTP={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (emailConfirmationFails) {
throw new Error("Invalid OTP");
}
}}
updateName={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
sendEmail={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (sendEmailFails) {
throw new Error("Email already exists");
}
}}
/>
<Toaster richColors />
</ThirdwebProvider>
</div>
);
}
@@ -24,10 +24,17 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { EllipsisIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -42,36 +49,44 @@ type MinimalAccount = Pick<
export function AccountSettingsPageUI(props: {
account: MinimalAccount;
updateAccountImage: (file: File | undefined) => Promise<void>;
// TODO - remove hide props these when these fields are functional
hideAvatar?: boolean;
hideDeleteAccount?: boolean;
sendEmail: (email: string) => Promise<void>;
updateName: (name: string) => Promise<void>;
updateEmailWithOTP: (otp: string) => Promise<void>;
}) {
return (
<div className="flex flex-col gap-8">
<AccountAvatarFormControl updateAccountImage={props.updateAccountImage} />
<AccountNameFormControl name={props.account.name || ""} />
{!props.hideAvatar && <AccountAvatarFormControl />}
<AccountNameFormControl
name={props.account.name || ""}
updateName={(name) => props.updateName(name)}
/>
<AccountEmailFormControl
email={props.account.email || ""}
// TODO - is this correct way to check this?
status={props.account.emailConfirmedAt ? "verified" : "unverified"}
sendEmail={props.sendEmail}
updateEmailWithOTP={props.updateEmailWithOTP}
/>
<DeleteAccountCard />
{!props.hideDeleteAccount && <DeleteAccountCard />}
</div>
);
}
function AccountAvatarFormControl(props: {
updateAccountImage: (file: File | undefined) => Promise<void>;
}) {
const [avatar, setAvatar] = useState<File>(); // TODO: prefill with account avatar
function AccountAvatarFormControl() {
const [avatar, setAvatar] = useState<File>();
// TODO - implement
const updateAvatarMutation = useMutation({
mutationFn: async (_avatar: File | undefined) => {
await props.updateAccountImage(_avatar);
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
},
});
function handleSave() {
const promises = updateAvatarMutation.mutateAsync(avatar);
const promises = updateAvatarMutation.mutateAsync();
toast.promise(promises, {
success: "Account avatar updated successfully",
error: "Failed to update account avatar",
@@ -111,17 +126,13 @@ function AccountAvatarFormControl(props: {
function AccountNameFormControl(props: {
name: string;
updateName: (name: string) => Promise<void>;
}) {
const [accountName, setAccountName] = useState(props.name);
const maxAccountNameLength = 32;
// TODO - implement
const updateAccountNameMutation = useMutation({
mutationFn: async (name: string) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating account name to", name);
throw new Error("Not implemented");
},
mutationFn: props.updateName,
});
function handleSave() {
@@ -203,9 +214,10 @@ function DeleteAccountCard() {
function AccountEmailFormControl(props: {
email: string;
status: "unverified" | "verfication-sent" | "verified";
sendEmail: (email: string) => Promise<void>;
updateEmailWithOTP: (otp: string) => Promise<void>;
}) {
// TODO - query for account changes when the email is updated
const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
return (
<SettingsCard
header={{
@@ -230,9 +242,8 @@ function AccountEmailFormControl(props: {
</div>
{/* End */}
<EmailUpdateDialog
currentEmail={props.email}
trigger={
<Dialog open={isEmailModalOpen} onOpenChange={setIsEmailModalOpen}>
<DialogTrigger asChild>
<Button
size="icon"
variant="ghost"
@@ -240,8 +251,18 @@ function AccountEmailFormControl(props: {
>
<EllipsisIcon className="size-5 text-muted-foreground" />
</Button>
}
/>
</DialogTrigger>
<DialogContent className="p-0">
<EmailUpdateDialogContent
currentEmail={props.email}
sendEmail={props.sendEmail}
updateEmailWithOTP={props.updateEmailWithOTP}
onSuccess={() => {
setIsEmailModalOpen(false);
}}
/>
</DialogContent>
</Dialog>
</div>
</SettingsCard>
);
@@ -251,9 +272,36 @@ const emailUpdateFormSchema = z.object({
email: z.string().min(1, "Email can not be empty").max(100),
});
function EmailUpdateDialog(props: {
function EmailUpdateDialogContent(props: {
currentEmail: string;
trigger: React.ReactNode;
sendEmail: (email: string) => Promise<void>;
updateEmailWithOTP: (otp: string) => Promise<void>;
onSuccess: () => void;
}) {
const [isEmailSent, setIsEmailSent] = useState(false);
if (isEmailSent) {
return (
<EnterEmailOTP
updateEmailWithOTP={props.updateEmailWithOTP}
onSuccess={props.onSuccess}
/>
);
}
return (
<SendEmailOTP
onEmailSent={() => setIsEmailSent(true)}
currentEmail={props.currentEmail}
sendEmail={props.sendEmail}
/>
);
}
function SendEmailOTP(props: {
onEmailSent: () => void;
currentEmail: string;
sendEmail: (email: string) => Promise<void>;
}) {
const form = useForm<z.infer<typeof emailUpdateFormSchema>>({
resolver: zodResolver(emailUpdateFormSchema),
@@ -262,71 +310,153 @@ function EmailUpdateDialog(props: {
},
});
// TODO - implement
const updateEmailMutation = useMutation({
mutationFn: async (_email: string) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating account email to", _email);
throw new Error("Not implemented");
},
const [showSendError, setShowSendError] = useState(false);
const sendEmail = useMutation({
mutationFn: props.sendEmail,
});
function onSubmit(values: z.infer<typeof emailUpdateFormSchema>) {
const promises = updateEmailMutation.mutateAsync(values.email);
toast.promise(promises, {
success: "Email updated successfully",
error: "Failed to update email",
sendEmail.mutateAsync(values.email, {
onSuccess: () => {
props.onEmailSent();
},
onError: (e) => {
console.error(e);
setShowSendError(true);
},
});
}
return (
<Dialog>
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4 p-6 pb-10">
<DialogHeader className="pr-10">
<DialogTitle className="text-2xl">Update Email</DialogTitle>
<DialogDescription>
A confirmation email will be sent to verify email address
</DialogDescription>
</DialogHeader>
<DialogContent className="p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4 p-6 pb-10">
<DialogHeader className="pr-10">
<DialogTitle className="text-2xl">Update Email</DialogTitle>
<DialogDescription>
A confirmation email will be sent to verify email address
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
{...field}
className="selection:bg-foreground/20"
/>
</FormControl>
<FormMessage />
</FormItem>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
{...field}
onChange={(e) => {
field.onChange(e);
setShowSendError(false);
}}
/>
</FormControl>
<FormMessage />
{showSendError && (
<p className="text-destructive-text">
{sendEmail.error?.message || "Failed to send email"}
</p>
)}
/>
</div>
</FormItem>
)}
/>
</div>
<DialogFooter className="gap-4 border-t bg-muted/50 p-6 lg:gap-1">
<DialogClose asChild>
<Button variant="outline"> Cancel </Button>
</DialogClose>
<Button className="min-w-24 gap-2" type="submit">
{updateEmailMutation.isPending && (
<Spinner className="size-4" />
)}
{updateEmailMutation.isPending ? "Saving" : "Save"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<DialogFooter className="gap-4 border-t bg-muted/50 p-6 lg:gap-1">
<DialogClose asChild>
<Button variant="outline"> Cancel </Button>
</DialogClose>
<Button
className="min-w-24 gap-2"
type="submit"
disabled={!form.formState.isDirty}
>
{sendEmail.isPending && <Spinner className="size-4" />}
Update
</Button>
</DialogFooter>
</form>
</Form>
);
}
function EnterEmailOTP(props: {
updateEmailWithOTP: (otp: string) => Promise<void>;
onSuccess: () => void;
}) {
const [otp, setOtp] = useState("");
const [showOTPError, setShowOTPError] = useState(false);
const updateEmail = useMutation({
mutationFn: props.updateEmailWithOTP,
onSuccess: () => {
props.onSuccess();
toast.success("Email updated successfully");
},
onError: () => {
setShowOTPError(true);
},
});
return (
<div>
<div className="flex flex-col p-6 pb-10">
<DialogHeader className="pr-10">
<DialogTitle className="text-2xl">Update Email</DialogTitle>
<DialogDescription>
Enter the OTP sent to new email address
</DialogDescription>
</DialogHeader>
<div className="h-6" />
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
value={otp}
onChange={(v) => {
setOtp(v);
setShowOTPError(false);
}}
disabled={updateEmail.isPending}
>
<InputOTPGroup className="w-full">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: static list
key={idx}
index={idx}
className={cn("h-12 grow text-lg", {
"border-red-500": showOTPError,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
{showOTPError && (
<p className="mt-3 text-center text-destructive-text">
Failed to verify email with this OTP
</p>
)}
</div>
<DialogFooter className="gap-4 border-t bg-muted/50 p-6 lg:gap-1">
<DialogClose asChild>
<Button variant="outline"> Cancel </Button>
</DialogClose>
<Button
className="min-w-24 gap-2"
onClick={() => {
updateEmail.mutate(otp);
}}
>
{updateEmail.isPending && <Spinner className="size-4" />}
Verify
</Button>
</DialogFooter>
</div>
);
}
@@ -1,14 +1,21 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { getAuthToken } from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
import { isOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
export async function getAccount() {
/**
* Just get the account object without enforcing onboarding.
* In most cases - you should just be using `getValidAccount`
*/
export async function getRawAccount() {
const authToken = await getAuthToken();
const apiServerURL = new URL(API_SERVER_URL);
apiServerURL.pathname = "/v1/account/me";
if (!authToken) {
return undefined;
}
const res = await fetch(apiServerURL, {
const res = await fetch(`${API_SERVER_URL}/v1/account/me`, {
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
@@ -24,3 +31,18 @@ export async function getAccount() {
return json.data as Account;
}
/**
* If there's no account or account onboarding not complete, redirect to login page
* @param pagePath - the path of the current page to redirect back to after login/onboarding
*/
export async function getValidAccount(pagePath: string) {
const account = await getRawAccount();
// enforce login & onboarding
if (!account || !isOnboardingComplete(account)) {
loginRedirect(pagePath);
}
return account;
}
@@ -1,15 +1,16 @@
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
import { AccountSettingsPage } from "./AccountSettingsPage";
import { getAccount } from "./getAccount";
import { getValidAccount } from "./getAccount";
export default async function Page() {
const account = await getAccount();
const pagePath = "/account";
const account = await getValidAccount(pagePath);
const token = await getAuthToken();
if (!account || !token) {
redirect(`/login?next=${encodeURIComponent("/account")}`);
if (!token) {
loginRedirect(pagePath);
}
return (
@@ -1,10 +1,8 @@
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { LinkWalletUI } from "./LinkWalletUI";
export default async function Page() {
return (
<div className="grow">
<BillingAlerts className="mb-10" />
<LinkWalletUI
// TODO - fetch the wallets of current user
wallets={[]}
@@ -1,17 +0,0 @@
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { cookies } from "next/headers";
export const POST = async () => {
const cookieStore = await cookies();
// delete all cookies that start with the token prefix
const allCookies = cookieStore.getAll();
for (const cookie of allCookies) {
if (cookie.name.startsWith(COOKIE_PREFIX_TOKEN)) {
cookieStore.delete(cookie.name);
}
}
// also delete the active account cookie
cookieStore.delete(COOKIE_ACTIVE_ACCOUNT);
return new Response(null, { status: 200 });
};
@@ -74,7 +74,7 @@ export const POST = async (req: NextRequest) => {
const account: { data: Account } = await accountRes.json();
// Make sure the logged-in account has verified its email
if (account.data.status === "noCustomer") {
if (!account.data.email) {
return NextResponse.json(
{
error: "Account owner hasn't verified email",
@@ -39,6 +39,7 @@ export function SecondaryNavLinks() {
<Link
href="https://portal.thirdweb.com/"
className="text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Docs
</Link>
@@ -1,14 +1,15 @@
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function TeamPlanBadge(props: {
plan: "free" | "growth" | "pro";
plan: Team["billingPlan"];
className?: string;
}) {
return (
<Badge
variant={
props.plan === "free"
props.plan === "free" || props.plan === "starter"
? "secondary"
: props.plan === "growth"
? "success"
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { getRawAccountAction } from "@/actions/getAccount";
import { ColorModeToggle } from "@/components/color-mode-toggle";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useTheme } from "next-themes";
import Link from "next/link";
import { Suspense, lazy, useState } from "react";
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
import { getSDKTheme } from "../components/sdk-component-theme";
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
const LazyOnboardingUI = lazy(
() => import("./onboarding/on-boarding-ui.client"),
);
const wallets = [
inAppWallet({
auth: {
options: [
"google",
"apple",
"facebook",
"github",
"email",
"phone",
"passkey",
],
},
}),
createWallet("io.metamask"),
createWallet("com.coinbase.wallet"),
createWallet("io.rabby"),
createWallet("me.rainbow"),
createWallet("io.zerion.wallet"),
];
export function LoginAndOnboardingPage(props: {
account: Account | undefined;
nextPath: string | undefined;
}) {
return (
<div className="relative flex min-h-screen flex-col overflow-hidden bg-background">
<div className="border-b bg-background">
<header className="container flex w-full flex-row items-center justify-between px-6 py-4">
<div className="flex shrink-0 items-center gap-3">
<ThirdwebMiniLogo className="size-7 md:size-8" />
<h1 className="font-medium text-lg tracking-tight md:text-xl">
Get started <span className="max-sm:hidden">with thirdweb</span>
</h1>
</div>
<div className="flex items-center gap-3">
<Link
href="/support"
target="_blank"
className="px-2 text-muted-foreground text-sm hover:text-foreground"
>
Support
</Link>
<Link
href="https://portal.thirdweb.com/"
className="px-2 text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Docs
</Link>
<ColorModeToggle />
</div>
</header>
</div>
<main className="container z-10 flex grow flex-col items-center justify-center gap-6 py-12">
<ClientOnly ssr={<LoadingCard />}>
<PageContent nextPath={props.nextPath} account={props.account} />
</ClientOnly>
</main>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
src="/assets/login/background.svg"
className="-bottom-12 -right-12 pointer-events-none fixed lg:right-0 lg:bottom-0"
/>
<Aurora
color="hsl(var(--foreground)/9%)"
pos={{ top: "55%", left: "50%" }}
size={{ width: "1400px", height: "1300px" }}
/>
</div>
);
}
function LoadingCard() {
return (
<div className="flex min-h-[450px] w-full items-center justify-center rounded-xl border border-border bg-background shadow-lg lg:w-[500px]">
<Spinner className="size-10" />
</div>
);
}
function PageContent(props: {
nextPath: string | undefined;
account: Account | undefined;
}) {
const [screen, setScreen] = useState<
| { id: "login" }
| {
id: "onboarding";
account: Account;
}
| {
id: "complete";
}
>(
props.account
? { id: "onboarding", account: props.account }
: { id: "login" },
);
const router = useDashboardRouter();
const connectionStatus = useActiveWalletConnectionStatus();
function onComplete() {
setScreen({ id: "complete" });
if (props.nextPath && isValidRedirectPath(props.nextPath)) {
router.replace(props.nextPath);
} else {
router.replace("/team");
}
}
if (connectionStatus === "connecting") {
return <LoadingCard />;
}
if (connectionStatus !== "connected" || screen.id === "login") {
return <CustomConnectEmbed onLogin={onLogin} />;
}
if (screen.id === "onboarding") {
return (
<Suspense fallback={<LoadingCard />}>
<LazyOnboardingUI
account={screen.account}
onComplete={onComplete}
redirectPath={props.nextPath || "/team"}
/>
</Suspense>
);
}
async function onLogin() {
const account = await getRawAccountAction();
// shouldn't happen - but if account is not found, stay on login page
if (!account) {
return;
}
if (!isOnboardingComplete(account)) {
setScreen({
id: "onboarding",
account,
});
} else {
onComplete();
}
}
return <LoadingCard />;
}
function CustomConnectEmbed(props: {
onLogin: () => void;
}) {
const { theme } = useTheme();
const client = useThirdwebClient();
return (
<ConnectEmbed
auth={{
getLoginPayload,
doLogin: async (params) => {
try {
await doLogin(params);
props.onLogin();
} catch (e) {
console.error("Failed to login", e);
throw e;
}
},
doLogout,
isLoggedIn: async (x) => {
const isLoggedInResult = await isLoggedIn(x);
if (isLoggedInResult) {
props.onLogin();
}
return isLoggedInResult;
},
}}
wallets={wallets}
client={client}
modalSize="wide"
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
className="shadow-lg"
privacyPolicyUrl="/privacy"
termsOfServiceUrl="/tos"
/>
);
}
function isValidRedirectPath(encodedPath: string): boolean {
try {
// Decode the URI component
const decodedPath = decodeURIComponent(encodedPath);
// ensure the path always starts with a _single_ slash
// double slash could be interpreted as `//example.com` which is not allowed
return decodedPath.startsWith("/") && !decodedPath.startsWith("//");
} catch {
// If decoding fails, return false
return false;
}
}
type AuroraProps = {
size: { width: string; height: string };
pos: { top: string; left: string };
color: string;
};
const Aurora: React.FC<AuroraProps> = ({ color, pos, size }) => {
return (
<div
className="pointer-events-none absolute"
style={{
top: pos.top,
left: pos.left,
width: size.width,
height: size.height,
transform: "translate(-50%, -50%)",
backgroundImage: `radial-gradient(ellipse at center, ${color}, transparent 60%)`,
}}
/>
);
};
+13 -1
View File
@@ -44,6 +44,18 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
}
const cookieStore = await cookies();
const utmCookies = cookieStore
.getAll()
.filter((cookie) => {
return cookie.name.startsWith("utm_");
})
.reduce(
(acc, cookie) => {
acc[cookie.name] = cookie.value;
return acc;
},
{} as Record<string, string>,
);
// forward the request to the API server
const res = await fetch(`${API_SERVER_URL}/v2/siwe/login`, {
@@ -53,7 +65,7 @@ export async function doLogin(payload: VerifyLoginPayloadParams) {
"x-service-api-key": THIRDWEB_API_SECRET,
},
// set the createAccount flag to true to create a new account if it does not exist
body: JSON.stringify({ ...payload, createAccount: true }),
body: JSON.stringify({ ...payload, createAccount: true, utm: utmCookies }),
});
// if the request failed, log the error and throw an error
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export function loginRedirect(path: string): never {
redirect(`/login?next=${encodeURIComponent(path)}`);
}
@@ -0,0 +1,168 @@
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { type Account, useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
type AccountValidationSchema,
accountValidationSchema,
} from "./validations";
interface AccountFormProps {
account: Account;
horizontal?: boolean;
showSubscription?: boolean;
hideName?: boolean;
buttonText?: string;
padded?: boolean;
trackingCategory?: string;
disableUnchanged?: boolean;
onSave?: (email: string) => void;
onDuplicateError?: (email: string) => void;
}
export const AccountForm: React.FC<AccountFormProps> = ({
account,
onSave,
onDuplicateError,
buttonText = "Save",
hideName = false,
showSubscription = false,
disableUnchanged = false,
}) => {
const [isSubscribing, setIsSubscribing] = useState(true);
const trackEvent = useTrack();
const form = useForm<AccountValidationSchema>({
resolver: zodResolver(accountValidationSchema),
defaultValues: {
name: account.name || "",
email: account.unconfirmedEmail || account.email || "",
},
values: {
name: account.name || "",
email: account.unconfirmedEmail || account.email || "",
},
});
const updateMutation = useUpdateAccount();
const handleSubmit = form.handleSubmit((values) => {
const formData = {
...values,
...(showSubscription
? {
subscribeToUpdates: isSubscribing,
}
: {}),
};
trackEvent({
category: "account",
action: "update",
label: "attempt",
data: formData,
});
updateMutation.mutate(formData, {
onSuccess: (data) => {
if (onSave) {
onSave(values.email);
}
trackEvent({
category: "account",
action: "update",
label: "success",
data,
});
},
onError: (err) => {
const error = err as Error;
if (
onDuplicateError &&
error?.message.match(/email address already exists/)
) {
onDuplicateError(values.email);
}
trackEvent({
category: "account",
action: "update",
label: "error",
error,
fromOnboarding: !!onDuplicateError,
});
},
});
});
return (
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col items-start gap-8 rounded-xl">
<div className="flex w-full flex-col gap-4">
<FormFieldSetup
isRequired
htmlFor="email"
errorMessage={
form.getFieldState("email", form.formState).error?.message
}
label="Email"
>
<Input
placeholder="[email protected]"
type="email"
{...form.register("email")}
id="email"
/>
</FormFieldSetup>
{!hideName && (
<FormFieldSetup
errorMessage={
form.getFieldState("name", form.formState).error?.message
}
label="Name"
htmlFor="name"
isRequired={false}
>
<Input
placeholder="Company Inc."
type="text"
{...form.register("name")}
id="name"
/>
</FormFieldSetup>
)}
{showSubscription && (
<CheckboxWithLabel>
<Checkbox
checked={isSubscribing}
onCheckedChange={(v) => setIsSubscribing(!!v)}
/>
Subscribe to new features and key product updates
</CheckboxWithLabel>
)}
</div>
<Button
type="button"
onClick={handleSubmit}
disabled={
updateMutation.isPending ||
(disableUnchanged && !form.formState.isDirty)
}
className="w-full gap-2"
>
{updateMutation.isPending && <Spinner className="size-4" />}
{buttonText}
</Button>
</div>
</form>
);
};
@@ -0,0 +1,70 @@
"use client";
import { TextDivider } from "@/components/TextDivider";
import { PricingCard } from "@/components/blocks/pricing-card";
import { Button } from "@/components/ui/button";
import { TitleAndDescription } from "./Title";
export function OnboardingChoosePlan(props: {
skipPlan: () => Promise<void>;
canTrialGrowth: boolean;
teamSlug: string;
redirectPath: string;
}) {
return (
<div>
<TitleAndDescription
heading="Choose your Plan"
description="Get started for free with our Starter plan or subscribe to Growth plan to unlock higher rate limits and advanced features"
/>
<div className="h-4" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-5">
<PricingCard
billingPlan="starter"
teamSlug={props.teamSlug}
cta={{
title: "Get started for free",
tracking: {
category: "account",
},
}}
redirectPath={props.redirectPath}
/>
<PricingCard
billingPlan="growth"
teamSlug={props.teamSlug}
cta={{
title: "Claim your 1-month free",
hint: "Your free trial will end after 30 days.",
tracking: {
category: "account",
label: "growthPlan",
},
variant: "default",
}}
canTrialGrowth={props.canTrialGrowth}
highlighted
redirectPath={props.redirectPath}
/>
</div>
<TextDivider text="OR" className="my-4" />
<Button
variant="outline"
onClick={props.skipPlan}
className="relative h-auto w-full items-center gap-2 rounded-xl bg-muted/50 py-2.5"
>
<span className="flex flex-col gap-0.5">
<span className="text-base text-foreground">Skip for now</span>
<span className="text-muted-foreground text-sm">
You will have limited access to services
</span>
</span>
</Button>
</div>
);
}
@@ -1,3 +1,8 @@
"use client";
import type { Team } from "@/api/team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
@@ -5,43 +10,48 @@ import {
} from "@/components/ui/input-otp";
import { cn } from "@/lib/utils";
import {
type Account,
useConfirmEmail,
useResendEmailConfirmation,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { Flex } from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import {
type EmailConfirmationValidationSchema,
emailConfirmationValidationSchema,
} from "components/settings/Account/validations";
import { useErrorHandler } from "contexts/error-handler";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button, Text } from "tw-components";
import { toast } from "sonner";
import { shortenString } from "utils/usedapp-external";
import { TitleAndDescription } from "./Title";
import {
type EmailConfirmationValidationSchema,
emailConfirmationValidationSchema,
} from "./validations";
interface OnboardingConfirmEmailProps {
email: string;
linking?: boolean;
onSave: () => void;
onEmailConfirm: (params: {
team: Team;
account: Account;
}) => void;
onComplete: () => void;
onBack: () => void;
}
const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
// TODO - separate out "linking" and "confirmLinking" states into separate components
export const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
email,
linking,
onSave,
onEmailConfirm,
onBack,
onComplete,
}) => {
const [token, setToken] = useState("");
const [completed, setCompleted] = useState(false);
const [saving, setSaving] = useState(false);
const { onError } = useErrorHandler();
const trackEvent = useTrack();
const { user } = useLoggedInUser();
@@ -62,7 +72,7 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
},
});
const mutation = useConfirmEmail();
const confirmEmail = useConfirmEmail();
const resendMutation = useResendEmailConfirmation();
const handleChange = (value: string) => {
@@ -81,10 +91,10 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
label: "attempt",
});
mutation.mutate(values, {
onSuccess: () => {
confirmEmail.mutate(values, {
onSuccess: (response) => {
if (!linking) {
onSave();
onEmailConfirm(response);
} else {
setCompleted(true);
}
@@ -103,7 +113,7 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
? error.message
: "Couldn't verify your email address. Try later!";
onError(message);
toast.error(message);
form.reset();
setToken("");
setSaving(false);
@@ -159,7 +169,7 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
<TitleAndDescription
heading={
!linking
? "You're almost done! Verify your email."
? "Verify your email"
: completed
? "Wallet linked"
: "Linking Wallets"
@@ -167,97 +177,84 @@ const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
description={
!completed ? (
<>
We&apos;ve sent a 6 letter confirmation code to{" "}
<strong>{email}</strong>. Copy the code and paste it below.
Enter the 6 letter confirmation code sent to{" "}
<span className="text-medium">{email}</span>
</>
) : (
<>
We&apos;ve linked{" "}
<strong>{shortenString(user?.address ?? "")}</strong> wallet to{" "}
<strong>{email}</strong> thirdweb account.
We've linked{" "}
<span className="font-mono">
{shortenString(user?.address ?? "")}
</span>{" "}
wallet to <span className="font-medium">{email}</span> thirdweb
account.
</>
)
}
/>
<div className="h-5" />
{completed && (
<Button
w="full"
size="lg"
fontSize="md"
onClick={onSave}
colorScheme="blue"
>
<Button onClick={onComplete} className="w-full">
Continue to Dashboard
</Button>
)}
{!completed && (
<form onSubmit={handleSubmit}>
<Flex gap={8} flexDir="column" w="full">
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
value={token}
onChange={handleChange}
disabled={saving}
>
<InputOTPGroup className="w-full">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={idx}
index={idx}
className={cn("h-12 grow text-lg", {
"border-red-500": form.getFieldState(
"confirmationToken",
form.formState,
).error,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
value={token}
onChange={handleChange}
disabled={saving}
>
<InputOTPGroup className="w-full">
{new Array(6).fill(0).map((_, idx) => (
<InputOTPSlot
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={idx}
index={idx}
className={cn("h-12 grow text-lg", {
"border-red-500": form.getFieldState(
"confirmationToken",
form.formState,
).error,
})}
/>
))}
</InputOTPGroup>
</InputOTP>
<Flex flexDir="column" gap={3}>
<Button
w="full"
size="lg"
fontSize="md"
colorScheme="blue"
type="submit"
onClick={handleSubmit}
isLoading={saving}
isDisabled={saving}
>
Verify
</Button>
<Button
w="full"
size="lg"
fontSize="md"
variant="outline"
onClick={onBack}
isDisabled={saving}
>
{!linking ? "Update email" : "Use another email"}
</Button>
<Button
size="lg"
fontSize="sm"
variant="link"
onClick={handleResend}
colorScheme="blue"
isDisabled={saving}
>
<Text color="blue.500">Resend verification code</Text>
</Button>
</Flex>
</Flex>
<div className="h-6" />
<div className="flex flex-col gap-4">
<Button
type="submit"
onClick={handleSubmit}
disabled={saving}
className="gap-2"
>
{saving && <Spinner className="size-4" />}
Verify
</Button>
<Button onClick={onBack} disabled={saving} variant="outline">
{!linking ? "Update email" : "Use another email"}
</Button>
<Button
variant="link"
onClick={handleResend}
disabled={saving}
className="text-link-foreground hover:text-foreground"
>
Resend Verification Code
</Button>
</div>
</form>
)}
</>
);
};
export default OnboardingConfirmEmail;
@@ -0,0 +1,87 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../auth-actions";
import { AccountForm } from "./AccountForm";
import { TitleAndDescription } from "./Title";
type OnboardingGeneralProps = {
account: Account;
onSave: (email: string) => void;
onDuplicate: (email: string) => void;
};
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
account,
onSave,
onDuplicate,
}) => {
const [existing, setExisting] = useState(false);
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
async function handleLogout() {
await doLogout();
if (activeWallet) {
disconnect(activeWallet);
}
}
const logoutMutation = useMutation({
mutationFn: handleLogout,
});
return (
<div>
<TitleAndDescription
heading={
!existing
? "Create your thirdweb account"
: "Log in to your thirdweb account"
}
description="Start building web3 apps and games, faster."
/>
<div className="h-6" />
<div className="flex flex-col gap-4">
<AccountForm
showSubscription={!existing}
hideName={existing}
account={account}
buttonText={!existing ? "Get Started for Free" : "Login"}
trackingCategory="onboarding"
onSave={onSave}
onDuplicateError={onDuplicate}
/>
{!existing ? (
<>
<Button variant="primary" onClick={() => setExisting(true)}>
I have a thirdweb account
</Button>
<Button
variant="outline"
onClick={() => {
logoutMutation.mutate();
}}
className="gap-2"
>
{logoutMutation.isPending && <Spinner className="size-4" />}
Log out
</Button>
</>
) : (
<Button variant="outline" onClick={() => setExisting(false)}>
I don&apos;t have an account
</Button>
)}
</div>
</div>
);
};
@@ -1,7 +1,11 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { useTrack } from "hooks/analytics/useTrack";
import { Button, TrackedLink } from "tw-components";
import { shortenString } from "utils/usedapp-external";
import { TitleAndDescription } from "./Title";
@@ -11,7 +15,7 @@ interface OnboardingLinkWalletProps {
onBack: () => void;
}
const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
export const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
email,
onSave,
onBack,
@@ -69,52 +73,51 @@ const OnboardingLinkWallet: React.FC<OnboardingLinkWalletProps> = ({
description={
<>
We&apos;ve noticed that there is another account associated with{" "}
<strong>{email}</strong>. Would you like to link your wallet{" "}
<strong>{shortenString(user?.address ?? "")}</strong> to the
existing account? Once you agree, we will email you the details.{" "}
<TrackedLink
<span className="text-foreground">{email}</span>. Would you like to
link your wallet{" "}
<span className="font-mono text-foreground">
{shortenString(user?.address ?? "")}
</span>{" "}
to the existing account?
<div className="h-2" />
Once you agree, we will email you the details.{" "}
<TrackedLinkTW
href="https://portal.thirdweb.com/account/billing/account-info"
color="blue.500"
category="account"
label="learn-wallet-linking"
isExternal
target="_blank"
className="underline hover:text-foreground"
>
Learn more about wallet linking
</TrackedLink>
</TrackedLinkTW>
.
</>
}
/>
<div className="h-8" />
<form>
<div className="flex w-full flex-col gap-8">
<div className="flex flex-col gap-3">
<Button
w="full"
size="lg"
fontSize="md"
colorScheme="blue"
type="button"
onClick={handleSubmit}
isLoading={updateMutation.isPending}
isDisabled={updateMutation.isPending}
>
Yes, link them
</Button>
<Button
w="full"
size="lg"
fontSize="md"
variant="outline"
onClick={onBack}
isDisabled={updateMutation.isPending}
>
Use another email
</Button>
</div>
<div className="flex flex-col gap-3">
<Button
type="button"
onClick={handleSubmit}
disabled={updateMutation.isPending}
className="gap-2"
>
{updateMutation.isPending && <Spinner className="size-4" />}
{updateMutation.isPending ? "Linking" : "Yes, link them"}
</Button>
<Button
size="lg"
variant="outline"
onClick={onBack}
disabled={updateMutation.isPending}
>
Use another email
</Button>
</div>
</form>
</>
);
};
export default OnboardingLinkWallet;
@@ -1,4 +1,5 @@
import type { JSX } from "react";
type TitleAndDescriptionProps = {
heading: string | JSX.Element;
description: string | JSX.Element;
@@ -0,0 +1,6 @@
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
export function isOnboardingComplete(account: Account) {
// if email is confirmed, onboarding is considered complete
return !!account.emailConfirmedAt;
}
@@ -0,0 +1,146 @@
"use client";
import type { Team } from "@/api/team";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { OnboardingChoosePlan } from "./ChoosePlan";
import { OnboardingConfirmEmail } from "./ConfirmEmail";
import { OnboardingGeneral } from "./General";
import { OnboardingLinkWallet } from "./LinkWallet";
import { useSkipOnboarding } from "./useSkipOnboarding";
type OnboardingScreen =
| { id: "onboarding" }
| { id: "linking" }
| { id: "confirming" }
| { id: "confirmLinking" }
| { id: "plan"; team: Team };
function OnboardingUI(props: {
account: Account;
onComplete: () => void;
// path to redirect from stripe
redirectPath: string;
}) {
const { account } = props;
const [screen, setScreen] = useState<OnboardingScreen>({ id: "onboarding" });
const trackEvent = useTrack();
const [updatedEmail, setUpdatedEmail] = useState<string | undefined>();
const skipOnboarding = useSkipOnboarding();
function trackOnboardingStep(params: {
nextStep: OnboardingScreen["id"];
email?: string;
}) {
trackEvent({
category: "account",
action: "onboardingStep",
label: "next",
data: {
email: params.email || account.unconfirmedEmail || updatedEmail,
currentStep: screen,
nextStep: params.nextStep,
},
});
}
const handleDuplicateEmail = (email: string) => {
setScreen({
id: "linking",
});
trackOnboardingStep({
nextStep: "linking",
email,
});
};
return (
<div
className={cn(
"relative w-full rounded-xl border border-border bg-background p-6 shadow-lg",
screen.id === "plan" ? "lg:max-w-[750px]" : "lg:w-[500px]",
)}
>
{screen.id === "onboarding" && (
<OnboardingGeneral
account={account}
onSave={(email) => {
setUpdatedEmail(email);
setScreen({
id: "confirming",
});
trackOnboardingStep({
nextStep: "confirming",
email,
});
}}
onDuplicate={(email) => {
setUpdatedEmail(email);
handleDuplicateEmail(email);
}}
/>
)}
{screen.id === "linking" && (
<OnboardingLinkWallet
onSave={() => {
setScreen({
id: "confirmLinking",
});
trackOnboardingStep({
nextStep: "confirmLinking",
});
}}
onBack={() => {
setUpdatedEmail(undefined);
setScreen({
id: "onboarding",
});
}}
email={updatedEmail as string}
/>
)}
{/* TODO - separate the confirming and confirmLinking into separate components */}
{(screen.id === "confirming" || screen.id === "confirmLinking") && (
<OnboardingConfirmEmail
linking={screen.id === "confirmLinking"}
onComplete={props.onComplete}
onEmailConfirm={(res) => {
if (screen.id === "confirmLinking") {
props.onComplete();
} else if (screen.id === "confirming") {
if (account.onboardSkipped) {
props.onComplete();
} else {
setScreen({ id: "plan", team: res.team });
}
}
}}
onBack={() =>
setScreen({
id: "onboarding",
})
}
email={(account.unconfirmedEmail || updatedEmail) as string}
/>
)}
{screen.id === "plan" && (
<OnboardingChoosePlan
redirectPath={props.redirectPath}
teamSlug={screen.team.slug}
skipPlan={async () => {
await skipOnboarding().catch(() => {});
props.onComplete();
}}
canTrialGrowth={true}
/>
)}
</div>
);
}
export default OnboardingUI;
@@ -0,0 +1,42 @@
"use client";
import { useUpdateAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useTrack } from "hooks/analytics/useTrack";
export function useSkipOnboarding() {
const mutation = useUpdateAccount();
const trackEvent = useTrack();
async function skipOnboarding() {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "attempt",
});
return mutation.mutateAsync(
{
onboardSkipped: true,
},
{
onSuccess: () => {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "success",
});
},
onError: (error) => {
trackEvent({
category: "account",
action: "onboardSkippedBilling",
label: "error",
error,
});
},
},
);
}
return skipOnboarding;
}
+16 -132
View File
@@ -1,139 +1,23 @@
"use client";
import { redirect } from "next/navigation";
import { getRawAccount } from "../account/settings/getAccount";
import { LoginAndOnboardingPage } from "./LoginPage";
import { isOnboardingComplete } from "./onboarding/isOnboardingRequired";
import { ColorModeToggle } from "@/components/color-mode-toggle";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useTheme } from "next-themes";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { ConnectEmbed } from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
import { getSDKTheme } from "../components/sdk-component-theme";
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
export default async function Page(props: {
searchParams: Promise<{
next?: string;
}>;
}) {
const nextPath = (await props.searchParams).next;
const account = await getRawAccount();
const wallets = [
inAppWallet({
auth: {
options: [
"google",
"apple",
"facebook",
"github",
"email",
"phone",
"passkey",
],
},
}),
createWallet("io.metamask"),
createWallet("com.coinbase.wallet"),
createWallet("io.rabby"),
createWallet("me.rainbow"),
createWallet("io.zerion.wallet"),
];
export default function LoginPage() {
return (
<div className="relative grid h-screen place-items-center bg-muted/30">
<nav className="absolute top-0 z-20 flex w-full flex-row items-center justify-between px-6 py-4">
<ThirdwebMiniLogo className="max-h-7" />
<ColorModeToggle />
</nav>
<main className="z-10 flex flex-col gap-6">
<h1 className="text-center font-semibold text-2xl tracking-tight lg:text-3xl">
Get started with thirdweb
</h1>
<Suspense>
<CustomConnectEmmbed />
</Suspense>
</main>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
src="/assets/login/background.svg"
className="-bottom-12 -right-12 fixed lg:right-0 lg:bottom-0"
/>
</div>
);
}
function CustomConnectEmmbed() {
const isLG = useMediaQuery("(min-width: 1024px)");
const searchParams = useSearchParams();
const router = useDashboardRouter();
const { theme } = useTheme();
const nextSearchParam = searchParams?.get("next");
const client = useThirdwebClient();
function onLoginSuccessful() {
if (nextSearchParam && isValidRedirectPath(nextSearchParam)) {
router.replace(nextSearchParam);
if (account && isOnboardingComplete(account)) {
if (nextPath) {
redirect(nextPath);
} else {
router.replace("/team");
redirect("/team");
}
}
return (
<ConnectEmbed
auth={{
getLoginPayload,
doLogin: async (params) => {
try {
await doLogin(params);
onLoginSuccessful();
} catch (e) {
console.error("Failed to login", e);
throw e;
}
},
doLogout,
isLoggedIn: async (x) => {
const isLoggedInResult = await isLoggedIn(x);
if (isLoggedInResult) {
onLoginSuccessful();
}
return isLoggedInResult;
},
}}
wallets={wallets}
client={client}
modalSize={isLG ? "wide" : "compact"}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
className="shadow-lg"
privacyPolicyUrl="/privacy"
termsOfServiceUrl="/tos"
/>
);
}
function isValidRedirectPath(encodedPath: string): boolean {
try {
// Decode the URI component
const decodedPath = decodeURIComponent(encodedPath);
// ensure the path always starts with a _single_ slash
// dobule slash could be interpreted as `//example.com` which is not allowed
return decodedPath.startsWith("/") && !decodedPath.startsWith("//");
} catch {
// If decoding fails, return false
return false;
}
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
// required here
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
return <LoginAndOnboardingPage account={account} nextPath={nextPath} />;
}
-2
View File
@@ -5,7 +5,6 @@ import { ThemeProvider } from "next-themes";
import { useMemo } from "react";
import { ThirdwebProvider, useActiveAccount } from "thirdweb/react";
import { CustomConnectWallet } from "../@3rdweb-sdk/react/components/connect-wallet";
import { Onboarding } from "../components/onboarding";
import { OpCreditsGrantedModalWrapper } from "../components/onboarding/OpCreditsGrantedModalWrapper";
import { PosthogIdentifier } from "../components/wallets/PosthogIdentifier";
import { isSanctionedAddress } from "../data/eth-sanctioned-addresses";
@@ -23,7 +22,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
<TWAutoConnect />
<OpCreditsGrantedModalWrapper />
<PosthogIdentifier />
<Onboarding />
<ThemeProvider
attribute="class"
disableTransitionOnChange
@@ -0,0 +1,129 @@
import type { UserOpStats } from "@/api/analytics";
import { cn } from "@/lib/utils";
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { EmptyAccountAbstractionChartContent } from "../../../../../components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
import { BarChart } from "../../../components/Analytics/BarChart";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
export async function TotalSponsoredChartCardUI({
data,
aggregatedData,
searchParams,
className,
onlyMainnet,
title,
description,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
searchParams?: { [key: string]: string | string[] | undefined };
className?: string;
onlyMainnet?: boolean;
title?: string;
description?: string;
}) {
const chains = await Promise.all(
data.map(
(item) =>
// eslint-disable-next-line no-restricted-syntax
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
for (const item of data) {
const chain = chains.find((c) => c.chainId === Number(item.chainId));
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
if (chain?.testnet) {
existing.testnet += item.sponsoredUsd;
} else {
existing.mainnet += item.sponsoredUsd;
}
dateMap.set(item.date, existing);
}
// Convert to array and sort by date
const timeSeriesData = Array.from(dateMap.entries())
.map(([date, values]) => ({
date,
mainnet: values.mainnet,
testnet: values.testnet,
total: values.mainnet + values.testnet,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const processedAggregatedData = {
mainnet: aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
};
const chartConfig = {
mainnet: {
label: "Mainnet Chains",
color: "hsl(var(--chart-1))",
},
testnet: {
label: "Testnet Chains",
color: "hsl(var(--chart-2))",
},
total: {
label: "All Chains",
color: "hsl(var(--chart-3))",
},
};
if (onlyMainnet) {
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">
{title || "Total Sponsored"}
</h3>
<p className="text-muted-foreground"> {description}</p>
<BarChart
isCurrency
chartConfig={chartConfig}
data={filteredData}
activeKey="mainnet"
emptyChartContent={<EmptyAccountAbstractionChartContent />}
/>
</div>
);
}
return (
<CombinedBarChartCard
isCurrency
title={title || "Total Sponsored"}
chartConfig={chartConfig}
data={timeSeriesData}
activeChart={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
}
queryKey="totalSponsored"
existingQueryParams={searchParams}
aggregateFn={(_data, key) => processedAggregatedData[key]}
className={className}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
@@ -1,7 +1,7 @@
import { getProjects } from "@/api/projects";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { TabPathLinks } from "@/components/ui/tabs";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
export default async function TeamLayout(props: {
@@ -10,6 +10,11 @@ export default async function TeamLayout(props: {
}) {
const params = await props.params;
const teams = await getTeams();
if (!teams) {
redirect("/login");
}
const team = teams.find((t) => t.slug === params.team_slug);
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
@@ -4,11 +4,10 @@ import {
getWalletConnections,
getWalletUsers,
} from "@/api/analytics";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import type {
InAppWalletStats,
UserOpStats,
WalletStats,
WalletUserStats,
} from "@/api/analytics";
@@ -19,11 +18,6 @@ import {
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import {
type ChainMetadata,
defineChain,
getChainMetadata,
} from "thirdweb/chains";
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
@@ -31,22 +25,28 @@ import { EmptyState } from "../../components/Analytics/EmptyState";
import { PieChartCard } from "../../components/Analytics/PieChartCard";
import { getTeamBySlug } from "@/api/team";
import { getAccount } from "app/account/settings/getAccount";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { getValidAccount } from "app/account/settings/getAccount";
import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard";
import { Changelog, type ChangelogItem } from "components/dashboard/Changelog";
import { Suspense } from "react";
import { TotalSponsoredChartCardUI } from "./_components/TotalSponsoredCard";
// revalidate every 5 minutes
export const revalidate = 300;
type SearchParams = {
usersChart?: string;
from?: string;
to?: string;
type?: string;
interval?: string;
};
export default async function TeamOverviewPage(props: {
params: { team_slug: string };
searchParams: {
usersChart?: string;
from?: string;
to?: string;
type?: string;
interval?: string;
};
params: Promise<{ team_slug: string }>;
searchParams: Promise<SearchParams>;
}) {
const changelog = await getChangelog();
const [params, searchParams] = await Promise.all([
@@ -54,8 +54,13 @@ export default async function TeamOverviewPage(props: {
props.searchParams,
]);
const account = await getValidAccount(`/team/${params.team_slug}`);
const team = await getTeamBySlug(params.team_slug);
const account = await getAccount();
if (!team) {
redirect("/team");
}
const interval = (searchParams.interval as "day" | "week") ?? "week";
const rangeType = (searchParams.type as DurationId) || "last-120";
const range: Range = {
@@ -64,13 +69,42 @@ export default async function TeamOverviewPage(props: {
type: rangeType,
};
if (!team) {
notFound();
}
return (
<div className="flex grow flex-col">
<div className="border-b">
<AnalyticsHeader
title="Team Overview"
interval={interval}
range={range}
/>
</div>
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16 xl:flex-row">
<Suspense fallback={<GenericLoadingPage />}>
<OverviewPageContent
account={account}
range={range}
interval={interval}
searchParams={searchParams}
/>
</Suspense>
<div className="shrink-0 max-md:container max-xl:hidden lg:w-[320px]">
<h2 className="mb-4 font-semibold text-lg tracking-tight">
Latest changes
</h2>
<Changelog changelog={changelog} />
</div>
</div>
</div>
);
}
if (!account) {
redirect("/login");
}
async function OverviewPageContent(props: {
account: Account;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
}) {
const { account, range, interval, searchParams } = props;
const [
walletConnections,
@@ -121,78 +155,56 @@ export default async function TeamOverviewPage(props: {
inAppWalletUsage.length === 0 &&
userOpUsage.length === 0;
if (isEmpty) {
return <EmptyState />;
}
return (
<div>
<div className="w-full border-border-800 border-b px-6 dark:bg-muted/50">
<AnalyticsHeader
title="Team Overview"
interval={interval}
range={range}
<div className="flex grow flex-col gap-6">
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
<div className="">
<UsersChartCard
userStats={walletUserStatsTimeSeries}
searchParams={searchParams}
/>
</div>
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
{walletConnections.length > 0 ? (
<WalletDistributionCard data={walletConnections} />
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
{inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
) : (
<EmptyStateCard
metric="In-App Wallets"
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
/>
)}
</div>
<div className="flex flex-col justify-between gap-16 md:container md:pt-8 md:pb-16 xl:flex-row">
<div className="grow">
{isEmpty ? (
<div className="container p-6">
<EmptyState />
</div>
) : (
<div className="space-y-6">
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
<div className="">
<UsersChartCard
userStats={walletUserStatsTimeSeries}
searchParams={searchParams}
/>
</div>
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
{walletConnections.length > 0 ? (
<WalletDistributionCard data={walletConnections} />
) : (
<EmptyStateCard
metric="Connect"
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
{inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
) : (
<EmptyStateCard
metric="In-App Wallets"
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
/>
)}
</div>
{userOpUsage.length > 0 ? (
<div className="">
<TotalSponsoredCard
searchParams={searchParams}
data={userOpUsageTimeSeries}
aggregatedData={userOpUsage}
/>
</div>
) : (
<EmptyStateCard
metric="Sponsored Transactions"
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
/>
)}
</div>
)}
</div>
<div className="shrink-0 max-md:container max-xl:hidden lg:w-[320px]">
<h2 className="mb-4 font-semibold text-lg tracking-tight">
Latest changes
</h2>
<Changelog changelog={changelog} />
</div>
</div>
{userOpUsage.length > 0 ? (
<TotalSponsoredChartCardUI
searchParams={searchParams}
data={userOpUsageTimeSeries}
aggregatedData={userOpUsage}
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
/>
) : (
<EmptyStateCard
metric="Sponsored Transactions"
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
/>
)}
</div>
);
}
@@ -257,6 +269,7 @@ function UsersChartCard({
return (
<CombinedBarChartCard
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
title="Users"
chartConfig={chartConfig}
activeChart={
@@ -321,97 +334,3 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
/>
);
}
async function TotalSponsoredCard({
data,
aggregatedData,
searchParams,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
searchParams: { [key: string]: string | string[] | undefined };
}) {
const chains = await Promise.all(
data.map(
(item) =>
// eslint-disable-next-line no-restricted-syntax
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
for (const item of data) {
const chain = chains.find((c) => c.chainId === Number(item.chainId));
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
if (chain?.testnet) {
existing.testnet += item.sponsoredUsd;
} else {
existing.mainnet += item.sponsoredUsd;
}
dateMap.set(item.date, existing);
}
// Convert to array and sort by date
const timeSeriesData = Array.from(dateMap.entries())
.map(([date, values]) => ({
date,
mainnet: values.mainnet,
testnet: values.testnet,
total: values.mainnet + values.testnet,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const processedAggregatedData = {
mainnet: aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
};
const chartConfig = {
mainnet: {
label: "Mainnet Chains",
color: "hsl(var(--chart-1))",
},
testnet: {
label: "Testnet Chains",
color: "hsl(var(--chart-2))",
},
total: {
label: "All Chains",
color: "hsl(var(--chart-3))",
},
};
return (
<CombinedBarChartCard
isCurrency
title="Total Sponsored"
chartConfig={chartConfig}
data={timeSeriesData}
activeChart={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
}
queryKey="totalSponsored"
existingQueryParams={searchParams}
aggregateFn={(_data, key) => processedAggregatedData[key]}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
@@ -154,8 +154,8 @@ export function EcosystemWalletUsersChartCard(props: {
chartData.every((data) => data[authMethod] === 0),
) ? (
<EmptyChartState>
<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">
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">
@@ -1,4 +1,3 @@
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { EcosystemLayoutSlug } from "./components/EcosystemSlugLayout";
export default async function Layout(props: {
@@ -11,7 +10,6 @@ export default async function Layout(props: {
params={await props.params}
ecosystemLayoutPath={`/team/${team_slug}/~/ecosystem`}
>
<BillingAlerts className="mb-10" />
{props.children}
</EcosystemLayoutSlug>
);
@@ -1,9 +1,7 @@
import { CreateEcosystemForm } from "./components/client/create-ecosystem-form.client";
import { EcosystemWalletPricingCard } from "./components/pricing-card";
export function EcosystemCreatePage(props: {
ecosystemLayoutPath: string;
}) {
export async function EcosystemCreatePage(props: { teamSlug: string }) {
return (
<div className="flex w-full flex-col gap-6 md:mx-auto md:max-w-lg lg:max-w-4xl">
<header className="flex flex-col gap-1">
@@ -19,9 +17,7 @@ export function EcosystemCreatePage(props: {
<EcosystemWalletPricingCard />
</section>
<section className="mb-12 lg:px-4">
<CreateEcosystemForm
ecosystemLayoutPath={props.ecosystemLayoutPath}
/>
<CreateEcosystemForm teamSlug={props.teamSlug} />
</section>
</main>
</div>
@@ -0,0 +1,65 @@
"use server";
import "server-only";
import { API_SERVER_URL, BASE_URL } from "@/constants/env";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { redirect } from "next/navigation";
import { upload } from "thirdweb/storage";
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
export async function createEcosystem(options: {
teamSlug: string;
name: string;
logo: File;
permission: "PARTNER_WHITELIST" | "ANYONE";
}) {
const token = await getAuthToken();
if (!token) {
return {
status: 401,
};
}
const { teamSlug, logo, ...data } = options;
const imageUrl = await upload({
client: getThirdwebClient(token),
files: [logo],
});
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamSlug}/checkout/create-link`,
{
method: "POST",
body: JSON.stringify({
baseUrl: BASE_URL,
sku: "product:ecosystem_wallets",
metadata: {
...data,
imageUrl,
// not something we pass in today during creation, but required to be there
authOptions: [],
},
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return {
status: res.status,
};
}
const json = await res.json();
if (!json.result) {
return {
status: 500,
};
}
// redirect to the stripe billing portal
redirect(json.result);
}
@@ -1,5 +1,4 @@
"use client";
import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -14,18 +13,13 @@ import {
import { ImageUpload } from "@/components/ui/image-upload";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import invariant from "tiny-invariant";
import { z } from "zod";
import { useCreateEcosystem } from "../../hooks/use-create-ecosystem";
import { createEcosystem } from "../../actions/create-ecosystem";
const formSchema = z.object({
name: z
@@ -42,16 +36,7 @@ const formSchema = z.object({
permission: z.union([z.literal("PARTNER_WHITELIST"), z.literal("ANYONE")]),
});
export function CreateEcosystemForm(props: {
ecosystemLayoutPath: string;
}) {
// When set, the confirmation modal is open the this contains the form data to be submitted
const [formDataToBeConfirmed, setFormDataToBeConfirmed] = useState<
z.infer<typeof formSchema> | undefined
>();
const { data: billingAccountInfo } = useAccount();
const router = useDashboardRouter();
export function CreateEcosystemForm(props: { teamSlug: string }) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -59,25 +44,39 @@ export function CreateEcosystemForm(props: {
},
});
const { mutateAsync: createEcosystem, isPending } = useCreateEcosystem({
onError: (error) => {
const message =
error instanceof Error ? error.message : "Failed to create ecosystem";
toast.error(message);
},
onSuccess: (slug: string) => {
form.reset();
router.push(`${props.ecosystemLayoutPath}/${slug}`);
},
});
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
setFormDataToBeConfirmed(values),
)}
onSubmit={form.handleSubmit(async (values) => {
const res = await createEcosystem({
teamSlug: props.teamSlug,
...values,
});
switch (res.status) {
case 401: {
toast.error("Please login to create an ecosystem");
break;
}
case 403:
{
toast.error(
"You are not authorized to create an ecosystem, please ask your team owner to create it.",
);
}
break;
case 409: {
toast.error("An ecosystem with that name already exists.");
break;
}
// any other status code treat as a random failure
default: {
toast.error(
"Failed to create ecosystem, please try again later.",
);
}
}
})}
className="flex flex-col items-stretch gap-8"
>
<div className="grid gap-6">
@@ -164,49 +163,20 @@ export function CreateEcosystemForm(props: {
)}
/>
</div>
{billingAccountInfo?.status !== accountStatus.validPayment ? (
<ToolTipLabel label="Please update your payment method to create an ecosystem">
{/* Allows the button to be disabled but the tooltip still works */}
<div className="w-full">
<Button
type="submit"
disabled={true}
variant="primary"
className="w-full opacity-50"
>
Create
</Button>
</div>
</ToolTipLabel>
) : (
<Button
type="submit"
variant="primary"
className="w-full"
disabled={isPending}
>
{isPending && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
Create
</Button>
)}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting && (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
)}
Create
</Button>
</form>
</Form>
<ConfirmationDialog
open={formDataToBeConfirmed !== undefined}
onOpenChange={(open) =>
!open ? setFormDataToBeConfirmed(undefined) : null
}
title={`Are you sure you want to create ecosystem ${form.getValues().name}?`}
description="Your account will be charged $250 per month."
onSubmit={() => {
invariant(formDataToBeConfirmed, "Form data not found");
createEcosystem({
name: formDataToBeConfirmed.name,
logo: formDataToBeConfirmed.logo,
permission: formDataToBeConfirmed.permission,
});
}}
/>
</>
);
}
@@ -1,86 +0,0 @@
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import {
type UseMutationOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { THIRDWEB_API_HOST } from "constants/urls";
import { upload } from "thirdweb/storage";
type CreateEcosystemParams = {
name: string;
logo: File;
permission: "PARTNER_WHITELIST" | "ANYONE";
};
export function useCreateEcosystem(
options?: Omit<
UseMutationOptions<string, unknown, CreateEcosystemParams>,
"mutationFn"
>,
) {
const { onSuccess, ...queryOptions } = options ?? {};
const { isLoggedIn } = useLoggedInUser();
const queryClient = useQueryClient();
const client = useThirdwebClient();
return useMutation({
// Returns the created ecosystem slug
mutationFn: async (params: CreateEcosystemParams): Promise<string> => {
if (!isLoggedIn) {
throw new Error("Please login to create an ecosystem");
}
const imageUri = await upload({
client,
files: [params.logo],
});
const res = await fetch(
`${THIRDWEB_API_HOST}/v1/ecosystem-wallet/add-cloud-hosted`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: params.name,
imageUrl: imageUri,
permission: params.permission,
}),
},
);
if (!res.ok) {
const body = await res.json();
console.error(body);
if (res.status === 401) {
throw new Error(
"You're not authorized to create an ecosystem, are you logged in?",
);
}
if (res.status === 406) {
throw new Error(
"Please setup billing on your account to create an ecosystem",
);
}
throw new Error(
body.message ?? body?.error?.message ?? "Failed to create ecosystem",
);
}
const data = await res.json();
return data.result.slug;
},
onSuccess: async (id, variables, context) => {
if (onSuccess) {
await onSuccess(id, variables, context);
}
return queryClient.invalidateQueries({
queryKey: ["ecosystems"],
});
},
...queryOptions,
});
}
@@ -4,9 +4,5 @@ export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const { team_slug } = await props.params;
return (
<EcosystemCreatePage
ecosystemLayoutPath={`/team/${team_slug}/~/ecosystem`}
/>
);
return <EcosystemCreatePage teamSlug={team_slug} />;
}
@@ -1,51 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RocketIcon } from "lucide-react";
import { MONTHLY_PRICE_USD } from "./tier-card";
export const ConfirmEngineTierDialog = (props: {
tier: "STARTER" | "PREMIUM";
onConfirm: () => void;
onOpenChange: (v: boolean) => void;
open: boolean;
}) => {
const { tier, onConfirm, open, onOpenChange } = props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] md:max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogHeader>
<DialogTitle className="pr-4 leading-snug">
Are you sure you want to deploy a{" "}
{tier === "STARTER" ? "Standard" : "Premium"} Engine?
</DialogTitle>
<DialogDescription>
You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the
subscription
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-5">
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancel
</Button>
<Button onClick={onConfirm} variant="primary" className="gap-2">
<RocketIcon className="size-3" />
Deploy now
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,112 +1,8 @@
"use client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { ConfirmEngineTierDialog } from "app/team/[team_slug]/(team)/~/engine/(general)/create/ConfirmEngineTierDialog";
import { EngineTierCard } from "app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card";
import { LazyOnboardingBilling } from "components/onboarding/LazyOnboardingBilling";
import { OnboardingModal } from "components/onboarding/Modal";
import { THIRDWEB_API_HOST } from "constants/urls";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { toast } from "sonner";
import { EngineTierCard } from "./tier-card";
export default function Page() {
const trackEvent = useTrack();
const router = useDashboardRouter();
const [selectedTier, setSelectedTier] = useState<"STARTER" | "PREMIUM">();
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
useState(false);
const accountQuery = useAccount();
const [isBillingModalOpen, setIsBillingModalOpen] = useState(false);
async function createEngineInstance(tier: "STARTER" | "PREMIUM") {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier: tier,
});
try {
toast.info("Starting Engine deployment");
const res = await fetch(
`${THIRDWEB_API_HOST}/v1/engine/add-cloud-hosted`,
{
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({
tier: tier,
}),
},
);
if (!res.ok) {
if (res.status === 409) {
toast.warning(
"There is a pending Engine deployment. Please contact [email protected] if this takes longer than 2 hours.",
);
return;
}
const json = await res.json();
const error =
json.error?.message ??
"Unexpected error. Please try again or visit https://thirdweb.com/support.";
throw error;
}
toast.success(
"Thank you! Your Engine deployment will begin shortly. You can view the progress in Overview page.",
);
} catch (e) {
toast.error(`${e}`);
}
}
return (
<div>
{selectedTier && (
<ConfirmEngineTierDialog
tier={selectedTier}
onOpenChange={setIsConfirmationDialogOpen}
open={isConfirmationDialogOpen}
onConfirm={async () => {
setIsConfirmationDialogOpen(false);
// If Payment is already setup, deploy the Engine
if (accountQuery.data?.status === accountStatus.validPayment) {
await createEngineInstance(selectedTier);
} else {
trackEvent({
category: "engine",
action: "click",
label: "clicked-add-payment-method",
});
setIsBillingModalOpen(true);
}
}}
/>
)}
<OnboardingModal isOpen={isBillingModalOpen}>
<LazyOnboardingBilling
onSave={async () => {
if (!selectedTier) {
return;
}
await createEngineInstance(selectedTier);
setIsBillingModalOpen(false);
}}
onCancel={() => setIsBillingModalOpen(false)}
/>
</OnboardingModal>
<h1 className="mb-2 font-semibold text-2xl tracking-tight">
Choose an Engine deployment
</h1>
@@ -116,36 +12,9 @@ export default function Page() {
</p>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<EngineTierCard
tier="STARTER"
onClick={() => {
setSelectedTier("STARTER");
setIsConfirmationDialogOpen(true);
}}
/>
<EngineTierCard
tier="PREMIUM"
isPrimaryCta
onClick={() => {
setSelectedTier("PREMIUM");
setIsConfirmationDialogOpen(true);
}}
/>
<EngineTierCard
tier="ENTERPRISE"
previousTier="Premium Engine"
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier: "ENTERPRISE",
});
router.push("/contact-us");
}}
/>
<EngineTierCard tier="STARTER" />
<EngineTierCard tier="PREMIUM" isPrimaryCta />
<EngineTierCard tier="ENTERPRISE" previousTier="Premium Engine" />
</div>
</div>
);
@@ -3,7 +3,11 @@
import { Button } from "@/components/ui/button";
import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine";
import { Flex, Spacer } from "@chakra-ui/react";
import { useTrack } from "hooks/analytics/useTrack";
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CheckoutButton } from "../../../../../../../../@/components/billing";
interface EngineTierCardConfig {
name: string;
@@ -40,25 +44,19 @@ const ENGINE_TIER_CARD_CONFIG: Record<EngineTier, EngineTierCardConfig> = {
},
};
export const MONTHLY_PRICE_USD: Record<EngineTier, number> = {
STARTER: 99,
PREMIUM: 299,
ENTERPRISE: 0,
};
export const EngineTierCard = ({
tier,
previousTier,
isPrimaryCta = false,
onClick,
ctaText,
}: {
tier: EngineTier;
previousTier?: string;
isPrimaryCta?: boolean;
onClick: () => void;
ctaText?: string;
}) => {
const trackEvent = useTrack();
const params = useParams<{ team_slug: string }>();
const { name, monthlyPriceUsd } = ENGINE_TIER_CARD_CONFIG[tier];
let features = ENGINE_TIER_CARD_CONFIG[tier].features;
if (tier === "PREMIUM") {
@@ -68,18 +66,18 @@ export const EngineTierCard = ({
const defaultCtaText =
monthlyPriceUsd === "custom" ? "Contact us" : "Deploy now";
const card = (
return (
<div
className="flex flex-col gap-6 rounded-xl border border-border bg-muted/20 p-6"
className="flex flex-col gap-6 rounded-xl border border-border bg-muted/50 p-6"
style={{
background: isPrimaryCta
? "linear-gradient(to top, hsl(var(--muted)), hsl(var(--muted)/50%))"
backgroundImage: isPrimaryCta
? "linear-gradient(to top, hsl(var(--muted)) 40%, transparent)"
: undefined,
}}
>
<Flex flexDir="column" gap={4}>
{/* Name */}
<h3 className="font-semibold text-3xl tracking-tight">{name}</h3>
<h3 className="font-semibold text-2xl tracking-tight">{name}</h3>
{/* Price */}
{monthlyPriceUsd === "custom" ? (
@@ -114,11 +112,43 @@ export const EngineTierCard = ({
<Spacer />
{/* CTA */}
<Button onClick={onClick} variant={isPrimaryCta ? "default" : "outline"}>
{ctaText ?? defaultCtaText}
</Button>
{tier === "ENTERPRISE" ? (
<Button
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier,
});
}}
variant={isPrimaryCta ? "default" : "outline"}
asChild
>
<Link href="/contact-us">{ctaText ?? defaultCtaText}</Link>
</Button>
) : (
<CheckoutButton
sku={
tier === "STARTER"
? "product:engine_standard"
: "product:engine_premium"
}
redirectPath={`/team/${params?.team_slug}/~/engine`}
teamSlug={params?.team_slug || "~"}
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier,
});
}}
variant={isPrimaryCta ? "default" : "outline"}
>
{ctaText ?? defaultCtaText}
</CheckoutButton>
)}
</div>
);
return card;
};
@@ -1,6 +1,5 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
export default async function Layout(props: {
params: Promise<{
@@ -27,9 +26,6 @@ export default async function Layout(props: {
];
return (
<SidebarLayout sidebarLinks={sidebarLinks}>
<BillingAlerts className="mb-10" />
{props.children}
</SidebarLayout>
<SidebarLayout sidebarLinks={sidebarLinks}>{props.children}</SidebarLayout>
);
}
@@ -1,4 +1,3 @@
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { EngineSidebarLayout } from "./_components/EnginePageLayout";
export default async function Layout(props: {
@@ -11,7 +10,6 @@ export default async function Layout(props: {
const params = await props.params;
return (
<EngineSidebarLayout engineId={params.engineId} teamSlug={params.team_slug}>
<BillingAlerts className="mb-10" />
{props.children}
</EngineSidebarLayout>
);
@@ -22,7 +22,6 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
type SortyById = "name" | "createdAt";
@@ -62,7 +61,6 @@ export function TeamProjectsPage(props: {
return (
<div className="container ">
<div className="h-10" />
<BillingAlerts className="mb-10" />
{/* Filters + Add New */}
<div className="flex flex-col gap-4 md:flex-row md:items-center">
@@ -1,28 +0,0 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { accountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { Billing } from "components/settings/Account/Billing";
export const SettingsBillingPage = (props: {
teamId: string | undefined;
}) => {
const meQuery = useAccount({
refetchInterval: (query) => {
const status = query.state?.status as string;
const isInvalidPayment =
status === accountStatus.invalidPayment ||
status === accountStatus.invalidPaymentMethod;
return isInvalidPayment ? 1000 : false;
},
});
const { data: account } = meQuery;
if (!account) {
return <GenericLoadingPage />;
}
return <Billing account={account} teamId={props.teamId} />;
};
@@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/react";
import { addDays } from "date-fns";
import { teamStub, teamSubscriptionsStub } from "stories/stubs";
import {
BadgeContainer,
mobileViewport,
} from "../../../../../../../../stories/utils";
import { PlanInfoCard } from "./PlanInfoCard";
const meta = {
title: "Billing/PlanInfoCard",
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 team = teamStub("foo", "growth");
const zeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth");
const trialPlanZeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth", {
trialEnd: addDays(new Date(), 7).toISOString(),
});
const subsWith1Usage = teamSubscriptionsStub("plan:growth", {
usage: {
storage: {
amount: 10000,
quantity: 4,
},
},
});
const subsWith4Usage = teamSubscriptionsStub("plan:growth", {
usage: {
storage: {
amount: 10000,
quantity: 4,
},
aaSponsorshipAmount: {
amount: 7500,
quantity: 4,
},
aaSponsorshipOpGrantAmount: {
amount: 2500,
quantity: 4,
},
inAppWalletAmount: {
amount: 40000,
quantity: 100,
},
},
});
return (
<div className="container flex max-w-[1130px] flex-col gap-12 lg:p-10">
<BadgeContainer label="On-demand Subscriptions with 0 usage">
<PlanInfoCard team={team} subscriptions={zeroUsageOnDemandSubs} />
</BadgeContainer>
<BadgeContainer label="Trial Plan - On-demand Subscriptions with 0 usage">
<PlanInfoCard
team={team}
subscriptions={trialPlanZeroUsageOnDemandSubs}
/>
</BadgeContainer>
<BadgeContainer label="On-demand Subscriptions with 1 usage">
<PlanInfoCard team={team} subscriptions={subsWith1Usage} />
</BadgeContainer>
<BadgeContainer label="On-demand Subscriptions with 4 usage">
<PlanInfoCard team={team} subscriptions={subsWith4Usage} />
</BadgeContainer>
</div>
);
}
@@ -0,0 +1,166 @@
import type { Team } from "@/api/team";
import type { TeamSubscription } from "@/api/team-subscription";
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";
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
export function PlanInfoCard(props: {
subscriptions: TeamSubscription[];
team: Team;
}) {
const { subscriptions, team } = props;
const validPlan = getValidTeamPlan(team);
const isActualFreePlan = team.billingPlan === "free";
const planSub = subscriptions.find(
(subscription) => subscription.type === "PLAN",
);
// considers hours, mins ... etc as well
const trialEndsInFuture =
planSub?.trialEnd && isAfter(new Date(planSub.trialEnd), new Date());
const trialEndsAfterDays = planSub?.trialEnd
? differenceInDays(new Date(planSub.trialEnd), new Date())
: 0;
return (
<div className="rounded-lg border border-border bg-muted/50">
<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 items-center gap-2">
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{validPlan} Plan
</h3>
{trialEndsInFuture && <Badge>Trial</Badge>}
</div>
{trialEndsAfterDays > 0 && (
<p className="text-muted-foreground text-sm">
Your trial ends in {trialEndsAfterDays} days
</p>
)}
</div>
<div className="flex flex-row gap-2">
{/* manage team billing */}
<BillingPortalButton teamSlug={team.slug} variant="outline">
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>
<Separator />
<div className="p-4 lg:p-6">
{isActualFreePlan ? (
<div className="flex flex-col items-center py-8 text-center max-sm:gap-4">
<CircleAlertIcon className="mb-3 text-muted-foreground lg:size-6" />
<p>Your plan includes a fixed amount of free usage.</p>
<p>
To unlock additional usage, upgrade your plan to Starter or
Growth.
</p>
</div>
) : (
<BillingInfo subscriptions={subscriptions} />
)}
</div>
</div>
);
}
function BillingInfo({
subscriptions,
}: {
subscriptions: TeamSubscription[];
}) {
const planSubscription = subscriptions.find(
(subscription) => subscription.type === "PLAN",
);
const usageSubscription = subscriptions.find(
(subscription) => subscription.type === "USAGE",
);
return (
<div>
{planSubscription && (
<SubscriptionOverview subscription={planSubscription} />
)}
{usageSubscription && (
<>
<Separator className="my-4" />
<SubscriptionOverview
subscription={usageSubscription}
title="On-Demand Charges"
/>
</>
)}
</div>
);
}
function SubscriptionOverview(props: {
subscription: TeamSubscription;
title?: string;
}) {
const { subscription } = props;
return (
<div>
<div className="flex items-center justify-between gap-6">
<div>
{props.title && (
<h5 className="font-medium text-lg">{props.title}</h5>
)}
<p className="text-muted-foreground text-sm lg:text-base">
{format(
new Date(props.subscription.currentPeriodStart),
"MMMM dd yyyy",
)}{" "}
-{" "}
{format(
new Date(props.subscription.currentPeriodEnd),
"MMMM dd yyyy",
)}{" "}
</p>
</div>
<p className="text-foreground">
{formatCurrencyAmount(
subscription.upcomingInvoice.amount || 0,
subscription.upcomingInvoice.currency,
)}
</p>
</div>
</div>
);
}
function formatCurrencyAmount(centsAmount: number, currency: string) {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency,
}).format(centsAmount / 100);
}
@@ -1,22 +1,35 @@
import { getTeamBySlug } from "@/api/team";
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { notFound } from "next/navigation";
import { SettingsBillingPage } from "./BillingSettingsPage";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { redirect } from "next/navigation";
import { Billing } from "../../../../../../../components/settings/Account/Billing";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
}) {
const team = await getTeamBySlug((await props.params).team_slug);
const params = await props.params;
const pagePath = `/team/${params.team_slug}/settings/billing`;
// ensure that user is logged in and onboarded
await getValidAccount(pagePath);
const team = await getTeamBySlug(params.team_slug);
if (!team) {
notFound();
redirect("/team");
}
return (
<ChakraProviderSetup>
<SettingsBillingPage teamId={team.id} />
</ChakraProviderSetup>
);
const subscriptions = await getTeamSubscriptions(team.slug);
if (!subscriptions) {
return (
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
Something went wrong. Please try again later.
</div>
);
}
return <Billing team={team} subscriptions={subscriptions} />;
}
@@ -1,21 +1,13 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { ApplyForOpCreditsModal } from "components/onboarding/ApplyForOpCreditsModal";
import type { Team } from "@/api/team";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ApplyForOpCredits } from "components/onboarding/ApplyForOpCreditsModal";
import { Heading, LinkButton } from "tw-components";
export const SettingsGasCreditsPage = () => {
const { isPending } = useLoggedInUser();
if (isPending) {
return (
<div className="grid min-h-[400px] w-full place-items-center">
<Spinner className="size-10" />
</div>
);
}
export const SettingsGasCreditsPage = (props: {
team: Team;
account: Account;
}) => {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-row items-center gap-4">
@@ -33,7 +25,7 @@ export const SettingsGasCreditsPage = () => {
</LinkButton>
</div>
<ApplyForOpCreditsModal />
<ApplyForOpCredits team={props.team} account={props.account} />
</div>
);
};
@@ -1,10 +1,27 @@
import { getTeamBySlug } from "@/api/team";
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import { redirect } from "next/navigation";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { SettingsGasCreditsPage } from "./SettingsCreditsPage";
export default function Page() {
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
}) {
const params = await props.params;
const account = await getValidAccount(
`/team/${params.team_slug}/~/settings/credits`,
);
const team = await getTeamBySlug(params.team_slug);
if (!team) {
return redirect("/team");
}
return (
<ChakraProviderSetup>
<SettingsGasCreditsPage />
<SettingsGasCreditsPage team={team} account={account} />
</ChakraProviderSetup>
);
}
@@ -1,7 +1,7 @@
import type { Team } from "@/api/team";
import { Toaster } from "@/components/ui/sonner";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { teamStub } from "../../../../../../../stories/stubs";
import { mobileViewport } from "../../../../../../../stories/utils";
import {
DeleteTeamCard,
@@ -33,16 +33,7 @@ export const Mobile: Story = {
},
};
const testTeam: Team = {
id: "team-id-foo-bar",
name: "Team XYZ",
slug: "team-slug-foo-bar",
createdAt: "2023-07-07T19:21:33.604Z",
updatedAt: "2024-07-11T00:01:02.241Z",
billingStatus: "validPayment",
billingPlan: "free",
billingEmail: "[email protected]",
};
const testTeam = teamStub("foo", "free");
function Story() {
return (
@@ -1,5 +1,4 @@
import { getTeamBySlug } from "@/api/team";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
import { notFound } from "next/navigation";
import { SettingsLayout } from "./SettingsLayout";
@@ -15,10 +14,5 @@ export default async function Layout(props: {
notFound();
}
return (
<SettingsLayout team={team}>
<BillingAlerts className="mb-10" />
{props.children}
</SettingsLayout>
);
return <SettingsLayout team={team}>{props.children}</SettingsLayout>;
}
@@ -1,7 +1,7 @@
import { getTeamBySlug } from "@/api/team";
import { getMembers } from "@/api/team-members";
import { notFound } from "next/navigation";
import { getAccount } from "../../../../../../account/settings/getAccount";
import { notFound, redirect } from "next/navigation";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
export default async function Page(props: {
@@ -9,18 +9,26 @@ export default async function Page(props: {
team_slug: string;
}>;
}) {
const params = await props.params;
const [account, team, members] = await Promise.all([
getAccount(),
getTeamBySlug((await props.params).team_slug),
getMembers((await props.params).team_slug),
getValidAccount(`/team/${params.team_slug}/~/settings/members`),
getTeamBySlug(params.team_slug),
getMembers(params.team_slug),
]);
if (!team || !account || !members) {
if (!team) {
redirect("/team");
}
// unexpected error
if (!members) {
notFound();
}
const accountMemberInfo = members.find((m) => m.accountId === account.id);
// unexpected error
if (!accountMemberInfo) {
notFound();
}
@@ -1,5 +1,4 @@
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
export default async function Layout(props: {
children: React.ReactNode;
@@ -9,22 +8,30 @@ export default async function Layout(props: {
}) {
const params = await props.params;
return (
<SidebarLayout
sidebarLinks={[
{
href: `/team/${params.team_slug}/~/usage`,
exactMatch: true,
label: "Overview",
},
{
href: `/team/${params.team_slug}/~/usage/storage`,
exactMatch: true,
label: "Storage",
},
]}
>
<BillingAlerts className="mb-10" />
{props.children}
</SidebarLayout>
<div className="flex grow flex-col">
<div className="border-border border-b py-10">
<div className="container">
<h1 className="font-semibold text-3xl tracking-tight lg:px-2">
Usage
</h1>
</div>
</div>
<SidebarLayout
sidebarLinks={[
{
href: `/team/${params.team_slug}/~/usage`,
exactMatch: true,
label: "Overview",
},
{
href: `/team/${params.team_slug}/~/usage/storage`,
exactMatch: true,
label: "Storage",
},
]}
>
{props.children}
</SidebarLayout>
</div>
);
}
@@ -1,29 +1,34 @@
import type { UsageBillableByService } from "@3rdweb-sdk/react/hooks/useApi";
import { useMemo } from "react";
import { toNumber, toPercent, toSize } from "utils/number";
import { getInAppWalletUsage, getUserOpUsage } from "@/api/analytics";
import type { Team } from "@/api/team";
import type { TeamSubscription } from "@/api/team-subscription";
import { Button } from "@/components/ui/button";
import type {
Account,
UsageBillableByService,
} from "@3rdweb-sdk/react/hooks/useApi";
import { InAppWalletUsersChartCardUI } from "components/embedded-wallets/Analytics/InAppWalletUsersChartCard";
import { subMonths } from "date-fns";
import Link from "next/link";
import { Suspense, useMemo } from "react";
import { toPercent, toSize } from "utils/number";
import { TotalSponsoredChartCardUI } from "../../../../_components/TotalSponsoredCard";
import { UsageCard } from "./UsageCard";
interface UsageProps {
type UsageProps = {
// TODO - remove when we have all the data available from team
usage: UsageBillableByService;
}
export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
const bundlerMetrics = useMemo(() => {
const metric = {
title: "Total sponsored fees",
total: 0,
};
if (!usageData) {
return metric;
}
return {
title: metric.title,
total: usageData.billableUsd.bundler,
};
}, [usageData]);
subscriptions: TeamSubscription[];
account: Account;
team: Team;
};
export const Usage: React.FC<UsageProps> = ({
usage: usageData,
subscriptions,
account,
team,
}) => {
// TODO - get this from team instead of account
const storageMetrics = useMemo(() => {
if (!usageData) {
return {};
@@ -34,47 +39,20 @@ export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
const percent = toPercent(consumedBytes, limitBytes);
return {
total: `${toSize(consumedBytes, "MB")} / ${toSize(
limitBytes,
)} (${percent}%)`,
total: `${toSize(Math.min(consumedBytes, limitBytes), "MB")} of ${toSize(limitBytes)} included storage used`,
progress: percent,
...(usageData.billableUsd.storage > 0
? {
overage: usageData.billableUsd.storage,
}
: {}),
};
}, [usageData]);
const walletsMetrics = useMemo(() => {
if (!usageData) {
return {};
}
const numOfWallets = usageData.usage.embeddedWallets.countWalletAddresses;
const limitWallets = usageData.limits.embeddedWallets;
const percent = toPercent(numOfWallets, limitWallets);
return {
total: `${toNumber(numOfWallets)} / ${toNumber(
limitWallets,
)} (${percent}%)`,
progress: percent,
...(usageData.billableUsd.embeddedWallets > 0
? {
overage: usageData.billableUsd.embeddedWallets,
}
: {}),
totalUsage: `Total Usage: ${toSize(consumedBytes, "MB")}`,
};
}, [usageData]);
// TODO - get this from team instead of account
const rpcMetrics = useMemo(() => {
if (!usageData) {
return {};
}
return {
title: "Unlimited requests",
title: "Unlimited Requests",
total: (
<span className="text-muted-foreground">
{usageData.rateLimits.rpc} Requests Per Second
@@ -89,7 +67,7 @@ export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
}
return {
title: "Unlimited requests",
title: "Unlimited Requests",
total: (
<span className="text-muted-foreground">
{usageData.rateLimits.storage} Requests Per Second
@@ -98,35 +76,177 @@ export const Usage: React.FC<UsageProps> = ({ usage: usageData }) => {
};
}, [usageData]);
const usageSub = subscriptions.find((sub) => sub.type === "USAGE");
// we don't have `usageSub` for free plan - so we use "1 month ago" as the period
// even free plan can have usage data
const currentPeriodStart = usageSub?.currentPeriodStart
? new Date(usageSub.currentPeriodStart)
: subMonths(new Date(), 1);
const currentPeriodEnd = usageSub?.currentPeriodEnd
? new Date(usageSub.currentPeriodEnd)
: new Date();
return (
<div className="flex grow flex-col gap-12">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
<UsageCard
{...rpcMetrics}
name="RPC"
tooltip="RPC usage is calculated by requests per second."
/>
<UsageCard
{...gatewayMetrics}
name="Storage Gateway"
tooltip="Storage gateway usage is calculated by GB per file size."
/>
<UsageCard
{...storageMetrics}
name="Storage Pinning"
tooltip="Storage pinning usage is calculated by GB per file size."
/>
<UsageCard
{...walletsMetrics}
name="Email Wallets"
tooltip="Email wallet (with managed recovery code) usage is calculated by monthly active wallets (i.e. active as defined by at least 1 user log-in via email or social within the billing period month)."
/>
<UsageCard
{...bundlerMetrics}
name="Account Abstraction"
tooltip="(Gasless, Paymaster, Bundler) usage is calculated by sponsored network fees."
/>
</div>
<div className="flex grow flex-col gap-8">
<InAppWalletUsersChartCard
accountId={account.id}
from={currentPeriodStart}
to={currentPeriodEnd}
/>
<TotalSponsoredCard
accountId={account.id}
from={currentPeriodStart}
to={currentPeriodEnd}
/>
<UsageCard
{...rpcMetrics}
name="RPC"
description="Amount of RPC requests allowed per second in your plan"
/>
<UsageCard
{...gatewayMetrics}
name="Storage Gateway"
description="Amount of storage gateway requests allowed per second in your plan"
/>
<UsageCard
{...storageMetrics}
name="Storage Pinning"
description="Amount of IPFS Storage pinning allowed in your plan"
>
<Button
variant="outline"
className="top-6 right-6 mt-6 md:absolute md:mt-0"
asChild
size="sm"
>
<Link href={`/team/${team.slug}/~/usage/storage`}>
View Pinned Files
</Link>
</Button>
</UsageCard>
</div>
);
};
type ChartCardProps = {
from: Date;
to: Date;
accountId: string;
};
function InAppWalletUsersChartCard(props: ChartCardProps) {
const title = "In-App Wallets";
const description =
"Number of unique users interacting with your apps using in-app wallets every day";
return (
<Suspense
fallback={
<InAppWalletUsersChartCardUI
inAppWalletStats={[]}
isPending={true}
title={title}
description={description}
/>
}
>
<AsyncInAppWalletUsersChartCard
{...props}
title={title}
description={description}
/>
</Suspense>
);
}
async function AsyncInAppWalletUsersChartCard(
props: ChartCardProps & {
title: string;
description: string;
},
) {
const inAppWalletUsage = await getInAppWalletUsage({
period: "day",
from: props.from,
to: props.to,
accountId: props.accountId,
}).catch(() => null);
return (
<InAppWalletUsersChartCardUI
inAppWalletStats={inAppWalletUsage || []}
isPending={false}
title={props.title}
description={props.description}
/>
);
}
function TotalSponsoredCard(props: ChartCardProps) {
const title = "Total Sponsored";
const description =
"Total amount of USD sponsored across all mainnets with account abstraction";
return (
<Suspense
fallback={
<TotalSponsoredChartCardUI
data={[]}
aggregatedData={[]}
className="bg-muted/50"
onlyMainnet
description={description}
title={title}
/>
}
>
<AsyncTotalSponsoredChartCard
{...props}
title={title}
description={description}
/>
</Suspense>
);
}
async function AsyncTotalSponsoredChartCard(
props: ChartCardProps & {
description: string;
title: string;
},
) {
const { accountId, from, to } = props;
const [userOpUsageTimeSeries, userOpUsage] = await Promise.all([
// User operations usage
getUserOpUsage({
accountId: accountId,
from: from,
to: to,
period: "week",
}),
getUserOpUsage({
accountId: accountId,
from: from,
to: to,
period: "all",
}),
]);
return (
<TotalSponsoredChartCardUI
data={userOpUsageTimeSeries}
aggregatedData={userOpUsage}
className="bg-muted/50"
onlyMainnet
description={props.description}
title={props.title}
/>
);
}
@@ -1,6 +1,3 @@
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CircleHelpIcon } from "lucide-react";
import type { JSX } from "react";
import { toUSD } from "utils/number";
@@ -10,7 +7,9 @@ interface UsageCardProps {
title?: string;
total?: string | number | JSX.Element;
progress?: number;
tooltip?: string;
description: string;
children?: JSX.Element;
totalUsage?: string;
}
export const UsageCard: React.FC<UsageCardProps> = ({
@@ -19,29 +18,28 @@ export const UsageCard: React.FC<UsageCardProps> = ({
total,
overage,
progress,
tooltip,
description,
children,
totalUsage,
}) => {
return (
<div className="relative flex min-h-[150px] flex-col rounded-lg border border-border bg-muted/50 p-4">
<h3 className="pr-10 font-medium text-lg">{name}</h3>
{tooltip && (
<ToolTipLabel label={tooltip}>
<CircleHelpIcon className="absolute top-4 right-4 size-5 text-muted-foreground hover:text-foreground" />
</ToolTipLabel>
)}
<div className="relative flex min-h-[190px] flex-col rounded-lg border border-border bg-muted/50 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="h-6" />
<div className="mt-auto flex flex-col gap-1.5">
{title && <p className="text-foreground text-sm">{title}</p>}
{title && <p className="text-foreground">{title}</p>}
{total !== undefined && (
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground">
{typeof total === "number" ? toUSD(total) : total}
</p>
)}
{progress !== undefined && <Progress value={progress} />}
{totalUsage && <p className="mt-2 text-foreground">{totalUsage}</p>}
{overage && (
<p className="mt-2 text-muted-foreground text-sm">
@@ -50,6 +48,8 @@ export const UsageCard: React.FC<UsageCardProps> = ({
</p>
)}
</div>
{children}
</div>
);
};
@@ -60,14 +60,7 @@ function Progress(props: {
return (
<div className="rounded-full bg-muted">
<div
className={cn(
"h-2 rounded-full",
props.value > 90
? "bg-red-600"
: props.value > 50
? "bg-yellow-600"
: "bg-blue-600",
)}
className={"h-2 rounded-full bg-blue-600"}
style={{
width: `${Math.min(props.value, 100)}%`,
}}
@@ -1,15 +1,7 @@
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import type {
Account,
UsageBillableByService,
} from "@3rdweb-sdk/react/hooks/useApi";
import { format } from "date-fns/format";
import Link from "next/link";
import { getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { redirect } from "next/navigation";
import { PLANS } from "utils/pricing";
import { getAccount } from "../../../../../account/settings/getAccount";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { getAccountUsage } from "./getAccountUsage";
import { Usage } from "./overview/components/Usage";
@@ -19,16 +11,19 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
const account = await getAccount();
const account = await getValidAccount(`/team/${params.team_slug}/~/usage`);
const team = await getTeamBySlug(params.team_slug);
if (!account) {
return redirect(
`/login?next=${encodeURIComponent(`/team/${params.team_slug}/~/usage`)}`,
);
if (!team) {
redirect("/team");
}
const accountUsage = await getAccountUsage();
if (!accountUsage) {
const [accountUsage, subscriptions] = await Promise.all([
getAccountUsage(),
getTeamSubscriptions(team.slug),
]);
if (!accountUsage || !subscriptions) {
return (
<div className="flex min-h-[350px] items-center justify-center rounded-lg border p-4 text-destructive-text">
Something went wrong. Please try again later.
@@ -37,114 +32,12 @@ export default async function Page(props: {
}
return (
<div className="flex grow flex-col gap-10">
<PlanInfoCard
account={account}
accountUsage={accountUsage}
team_slug={params.team_slug}
/>
<Usage usage={accountUsage} />
</div>
<Usage
// TODO - remove accountUsage when we have all the data available from team
usage={accountUsage}
subscriptions={subscriptions}
account={account}
team={team}
/>
);
}
function PlanInfoCard(props: {
account: Account;
accountUsage: UsageBillableByService;
team_slug: string;
}) {
const { account, accountUsage } = props;
return (
<div className="rounded-lg border bg-muted/50">
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
<h3 className="font-semibold text-2xl tracking-tight">
{PLANS[account.plan as keyof typeof PLANS].title} Plan
</h3>
<div className="flex items-center gap-3">
<Button asChild variant="outline" className="gap-2">
<Link href={`/team/${props.team_slug}/~/settings/billing`}>
Manage Billing
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<TrackedLinkTW
href="/pricing"
category="billingAccount"
label="learn-more-pricing"
target="_blank"
>
View all plans
</TrackedLinkTW>
</Button>
</div>
</div>
<Separator />
<div className="p-6 lg:p-6">
<BillingInfo account={account} usage={accountUsage} />
</div>
</div>
);
}
function BillingInfo({
account,
usage,
}: {
account: Account;
usage: UsageBillableByService;
}) {
if (
!account.currentBillingPeriodStartsAt ||
!account.currentBillingPeriodEndsAt
) {
return null;
}
const totalUsd = getBillingAmountInUSD(usage);
return (
<div>
<div>
<h5 className="font-medium">Current Billing Cycle</h5>
<p className="text-muted-foreground">
{format(
new Date(account.currentBillingPeriodStartsAt),
"MMMM dd yyyy",
)}{" "}
-{" "}
{format(
new Date(account.currentBillingPeriodEndsAt),
"MMMM dd yyyy",
)}{" "}
</p>
</div>
<Separator className="my-4" />
<div className="flex items-center gap-2">
<h5 className="font-medium">Total Upcoming Bill</h5>
<p className="text-lg text-muted-foreground">{totalUsd}</p>
</div>
</div>
);
}
function getBillingAmountInUSD(usage: UsageBillableByService) {
let total = 0;
if (usage.billableUsd) {
for (const amount of Object.values(usage.billableUsd)) {
total += amount;
}
}
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
}).format(total);
}
@@ -6,16 +6,12 @@ import { YourFilesSection } from "./your-files";
export default function Page() {
return (
<div>
<h1 className="font-semibold text-3xl tracking-tight">Storage</h1>
<div className="h-6" />
<div className="flex flex-col gap-14">
<YourFilesSection />
<GatewaySection />
<CLISection />
<SDKSection />
<GuidesSection />
</div>
<div className="flex flex-col gap-14">
<YourFilesSection />
<GatewaySection />
<CLISection />
<SDKSection />
<GuidesSection />
</div>
);
}
@@ -1,12 +1,10 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import {
type ApiKeyService,
accountStatus,
useAccount,
} from "@3rdweb-sdk/react/hooks/useApi";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
@@ -26,24 +24,20 @@ export function AccountAbstractionPage(props: {
teamSlug: string;
projectKey: string;
apiKeyServices: ApiKeyService[];
billingStatus: "validPayment" | (string & {}) | null;
tab?: string;
}) {
const { apiKeyServices } = props;
const looggedInUserQuery = useLoggedInUser();
const accountQuery = useAccount();
const chain = useActiveWalletChain();
const hasSmartWalletsWithoutBilling = useMemo(() => {
if (!accountQuery.data) {
return;
}
return apiKeyServices.find(
(s) =>
accountQuery.data.status !== accountStatus.validPayment &&
props.billingStatus !== accountStatus.validPayment &&
s.name === "bundler",
);
}, [apiKeyServices, accountQuery.data]);
}, [apiKeyServices, props.billingStatus]);
const isOpChain = chain?.id ? isOpChainId(chain.id) : false;
@@ -1,7 +1,8 @@
import { getProject } from "@/api/projects";
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { getTeamBySlug } from "../../../../../../@/api/team";
import { getAbsoluteUrl } from "../../../../../../lib/vercel-utils";
import { getAPIKeyForProjectId } from "../../../../../api/lib/getAPIKeys";
import { AccountAbstractionPage } from "./AccountAbstractionPage";
@@ -11,7 +12,15 @@ export default async function Page(props: {
searchParams: Promise<{ tab?: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const project = await getProject(team_slug, project_slug);
const [team, project] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
notFound();
@@ -28,6 +37,7 @@ export default async function Page(props: {
<AccountAbstractionPage
projectSlug={project.slug}
teamSlug={team_slug}
billingStatus={team.billingStatus}
projectKey={project.publishableKey}
apiKeyServices={apiKey.services || []}
tab={(await props.searchParams).tab}
@@ -1,6 +1,5 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
export default async function Layout(props: {
params: Promise<{
@@ -30,10 +29,5 @@ export default async function Layout(props: {
},
];
return (
<SidebarLayout sidebarLinks={links}>
<BillingAlerts className="mb-10" />
{props.children}
</SidebarLayout>
);
return <SidebarLayout sidebarLinks={links}>{props.children}</SidebarLayout>;
}
@@ -0,0 +1,64 @@
"use client";
import {} from "@/components/ui/dropdown-menu";
import {} from "@/components/ui/select";
import { Layers3 } from "lucide-react";
import Link from "next/link";
export type Blueprint = {
id: string;
name: string;
slug: string;
description: string;
};
export function BlueprintsExplorer(props: {
blueprints: Blueprint[];
}) {
const { blueprints } = props;
return (
<div className="container">
{/* Blueprints */}
{blueprints.length === 0 ? (
<div className="flex h-[450px] items-center justify-center rounded-lg border border-border ">
No blueprints found
</div>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
{blueprints.map((blueprint) => {
return <BlueprintCard key={blueprint.id} blueprint={blueprint} />;
})}
</div>
)}
<div className="h-10" />
</div>
);
}
function BlueprintCard(props: {
blueprint: Blueprint;
}) {
const { blueprint } = props;
return (
<div
key={blueprint.id}
className="relative flex items-center gap-4 rounded-lg border border-border bg-muted/50 p-4 transition-colors hover:bg-muted/70"
>
<Layers3 className="size-10" />
<div>
<Link
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
href={`https://portal.thirdweb.com/insight/blueprints#${blueprint.slug}`}
>
<h2 className="font-medium text-base">{blueprint.name}</h2>
</Link>
<p className="my-1 text-muted-foreground/70 text-xs">
{blueprint.description}
</p>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { type Blueprint, BlueprintsExplorer } from "./BlueprintsExplorer";
import { BlueprintsPageHeader } from "./BlueprintsPageHeader";
export function BlueprintsPage() {
return (
<div>
<BlueprintsPageHeader />
<div className="h-6" />
<BlueprintsPageContent />
</div>
);
}
function getProjectBlueprints() {
return [
{
id: "1",
name: "Transactions",
slug: "transactions-blueprint",
description: "Query transaction data",
},
{
id: "2",
name: "Events",
slug: "events-blueprint",
description: "Query event data",
},
{
id: "3",
name: "Tokens",
slug: "tokens-blueprint",
description: "Query ERC-20, ERC-721, and ERC-1155 tokens",
},
] as Blueprint[];
}
function BlueprintsPageContent() {
const blueprints = getProjectBlueprints();
return <BlueprintsExplorer blueprints={blueprints} />;
}
@@ -0,0 +1,25 @@
"use client";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
export function BlueprintsPageHeader() {
return (
<div className="flex grow flex-col">
<div className="border-border border-b py-10">
<div className="container flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-semibold text-2xl tracking-tight sm:text-3xl">
Insight
</h1>
<Button
className="w-full cursor-not-allowed gap-2 opacity-50 sm:w-auto"
disabled
>
<PlusIcon className="size-4" />
Create Blueprint (Coming Soon)
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken";
import { BlueprintsPage } from "./components/BlueprintsPage";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const accountAddress = await getAuthTokenWalletAddress();
if (!accountAddress) {
const { team_slug, project_slug } = await props.params;
return redirect(
`/login?next=${encodeURIComponent(`/team/${team_slug}/${project_slug}/insight`)}`,
);
}
return <BlueprintsPage />;
}
@@ -11,6 +11,11 @@ export default async function TeamLayout(props: {
}) {
const params = await props.params;
const teams = await getTeams();
if (!teams) {
redirect("/login");
}
const team = teams.find((t) => t.slug === params.team_slug);
if (!team) {
@@ -69,6 +74,10 @@ export default async function TeamLayout(props: {
},
]
: []),
{
path: `/team/${params.team_slug}/${params.project_slug}/insight`,
name: "Insight",
},
{
path: `/team/${params.team_slug}/${params.project_slug}/settings`,
name: "Settings",
@@ -3,7 +3,7 @@ import { OrbitIcon } from "lucide-react";
import Link from "next/link";
import { ShareButton } from "./share-button.client";
export function NebulaWaitListPageUI() {
export function NebulaWaitListPageUI(props: { teamId: string }) {
return (
<div className="flex grow flex-col">
{/* Header */}
@@ -22,7 +22,15 @@ export function NebulaWaitListPageUI() {
<CenteredCard
title="You're on the waitlist"
description="You should receive access to Nebula soon!"
footer={<ShareButton />}
footer={
<div className="flex flex-col items-center gap-3">
<ShareButton teamId={props.teamId} />
<p className="text-balance text-center text-muted-foreground">
Share this invite link and get moved up the list when your
friends sign up!
</p>
</div>
}
/>
</div>
</div>
@@ -82,7 +90,7 @@ function CenteredCard(props: {
{props.description}
</p>
<div className="h-6" />
<div className="h-10" />
{props.footer}
</div>
@@ -26,20 +26,20 @@ export async function NebulaWaitListPage(props: {
// if not already on the waitlist, join the waitlist
if (!nebulaWaitList.onWaitlist) {
const joined = await joinTeamWaitlist({
const res = await joinTeamWaitlist({
scope: "nebula",
teamSlug: team.slug,
}).catch(() => null);
// this should never happen
if (!joined) {
if (!res?.success) {
return (
<UnexpectedErrorPage message="Failed to join Nebula waitlist status for your team" />
);
}
}
return <NebulaWaitListPageUI />;
return <NebulaWaitListPageUI teamId={team.id} />;
}
function UnexpectedErrorPage(props: {
@@ -29,5 +29,5 @@ export const Mobile: Story = {
};
function Story() {
return <NebulaWaitListPageUI />;
return <NebulaWaitListPageUI teamId="foo" />;
}
@@ -5,16 +5,21 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
import { CheckIcon, ShareIcon } from "lucide-react";
import { useState } from "react";
export function ShareButton() {
export function ShareButton(props: {
teamId: string;
}) {
const [isCopied, setIsCopied] = useState(false);
return (
<ToolTipLabel label="Copy link for joining waitlist">
<ToolTipLabel label="Copy Invite Link">
<Button
variant="outline"
className="gap-2"
onClick={() => {
navigator.clipboard.writeText("https://thirdweb.com/team/~/~/nebula");
const url = new URL("https://thirdweb.com/nebula");
url.searchParams.append("utm_content", props.teamId);
url.searchParams.append("utm_campaign", "nebula");
navigator.clipboard.writeText(url.href);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
@@ -245,6 +245,7 @@ function UsersChartCard({
return (
<CombinedBarChartCard
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
title="Users"
chartConfig={chartConfig}
activeChart={chartKey}
@@ -383,6 +384,7 @@ async function TotalSponsoredCard({
isCurrency
title="Total Sponsored"
chartConfig={chartConfig}
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
data={timeSeriesData}
activeChart={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
@@ -1,5 +1,4 @@
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { BillingAlerts } from "components/settings/Account/Billing/alerts/Alert";
export default async function Layout(props: {
children: React.ReactNode;
@@ -41,7 +40,6 @@ export default async function Layout(props: {
},
]}
>
<BillingAlerts className="mb-10" />
{props.children}
</SidebarLayout>
</div>
@@ -9,9 +9,11 @@ export function AnalyticsHeader(props: {
const { title, interval, range } = props;
return (
<div className="container flex flex-col items-start gap-6 px-2 py-6 md:h-[120px] md:flex-row md:items-center md:p-6 md:py-0">
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-3xl text-foreground">{title}</h1>
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
{title}
</h1>
</div>
<RangeSelector interval={interval} range={range} />
</div>
@@ -14,6 +14,7 @@ import {
YAxis,
} from "recharts";
import { toUSD } from "utils/number";
import { EmptyChartState } from "../../../../components/analytics/empty-chart-state";
export function BarChart({
chartConfig,
@@ -21,12 +22,14 @@ export function BarChart({
activeKey,
tooltipLabel,
isCurrency = false,
emptyChartContent,
}: {
chartConfig: ChartConfig;
data: { [key in string]: number | string }[];
activeKey: string;
tooltipLabel?: string;
isCurrency?: boolean;
emptyChartContent?: React.ReactNode;
}) {
return (
<ChartContainer
@@ -38,62 +41,66 @@ export function BarChart({
}}
className="aspect-auto h-[250px] w-full pt-6"
>
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
{data.length === 0 ? (
<EmptyChartState> {emptyChartContent} </EmptyChartState>
) : (
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
/>
<YAxis
width={48}
dataKey={activeKey}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatTickerNumber(value)}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
nameKey={activeKey}
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}}
valueFormatter={(v: unknown) =>
isCurrency
? toUSD(v as number)
: formatTickerNumber(v as number)
}
/>
}
/>
<Bar
dataKey={activeKey}
radius={4}
fill={chartConfig[activeKey]?.color ?? "hsl(var(--chart-1))"}
/>
</RechartsBarChart>
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<YAxis
width={48}
dataKey={activeKey}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatTickerNumber(value)}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
nameKey={activeKey}
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}}
valueFormatter={(v: unknown) =>
isCurrency
? toUSD(v as number)
: formatTickerNumber(v as number)
}
/>
}
/>
<Bar
dataKey={activeKey}
radius={4}
fill={chartConfig[activeKey]?.color ?? "hsl(var(--chart-1))"}
/>
</RechartsBarChart>
)}
</ChartContainer>
);
}
@@ -8,6 +8,8 @@ type CombinedBarChartConfig<K extends string> = {
[key in K]: { label: string; color: string };
};
// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client
export function CombinedBarChartCard<
T extends string,
K extends Exclude<T, "date">,
@@ -27,6 +29,7 @@ export function CombinedBarChartCard<
1
: undefined,
existingQueryParams,
className,
}: {
title?: string;
chartConfig: CombinedBarChartConfig<K>;
@@ -37,15 +40,17 @@ export function CombinedBarChartCard<
aggregateFn?: (d: typeof data, key: K) => number | undefined;
trendFn?: (d: typeof data, key: K) => number | undefined;
existingQueryParams?: { [key: string]: string | string[] | undefined };
className?: string;
}) {
return (
<Card className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0">
<Card className={className}>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
{title && (
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
<CardTitle className="font-semibold text-lg">{title}</CardTitle>
</div>
)}
<div className="max-md:no-scrollbar overflow-x-auto border-t">
<div className="flex flex-nowrap">
{Object.keys(chartConfig).map((chart: string) => {
@@ -17,7 +17,7 @@ import walletsIcon from "../../../../../public/assets/tw-icons/wallets.svg";
export function EmptyState() {
return (
<section className="flex items-start justify-center md:min-h-[500px]">
<section className="flex w-full items-start justify-center md:min-h-[500px]">
<div className="group container flex flex-col items-center justify-center gap-8 rounded-lg border bg-card p-6 py-24">
<div className="flex max-w-[500px] flex-col items-center justify-center gap-6">
<AnimatedIcons />
@@ -1,17 +1,9 @@
import type { Team } from "@/api/team";
export function getValidTeamPlan(team: Team): Team["billingPlan"] {
switch (true) {
case team.billingPlan === "pro" && team.billingStatus === "validPayment": {
return "pro";
}
case team.billingPlan === "growth" &&
team.billingStatus === "validPayment": {
return "growth";
}
default: {
return "free";
}
if (team.billingStatus !== "validPayment") {
return "free";
}
return team.billingPlan;
}
@@ -9,6 +9,7 @@ import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { doLogout } from "../../../login/auth-actions";
import {
type TeamHeaderCompProps,
TeamHeaderDesktopUI,
@@ -30,9 +31,7 @@ export function TeamHeaderLoggedIn(props: {
const logout = useCallback(async () => {
// log out the user
try {
await fetch("/api/auth/logout", {
method: "POST",
});
await doLogout();
if (activeWallet) {
disconnect(activeWallet);
}

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