Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
c547164320 Version Packages (#6417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-05 17:37:39 -08:00
Joaquim VergesandGitHub 38ae2d3c6b [SDK] expose getUserOpHash utility function (#6424) 2025-03-05 17:08:15 -08:00
jnsdls d75320191d update ecosystem API routes to include team slug (#6422)
Updates ecosystem API endpoints to be scoped under team routes

The ecosystem wallet API endpoints have been updated to be scoped under team routes, ensuring proper data isolation between teams. All ecosystem-related API calls now include the team ID or slug in the path.

Key changes:
- Updated API endpoints from `/v1/ecosystem-wallet/*` to `/v1/teams/{teamIdOrSlug}/ecosystem-wallet/*`
- Added team slug parameter to ecosystem fetch and list operations
- Modified React hooks to include team context in query keys
- Updated component props to pass team information through the component tree

This change ensures ecosystem wallets are properly scoped to their respective teams and maintains data isolation between different team contexts.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates various functions and components within the ecosystem module to include `team_slug` as a parameter. This change enhances the ability to fetch ecosystems and their details specific to a team, improving data handling across the application.

### Detailed summary
- Updated `fetchEcosystem` to accept `team_slug`.
- Modified `fetchEcosystemList` to include `team_slug`.
- Adjusted the `useEcosystemList` hook to utilize `teamIdOrSlug`.
- Enhanced `useEcosystem` to accept `teamIdOrSlug`.
- Updated components to pass `team_slug` where necessary.
- Changed redirect logic to incorporate team-specific ecosystems.

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

<!-- end pr-codex -->
2025-03-05 19:00:54 +00:00
saminacodes 6046d4510d [Portal] Universal Bridge Docs (#6421)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming and updating the documentation related to `thirdweb Pay`, transitioning to the `Universal Bridge` terminology. It enhances clarity and alignment with the new features and guides for web3 payments.

### Detailed summary
- Updated titles and descriptions in various `.mdx` files to reflect `Universal Bridge`.
- Changed sidebar navigation names and links to align with `Universal Bridge`.
- Revised content in `page.mdx` files to include new functionalities and guides.
- Added sections on fee sharing and supported chains in the `Overview` documentation.
- Modified FAQ entries to clarify `Universal Bridge` specifics.

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

<!-- end pr-codex -->
2025-03-05 18:28:34 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f1d32261f0 chore(deps-dev): bump eslint-plugin-react-compiler from 19.0.0-beta-e1e972c-20250221 to 19.0.0-beta-40c6c23-20250301 (#6395)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-05 09:19:51 -08:00
Manan TankandGitHub 04baa74ad7 [TOOL-3610] Dashboard: Fix z-index issue with network selector on chakra modal (#6418) 2025-03-05 07:51:33 -08:00
Phillip HoandGitHub 44bb0034a6 [service-utils] Add pay as a usage source (#6416) 2025-03-05 18:51:39 +08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
cfeba37e40 Version Packages (#6410)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-05 02:15:40 -08:00
Joaquim VergesandGitHub fb446bd099 [SDK] feat: Add transfer success callback (#6415) 2025-03-05 02:09:02 -08:00
jnsdls 24063af948 update engine API routes to include team slug (#6407)
blocked by: https://app.graphite.dev/github/pr/thirdweb-dev/api-server/1437/add-team-engine-management-endpoints

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the handling of `teamIdOrSlug` across various components and utility functions related to engine instances and alerts, ensuring that the correct team context is maintained throughout the application.

### Detailed summary
- Added `teamIdOrSlug` parameter to multiple functions and components.
- Updated API endpoints to include `teamIdOrSlug` in fetch calls.
- Modified components like `EngineAlertsPage`, `EngineSystem`, and `EngineInstancesTable` to pass `teamIdOrSlug`.
- Adjusted data structure handling in API responses to use `result` instead of `data`.

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

<!-- end pr-codex -->
2025-03-05 01:39:38 +00:00
MananTank e2c9d4de34 [TOOL-3606] playground: Remove Ethereum as default selected chain in insight playground (#6413)
<!-- start pr-codex -->

## PR-Codex overview
This PR refines the logic for setting default values in the `values` object within the `blueprint-playground.client.tsx` file. It consolidates the conditions for assigning a default value to parameters.

### Detailed summary
- Removed the specific check for `param.name === "chain"` that assigned a hardcoded value of `"1"`.
- Simplified the condition to check if `param.schema` has a `type` and a `default` property.
- Updated the assignment of `values[param.name]` to use `param.schema.default` when applicable, otherwise setting it to an empty string.

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

<!-- end pr-codex -->
2025-03-05 01:14:05 +00:00
jnsdls 0f6aa6b85d add /faucets to framer rewrites (#6414)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on adding a new route to the `framer-rewrites.js` file for redirecting to the `/faucets` page.

### Detailed summary
- Added the route `"/faucets"` to the array of paths in `apps/dashboard/framer-rewrites.js`.

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

<!-- end pr-codex -->
2025-03-05 01:06:38 +00:00
Joaquim VergesandGitHub 9f1a77f4c4 [SDK] fix: Allow resending OTP on failures (#6412) 2025-03-04 16:58:30 -08:00
Manan TankandGitHub 158a08aafc [TOOL-3598] Dashboard: Add error message in create ecosystem form for image size (#6411) 2025-03-04 12:16:25 -08:00
Phillip HoandGitHub 67354dc848 [service-utils] remove unneeded cf worker imports (#6409) 2025-03-04 21:57:02 +08:00
Joaquim VergesandGitHub 575b7b3a58 [SDK] feat: update zksync chain checks (#6408) 2025-03-04 01:50:43 -08:00
Jonas DanielsandGitHub 35ccf8f2e8 updated dependencies (#6404) 2025-03-03 20:19:54 -08:00
MananTank b5e02a4f10 [TOOl-3590] Dashboard: Remove unused components and stories (#6406)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on removing unused components and simplifying the `CouponCard` functionality by streamlining the handling of coupon applications and eliminating the modal for sharing free wallets.

### Detailed summary
- Deleted multiple files related to `share-free-wallets-modal` and analytics components.
- Removed the dynamic import of `LazyShareFreeWalletsModal` in `CouponCard.tsx`.
- Simplified the `onCouponApplied` function signature by removing the `isFreeWalletsCoupon` parameter.
- Eliminated state management for `showShareModal` in `CouponSection`.

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

<!-- end pr-codex -->
2025-03-04 01:47:57 +00:00
kumaryash90 cb71dd597e Update implementations (#6405)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `MarketplaceV3` addresses in the `ZKSYNC_IMPLEMENTATIONS` object for various entries to new contract addresses.

### Detailed summary
- Updated `MarketplaceV3` address from `0xBc02441a36Bb4029Cd191b20243c2e41B862F118` to `0x88e7F2bEf0a23073b7Cc2c79D307Dbd9138Fa79c` at index `324`.
- Updated `MarketplaceV3` address from `0x4027561E163a420c4e5Db46E07EBd581CAa8Bb62` to `0x4b14569c7B79DBe686Ac3Ba5996131E7EDaB7a93` at index `2741`.

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

<!-- end pr-codex -->
2025-03-04 00:03:26 +00:00
Alec AnanianandGitHub 83ec6957f9 playground: fix pay example imports (#6391) 2025-03-03 15:56:36 -08:00
kumaryash90 c0bbc21a84 Update deploy configs (#6299)
TOOL-3478

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the platform fee configuration in the contract deployment process, introducing a new fee constant, and modifying the logic to handle different fee scenarios based on the publisher address.

### Detailed summary
- Added `THIRDWEB_PUBLISHER_ADDRESS` constant in `addresses.ts`.
- Introduced `DEFAULT_FEE_BPS_NEW` constant in `addresses.ts`.
- Updated `PlatformFeeFieldset` to destructure `isMarketplace`.
- Modified `CustomContractForm` to conditionally use `DEFAULT_FEE_BPS_NEW` based on the publisher address.
- Adjusted imports in `custom-contract.tsx` to include new constants.

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

<!-- end pr-codex -->
2025-03-03 22:09:24 +00:00
Jonas DanielsandGitHub 9fcf1b80de skip smart wallet tests without TW_SECRET_KEY (#6403) 2025-03-03 13:43:07 -08:00
Jonas DanielsandGitHub 33ddf56a0a remove secretHash from rotate key response (#6401) 2025-03-03 13:32:38 -08:00
MananTank f9fa5df6b1 [TOOl-3587] Dashboard: Add in-app-wallet login mode (#6400)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces enhancements to the `LoginAndOnboardingPage` by adding support for an in-app wallet login option and refactoring the login handling logic.

### Detailed summary
- Added `loginWithInAppWallet` prop to `LoginAndOnboardingPage` and `LoginAndOnboardingPageContent`.
- Updated `Page` component to handle `in-app-wallet` search parameter.
- Introduced `inAppWalletLoginOptions` for in-app wallet authentication.
- Modified wallet selection logic based on `loginWithInAppWallet` prop.

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

<!-- end pr-codex -->
2025-03-03 21:17:31 +00:00
MananTank 0d82fc0dd1 [TOOL-3584] Dashboad: Move turnstile widget above connect embed in login page (#6394) 2025-03-03 20:21:59 +00:00
MananTank ef6029b939 [TOOL-3575] Dashboard: Fix /home link 404 after upgrading to nextjs 15.2 (#6393)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates routing and links in the dashboard application to improve navigation.

### Detailed summary
- In `apps/dashboard/next.config.ts`, the `destination` was changed from `"/"` to `"https://landing.thirdweb.com"`.
- In `apps/dashboard/src/app/components/MobileBurgerMenuButton.tsx`, the `href` for the `Link` component was updated from `"/"` to `"/home"`.

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

<!-- end pr-codex -->
2025-03-03 19:56:41 +00:00
MananTank 004601812a [TOOL-3581] Dashboard: Fix project overview page crashing because of ContractDistributionCard (#6392) 2025-03-03 18:39:45 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
cbc0efff31 Version Packages (#6383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-03 00:45:13 -08:00
gregfromstl 9799b0aae1 [SDK] Fix: add gpt protocol to legacy tx chains (#6390)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on adding support for the GPT Protocol to chains that only support legacy transactions.

### Detailed summary
- Updated the `.changeset/every-bees-exist.md` file to document the addition of the GPT Protocol.
- Added `1511670449` as the fee data for `GPT Mainnet` in the `packages/thirdweb/src/gas/fee-data.ts` file.

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

<!-- end pr-codex -->
2025-03-03 07:59:11 +00:00
Joaquim VergesandGitHub c3b90a6f1b [SDK] fix: Handle comma decimal input in PayEmbed (#6386) 2025-03-02 16:20:59 -08:00
MananTank 323e9d7a37 [TOOL-3572] Dashboard:Fix theming issue in engine pages (#6389) 2025-03-01 17:58:51 +00:00
Phillip HoandGitHub 80ae08bd5a [service-utils] fix: build error on kafka disconnect() (#6388) 2025-03-01 20:13:20 +08:00
Phillip HoandGitHub 0c0d837f23 [service-utils] fix: crash if Kafka connects more than once (#6387) 2025-03-01 20:08:42 +08:00
Phillip HoandGitHub 879148f37e fix: relative path import for usageV2 (#6385) 2025-03-01 13:47:53 +08:00
Phillip HoandGitHub 25b149d3a9 chore: forward origin header to usage tracker (#6384) 2025-03-01 13:21:30 +08:00
Phillip HoandGitHub 47c9c43a87 [service-utils] trim project ID prefix (#6382) 2025-03-01 10:53:56 +08:00
saminacodes 31dce55025 Portal Changes (#6381)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating descriptions and FAQ content related to `Nebula` and `EVM networks` across various markdown files, enhancing clarity and accuracy.

### Detailed summary
- Updated `FeatureCard` description for `Nebula`.
- Revised contract deployment description to specify "any EVM network".
- Changed RPC endpoints description to "every EVM chain".
- Added new FAQ entries and details about `Nebula`'s model and capabilities.
- Reformatted existing FAQs with `<Details>` components for better presentation.

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

<!-- end pr-codex -->
2025-03-01 02:48:06 +00:00
MananTank b535cc816f [TOOL-3571] Dashboard: Remove custom high z-indices (#6380) 2025-03-01 00:11:46 +00:00
catalyst17 c3ded8a37c docs: multichain queries (#6375)
[Portal] Feature: Add multichain queries documentation and examples

## Notes for the reviewer
This PR introduces comprehensive documentation for multichain query functionality in Insight, including detailed examples, response formats, and best practices.

## How to test
- Review the new multichain queries documentation page
- Verify the code examples work with the described response formats
- Check that the sidebar navigation correctly links to the new documentation
- Confirm the authentication methods and chain ID formats are accurately described

---

## PR-Codex overview
This PR enhances the `Insight` docs by documenting multichain queries. It updates various documentation files to reflect new features, examples, and response formats related to multichain functionality.

### Detailed summary
- Added `Multichain Queries` section to the sidebar in `sidebar.tsx`.
- Introduced examples for querying multiple chains in `page.mdx` files.
- Updated chain ID handling examples in `agents-and-llms/page.mdx` and `llmstxt/page.mdx`.
- Detailed multichain response structure added to documentation.
- Explained authentication methods for multichain queries.
- Provided best practices and use cases for multichain queries in `multichain-queries/page.mdx`.
- Updated response format details for multichain queries, including metadata and data array structure.
- Included examples for cross-chain portfolio tracking and activity monitoring.

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

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for multichain queries in the `thirdweb Insight` API, enhancing the ability to retrieve data across multiple blockchain networks in a single API call.

### Detailed summary
- Added a new entry for "Multichain Queries" in the `sidebar`.
- Updated examples to illustrate querying multiple chains using query parameters.
- Enhanced documentation for multichain queries with examples and best practices.
- Modified response formats to include `chain_ids` and `limit_per_chain` for multichain queries.
- Updated existing examples to reflect multichain capabilities.

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

<!-- end pr-codex -->
2025-02-28 23:32:19 +00:00
MananTank 257d0bddbf [NEB-85] Portal: Update Nebula sidebar grouping (#6378) 2025-02-28 23:28:11 +00:00
MananTank 5a9a76b7a2 [TOOL-3392] Dashboard: Add project Image field in project settings page (#6379) 2025-02-28 23:19:43 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
e772581044 Version Packages (#6367)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-28 13:51:45 -08:00
Joaquim VergesandGitHub 5625ff1f80 [SDK] expose estimateUserOpGasCost (#6370) 2025-02-28 13:46:39 -08:00
MananTank 062504f463 [TOOL-3569] Dashboard: Add acount.id to posthog identify calls (#6377) 2025-02-28 21:13:58 +00:00
MananTank 853ed473c6 [TOOL-3525] Dashboard: Add delete account in account settings page (#6339)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the management of teams and accounts in the application. It introduces a new method to fetch the default team and updates various components to utilize this information, improving user experience and functionality.

### Detailed summary
- Added `getDefaultTeam` function to fetch the default team using an API call.
- Updated `TeamRootPage` to use `getDefaultTeam` instead of fetching all teams.
- Modified middleware to redirect using the default team.
- Refactored account settings page to include `defaultTeamSlug` and `defaultTeamName`.
- Enhanced `DangerSettingCard` to manage confirmation dialog state.
- Updated `AccountSettingsPageUI` to handle account deletion with relevant alerts for errors.
- Added UI components for handling account deletion status responses in the storybook.

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

<!-- end pr-codex -->
2025-02-28 21:05:11 +00:00
MananTank f5765819d7 [TOOL-3566] Dashboard: Add static.reo.dev in Next.js CSP settings (#6374)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the Content Security Policy (CSP) in the `next.config.ts` file to include an additional source for `script-src`.

### Detailed summary
- Added `static.reo.dev` to the `script-src` directive in the CSP.

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

<!-- end pr-codex -->
2025-02-28 20:57:16 +00:00
jnsdls f4ca73c4bf add /storage to framer rewrites (#6373)
closes: CORE-758

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `framer-rewrites.js` file to include a new route related to storage within the application's routing configuration.

### Detailed summary
- Added a new route `"/storage"` to the existing routes in `framer-rewrites.js`.

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

<!-- end pr-codex -->
2025-02-28 20:23:53 +00:00
MananTank 1f517fa10d [NEB-110] Dashboard: Add Playground and API references links (#6376) 2025-02-28 20:16:25 +00:00
MananTank 8887e46a00 [TOOL-3562] Portal: Add llms.txt and llms-full.txt generation script (#6372)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the layout components and search data extraction in the application. It introduces new files for LLM content and modifies existing components to include additional properties for better indexing and rendering.

### Detailed summary
- Added `public/llms.txt` and `public/llms-full.txt` files.
- Removed `data-noindex` from `<div>` in multiple layout files.
- Added `noLLM` property to `DocLayout` in various components.
- Updated `extractSearchData` to return LLM content.
- Modified `extractContent` to handle new LLM data.
- Adjusted `ArticleCard` and `Code` components to include `data-noindex`.
- Updated dependencies in `package.json` and `pnpm-lock.yaml`.

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

<!-- end pr-codex -->
2025-02-28 19:13:59 +00:00
joaquim-verges 56301bc50e [thirdweb] deps: Upgrade viem to 2.23.5 (#6336)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates several dependencies, particularly the `viem` package, and makes adjustments to the `package.json` and lock files to reflect these changes. It also increases the size limit in the `.size-limit.json` file.

### Detailed summary
- Updated `viem` version from `2.22.17` to `2.23.5` in `package.json` and `pnpm-lock.yaml`.
- Increased size limit in `packages/thirdweb/.size-limit.json` from `130 kB` to `131 kB`.
- Adjusted various dependencies in `pnpm-lock.yaml` to include `@swc/[email protected]`.

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

<!-- end pr-codex -->
2025-02-28 10:31:01 +00:00
MananTank 36b4ea5260 [TOOL-3556] Dashboard: Add Notifications, Revamp Team Overview page (#6368)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the notification system in the dashboard by integrating changelog and inbox notifications, improving UI components, and updating dependencies.

### Detailed summary
- Added `getChangelogNotifications`, `getInboxNotifications`, and `markNotificationAsRead` functions.
- Integrated notifications in `AccountHeaderUI`, `TeamHeader`, and `SecondaryNav`.
- Updated styles for various components.
- Replaced `Changelog` component with a new layout in `TeamProjectsPage`.
- Enhanced notification handling using `idb-keyval` for local storage.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-02-28 01:19:00 +00:00
joaquim-verges e1f73c8286 [SDK] chore: Re-enable flaky smart wallet tests (#6369)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on addressing flaky tests in the `thirdweb` codebase by enabling previously skipped tests related to `SmartWallet` functionality, ensuring they are executed as part of the test suite.

### Detailed summary
- Removed `.github/workflows/issue.yml`.
- Enabled `describe` and `test` blocks previously marked as skipped in:
  - `smart-wallet-modular.test.ts`
  - `verify-typed-data.test.ts`
  - `smart-wallet-integration-v07.test.ts`
- Adjusted test configurations by removing `FIMXE` comments and retry limits.

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

<!-- end pr-codex -->
2025-02-28 01:05:01 +00:00
Joaquim VergesandGitHub c4e7f3b344 [SDK] chore: fix all await expect tests (#6359) 2025-02-27 16:45:52 -08:00
Joaquim VergesandGitHub 6235fe76d3 [SDK] fix: Initialize wallet connection status as unknown (#6360) 2025-02-27 16:19:23 -08:00
Jonas DanielsandGitHub d8e9245ecf upgrade Next.js to 15.2.0 (#6365) 2025-02-27 15:56:46 -08:00
232 changed files with 5456 additions and 5321 deletions
-49
View File
@@ -1,49 +0,0 @@
name: Linked Issue
on:
pull_request:
types: [opened, edited, ready_for_review]
env:
VALID_ISSUE_PREFIXES: "CORE|TOOL|NEB|INFRA"
jobs:
linear:
name: Linear
runs-on: ubuntu-latest
steps:
- name: Check for linked issue
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
// Check if contributor is external
const isInternalContributor = ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(
context.payload.pull_request.author_association
);
// Automatically pass for external contributors
if (!isInternalContributor) {
console.log('External contributor detected - automatically passing check');
return;
}
const body = pr.data.body || '';
const branchName = pr.data.head.ref;
const issueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES})-\\d+`, 'i');
const branchIssueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES.toLowerCase()})-\\d+`, 'i');
if (!issueRegex.test(body) && !branchIssueRegex.test(branchName)) {
core.setFailed(
`No valid issue reference found. PR body or branch name must contain an issue ID with one of these prefixes: ${process.env.VALID_ISSUE_PREFIXES}`
);
return;
}
const matches = body.match(issueRegex) || branchName.match(branchIssueRegex);
console.log(`Found issue reference: ${matches[0]}`);
+4
View File
@@ -13,6 +13,8 @@ module.exports = [
"/connect/universal-bridge",
"/connect/auth",
"/connect/in-app-wallets",
// -- storage
"/storage",
// -- nebula
"/nebula",
// --insight
@@ -53,4 +55,6 @@ module.exports = [
"/learn/guides/:guide_slug",
"/learn/courses",
"/learn/glossary",
// -- faucets --
"/faucets",
];
+2 -2
View File
@@ -12,7 +12,7 @@ const ContentSecurityPolicy = `
style-src 'self' 'unsafe-inline' vercel.live;
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
frame-src * data:;
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com static.reo.dev;
connect-src * data: blob:;
worker-src 'self' blob:;
block-all-mixed-content;
@@ -153,7 +153,7 @@ const baseNextConfig: NextConfig = {
// re-write /home to / (this is so that logged in users will be able to go to /home and NOT be redirected to the logged in app)
{
source: "/home",
destination: "/",
destination: "https://landing.thirdweb.com",
},
...FRAMER_PATHS.map((path) => ({
source: path,
+26 -25
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbo",
"dev": "next dev --turbopack",
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
"start": "next start",
"format": "biome format ./src --write",
@@ -45,9 +45,9 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@sentry/nextjs": "9.2.0",
"@shazow/whatsabi": "^0.19.0",
"@tanstack/react-query": "5.66.9",
"@sentry/nextjs": "9.3.0",
"@shazow/whatsabi": "0.20.0",
"@tanstack/react-query": "5.67.1",
"@tanstack/react-table": "^8.21.2",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "2.0.0",
@@ -61,13 +61,14 @@
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "12.4.7",
"framer-motion": "12.4.10",
"fuse.js": "7.1.0",
"idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.5.0",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.476.0",
"next": "15.1.7",
"lucide-react": "0.477.0",
"next": "15.2.0",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
@@ -93,7 +94,7 @@
"shiki": "1.27.0",
"sonner": "2.0.1",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.19.0",
"swagger-ui-react": "^5.20.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
@@ -103,21 +104,21 @@
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.4",
"@next/bundle-analyzer": "15.1.7",
"@next/eslint-plugin-next": "15.1.7",
"@chromatic-com/storybook": "3.2.5",
"@next/bundle-analyzer": "15.2.0",
"@next/eslint-plugin-next": "15.2.0",
"@playwright/test": "1.50.1",
"@storybook/addon-essentials": "8.6.0",
"@storybook/addon-interactions": "8.6.0",
"@storybook/addon-links": "8.6.0",
"@storybook/addon-onboarding": "8.6.0",
"@storybook/addon-viewport": "8.6.0",
"@storybook/blocks": "8.6.0",
"@storybook/nextjs": "8.6.0",
"@storybook/react": "8.6.0",
"@storybook/test": "8.6.0",
"@storybook/addon-essentials": "8.6.3",
"@storybook/addon-interactions": "8.6.3",
"@storybook/addon-links": "8.6.3",
"@storybook/addon-onboarding": "8.6.3",
"@storybook/addon-viewport": "8.6.3",
"@storybook/blocks": "8.6.3",
"@storybook/nextjs": "8.6.3",
"@storybook/react": "8.6.3",
"@storybook/test": "8.6.3",
"@types/color": "4.2.0",
"@types/node": "22.13.5",
"@types/node": "22.13.9",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
@@ -129,16 +130,16 @@
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.19",
"checkly": "^4.19.1",
"checkly": "5.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"eslint-plugin-storybook": "0.11.3",
"knip": "5.45.0",
"next-sitemap": "^4.2.3",
"postcss": "8.5.3",
"storybook": "8.6.0",
"storybook": "8.6.3",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
"typescript": "5.8.2"
}
}
+17
View File
@@ -57,6 +57,23 @@ export async function getTeams() {
return null;
}
export async function getDefaultTeam() {
const token = await getAuthToken();
if (!token) {
return null;
}
const res = await fetch(`${API_SERVER_URL}/v1/teams/~`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
return (await res.json())?.result as Team;
}
return null;
}
type TeamNebulaWaitList = {
onWaitlist: boolean;
createdAt: null | string;
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { BadgeContainer } from "../../../../stories/utils";
import { getThirdwebClient } from "../../../constants/thirdweb.server";
import { Button } from "../../ui/button";
import { ProjectAvatar } from "./ProjectAvatar";
@@ -17,17 +18,19 @@ export const Desktop: Story = {
args: {},
};
const client = getThirdwebClient();
function Story() {
return (
<div className="flex flex-col gap-10 p-10">
<p> All images below are set with size-6 className </p>
<BadgeContainer label="No Src - Skeleton">
<ProjectAvatar src={undefined} className="size-6" />
<ProjectAvatar src={undefined} className="size-6" client={client} />
</BadgeContainer>
<BadgeContainer label="Invalid/Empty Src - BoxIcon Fallback">
<ProjectAvatar src={""} className="size-6" />
<ProjectAvatar src={""} className="size-6" client={client} />
</BadgeContainer>
<ToggleTest />
@@ -62,13 +65,14 @@ function ToggleTest() {
<p> Src+Name is: {data ? "set" : "not set"} </p>
<BadgeContainer label="Valid Src">
<ProjectAvatar src={data?.src} className="size-6" />
<ProjectAvatar src={data?.src} className="size-6" client={client} />
</BadgeContainer>
<BadgeContainer label="invalid Src">
<ProjectAvatar
src={data ? "invalid-src" : undefined}
className="size-6"
client={client}
/>
</BadgeContainer>
</div>
@@ -1,15 +1,23 @@
import { Img } from "@/components/blocks/Img";
import { BoxIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
import { cn } from "../../../lib/utils";
export function ProjectAvatar(props: {
src: string | undefined;
className: string | undefined;
client: ThirdwebClient;
}) {
return (
<Img
src={props.src}
className={cn("rounded-lg border border-border", props.className)}
src={
resolveSchemeWithErrorHandler({
uri: props.src,
client: props.client,
}) || ""
}
className={cn("rounded-full border border-border", props.className)}
alt={""}
fallback={
<div className="flex items-center justify-center bg-card">
@@ -10,7 +10,9 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState } from "react";
import { cn } from "../../lib/utils";
import { DynamicHeight } from "../ui/DynamicHeight";
export function DangerSettingCard(props: {
title: string;
@@ -24,9 +26,14 @@ export function DangerSettingCard(props: {
confirmationDialog: {
title: string;
description: React.ReactNode;
children?: React.ReactNode;
onClose?: () => void;
};
children?: React.ReactNode;
}) {
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
useState(false);
return (
<div
className={cn(
@@ -50,7 +57,15 @@ export function DangerSettingCard(props: {
props.footerClassName,
)}
>
<Dialog>
<Dialog
open={isConfirmationDialogOpen}
onOpenChange={(v) => {
setIsConfirmationDialogOpen(v);
if (!v) {
props.confirmationDialog.onClose?.();
}
}}
>
<DialogTrigger asChild>
<Button
variant="destructive"
@@ -62,21 +77,21 @@ export function DangerSettingCard(props: {
</Button>
</DialogTrigger>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
>
<div className="p-6">
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogContent className="overflow-hidden p-0">
<DynamicHeight>
<div className="p-6">
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
</div>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
{props.confirmationDialog.children}
</div>
</DynamicHeight>
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
<DialogClose asChild>
@@ -225,7 +225,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
</Button>
</PopoverTrigger>
<PopoverContent
className="z-[10001] p-0"
className="p-0"
align="center"
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
@@ -137,7 +137,7 @@ export const SelectWithSearch = React.forwardRef<
</PopoverTrigger>
<PopoverContent
className={cn("z-[10001] p-0", popoverContentClassName)}
className={cn("p-0", popoverContentClassName)}
align={props.align || "center"}
side={props.side}
sideOffset={10}
@@ -47,13 +47,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const btnOnlyProps =
Comp === "button" ? { type: "button" as const } : undefined;
Comp === "button"
? { type: props.type || ("button" as const) }
: undefined;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...btnOnlyProps}
{...props}
{...btnOnlyProps}
/>
);
},
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+4 -3
View File
@@ -76,7 +76,7 @@ export function TabLinks(props: {
export function TabButtons(props: {
tabs: {
name: string;
name: React.ReactNode;
onClick: () => void;
isActive: boolean;
isEnabled?: boolean;
@@ -108,10 +108,11 @@ export function TabButtons(props: {
className={cn("flex", props.tabContainerClassName)}
ref={containerRef}
>
{props.tabs.map((tab) => {
{props.tabs.map((tab, index) => {
return (
<Button
key={tab.name}
// biome-ignore lint/suspicious/noArrayIndexKey: tabs don't change order, so index is stable
key={index}
variant="ghost"
ref={tab.isActive ? activeTabRef : undefined}
className={cn(
+7
View File
@@ -172,3 +172,10 @@ input:-webkit-autofill:active {
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
/* Remove this when chakra is removed */
/* Make chakra modals have same z-index as shadcn dialog */
body {
--chakra-zIndices-modal: 50;
--chakra-zIndices-overlay: 50;
}
@@ -371,7 +371,6 @@ export type RotateSecretKeyAPIReturnType = {
data: {
secret: string;
secretMasked: string;
secretHash: string;
};
};
@@ -232,13 +232,15 @@ export function useEngineUpdateDeployment() {
export type RemoveEngineFromDashboardIParams = {
instanceId: string;
teamIdOrSlug: string;
};
export async function removeEngineFromDashboard({
instanceId,
teamIdOrSlug,
}: RemoveEngineFromDashboardIParams) {
const res = await apiServerProxy({
pathname: `/v1/engine/${instanceId}`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${instanceId}`,
method: "DELETE",
});
@@ -273,18 +275,20 @@ export async function deleteCloudHostedEngine({
}
export type EditEngineInstanceParams = {
teamIdOrSlug: string;
instanceId: string;
name: string;
url: string;
};
export async function editEngineInstance({
teamIdOrSlug,
instanceId,
name,
url,
}: EditEngineInstanceParams) {
const res = await apiServerProxy({
pathname: `/v1/engine/${instanceId}`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${instanceId}`,
method: "PUT",
headers: {
"Content-Type": "application/json",
@@ -1628,7 +1632,7 @@ export function useEngineSubscriptionsLastBlock(params: {
interface EngineResourceMetrics {
error: string;
data: {
result: {
cpu: number;
memory: number;
errorRate: ResultItem[];
@@ -1637,7 +1641,7 @@ interface EngineResourceMetrics {
};
}
export function useEngineSystemMetrics(engineId: string) {
export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
const [enabled, setEnabled] = useState(true);
return useQuery({
@@ -1645,7 +1649,7 @@ export function useEngineSystemMetrics(engineId: string) {
queryFn: async () => {
const res = await apiServerProxy({
method: "GET",
pathname: `/v1/engine/${engineId}/metrics`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/metrics`,
});
if (!res.ok) {
@@ -1674,14 +1678,14 @@ export interface EngineAlertRule {
pausedAt: Date | null;
}
export function useEngineAlertRules(engineId: string) {
export function useEngineAlertRules(engineId: string, teamIdOrSlug: string) {
return useQuery({
queryKey: engineKeys.alertRules(engineId),
queryFn: async () => {
const res = await apiServerProxy<{
data: EngineAlertRule[];
}>({
pathname: `/v1/engine/${engineId}/alert-rules`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/alert-rules`,
method: "GET",
});
@@ -1703,14 +1707,19 @@ export interface EngineAlert {
endsAt: Date | null;
}
export function useEngineAlerts(engineId: string, limit: number, offset = 0) {
export function useEngineAlerts(
engineId: string,
teamIdOrSlug: string,
limit: number,
offset = 0,
) {
return useQuery({
queryKey: engineKeys.alerts(engineId),
queryFn: async () => {
const res = await apiServerProxy<{
data: EngineAlert[];
}>({
pathname: `/v1/engine/${engineId}/alerts`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/alerts`,
searchParams: {
limit: `${limit}`,
offset: `${offset}`,
@@ -1754,14 +1763,17 @@ export type EngineNotificationChannel = {
subscriptionRoutes: string[];
};
export function useEngineNotificationChannels(engineId: string) {
export function useEngineNotificationChannels(
engineId: string,
teamIdOrSlug: string,
) {
return useQuery({
queryKey: engineKeys.notificationChannels(engineId),
queryFn: async () => {
const res = await apiServerProxy<{
data: EngineNotificationChannel[];
}>({
pathname: `/v1/engine/${engineId}/notification-channels`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/notification-channels`,
method: "GET",
});
@@ -1781,7 +1793,10 @@ export interface CreateNotificationChannelInput {
value: string;
}
export function useEngineCreateNotificationChannel(engineId: string) {
export function useEngineCreateNotificationChannel(
engineId: string,
teamIdOrSlug: string,
) {
const queryClient = useQueryClient();
return useMutation({
@@ -1789,7 +1804,7 @@ export function useEngineCreateNotificationChannel(engineId: string) {
const res = await apiServerProxy<{
data: EngineNotificationChannel;
}>({
pathname: `/v1/engine/${engineId}/notification-channels`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/notification-channels`,
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1812,13 +1827,16 @@ export function useEngineCreateNotificationChannel(engineId: string) {
});
}
export function useEngineDeleteNotificationChannel(engineId: string) {
export function useEngineDeleteNotificationChannel(
engineId: string,
teamIdOrSlug: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (notificationChannelId: string) => {
const res = await apiServerProxy({
pathname: `/v1/engine/${engineId}/notification-channels/${notificationChannelId}`,
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/notification-channels/${notificationChannelId}`,
method: "DELETE",
});
@@ -74,6 +74,7 @@ export async function RouteListCard({
<CardHeader className="flex flex-row items-center justify-between p-4">
<div className="flex flex-row items-center gap-2">
{resolvedOriginTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
@@ -83,6 +84,7 @@ export async function RouteListCard({
<div className="size-8 rounded-full bg-white/10" />
)}
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
@@ -76,6 +76,7 @@ export async function RouteListRow({
<div className="flex items-center gap-1">
{resolvedOriginTokenIconUri ? (
// For now we're using a normal img tag because the domain for these images is unknown
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
@@ -110,6 +111,7 @@ export async function RouteListRow({
<div className="flex flex-row items-center gap-4">
<div className="flex items-center gap-1">
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
@@ -109,6 +109,7 @@ async function getTeamsAndProjectsIfLoggedIn() {
projects: (await getProjects(team.slug)).map((x) => ({
id: x.id,
name: x.name,
image: x.image,
})),
})),
);
@@ -133,7 +133,7 @@ export function ModuleCard(props: ModuleCardProps) {
open={isUninstallModalOpen}
onOpenChange={setIsUninstallModalOpen}
>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogContent>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -49,7 +49,7 @@ export const ProposalButton: React.FC<VoteButtonProps> = ({
Create Proposal
</Button>
</SheetTrigger>
<SheetContent className="z-[10000] w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
<SheetContent className="w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
<SheetHeader>
<SheetTitle>Create new proposal</SheetTitle>
</SheetHeader>
@@ -70,7 +70,7 @@ export const TokenBurnButton: React.FC<TokenBurnButtonProps> = ({
<Flame size={16} /> Burn
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">Burn tokens</SheetTitle>
</SheetHeader>
@@ -60,7 +60,7 @@ export const TokenClaimButton: React.FC<TokenClaimButtonProps> = ({
<GemIcon size={16} /> Claim
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">Claim tokens</SheetTitle>
</SheetHeader>
@@ -58,7 +58,7 @@ export const TokenMintButton: React.FC<TokenMintButtonProps> = ({
<PlusIcon size={16} /> Mint
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">
Mint additional tokens
@@ -64,7 +64,7 @@ export const TokenTransferButton: React.FC<TokenTransferButtonProps> = ({
<SendIcon size={16} /> Transfer
</Button>
</SheetTrigger>
<SheetContent className="z-[10000]">
<SheetContent>
<SheetHeader>
<SheetTitle className="text-left">Transfer tokens</SheetTitle>
</SheetHeader>
@@ -36,6 +36,7 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
projects: (await getProjects(team.slug)).map((x) => ({
id: x.id,
name: x.name,
image: x.image,
})),
})),
);
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateProjectDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { doLogout } from "../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../../team/components/NotificationButton/fetch-notifications";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -54,6 +59,9 @@ export function AccountHeader(props: {
account: props.account,
client,
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
return (
@@ -62,6 +62,9 @@ function Variants(props: {
email: "[email protected]",
}}
client={client}
getChangelogNotifications={() => Promise.resolve([])}
getInboxNotifications={() => Promise.resolve([])}
markNotificationAsRead={() => Promise.resolve()}
/>
</div>
</BadgeContainer>
@@ -8,6 +8,10 @@ import type { ThirdwebClient } from "thirdweb";
import { SecondaryNav } from "../../components/Header/SecondaryNav/SecondaryNav";
import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton";
import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo";
import {
NotificationButtonUI,
type NotificationMetadata,
} from "../../team/components/NotificationButton/NotificationButton";
import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton";
@@ -20,6 +24,9 @@ export type AccountHeaderCompProps = {
account: Pick<Account, "email" | "id" | "image">;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
@@ -59,6 +66,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
focus="team-selection"
createProject={props.createProject}
account={props.account}
client={props.client}
/>
)}
</div>
@@ -70,6 +78,9 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
</header>
);
@@ -111,13 +122,21 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
</div>
</div>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account?.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account?.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
</div>
</header>
);
}
@@ -1,18 +1,26 @@
"use client";
import { redirectToBillingPortal } from "@/actions/billing";
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
import { apiServerProxy } from "@/actions/proxies";
import { updateAccount } from "@/actions/updateAccount";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { upload } from "thirdweb/storage";
import { doLogout } from "../../login/auth-actions";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
export function AccountSettingsPage(props: {
account: Account;
client: ThirdwebClient;
defaultTeamSlug: string;
defaultTeamName: string;
}) {
const router = useDashboardRouter();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
return (
<div>
<header className="border-border border-b py-10">
@@ -25,8 +33,32 @@ export function AccountSettingsPage(props: {
<div className="container max-w-[950px] grow pt-8 pb-20">
<AccountSettingsPageUI
hideDeleteAccount
client={props.client}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
onAccountDeleted={async () => {
await doLogout();
if (activeWallet) {
disconnect(activeWallet);
}
router.replace("/login");
}}
deleteAccount={async () => {
try {
const res = await apiServerProxy({
method: "DELETE",
pathname: "/v1/account",
});
return {
status: res.status,
};
} catch (error) {
console.error(error);
return { status: 500 };
}
}}
redirectToBillingPortal={redirectToBillingPortal}
updateAccountAvatar={async (file) => {
let uri: string | undefined = undefined;
@@ -1,4 +1,12 @@
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
@@ -40,6 +48,13 @@ function Variants() {
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
const [sendEmailFails, setSendEmailFails] = useState(false);
const [emailConfirmationFails, setEmailConfirmationFails] = useState(false);
const [deleteAccountStatusResponse, setDeleteAccountStatusResponse] =
useState<400 | 402 | 500 | 200>(200);
const deleteAccountStub = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: deleteAccountStatusResponse };
};
return (
<div className="container flex max-w-[1132px] flex-col gap-10 py-10">
@@ -67,9 +82,37 @@ function Variants() {
/>
Email Confirmation Fails
</CheckboxWithLabel>
<div className="mt-3 flex flex-col gap-2">
<Label>Delete Account Response</Label>
<Select
value={String(deleteAccountStatusResponse)}
onValueChange={(value) =>
setDeleteAccountStatusResponse(
Number(value) as 400 | 402 | 500 | 200,
)
}
>
<SelectTrigger className="min-w-[320px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="200">200 - Success</SelectItem>
<SelectItem value="400">400 - Active Subscriptions</SelectItem>
<SelectItem value="402">402 - Unpaid Invoices</SelectItem>
<SelectItem value="500">500 - Unknown Error</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<AccountSettingsPageUI
defaultTeamSlug="foo"
defaultTeamName="Foo"
redirectToBillingPortal={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { status: 200 };
}}
account={{
name: "John Doe",
email: "[email protected]",
@@ -96,6 +139,10 @@ function Variants() {
throw new Error("Email already exists");
}
}}
deleteAccount={deleteAccountStub}
onAccountDeleted={() => {
console.log("Account deleted");
}}
/>
<Toaster richColors />
</div>
@@ -1,8 +1,11 @@
"use client";
import type { BillingBillingPortalAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -29,14 +32,14 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
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 { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -51,12 +54,16 @@ type MinimalAccount = Pick<
export function AccountSettingsPageUI(props: {
account: MinimalAccount;
hideDeleteAccount?: boolean;
sendEmail: (email: string) => Promise<void>;
updateName: (name: string) => Promise<void>;
updateEmailWithOTP: (otp: string) => Promise<void>;
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
client: ThirdwebClient;
deleteAccount: DeleteAccount;
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
}) {
return (
<div className="flex flex-col gap-8">
@@ -76,7 +83,13 @@ export function AccountSettingsPageUI(props: {
updateEmailWithOTP={props.updateEmailWithOTP}
/>
{!props.hideDeleteAccount && <DeleteAccountCard />}
<DeleteAccountCard
deleteAccount={props.deleteAccount}
onAccountDeleted={props.onAccountDeleted}
defaultTeamSlug={props.defaultTeamSlug}
defaultTeamName={props.defaultTeamName}
redirectToBillingPortal={props.redirectToBillingPortal}
/>
</div>
);
}
@@ -182,30 +195,43 @@ function AccountNameFormControl(props: {
);
}
function DeleteAccountCard() {
const router = useDashboardRouter();
const title = "Delete Account";
const description =
"Permanently remove your Personal Account and all of its contents from the thirdweb platform. This action is not reversible, please continue with caution.";
type DeleteAccount = () => Promise<{
status: number;
}>;
// TODO: when user can create multiple teams - the error status messaging needs to be updated
function DeleteAccountCard(props: {
deleteAccount: DeleteAccount;
onAccountDeleted: () => void;
defaultTeamSlug: string;
defaultTeamName: string;
redirectToBillingPortal: BillingBillingPortalAction;
}) {
const title = "Delete Account";
const description = (
<>
Permanently delete your thirdweb account, the default team "
{props.defaultTeamName}" and all associated data
<br />
This action is not reversible
</>
);
// TODO
const deleteAccount = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Deleting account");
throw new Error("Not implemented");
},
onSuccess: () => {
router.push("/team");
mutationFn: props.deleteAccount,
onSuccess: (data) => {
if (data.status === 200) {
props.onAccountDeleted();
toast.success("Account deleted successfully");
} else {
toast.error("Failed to delete account");
}
},
});
function handleDelete() {
const promises = deleteAccount.mutateAsync();
toast.promise(promises, {
success: "Account deleted successfully",
error: "Failed to delete account",
});
deleteAccount.mutate();
}
return (
@@ -216,9 +242,77 @@ function DeleteAccountCard() {
buttonOnClick={handleDelete}
isPending={deleteAccount.isPending}
confirmationDialog={{
title: "Are you sure you want to delete your account?",
description:
"This action is not reversible and will delete all of your data.",
title: "Delete Account",
description: (
<span className="block space-y-2">
<span className="block">
Permanently delete your thirdweb account, the default team "
{props.defaultTeamName}" and all associated data
</span>
<span className="block font-medium">
This action is not reversible
</span>
</span>
),
onClose: () => {
deleteAccount.reset();
},
children: (
<>
{deleteAccount.data?.status === 400 && (
<div className="mt-4">
<Alert variant="destructive">
<AlertTitle>Failed to delete account</AlertTitle>
<AlertDescription>
<span className="mb-1 block">
Your default team "{props.defaultTeamName}" has active
subscriptions. These subscriptions have to be cancelled
first before deleting account
</span>
<span className="mb-4 block">
Reach out to support to help you cancel them
</span>
<Button
variant="default"
asChild
size="sm"
className="gap-2"
>
<Link href="/support/create-ticket">
Contact Support
<ExternalLinkIcon className="size-4" />
</Link>
</Button>
</AlertDescription>
</Alert>
</div>
)}
{deleteAccount.data?.status === 402 && (
<div className="mt-4">
<Alert variant="destructive">
<AlertTitle>Failed to delete account</AlertTitle>
<AlertDescription>
<span className="mb-4 block">
Your default team "{props.defaultTeamName}" has unpaid
invoices. These invoices have to be paid before deleting
account
</span>
<BillingPortalButton
size="sm"
teamSlug={props.defaultTeamSlug}
variant="default"
redirectPath="/team"
redirectToBillingPortal={props.redirectToBillingPortal}
>
Manage Billing
</BillingPortalButton>
</AlertDescription>
</Alert>
</div>
)}
</>
),
}}
/>
);
@@ -1,3 +1,4 @@
import { getDefaultTeam } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { getAuthToken } from "../../api/lib/getAuthToken";
import { loginRedirect } from "../../login/loginRedirect";
@@ -6,14 +7,23 @@ import { getValidAccount } from "./getAccount";
export default async function Page() {
const pagePath = "/account";
const account = await getValidAccount(pagePath);
const token = await getAuthToken();
if (!token) {
const [defaultTeam, account, token] = await Promise.all([
getDefaultTeam(),
getValidAccount(pagePath),
getAuthToken(),
]);
if (!token || !defaultTeam) {
loginRedirect(pagePath);
}
return (
<AccountSettingsPage account={account} client={getThirdwebClient(token)} />
<AccountSettingsPage
account={account}
client={getThirdwebClient(token)}
defaultTeamSlug={defaultTeam.slug}
defaultTeamName={defaultTeam.name}
/>
);
}
@@ -2,6 +2,10 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import Link from "next/link";
import type React from "react";
import type { ThirdwebClient } from "thirdweb";
import {
NotificationButtonUI,
type NotificationMetadata,
} from "../../../team/components/NotificationButton/NotificationButton";
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
import { AccountButton } from "./account-button.client";
@@ -11,17 +15,27 @@ export function SecondaryNav(props: {
connectButton: React.ReactNode;
client: ThirdwebClient;
accountAddress: string;
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
return (
<div className="flex items-center gap-6">
<SecondaryNavLinks />
<AccountButton
logout={props.logout}
connectButton={props.connectButton}
account={props.account}
client={props.client}
accountAddress={props.accountAddress}
/>
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogs}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
<AccountButton
logout={props.logout}
connectButton={props.connectButton}
account={props.account}
client={props.client}
accountAddress={props.accountAddress}
/>
</div>
</div>
);
}
@@ -33,7 +33,7 @@ export function AccountButton(props: {
<Button
size="icon"
asChild
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-offset-2"
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-ring hover:ring-offset-1"
variant="ghost"
>
{/* Don't remove the div */}
@@ -59,10 +59,10 @@ export function MobileBurgerMenuButton(
/> */}
<Button
variant="outline"
className="!h-auto p-1"
className="flex size-10 items-center justify-center rounded-full bg-background p-0"
onClick={() => setIsMenuOpen(true)}
>
<MenuIcon className="size-6 text-muted-foreground" />
<MenuIcon className="size-4 text-muted-foreground" />
</Button>
{isMenuOpen && (
@@ -166,7 +166,7 @@ export function MobileBurgerMenuButton(
</Link>
<Link
href="/"
href="/home"
className="text-base text-muted-foreground hover:text-foreground"
>
Home Page
+4
View File
@@ -8,6 +8,7 @@ import NextTopLoader from "nextjs-toploader";
import { Suspense } from "react";
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
import { PostHogProvider } from "./components/root-providers";
import { AppRouterProviders } from "./providers";
@@ -80,6 +81,9 @@ export default function RootLayout({
<Suspense fallback={null}>
<EnsureValidConnectedWalletLoginServer />
</Suspense>
<Suspense fallback={null}>
<PosthogIdentifierServer />
</Suspense>
</AppRouterProviders>
<DashboardRouterTopProgressBar />
<NextTopLoader
+43 -13
View File
@@ -24,7 +24,7 @@ const LazyOnboardingUI = lazy(
() => import("./onboarding/on-boarding-ui.client"),
);
const wallets = [
const loginOptions = [
inAppWallet({
auth: {
options: [
@@ -45,9 +45,27 @@ const wallets = [
createWallet("io.zerion.wallet"),
];
const inAppWalletLoginOptions = [
inAppWallet({
auth: {
options: [
"google",
"apple",
"facebook",
"github",
"email",
"phone",
"passkey",
"wallet",
],
},
}),
];
export function LoginAndOnboardingPage(props: {
account: Account | undefined;
redirectPath: string;
loginWithInAppWallet: boolean;
}) {
return (
<div className="relative flex min-h-dvh flex-col overflow-hidden bg-background">
@@ -91,6 +109,7 @@ export function LoginAndOnboardingPage(props: {
<LoginAndOnboardingPageContent
account={props.account}
redirectPath={props.redirectPath}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</div>
);
@@ -99,6 +118,7 @@ export function LoginAndOnboardingPage(props: {
export function LoginAndOnboardingPageContent(props: {
account: Account | undefined;
redirectPath: string;
loginWithInAppWallet: boolean;
}) {
return (
<div className="relative flex grow flex-col">
@@ -114,6 +134,7 @@ export function LoginAndOnboardingPageContent(props: {
<PageContent
redirectPath={props.redirectPath}
account={props.account}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
</ClientOnly>
</main>
@@ -139,6 +160,7 @@ function LoadingCard() {
function PageContent(props: {
redirectPath: string;
account: Account | undefined;
loginWithInAppWallet: boolean;
}) {
const [screen, setScreen] = useState<
| { id: "login" }
@@ -190,7 +212,12 @@ function PageContent(props: {
}
if (connectionStatus !== "connected" || screen.id === "login") {
return <CustomConnectEmbed onLogin={onLogin} />;
return (
<CustomConnectEmbed
onLogin={onLogin}
loginWithInAppWallet={props.loginWithInAppWallet}
/>
);
}
if (screen.id === "onboarding") {
@@ -215,6 +242,7 @@ function PageContent(props: {
function CustomConnectEmbed(props: {
onLogin: () => void;
loginWithInAppWallet: boolean;
}) {
const { theme } = useTheme();
const client = useThirdwebClient();
@@ -222,6 +250,16 @@ function CustomConnectEmbed(props: {
return (
<div className="flex flex-col items-center gap-4">
<Turnstile
options={{
// only show if interaction is required
appearance: "interaction-only",
// match the theme of the rest of the app
theme: theme === "light" ? "light" : "dark",
}}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => setTurnstileToken(token)}
/>
<ConnectEmbed
auth={{
getLoginPayload,
@@ -247,7 +285,9 @@ function CustomConnectEmbed(props: {
return isLoggedInResult;
},
}}
wallets={wallets}
wallets={
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
}
client={client}
modalSize="wide"
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
@@ -255,16 +295,6 @@ function CustomConnectEmbed(props: {
privacyPolicyUrl="/privacy-policy"
termsOfServiceUrl="/terms"
/>
<Turnstile
options={{
// only show if interaction is required
appearance: "interaction-only",
// match the theme of the rest of the app
theme: theme === "light" ? "light" : "dark",
}}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => setTurnstileToken(token)}
/>
</div>
);
}
+10 -3
View File
@@ -4,10 +4,13 @@ import { isValidEncodedRedirectPath } from "./isValidEncodedRedirectPath";
export default async function Page(props: {
searchParams: Promise<{
next?: string;
next: string | string[] | undefined;
"in-app-wallet": string | string[] | undefined;
}>;
}) {
const nextPath = (await props.searchParams).next;
const searchParams = await props.searchParams;
const nextPath =
typeof searchParams.next === "string" ? searchParams.next : undefined;
const account = await getRawAccount();
// don't redirect away from login page if authToken is already present and onboarding is done
@@ -20,6 +23,10 @@ export default async function Page(props: {
nextPath && isValidEncodedRedirectPath(nextPath) ? nextPath : "/team";
return (
<LoginAndOnboardingPage account={account} redirectPath={redirectPath} />
<LoginAndOnboardingPage
account={account}
redirectPath={redirectPath}
loginWithInAppWallet={searchParams["in-app-wallet"] === "true"}
/>
);
}
@@ -53,6 +53,7 @@ export function NebulaLoginPage(props: {
{showPage === "connect" && (
<LoginAndOnboardingPageContent
loginWithInAppWallet={false}
account={props.account}
redirectPath={
message ? `/?prompt=${encodeURIComponent(message)}` : "/"
-2
View File
@@ -11,7 +11,6 @@ import {
useConnectionManager,
} from "thirdweb/react";
import { CustomConnectWallet } from "../@3rdweb-sdk/react/components/connect-wallet";
import { PosthogIdentifier } from "../components/wallets/PosthogIdentifier";
import { isSanctionedAddress } from "../data/eth-sanctioned-addresses";
import { useAllChainsData } from "../hooks/chains/allChains";
import { SyncChainStores } from "../stores/chainStores";
@@ -27,7 +26,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
<ThirdwebProvider>
<SyncChainDefinitionsToConnectionManager />
<TWAutoConnect />
<PosthogIdentifier />
<ThemeProvider
attribute="class"
disableTransitionOnChange
@@ -1,9 +1,11 @@
import { getWalletConnections } from "@/api/analytics";
import { type Project, getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { Changelog } from "components/dashboard/Changelog";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { subDays } from "date-fns";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../login/loginRedirect";
import {
type ProjectWithAnalytics,
TeamProjectsPage,
@@ -13,7 +15,14 @@ export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const team = await getTeamBySlug(params.team_slug);
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}`);
}
if (!team) {
redirect("/team");
@@ -23,16 +32,21 @@ export default async function Page(props: {
const projectsWithTotalWallets = await getProjectsWithAnalytics(projects);
return (
<div className="container flex grow flex-col gap-12 py-8 lg:flex-row">
<div className="flex grow flex-col">
<h1 className="mb-4 font-semibold text-2xl tracking-tight">Projects</h1>
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
<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">
Team Overview
</h1>
</div>
</div>
<div className="shrink-0 lg:w-[320px]">
<h2 className="mb-4 font-semibold text-2xl tracking-tight">
Changelog
</h2>
<Changelog />
<div className="container flex grow flex-col pt-8 pb-20">
<TeamProjectsPage
projects={projectsWithTotalWallets}
team={team}
client={getThirdwebClient(authToken)}
/>
</div>
</div>
);
@@ -28,7 +28,7 @@ export default async function Page(props: {
}
const [ecosystem, team] = await Promise.all([
fetchEcosystem(params.slug, authToken),
fetchEcosystem(params.slug, authToken, params.team_slug),
getTeamBySlug(params.team_slug),
]);
@@ -10,7 +10,7 @@ export async function EcosystemLayoutSlug({
ecosystemLayoutPath,
}: {
children: React.ReactNode;
params: { slug: string };
params: { slug: string; team_slug: string };
ecosystemLayoutPath: string;
}) {
const authToken = await getAuthToken();
@@ -19,7 +19,11 @@ export async function EcosystemLayoutSlug({
redirect(ecosystemLayoutPath);
}
const ecosystem = await fetchEcosystem(params.slug, authToken);
const ecosystem = await fetchEcosystem(
params.slug,
authToken,
params.team_slug,
);
if (!ecosystem) {
redirect(ecosystemLayoutPath);
@@ -30,6 +34,7 @@ export async function EcosystemLayoutSlug({
<EcosystemHeader
ecosystem={ecosystem}
ecosystemLayoutPath={ecosystemLayoutPath}
teamIdOrSlug={params.team_slug}
/>
<SidebarLayout
@@ -62,8 +62,11 @@ function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) {
function EcosystemSelect(props: {
ecosystem: Ecosystem;
ecosystemLayoutPath: string;
teamIdOrSlug: string;
}) {
const { data: ecosystems, isPending } = useEcosystemList();
const { data: ecosystems, isPending } = useEcosystemList({
teamIdOrSlug: props.teamIdOrSlug,
});
return isPending ? (
<Skeleton className="h-10 w-full md:w-[160px]" />
@@ -109,8 +112,10 @@ function EcosystemSelect(props: {
export function EcosystemHeader(props: {
ecosystem: Ecosystem;
ecosystemLayoutPath: string;
teamIdOrSlug: string;
}) {
const { data: fetchedEcosystem } = useEcosystem({
teamIdOrSlug: props.teamIdOrSlug,
slug: props.ecosystem.slug,
refetchInterval:
props.ecosystem.status === "requested"
@@ -192,6 +197,7 @@ export function EcosystemHeader(props: {
<EcosystemSelect
ecosystem={ecosystem}
ecosystemLayoutPath={props.ecosystemLayoutPath}
teamIdOrSlug={props.teamIdOrSlug}
/>
</div>
</div>
@@ -7,8 +7,11 @@ import { IntegrationPermissionsSection } from "../server/integration-permissions
export function EcosystemPermissionsPage({
params,
authToken,
}: { params: { slug: string }; authToken: string }) {
const { data: ecosystem } = useEcosystem({ slug: params.slug });
}: { params: { slug: string; team_slug: string }; authToken: string }) {
const { data: ecosystem } = useEcosystem({
slug: params.slug,
teamIdOrSlug: params.team_slug,
});
return (
<div className="flex flex-col gap-8">
@@ -26,7 +26,7 @@ export function UpdatePartnerModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Update {partner.name}</DialogTitle>
</DialogHeader>
@@ -3,21 +3,23 @@ import { useQuery } from "@tanstack/react-query";
import type { Ecosystem } from "../../../types";
export function useEcosystem({
teamIdOrSlug,
slug,
refetchInterval,
refetchOnWindowFocus,
initialData,
}: {
teamIdOrSlug: string;
slug: string;
refetchInterval?: number;
refetchOnWindowFocus?: boolean;
initialData?: Ecosystem;
}) {
return useQuery({
queryKey: ["ecosystems", slug],
queryKey: ["ecosystems", teamIdOrSlug, slug],
queryFn: async () => {
const res = await apiServerProxy({
pathname: `/v1/ecosystem-wallet/${slug}`,
pathname: `/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
method: "GET",
});
@@ -31,9 +31,13 @@ const formSchema = z.object({
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
logo: z.instanceof(File, {
message: "Logo is required",
}),
logo: z
.instanceof(File, {
message: "Logo is required",
})
.refine((file) => file.size <= 500 * 1024, {
message: "Logo size must be less than 500KB",
}),
permission: z.union([z.literal("PARTNER_WHITELIST"), z.literal("ANYONE")]),
});
@@ -118,8 +122,9 @@ export function CreateEcosystemForm(props: { teamSlug: string }) {
accept="image/png, image/jpeg"
onUpload={(files) => {
if (files[0]) {
form.setValue("logo", files[0]);
form.clearErrors("logo");
form.setValue("logo", files[0], {
shouldValidate: true,
});
}
}}
/>
@@ -3,13 +3,17 @@ import { useQuery } from "@tanstack/react-query";
import { useActiveAccount } from "thirdweb/react";
import type { Ecosystem } from "../types";
export function useEcosystemList() {
export function useEcosystemList({
teamIdOrSlug,
}: {
teamIdOrSlug: string;
}) {
const address = useActiveAccount()?.address;
return useQuery({
queryKey: ["ecosystems", address],
queryKey: ["ecosystems", teamIdOrSlug, address],
queryFn: async () => {
const res = await apiServerProxy({
pathname: "/v1/ecosystem-wallet/list",
pathname: `/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
method: "GET",
});
@@ -20,10 +20,12 @@ export default async function Page(props: {
loginRedirect(ecosystemLayoutPath);
}
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
console.error("failed to fetch ecosystems", err);
return [];
});
const ecosystems = await fetchEcosystemList(authToken, team_slug).catch(
(err) => {
console.error("failed to fetch ecosystems", err);
return [];
},
);
if (ecosystems[0]) {
redirect(`${ecosystemLayoutPath}/${ecosystems[0].slug}`);
}
@@ -1,12 +1,19 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../types";
export async function fetchEcosystem(slug: string, authToken: string) {
const res = await fetch(`${API_SERVER_URL}/v1/ecosystem-wallet/${slug}`, {
headers: {
Authorization: `Bearer ${authToken}`,
export async function fetchEcosystem(
slug: string,
authToken: string,
teamIdOrSlug: string,
) {
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
});
);
if (!res.ok) {
const data = await res.json();
console.error(data);
@@ -1,12 +1,18 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../types";
export async function fetchEcosystemList(authToken: string) {
const res = await fetch(`${API_SERVER_URL}/v1/ecosystem-wallet/list`, {
headers: {
Authorization: `Bearer ${authToken}`,
export async function fetchEcosystemList(
authToken: string,
teamIdOrSlug: string,
) {
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
},
});
);
if (!res.ok) {
const data = await res.json();
@@ -28,12 +28,15 @@ const formSchema = z.object({
type ImportEngineParams = z.infer<typeof formSchema>;
async function importEngine(data: ImportEngineParams) {
async function importEngine({
teamIdOrSlug,
...data
}: ImportEngineParams & { teamIdOrSlug: string }) {
// Instance URLs should end with a /.
const url = data.url.endsWith("/") ? data.url : `${data.url}/`;
const res = await apiServerProxy({
pathname: "/v1/engine",
pathname: `/v1/teams/${teamIdOrSlug}/engine`,
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -59,7 +62,7 @@ export function EngineImportCard(props: {
<EngineImportCardUI
prefillImportUrl={props.prefillImportUrl}
importEngine={async (params) => {
await importEngine(params);
await importEngine({ ...params, teamIdOrSlug: props.teamSlug });
router.push(`/team/${props.teamSlug}/~/engine`);
}}
/>
@@ -74,6 +74,7 @@ type RemovedEngineFromDashboard = (
) => Promise<void>;
export function EngineInstancesTable(props: {
teamIdOrSlug: string;
instances: EngineInstance[];
engineLinkPrefix: string;
}) {
@@ -81,6 +82,7 @@ export function EngineInstancesTable(props: {
return (
<EngineInstancesTableUI
teamIdOrSlug={props.teamIdOrSlug}
instances={props.instances}
engineLinkPrefix={props.engineLinkPrefix}
deleteCloudHostedEngine={async (params) => {
@@ -100,6 +102,7 @@ export function EngineInstancesTable(props: {
}
export function EngineInstancesTableUI(props: {
teamIdOrSlug: string;
instances: EngineInstance[];
engineLinkPrefix: string;
deleteCloudHostedEngine: DeletedCloudHostedEngine;
@@ -124,6 +127,7 @@ export function EngineInstancesTableUI(props: {
{props.instances.map((instance) => (
<EngineInstanceRow
key={instance.id}
teamIdOrSlug={props.teamIdOrSlug}
instance={instance}
engineLinkPrefix={props.engineLinkPrefix}
deleteCloudHostedEngine={props.deleteCloudHostedEngine}
@@ -145,6 +149,7 @@ export function EngineInstancesTableUI(props: {
}
function EngineInstanceRow(props: {
teamIdOrSlug: string;
instance: EngineInstance;
engineLinkPrefix: string;
deleteCloudHostedEngine: DeletedCloudHostedEngine;
@@ -187,6 +192,7 @@ function EngineInstanceRow(props: {
</TableRow>
<EditModal
teamIdOrSlug={props.teamIdOrSlug}
instance={instance}
open={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
@@ -194,6 +200,7 @@ function EngineInstanceRow(props: {
/>
<RemoveModal
teamIdOrSlug={props.teamIdOrSlug}
instance={instance}
onOpenChange={setIsRemoveModalOpen}
open={isRemoveModalOpen}
@@ -349,6 +356,7 @@ function EngineActionsDropdown(props: {
function EditModal(props: {
open: boolean;
teamIdOrSlug: string;
onOpenChange: (open: boolean) => void;
instance: EngineInstance;
editEngineInstance: EditedEngineInstance;
@@ -357,6 +365,7 @@ function EditModal(props: {
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="overflow-hidden p-0">
<EditModalContent
teamIdOrSlug={props.teamIdOrSlug}
instance={props.instance}
editEngineInstance={props.editEngineInstance}
closeModal={() => props.onOpenChange(false)}
@@ -372,6 +381,7 @@ const editEngineFormSchema = z.object({
});
function EditModalContent(props: {
teamIdOrSlug: string;
instance: EngineInstance;
editEngineInstance: EditedEngineInstance;
closeModal: () => void;
@@ -396,6 +406,7 @@ function EditModalContent(props: {
onSubmit={form.handleSubmit((data) =>
editInstance.mutate(
{
teamIdOrSlug: props.teamIdOrSlug,
instanceId: props.instance.id,
name: data.name,
url: data.url,
@@ -475,6 +486,7 @@ function EditModalContent(props: {
}
function RemoveModal(props: {
teamIdOrSlug: string;
instance: EngineInstance;
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -491,6 +503,7 @@ function RemoveModal(props: {
(instance.status === "active" && !instance.deploymentId) ? (
<RemoveEngineFromDashboardModalContent
instance={instance}
teamIdOrSlug={props.teamIdOrSlug}
close={() => onOpenChange(false)}
removeEngineFromDashboard={props.removeEngineFromDashboard}
/>
@@ -507,6 +520,7 @@ function RemoveModal(props: {
}
function RemoveEngineFromDashboardModalContent(props: {
teamIdOrSlug: string;
instance: EngineInstance;
close: () => void;
removeEngineFromDashboard: RemovedEngineFromDashboard;
@@ -552,6 +566,7 @@ function RemoveEngineFromDashboardModalContent(props: {
removeFromDashboard.mutate(
{
instanceId: instance.id,
teamIdOrSlug: props.teamIdOrSlug,
},
{
onSuccess: () => {
@@ -12,6 +12,7 @@ export const EngineInstancesList = (props: {
return (
<div className="flex grow flex-col">
<EngineInstancesTable
teamIdOrSlug={props.team_slug}
instances={props.instances}
engineLinkPrefix={engineLinkPrefix}
/>
@@ -29,7 +29,10 @@ export default async function Page(props: {
loginRedirect(`/team/${params.team_slug}/~/engine`);
}
const res = await getEngineInstances({ authToken });
const res = await getEngineInstances({
authToken,
teamIdOrSlug: params.team_slug,
});
return (
<EngineInstancesList
@@ -132,10 +132,7 @@ const ChangeVersionModal = (props: {
// Self-hosted modal: prompt to update manually.
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="max-w-[400px]">
<DialogHeader>
<DialogTitle className="mb-6 pr-4 font-semibold text-2xl tracking-tight">
Update your self-hosted Engine
@@ -182,10 +179,7 @@ const ChangeVersionModal = (props: {
// For cloud-hosted, prompt the user to select a version to update to.
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="max-w-[400px]">
<DialogHeader>
<DialogTitle>Update Engine version</DialogTitle>
</DialogHeader>
@@ -198,7 +192,7 @@ const ChangeVersionModal = (props: {
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{serverVersions.map(({ name, createdAt }, idx) => {
const isCurrentVersion = name === currentVersion;
@@ -91,10 +91,7 @@ export function EngineAlertDialogForm(props: {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
className="z-[10001] p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
@@ -120,7 +117,7 @@ export function EngineAlertDialogForm(props: {
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{alertRuleOptions.map((option) => (
<SelectItem
@@ -161,7 +158,7 @@ export function EngineAlertDialogForm(props: {
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select Notification Type" />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{Object.entries(
EngineNotificationChannelTypeConfig,
@@ -9,8 +9,12 @@ import { RecentEngineAlertsSection } from "./RecentEngineAlerts";
export function EngineAlertsPage(props: {
instance: EngineInstance;
teamIdOrSlug: string;
}) {
const alertRulesQuery = useEngineAlertRules(props.instance.id);
const alertRulesQuery = useEngineAlertRules(
props.instance.id,
props.teamIdOrSlug,
);
const alertRules = alertRulesQuery.data ?? [];
return (
@@ -19,12 +23,14 @@ export function EngineAlertsPage(props: {
alertRules={alertRules}
engineId={props.instance.id}
alertRulesIsLoading={alertRulesQuery.isLoading}
teamIdOrSlug={props.teamIdOrSlug}
/>
<div className="h-8" />
<RecentEngineAlertsSection
alertRules={alertRules}
engineId={props.instance.id}
alertRulesIsLoading={alertRulesQuery.isLoading}
teamIdOrSlug={props.teamIdOrSlug}
/>
</div>
);
@@ -43,19 +43,23 @@ type CreateAlertMutation = UseMutationResult<
>;
export function ManageEngineAlertsSection(props: {
teamIdOrSlug: string;
alertRules: EngineAlertRule[];
alertRulesIsLoading: boolean;
engineId: string;
}) {
const notificationChannelsQuery = useEngineNotificationChannels(
props.engineId,
props.teamIdOrSlug,
);
const deleteAlertMutation = useEngineDeleteNotificationChannel(
props.engineId,
props.teamIdOrSlug,
);
const createAlertMutation = useEngineCreateNotificationChannel(
props.engineId,
props.teamIdOrSlug,
);
// not passing the mutation to avoid multiple rows sharing the same mutation state, we create the new mutation for each row instead in each component instead
@@ -23,10 +23,16 @@ export function RecentEngineAlertsSection(props: {
alertRules: EngineAlertRule[];
alertRulesIsLoading: boolean;
engineId: string;
teamIdOrSlug: string;
}) {
// TODO - pagination
// required : return the total number of alerts in response from API
const alertsQuery = useEngineAlerts(props.engineId, 100, 0);
const alertsQuery = useEngineAlerts(
props.engineId,
props.teamIdOrSlug,
100,
0,
);
const alerts = alertsQuery.data ?? [];
return (
@@ -9,5 +9,7 @@ export default async function Page(props: EngineInstancePageProps) {
teamSlug: params.team_slug,
});
return <EngineAlertsPage instance={instance} />;
return (
<EngineAlertsPage instance={instance} teamIdOrSlug={params.team_slug} />
);
}
@@ -29,7 +29,7 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
instanceUrl={instance.url}
authToken={authToken}
/>
<EngineSystem instance={instance} />
<EngineSystem instance={instance} teamIdOrSlug={teamSlug} />
</div>
);
};
@@ -6,11 +6,15 @@ import {
interface EngineSystemProps {
instance: EngineInstance;
teamIdOrSlug: string;
}
export const EngineSystem: React.FC<EngineSystemProps> = ({ instance }) => {
export const EngineSystem: React.FC<EngineSystemProps> = ({
instance,
teamIdOrSlug,
}) => {
const healthQuery = useEngineSystemHealth(instance.url);
const metricsQuery = useEngineSystemMetrics(instance.id);
const metricsQuery = useEngineSystemMetrics(instance.id, teamIdOrSlug);
if (!healthQuery.data) {
return null;
}
@@ -21,9 +25,9 @@ export const EngineSystem: React.FC<EngineSystemProps> = ({ instance }) => {
<br />
Enabled: {healthQuery.data.features?.join(", ")}
<br />
CPU: {metricsQuery.data?.data?.cpu?.toFixed(2) ?? "..."}%
CPU: {metricsQuery.data?.result?.cpu?.toFixed(2) ?? "..."}%
<br />
Memory: {metricsQuery.data?.data?.memory?.toFixed(0) ?? "..."}MB
Memory: {metricsQuery.data?.result?.memory?.toFixed(0) ?? "..."}MB
</p>
);
};
@@ -7,7 +7,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { ChakraProvider } from "@chakra-ui/react";
import { ChakraProviderSetup } from "../../../../../../../../@/components/ChakraProviderSetup";
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../login/loginRedirect";
@@ -33,6 +33,7 @@ export default async function Layout(props: {
}
const instance = await getEngineInstance({
teamIdOrSlug: params.team_slug,
authToken,
engineId: params.engineId,
accountId: account.id,
@@ -54,7 +55,7 @@ export default async function Layout(props: {
}
return (
<ChakraProvider>
<ChakraProviderSetup>
<div className="flex grow flex-col">
<EngineInstanceHeader
instance={instance}
@@ -77,7 +78,7 @@ export default async function Layout(props: {
</EnsureEnginePermission>
</EngineSidebarLayout>
</div>
</ChakraProvider>
</ChakraProviderSetup>
);
}
@@ -92,7 +93,7 @@ function EngineInstanceHeader(props: {
: instance.url;
return (
<div className="border-b">
<div className="border-border border-b">
<div className="container py-6">
<Breadcrumb>
<BreadcrumbList>
@@ -23,7 +23,7 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
teamSlug,
authToken,
}) => {
const systemMetricsQuery = useEngineSystemMetrics(instance.id);
const systemMetricsQuery = useEngineSystemMetrics(instance.id, teamSlug);
const queueMetricsQuery = useEngineQueueMetrics({
authToken,
instanceUrl: instance.url,
@@ -69,8 +69,10 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
<div className="mt-10 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Healthcheck instance={instance} />
</div>
<StatusCodes datapoints={systemMetricsQuery.data.data.statusCodes} />
<ErrorRate datapoints={systemMetricsQuery.data.data.errorRate} />
<StatusCodes
datapoints={systemMetricsQuery.data.result.statusCodes}
/>
<ErrorRate datapoints={systemMetricsQuery.data.result.errorRate} />
</div>
</Card>
);
@@ -129,10 +129,7 @@ export const CreateBackendWalletButton: React.FC<
Create
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
@@ -162,7 +159,7 @@ export const CreateBackendWalletButton: React.FC<
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{walletTypeOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
@@ -147,10 +147,7 @@ export const ImportBackendWalletButton: React.FC<
</Button>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="z-[10001] p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
@@ -175,7 +172,7 @@ export const ImportBackendWalletButton: React.FC<
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{walletTypeOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
@@ -67,10 +67,7 @@ export const CredentialForm = ({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => {
@@ -114,7 +111,7 @@ export const CredentialForm = ({
<SelectTrigger className="bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[10001]">
<SelectContent>
<SelectGroup>
{CREDENTIAL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
@@ -2,6 +2,7 @@ import { API_SERVER_URL } from "@/constants/env";
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
export async function getEngineInstance(params: {
teamIdOrSlug: string;
authToken: string;
engineId: string;
accountId: string;
@@ -19,11 +20,14 @@ export async function getEngineInstance(params: {
return sandboxEngine;
}
const res = await fetch(`${API_SERVER_URL}/v1/engine/${params.engineId}`, {
headers: {
Authorization: `Bearer ${params.authToken}`,
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${params.teamIdOrSlug}/engine/${params.engineId}`,
{
headers: {
Authorization: `Bearer ${params.authToken}`,
},
},
});
);
if (!res.ok) {
const errorMessage = await res.text();
@@ -33,8 +37,8 @@ export async function getEngineInstance(params: {
}
const json = (await res.json()) as {
data: EngineInstance;
result: EngineInstance;
};
return json.data;
return json.result;
}
@@ -20,6 +20,7 @@ export async function engineInstancePageHandler(params: {
}
const instance = await getEngineInstance({
teamIdOrSlug: params.teamSlug,
authToken,
engineId: params.engineId,
accountId: account.id,
@@ -3,12 +3,16 @@ import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
export async function getEngineInstances(params: {
authToken: string;
teamIdOrSlug: string;
}) {
const res = await fetch(`${API_SERVER_URL}/v1/engine`, {
headers: {
Authorization: `Bearer ${params.authToken}`,
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${params.teamIdOrSlug}/engine`,
{
headers: {
Authorization: `Bearer ${params.authToken}`,
},
},
});
);
if (!res.ok) {
const errorMessage = await res.text();
@@ -20,12 +24,10 @@ export async function getEngineInstances(params: {
}
const json = (await res.json()) as {
data: {
instances: EngineInstance[];
};
result: EngineInstance[];
};
return {
data: json.data.instances,
data: json.result,
};
}
@@ -6,33 +6,30 @@ import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { PaginationButtons } from "@/components/pagination-buttons";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import {
ChevronDownIcon,
EllipsisVerticalIcon,
PlusIcon,
SearchIcon,
} from "lucide-react";
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { formatDate } from "date-fns";
import { PlusIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
@@ -43,6 +40,7 @@ export type ProjectWithAnalytics = Project & {
export function TeamProjectsPage(props: {
projects: ProjectWithAnalytics[];
team: Team;
client: ThirdwebClient;
}) {
const { projects } = props;
const [searchTerm, setSearchTerm] = useState("");
@@ -80,7 +78,7 @@ export function TeamProjectsPage(props: {
return _projectsToShow;
}, [searchTerm, sortBy, projects]);
const pageSize = 8;
const pageSize = 10;
const [page, setPage] = useState(1);
const paginatedProjects = sortedProjects.slice(
(page - 1) * pageSize,
@@ -92,78 +90,135 @@ export function TeamProjectsPage(props: {
return (
<div className="flex grow flex-col">
{/* Filters + Add New */}
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<SearchInput
value={searchTerm}
onValueChange={(v) => {
setSearchTerm(v);
setPage(1);
}}
/>
<div className="flex gap-4">
<SelectBy
value={sortBy}
onChange={(v) => {
setSortBy(v);
<div className="relative flex flex-col gap-5 rounded-t-lg pb-4 lg:flex-row lg:items-center lg:justify-between lg:border lg:border-b-0 lg:bg-card lg:px-6 lg:py-6">
<h2 className="font-semibold text-2xl tracking-tight">Projects</h2>
{/* Filters + Add New */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<SearchInput
value={searchTerm}
onValueChange={(v) => {
setSearchTerm(v);
setPage(1);
}}
/>
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
/>
<div className="flex gap-3">
<SelectBy
value={sortBy}
onChange={(v) => {
setSortBy(v);
setPage(1);
}}
/>
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
/>
</div>
</div>
</div>
<div className="h-6" />
{/* Projects */}
{/* Projects Table */}
{paginatedProjects.length === 0 ? (
<>
{searchTerm !== "" ? (
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
<div className="flex min-h-[450px] grow items-center justify-center border border-border bg-card">
<div className="flex flex-col items-center">
<p className="mb-5 text-center">No projects found</p>
</div>
</div>
) : (
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
<div className="flex min-h-[450px] grow items-center justify-center rounded-b-lg border border-border bg-card">
<div className="flex flex-col items-center">
<p className="mb-5 text-center">No projects created</p>
<p className="mb-5 text-center font-semibold text-lg">
No projects created
</p>
<Button
className="gap-2"
className="gap-2 bg-background"
onClick={() => setIsCreateProjectDialogOpen(true)}
variant="outline"
>
<PlusIcon className="size-4" />
Create a Project
Create Project
</Button>
</div>
</div>
)}
</>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{paginatedProjects.map((project) => {
return (
<ProjectCard
key={project.id}
project={project}
team_slug={props.team.slug}
/>
);
})}
</div>
)}
<div>
<TableContainer
className={cn(
"lg:rounded-t-none",
showPagination && "rounded-b-none",
)}
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[300px]">Project</TableHead>
<TableHead>Monthly Active Users</TableHead>
<TableHead className="w-[250px]">Client ID</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProjects.map((project) => (
<TableRow
key={project.id}
linkBox
className="hover:bg-accent/50"
>
<TableCell>
<Link
className="flex items-center gap-3 before:absolute before:inset-0"
href={`/team/${props.team.slug}/${project.slug}`}
>
<ProjectAvatar
className="size-8 rounded-full"
src={project.image || ""}
client={props.client}
/>
<span className="font-medium text-sm">
{project.name}
</span>
</Link>
</TableCell>
<TableCell>
{project.monthlyActiveUsers === 0 ? (
<span className="text-muted-foreground">No Users</span>
) : (
project.monthlyActiveUsers
)}
</TableCell>
<TableCell>
<CopyTextButton
textToCopy={project.publishableKey}
textToShow={`${project.publishableKey.slice(0, 4)}...${project.publishableKey.slice(-4)}`}
copyIconPosition="right"
tooltip={undefined}
variant="ghost"
className="-translate-x-2 z-10 font-mono text-muted-foreground"
/>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(new Date(project.createdAt), "MMM d, yyyy")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
{showPagination && (
<div className="rounded-b-lg border border-t-0 bg-card p-4">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
)}
@@ -184,64 +239,6 @@ export function TeamProjectsPage(props: {
);
}
function ProjectCard(props: {
project: ProjectWithAnalytics;
team_slug: string;
}) {
const { project, team_slug } = props;
return (
<div
key={project.id}
className="relative flex items-center gap-4 rounded-lg border border-border bg-card p-5 transition-colors hover:border-active-border"
>
{/* TODO - set image */}
<ProjectAvatar className="size-10 rounded-full" src="" />
<div className="flex flex-grow flex-col gap-1">
<Link
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
// remove /connect when we have overview page
href={`/team/${team_slug}/${project.slug}`}
>
<h2 className="font-medium text-base">{project.name}</h2>
</Link>
<p className="flex items-center gap-1 text-muted-foreground text-sm">
<span>{project.monthlyActiveUsers}</span>
Monthly Active Users
</p>
</div>
<Popover>
<PopoverTrigger asChild>
<Button className="z-10 h-auto w-auto p-2" variant="ghost">
<EllipsisVerticalIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-1">
<CopyTextButton
textToCopy={project.publishableKey}
textToShow="Copy Client ID"
copyIconPosition="right"
tooltip={undefined}
variant="ghost"
className="flex h-10 w-full justify-between gap-3 rounded-md px-4 py-2"
/>
<Button
variant="ghost"
className="w-full justify-start gap-3"
asChild
>
<Link href={`/team/${team_slug}/${project.slug}/settings`}>
Settings
</Link>
</Button>
</PopoverContent>
</Popover>
</div>
);
}
function SearchInput(props: {
value: string;
onValueChange: (value: string) => void;
@@ -252,7 +249,7 @@ function SearchInput(props: {
placeholder="Search Projects by name or Client ID"
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="bg-card pl-9"
className="bg-background pl-9 lg:w-[400px]"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
@@ -264,28 +261,16 @@ function AddNewButton(props: {
teamMembersSettingsPath: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="gap-2">
Add New
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[220px] p-2" sideOffset={12}>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="justify-between px-2"
onClick={props.createProject}
>
Project
</Button>
<Button variant="ghost" className="justify-between px-2" asChild>
<Link href={props.teamMembersSettingsPath}>Team Member</Link>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="default"
className="absolute top-0 right-0 gap-2 lg:static"
onClick={props.createProject}
>
<PlusIcon className="size-4" />
<span>
<span className="hidden lg:inline">Create</span> Project
</span>
</Button>
);
}
@@ -307,11 +292,9 @@ function SelectBy(props: {
props.onChange(v as SortById);
}}
>
<SelectTrigger className="min-w-[200px] bg-card capitalize">
<SelectTrigger className="min-w-[200px] bg-background capitalize">
<div className="flex items-center gap-1.5">
<span className="!hidden lg:!inline text-muted-foreground">
Sort by
</span>
<span className="text-muted-foreground">Sort by</span>
{valueToLabel[props.value]}
</div>
</SelectTrigger>
@@ -66,7 +66,7 @@ async function ChainDistributionCard({ data }: { data: TransactionStats[] }) {
async function ContractDistributionCard({
data,
}: { data: TransactionStats[] }) {
const reducedData = await Promise.all(
const _reducedData = await Promise.all(
Object.entries(
data
.filter((d) => d.contractAddress)
@@ -83,25 +83,36 @@ async function ContractDistributionCard({
.sort((a, b) => b[1] - a[1])
.slice(0, 10) // only top ten
.map(async ([key, value]) => {
const [chainId, contractAddress] = key.split(":");
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(chainId));
const chainMeta = await getChainMetadata(chain).catch(() => undefined);
const contractData = await fetchDashboardContractMetadata(
getContract({
chain,
address: contractAddress as string, // we filter above
client: getThirdwebClient(),
}),
).catch(() => undefined);
return {
label: `${contractData?.name} (${chainMeta?.slug || chainId})`,
link: `/${chainId}/${contractAddress}`,
value,
};
try {
const [chainId, contractAddress] = key.split(":");
if (Number(chainId) === 0) {
return undefined;
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(chainId));
const chainMeta = await getChainMetadata(chain).catch(
() => undefined,
);
const contractData = await fetchDashboardContractMetadata(
getContract({
chain,
address: contractAddress as string, // we filter above
client: getThirdwebClient(),
}),
).catch(() => undefined);
return {
label: `${contractData?.name} (${chainMeta?.slug || chainId})`,
link: `/${chainId}/${contractAddress}`,
value,
};
} catch {
return undefined;
}
}),
);
const reducedData = _reducedData.filter((d) => !!d);
const aggregateFn = () =>
new Set(
data
@@ -1,164 +0,0 @@
import { Button } from "@/components/ui/button";
import type { Range } from "components/analytics/date-range-selector";
import { Stat } from "components/analytics/stat";
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
import { AccountAbstractionSummary } from "components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { differenceInDays } from "date-fns";
import { ArrowRightIcon, CableIcon, WalletCardsIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import type {
InAppWalletStats,
UserOpStats,
WalletStats,
} from "types/analytics";
import { DateRangeSelector } from "../../../../../../components/analytics/date-range-selector";
import { IntervalSelector } from "../../../../../../components/analytics/interval-selector";
import { DailyConnectionsChartCard } from "./_components/DailyConnectionsChartCard";
import { WalletConnectorsChartCard } from "./_components/WalletConnectorsChartCard";
import { WalletDistributionChartCard } from "./_components/WalletDistributionChartCard";
export function ConnectAnalyticsDashboardUI(props: {
walletUsage: WalletStats[];
aggregateWalletUsage: WalletStats[];
aggregateUserOpUsageQuery?: UserOpStats;
connectLayoutSlug: string;
isPending: boolean;
inAppAggregateQuery?: {
allTimeStats: InAppWalletStats[];
monthlyStats: InAppWalletStats[];
};
setRange: (range: Range) => void;
range: Range;
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
const { totalWallets, uniqueWallets } = useMemo(() => {
return props.aggregateWalletUsage.reduce(
(acc, curr) => {
acc.totalWallets += curr.totalConnections;
acc.uniqueWallets += curr.uniqueWalletsConnected;
return acc;
},
{ uniqueWallets: 0, totalWallets: 0 },
);
}, [props.aggregateWalletUsage]);
return (
<div className="flex flex-col gap-4 lg:gap-6">
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Connections
</h3>
<p className="text-muted-foreground">
View how many users are connecting to your app.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat label="Connections" value={totalWallets} icon={CableIcon} />
<Stat
label="Unique Wallets"
value={uniqueWallets}
icon={WalletCardsIcon}
/>
</div>
</div>
<div className="h-4">
<div className="w-full border-separate border-b" />
</div>
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
In-App Wallets
</h3>
<p className="text-muted-foreground">
View metrics for your in-app wallets.
</p>
</div>
<div>
<Button asChild variant="primary" size="sm" className="w-full">
<Link href={`${props.connectLayoutSlug}/in-app-wallets`}>
In-App Wallet Metrics
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
<InAppWalletsSummary
allTimeStats={props.inAppAggregateQuery?.allTimeStats}
monthlyStats={props.inAppAggregateQuery?.monthlyStats}
/>
</div>
<div className="h-4">
<div className="w-full border-separate border-b" />
</div>
<div>
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Account Abstraction
</h3>
<p className="text-muted-foreground">
View how smart wallets are used in your app.
</p>
</div>
<div>
<Button asChild variant="primary" size="sm" className="w-full">
<Link href={`${props.connectLayoutSlug}/account-abstraction`}>
Account Abstraction Metrics
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
<AccountAbstractionSummary
aggregateUserOpUsageQuery={props.aggregateUserOpUsageQuery}
/>
</div>
<div className="h-4">
<div className="w-full border-separate border-b" />
</div>
<div className="flex justify-end gap-3">
<DateRangeSelector
range={props.range}
setRange={(newRange) => {
props.setRange(newRange);
const days = differenceInDays(newRange.to, newRange.from);
props.setIntervalType(days > 30 ? "week" : "day");
}}
/>
<IntervalSelector
intervalType={props.intervalType}
setIntervalType={props.setIntervalType}
/>
</div>
<DailyConnectionsChartCard
walletStats={props.walletUsage}
isPending={props.isPending}
/>
<WalletConnectorsChartCard
walletStats={props.walletUsage}
isPending={props.isPending}
/>
<WalletDistributionChartCard
walletStats={props.walletUsage}
isPending={props.isPending}
/>
</div>
);
}
@@ -1,46 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createUserOpStatsStub } from "components/smart-wallets/AccountAbstractionAnalytics/storyUtils";
import { getLastNDaysRange } from "../../../../../../../components/analytics/date-range-selector";
import { mobileViewport } from "../../../../../../../stories/utils";
import { ConnectAnalyticsDashboardUI } from "../ConnectAnalyticsDashboardUI";
import { createWalletStatsStub } from "./storyUtils";
const meta = {
title: "Charts/Connect/Analytics Dashboard",
component: Component,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Component() {
return (
<div className="container max-w-[1150px] py-8">
<ConnectAnalyticsDashboardUI
setRange={() => {}}
range={getLastNDaysRange("last-120")}
intervalType="day"
setIntervalType={() => {}}
walletUsage={createWalletStatsStub(30)}
aggregateWalletUsage={createWalletStatsStub(30)}
aggregateUserOpUsageQuery={createUserOpStatsStub(1)?.[0]}
connectLayoutSlug="connectLayoutSlug"
isPending={false}
/>
</div>
);
}
@@ -1,70 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
BadgeContainer,
mobileViewport,
} from "../../../../../../../stories/utils";
import { DailyConnectionsChartCard } from "./DailyConnectionsChartCard";
import { createWalletStatsStub } from "./storyUtils";
const meta = {
title: "Charts/Connect/Daily Connections",
component: Component,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Component() {
return (
<div className="container flex max-w-[1150px] flex-col gap-6 py-8">
<BadgeContainer label="30 days">
<DailyConnectionsChartCard
walletStats={createWalletStatsStub(30)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="60 days">
<DailyConnectionsChartCard
walletStats={createWalletStatsStub(60)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<DailyConnectionsChartCard
walletStats={createWalletStatsStub(10)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="0 days">
<DailyConnectionsChartCard
walletStats={createWalletStatsStub(0)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="Loading">
<DailyConnectionsChartCard
walletStats={createWalletStatsStub(0)}
isPending={true}
/>
</BadgeContainer>
</div>
);
}
@@ -1,243 +0,0 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
LabelList,
XAxis,
YAxis,
} from "recharts";
import type { WalletStats } from "types/analytics";
import { formatTickerNumber } from "../../../../../../../lib/format-utils";
type ChartToShow = "uniqueWallets" | "totalWallets";
type ChartData = {
time: string; // human readable date
totalWallets: number;
uniqueWallets: number;
};
const chartConfig = {
uniqueWallets: {
label: "Unique Wallets",
color: "hsl(var(--chart-1))",
},
totalWallets: {
label: "Total Wallets",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
const chartLabelToShow: Record<ChartToShow, string> = {
uniqueWallets: "Unique Wallets",
totalWallets: "Total Wallets",
};
export function DailyConnectionsChartCard(props: {
walletStats: WalletStats[];
isPending: boolean;
}) {
const { walletStats } = props;
const [chartToShow, setChartToShow] = useState<ChartToShow>("uniqueWallets");
const chartToShowOptions: ChartToShow[] = ["uniqueWallets", "totalWallets"];
const barChartData: ChartData[] = useMemo(() => {
const chartDataMap: Map<string, ChartData> = new Map();
for (const data of walletStats) {
const chartData = chartDataMap.get(data.date);
if (!chartData) {
chartDataMap.set(data.date, {
time: format(new Date(data.date), "MMM dd"),
totalWallets: data.totalConnections,
uniqueWallets: data.uniqueWalletsConnected,
});
} else {
chartData.totalWallets += data.totalConnections;
chartData.uniqueWallets += data.uniqueWalletsConnected;
}
}
return Array.from(chartDataMap.values());
}, [walletStats]);
const disableActions = props.isPending || barChartData.length === 0;
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Daily Connections
</h3>
<p className="mb-3 text-muted-foreground text-sm">
Total and unique wallets addresses that connected to your app each day.
</p>
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
{/* Selector */}
<Select
onValueChange={(v) => {
setChartToShow(v as ChartToShow);
}}
value={chartToShow}
disabled={disableActions}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{chartToShowOptions.map((option) => (
<SelectItem key={option} value={option}>
{chartLabelToShow[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<ExportToCSVButton
disabled={disableActions}
className="bg-background"
getData={async () => {
const header = ["Date", "Total Wallets", "Unique Wallets"];
const rows = barChartData.map((row) => [
row.time,
row.totalWallets.toString(),
row.uniqueWallets.toString(),
]);
return { header, rows };
}}
fileName="DailyConnections"
/>
</div>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
{props.isPending ? (
<LoadingChartState />
) : barChartData.length === 0 ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Send your first connect event
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/getting-started"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react-native/v5"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/dotnet/getting-started"
label="Unity"
icon={UnityIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unreal-engine/getting-started"
label="Unreal Engine"
icon={UnrealIcon}
/>
<DocLink
link="https://portal.thirdweb.com/dotnet/getting-started"
label=".NET"
icon={DotNetIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={barChartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) => data[chartToShow]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<Bar
dataKey={chartToShow}
fill={`var(--color-${chartToShow})`}
radius={8}
strokeWidth={1}
className="stroke-background"
>
{barChartData.length < 50 && (
<LabelList
position="top"
offset={12}
className="invisible fill-foreground sm:visible"
fontSize={12}
formatter={(value: number) => formatTickerNumber(value)}
/>
)}
</Bar>
</BarChart>
)}
</ChartContainer>
</div>
);
}
@@ -1,70 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
BadgeContainer,
mobileViewport,
} from "../../../../../../../stories/utils";
import { WalletConnectorsChartCard } from "./WalletConnectorsChartCard";
import { createWalletStatsStub } from "./storyUtils";
const meta = {
title: "Charts/Connect/Wallet Connectors",
component: Component,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Component() {
return (
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<BadgeContainer label="30 days">
<WalletConnectorsChartCard
walletStats={createWalletStatsStub(30)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="60 days">
<WalletConnectorsChartCard
walletStats={createWalletStatsStub(60)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<WalletConnectorsChartCard
walletStats={createWalletStatsStub(10)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="0 days">
<WalletConnectorsChartCard
walletStats={createWalletStatsStub(0)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="Loading">
<WalletConnectorsChartCard
walletStats={createWalletStatsStub(0)}
isPending={true}
/>
</BadgeContainer>
</div>
);
}
@@ -1,260 +0,0 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { WalletStats } from "types/analytics";
import {
formatTickerNumber,
formatWalletType,
} from "../../../../../../../lib/format-utils";
type ChartToShow = "uniqueWalletsConnected" | "totalConnections";
type ChartData = Record<string, number> & {
time: string; // human readable date
};
const chartLabelToShow: Record<ChartToShow, string> = {
uniqueWalletsConnected: "Unique Wallets",
totalConnections: "Total Wallets",
};
export function WalletConnectorsChartCard(props: {
walletStats: WalletStats[];
isPending: boolean;
}) {
// show top 10 wallets as distinct, and combine the rest as "Others"
const topWalletsToShow = 10;
const { walletStats } = props;
const [chartToShow, setChartToShow] = useState<ChartToShow>(
"uniqueWalletsConnected",
);
const chartToShowOptions: ChartToShow[] = [
"uniqueWalletsConnected",
"totalConnections",
];
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const walletTypeToValueMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of walletStats) {
const chartData = _chartDataMap.get(stat.date);
const { walletType: rawWalletType } = stat;
const walletType = formatWalletType(rawWalletType);
// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
[walletType]: stat[chartToShow],
} as ChartData);
} else {
chartData[walletType] =
(chartData[walletType] || 0) + stat[chartToShow];
}
walletTypeToValueMap.set(
walletType,
stat[chartToShow] + (walletTypeToValueMap.get(walletType) || 0),
);
}
const walletTypesSorted = Array.from(walletTypeToValueMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const walletTypesToShow = walletTypesSorted.slice(0, topWalletsToShow);
const walletTypesToTagAsOthers = walletTypesSorted.slice(topWalletsToShow);
// replace walletTypesToTagAsOthers walletType with "other"
for (const data of _chartDataMap.values()) {
for (const walletType in data) {
if (walletTypesToTagAsOthers.includes(walletType)) {
data.others = (data.others || 0) + (data[walletType] || 0);
delete data[walletType];
}
}
}
walletTypesToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
label: walletTypesToShow[i],
color: `hsl(var(--chart-${(i % 10) + 1}))`,
};
});
// Add Other
walletTypesToShow.push("others");
_chartConfig.others = {
label: "Others",
color: "hsl(var(--muted-foreground))",
};
return {
chartData: Array.from(_chartDataMap.values()),
chartConfig: _chartConfig,
};
}, [walletStats, chartToShow]);
const uniqueWalletTypes = Object.keys(chartConfig);
const disableActions = props.isPending || chartData.length === 0;
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Connected Wallets
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The different types of wallets used to connect to your app each day.
</p>
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<Select
onValueChange={(v) => {
setChartToShow(v as ChartToShow);
}}
value={chartToShow}
disabled={disableActions}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{chartToShowOptions.map((option) => (
<SelectItem key={option} value={option}>
{chartLabelToShow[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<ExportToCSVButton
className="bg-background"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// Shows the number of each type of wallet connected on all dates
const header = ["Date", ...uniqueWalletTypes];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueWalletTypes.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
{/* Chart */}
<ChartContainer config={chartConfig} className="h-[400px] w-full">
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Connect any wallet to your app
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React Native"
icon={ReactIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<YAxis
dataKey={(data) =>
Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0)
}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueWalletTypes.map((walletType) => {
return (
<Bar
key={walletType}
dataKey={walletType}
fill={chartConfig[walletType]?.color}
radius={4}
stackId="a"
strokeWidth={1}
className="stroke-background"
/>
);
})}
</BarChart>
)}
</ChartContainer>
</div>
);
}
@@ -1,70 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
BadgeContainer,
mobileViewport,
} from "../../../../../../../stories/utils";
import { WalletDistributionChartCard } from "./WalletDistributionChartCard";
import { createWalletStatsStub } from "./storyUtils";
const meta = {
title: "Charts/Connect/Wallet Distribution",
component: Component,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Component() {
return (
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
<BadgeContainer label="30 days">
<WalletDistributionChartCard
walletStats={createWalletStatsStub(30)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="60 days">
<WalletDistributionChartCard
walletStats={createWalletStatsStub(60)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="10 days">
<WalletDistributionChartCard
walletStats={createWalletStatsStub(10)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="0 days">
<WalletDistributionChartCard
walletStats={createWalletStatsStub(0)}
isPending={false}
/>
</BadgeContainer>
<BadgeContainer label="Loading">
<WalletDistributionChartCard
walletStats={createWalletStatsStub(0)}
isPending={true}
/>
</BadgeContainer>
</div>
);
}
@@ -1,263 +0,0 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
import { DocLink } from "components/shared/DocLink";
import { useMemo, useState } from "react";
import { Pie, PieChart } from "recharts";
import type { WalletStats } from "types/analytics";
import {
formatTickerNumber,
formatWalletType,
} from "../../../../../../../lib/format-utils";
type ChartToShow = "totalConnections" | "uniqueWalletsConnected";
type ChartData = {
walletType: string;
value: number;
fill: string;
};
const chartLabelToShow: Record<ChartToShow, string> = {
uniqueWalletsConnected: "Unique Wallets",
totalConnections: "Total Wallets",
};
export function WalletDistributionChartCard(props: {
walletStats: WalletStats[];
isPending: boolean;
}) {
// show these many top wallets as distinct, and combine the rest as "Others"
const topWalletsToShow = 10;
const { walletStats } = props;
const [chartToShow, setChartToShow] =
useState<ChartToShow>("totalConnections");
const chartToShowOptions: ChartToShow[] = [
"totalConnections",
"uniqueWalletsConnected",
];
const { chartConfig, chartData, totalConnections, uniqueConnections } =
useMemo(() => {
const _chartDataMap: Map<string, number> = new Map();
const walletTypeToValueMap: Map<string, number> = new Map();
let _totalConnections = 0;
let _uniqueConnections = 0;
for (const stat of walletStats) {
const { walletType: rawWalletType } = stat;
const walletType = formatWalletType(rawWalletType);
const chartData = _chartDataMap.get(walletType);
_totalConnections += stat.totalConnections;
_uniqueConnections += stat.uniqueWalletsConnected;
_chartDataMap.set(walletType, (chartData || 0) + stat[chartToShow]);
walletTypeToValueMap.set(
walletType,
(walletTypeToValueMap.get(walletType) || 0) + stat[chartToShow],
);
}
const walletTypesSortedByValue = Array.from(
walletTypeToValueMap.entries(),
)
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const walletTypesToShow = walletTypesSortedByValue.slice(
0,
topWalletsToShow,
);
const walletTypesToTagAsOthers =
walletTypesSortedByValue.slice(topWalletsToShow);
for (const walletType of walletTypesToTagAsOthers) {
const val = _chartDataMap.get(walletType);
if (val) {
const othersVal = _chartDataMap.get("others");
_chartDataMap.set("others", othersVal ? othersVal + val : val);
}
_chartDataMap.delete(walletType);
}
const _chartConfig: ChartConfig = {};
walletTypesToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
label: walletTypesToShow[i],
color: `hsl(var(--chart-${(i % 10) + 1}))`,
};
});
// Add Others
_chartConfig.others = {
label: "Others",
color: "hsl(var(--muted-foreground))",
};
const _chartData: ChartData[] = Array.from(_chartDataMap).map(
([walletType, data]) => {
return {
walletType,
value: data,
fill: _chartConfig[walletType]?.color || "transparent",
};
},
);
// sort the data
_chartData.sort((a, b) => b.value - a.value);
return {
chartData: _chartData,
chartConfig: _chartConfig,
totalConnections: _totalConnections,
uniqueConnections: _uniqueConnections,
};
}, [walletStats, chartToShow]);
const disableActions = props.isPending || chartData.length === 0;
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Wallet Distribution
</h3>
<p className="mb-3 text-muted-foreground text-sm">
Distribution of wallet types used to connect to your app.
</p>
{/* Selector */}
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<Select
onValueChange={(v) => {
setChartToShow(v as ChartToShow);
}}
value={chartToShow}
disabled={props.isPending || chartData.length === 0}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{chartToShowOptions.map((option) => (
<SelectItem key={option} value={option}>
{chartLabelToShow[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<ExportToCSVButton
fileName="Wallet Distribution"
className="bg-background"
disabled={disableActions}
getData={async () => {
const header = [
"Wallet",
`${chartToShow === "totalConnections" ? "Total" : "Unique"} Connections`,
`Percentage of ${chartToShow === "totalConnections" ? "Total" : "Unique"} Connections`,
];
const rows = chartData.map((d) => {
return [
d.walletType,
d.value.toString(),
`${((d.value / (chartToShow === "totalConnections" ? totalConnections : uniqueConnections)) * 100).toFixed(2)}%`,
];
});
return {
header,
rows,
};
}}
/>
</div>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="mt-6 h-[500px] w-full [&_.recharts-pie-label-text]:fill-foreground"
>
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Connect any wallet to your app
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
label="React Native"
icon={ReactIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<PieChart>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
valueFormatter={(v) => {
if (typeof v === "number") {
const sumValue =
chartToShow === "uniqueWalletsConnected"
? uniqueConnections
: totalConnections;
const percentageValue = ((v / sumValue) * 100).toFixed(2);
return `${percentageValue}% - ${formatTickerNumber(v)}`;
}
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} className="" />
<Pie
data={chartData}
dataKey="value"
nameKey="walletType"
innerRadius={60}
strokeWidth={2}
stroke="hsl(var(--muted))"
/>
</PieChart>
)}
</ChartContainer>
</div>
);
}
@@ -1,52 +0,0 @@
import type { WalletId } from "thirdweb/wallets";
import type { WalletStats } from "types/analytics";
const walletsToPickFrom: WalletId[] = [
"io.metamask",
"com.binance",
"com.coinbase.wallet",
"io.zerion.wallet",
"me.rainbow",
"com.trustwallet.app",
"com.zengo",
"xyz.argent",
"com.exodus",
"app.phantom",
"com.okex.wallet",
"io.clingon",
"com.broearn",
"com.coinomi",
"com.ripio",
"com.sabay.wallet",
"io.tokoin",
"world.fncy",
"io.copiosa",
];
const pickRandomWallet = () => {
return (
walletsToPickFrom[Math.floor(Math.random() * walletsToPickFrom.length)] ||
"io.metamask"
);
};
export function createWalletStatsStub(days: number): WalletStats[] {
const stubbedData: WalletStats[] = [];
let d = days;
while (d !== 0) {
const uniqueWallets = Math.floor(Math.random() * 100);
stubbedData.push({
date: new Date(2024, 1, d).toLocaleString(),
totalConnections: uniqueWallets + Math.floor(Math.random() * 100),
uniqueWalletsConnected: uniqueWallets,
walletType: pickRandomWallet(),
});
if (Math.random() > 0.7) {
d--;
}
}
return stubbedData;
}
@@ -238,7 +238,7 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
@@ -1,3 +1,6 @@
import { Button } from "@/components/ui/button";
import { FileCode2Icon, MessageSquareQuoteIcon } from "lucide-react";
import Link from "next/link";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
@@ -20,17 +23,32 @@ export function NebulaAnalyticsPage(props: {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<header className="border-b">
<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-2xl tracking-tight md:text-3xl">
Nebula
</h1>
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
<div className="flex gap-3">
<Button variant="outline" className="gap-2 bg-card" asChild>
<Link href="https://nebula.thirdweb.com" target="_blank">
<MessageSquareQuoteIcon className="size-4 text-muted-foreground" />
Playground
</Link>
</Button>
<Button variant="outline" className="gap-2 bg-card" asChild>
<Link href="https://portal.thirdweb.com/nebula" target="_blank">
<FileCode2Icon className="size-4 text-muted-foreground" />
API Reference
</Link>
</Button>
</div>
<NebulaAnalyticsFilter />
</div>
</header>
<div className="container pt-8 pb-20">
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
<NebulaAnalyticsFilter />
</div>
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={<NebulaAnalyticsDashboardUI data={[]} isPending={true} />}
@@ -36,6 +36,10 @@ function Story(props: {
return (
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
<ProjectGeneralSettingsPageUI
updateProjectImage={async (file) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("updateProjectImage", file);
}}
isOwnerAccount={props.isOwnerAccount}
transferProject={async (newTeam) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -72,9 +76,8 @@ function Story(props: {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
data: {
secret: new Array(86).fill("x").join(""),
secretHash: new Array(64).fill("x").join(""),
secretMasked: "123...4567",
secret: `sk_${new Array(86).fill("x").join("")}`,
secretMasked: "sk_123...4567",
},
};
}}
@@ -1,4 +1,5 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
@@ -31,6 +32,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { cn } from "@/lib/utils";
import type { RotateSecretKeyAPIReturnType } from "@3rdweb-sdk/react/hooks/useApi";
import {
@@ -46,6 +48,7 @@ import {
type ServiceName,
getServiceByName,
} from "@thirdweb-dev/service-utils";
import { FileInput } from "components/shared/FileInput";
import { format } from "date-fns";
import { useTrack } from "hooks/analytics/useTrack";
import {
@@ -60,11 +63,11 @@ import { type UseFormReturn, useForm } from "react-hook-form";
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { RE_BUNDLE_ID } from "utils/regex";
import { joinWithComma, toArrFromList } from "utils/string";
import { validStrList } from "utils/validations";
import { z } from "zod";
import { apiServerProxy } from "../../../../../@/actions/proxies";
import {
HIDDEN_SERVICES,
projectDomainsSchema,
@@ -119,6 +122,29 @@ export function ProjectGeneralSettingsPage(props: {
client={props.client}
teamSlug={props.teamSlug}
project={props.project}
updateProjectImage={async (file) => {
let uri: string | undefined = undefined;
if (file) {
// upload to IPFS
uri = await upload({
client: props.client,
files: [file],
});
}
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
image: uri,
},
);
router.refresh();
}}
updateProject={async (projectValues) => {
return updateProjectClient(
{
@@ -184,6 +210,7 @@ export function ProjectGeneralSettingsPageUI(props: {
client: ThirdwebClient;
transferProject: (newTeam: Team) => Promise<void>;
isOwnerAccount: boolean;
updateProjectImage: (file: File | undefined) => Promise<void>;
}) {
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
@@ -320,6 +347,12 @@ export function ProjectGeneralSettingsPageUI(props: {
handleSubmit={handleSubmit}
/>
<ProjectImageSetting
updateProjectImage={props.updateProjectImage}
avatar={project.image || null}
client={props.client}
/>
<ProjectKeyDetails
project={project}
rotateSecretKey={props.rotateSecretKey}
@@ -402,6 +435,66 @@ function ProjectNameSetting(props: {
);
}
function ProjectImageSetting(props: {
updateProjectImage: (file: File | undefined) => Promise<void>;
avatar: string | null;
client: ThirdwebClient;
}) {
const projectAvatarUrl = resolveSchemeWithErrorHandler({
client: props.client,
uri: props.avatar || undefined,
});
const [projectAvatar, setProjectAvatar] = useState<File | undefined>();
const updateProjectAvatarMutation = useMutation({
mutationFn: async (_avatar: File | undefined) => {
await props.updateProjectImage(_avatar);
},
});
function handleSave() {
const promise = updateProjectAvatarMutation.mutateAsync(projectAvatar);
toast.promise(promise, {
success: "Project avatar updated successfully",
error: "Failed to update project avatar",
});
}
return (
<SettingsCard
bottomText="An avatar is optional but strongly recommended."
saveButton={{
onClick: handleSave,
disabled: false,
isPending: updateProjectAvatarMutation.isPending,
}}
noPermissionText={undefined}
errorText={undefined}
>
<div className="flex flex-row gap-4 md:justify-between">
<div>
<h3 className="font-semibold text-xl tracking-tight">
Project Avatar
</h3>
<p className="mt-1.5 mb-4 text-foreground text-sm leading-relaxed">
This is your project's avatar. <br /> Click on the avatar to upload
a custom one
</p>
</div>
<FileInput
accept={{ "image/*": [] }}
value={projectAvatar}
setValue={setProjectAvatar}
className="w-20 rounded-full lg:w-28"
disableHelperText
fileUrl={projectAvatarUrl}
/>
</div>
</SettingsCard>
);
}
function AllowedDomainsSetting(props: {
form: UpdateAPIForm;
isUpdatingProject: boolean;
@@ -0,0 +1,183 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
NotificationButtonInner,
type NotificationMetadata,
} from "./NotificationButton";
const meta = {
title: "headers/components/NotificationButton",
component: NotificationButtonInner,
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<div className="flex justify-end px-6 py-4">
<Story />
</div>
),
],
} satisfies Meta<typeof NotificationButtonInner>;
export default meta;
type Story = StoryObj<typeof meta>;
const titlesToPickFrom = [
"Insight API Supports Block Queries",
"Insight Adds Search Capability",
"RPC Edge: Faster, Leaner, and Smarter",
".NET/Unity - Insight Indexer and Engine Wallet Integration",
"Invite and Manage Team Members on Dashboard",
"Insight API ENS Support",
"Insight Token Queries Just Got 10X Faster",
"Insight indexer major version upgrade",
"Introducing Universal Bridge",
"Unreal Engine Plugin v2",
"Cross-chain deterministic contract deployments",
"Organize Contracts into Projects on Dashboard",
".NET/Unity - Auth token login, timeout improvements, rotating secret keys.",
"Mint fees for contract deployments update",
"Engine v2.1.32: Circle Wallet and Secure Credential Management",
"Nebula Update v0.0.7: Support for swapping and bridging, Offchain",
"Insight - Automatic Event & Transaction Resolution",
"Nebula Update v0.0.6: Upgraded Model, Speed & Accuracy Improvements",
"Enhancing RPC Edge Support with Support IDs",
"Blocks data retrieval with Insight [v0.2.2-beta]",
"Insight reorg handling improvement",
"Resilient Infrastructure & Smarter Insights",
"Nebula Update v0.0.5: Advanced configurations, new endpoints, and simplified context filters",
"Universal Smart Wallet Addresses",
".NET/Unity - Nebula Integration for native apps and games!",
];
function generateRandomNotification(isRead: boolean): NotificationMetadata {
const index = Math.floor(Math.random() * titlesToPickFrom.length);
const safeIndex = Math.min(index, titlesToPickFrom.length - 1);
const title = titlesToPickFrom[safeIndex] || "";
const randomTimeAgo = Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // Random time within last 7 days
return {
title,
href: "https://blog.thirdweb.com/changelog",
isRead,
createdAt: new Date(Date.now() - randomTimeAgo).toISOString(),
id: crypto.randomUUID(),
};
}
function randomNotifications(count: number): NotificationMetadata[] {
return Array.from({ length: count }, () => generateRandomNotification(false));
}
export const AllUnread: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const Loading: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: true,
},
changelogs: {
notifications: [],
isPending: true,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: [],
isPending: false,
},
changelogs: {
notifications: [],
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const AllRead: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const MixedNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(10).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
export const NoUnreadInboxNotifications: Story = {
args: {
notificationTabs: {
inbox: {
notifications: randomNotifications(5).map((x) => ({
...x,
isRead: true,
})),
isPending: false,
},
changelogs: {
notifications: randomNotifications(30).map((x) => ({
...x,
isRead: Math.random() > 0.5,
})),
isPending: false,
},
},
markNotificationAsRead: () => Promise.resolve(),
},
};
@@ -0,0 +1,307 @@
"use client";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TabButtons } from "@/components/ui/tabs";
import { useQuery } from "@tanstack/react-query";
import { formatDistance } from "date-fns";
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { cleanupReadNotifications } from "./fetch-notifications";
export type NotificationMetadata = {
title: string;
id: string;
href: string;
isRead: boolean;
createdAt: string;
};
type NotificationTabs = {
changelogs: {
notifications: NotificationMetadata[];
isPending: boolean;
};
inbox: {
notifications: NotificationMetadata[];
isPending: boolean;
};
};
export function NotificationButtonUI(props: {
getChangelogs: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const inboxNotificationsQuery = useQuery({
queryKey: ["inbox-notifications"],
queryFn: props.getInboxNotifications,
});
const changelogsQuery = useQuery({
queryKey: ["changelogs"],
queryFn: props.getChangelogs,
});
const [readNotifications, setReadNotifications] = useState<Set<string>>(
new Set(),
);
const changelogNotifications = useMemo(() => {
return (changelogsQuery.data || []).map((notification) => ({
...notification,
isRead: notification.isRead || readNotifications.has(notification.id),
}));
}, [changelogsQuery.data, readNotifications]);
const inboxNotifications = useMemo(() => {
return (inboxNotificationsQuery.data || []).map((notification) => ({
...notification,
isRead: notification.isRead || readNotifications.has(notification.id),
}));
}, [inboxNotificationsQuery.data, readNotifications]);
// Ensure we don't keep adding things to the IndexedDB storage by removing notification ids that are no longer being displayed
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (changelogNotifications.length > 0 && inboxNotifications.length > 0) {
cleanupReadNotifications({
changelogs: changelogNotifications,
updates: inboxNotifications,
});
}
}, [changelogNotifications, inboxNotifications]);
const markAsRead = useCallback(
async (id: string) => {
setReadNotifications((prev) => new Set([...prev, id]));
await props.markNotificationAsRead(id);
},
[props.markNotificationAsRead],
);
const notificationTabs: NotificationTabs = {
inbox: {
notifications: inboxNotifications,
isPending: inboxNotificationsQuery.isPending,
},
changelogs: {
notifications: changelogNotifications,
isPending: changelogsQuery.isPending,
},
};
return (
<NotificationButtonInner
notificationTabs={notificationTabs}
markNotificationAsRead={markAsRead}
/>
);
}
export function NotificationButtonInner(props: {
notificationTabs: NotificationTabs;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const hasUnreadInboxNotifications =
props.notificationTabs.inbox.notifications.some((x) => !x.isRead);
const [tab, setTab] = useState<keyof NotificationTabs>("inbox");
const unreadNotifications = {
inbox: props.notificationTabs.inbox.notifications.filter((x) => !x.isRead),
changelogs: props.notificationTabs.changelogs.notifications.filter(
(x) => !x.isRead,
),
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
className="relative size-10 rounded-full bg-background p-0"
variant="outline"
>
<BellIcon className="size-4 text-muted-foreground" />
{hasUnreadInboxNotifications && (
<div className="absolute top-0 right-0 flex size-2.5 items-center justify-center rounded-full bg-red-500" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="!z-50 ml-4 w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-border p-0 shadow-xl lg:ml-0 lg:w-[500px]"
align="end"
sideOffset={10}
side="bottom"
>
<TabButtons
tabClassName="!text-sm"
tabContainerClassName="px-2 pt-1.5"
tabs={[
{
isActive: tab === "inbox",
name: (
<BadgeTab
name="Inbox"
unreadCount={unreadNotifications.inbox.length}
/>
),
onClick: () => setTab("inbox"),
isEnabled: true,
},
{
isActive: tab === "changelogs",
name: (
<BadgeTab
name="Changelog"
unreadCount={unreadNotifications.changelogs.length}
/>
),
onClick: () => setTab("changelogs"),
isEnabled: true,
},
]}
/>
{tab === "inbox" && (
<NotificationContent
notifications={props.notificationTabs.inbox.notifications}
isPending={props.notificationTabs.inbox.isPending}
icon={InboxIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
{tab === "changelogs" && (
<NotificationContent
notifications={props.notificationTabs.changelogs.notifications}
isPending={props.notificationTabs.changelogs.isPending}
icon={ActivityIcon}
markNotificationAsRead={props.markNotificationAsRead}
/>
)}
</PopoverContent>
</Popover>
);
}
function BadgeTab(props: {
name: string;
unreadCount: number;
}) {
return (
<span className="flex items-center gap-2">
{props.name}
{props.unreadCount > 0 && (
<span className="rounded-lg bg-accent px-1.5 py-1 text-xs">
{props.unreadCount}
</span>
)}
</span>
);
}
function NotificationContent(props: {
notifications: NotificationMetadata[];
isPending: boolean;
icon: React.FC<{ className?: string }>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
return (
<div>
<ScrollShadow
scrollableClassName="h-[70vh] overscroll-contain"
shadowClassName="z-20"
shadowColor="hsl(var(--background))"
>
{props.notifications?.map((notification) => (
<NotificationLink
notification={notification}
key={notification.id}
icon={props.icon}
markNotificationAsRead={props.markNotificationAsRead}
/>
))}
{props.isPending && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-10" />
</div>
)}
{!props.isPending && props.notifications.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div>
<div className="mb-3 flex justify-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
<props.icon className="size-4" />
</div>
</div>
<p className="text-center text-muted-foreground text-sm">
No Notifications
</p>
</div>
</div>
)}
</ScrollShadow>
</div>
);
}
function NotificationLink(props: {
notification: NotificationMetadata;
icon: React.FC<{ className?: string }>;
markNotificationAsRead: (id: string) => Promise<void>;
}) {
const { notification } = props;
const link = new URL(notification.href);
link.searchParams.append("utm_source", "notification");
const handleClick = useCallback(async () => {
if (!notification.isRead) {
await props.markNotificationAsRead(notification.id);
}
}, [notification.id, notification.isRead, props.markNotificationAsRead]);
return (
<Link
href={link.href}
target="_blank"
className="relative flex items-start gap-4 border-b px-4 py-4 hover:bg-accent/50"
onClick={handleClick}
>
{/* Icon */}
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
<props.icon className="size-4" />
</div>
{/* Content */}
<div className="grow">
<h3 className="font-medium text-foreground text-sm">
{notification.title}
</h3>
<p className="mt-1 text-muted-foreground text-xs">
{formatDistance(new Date(notification.createdAt), new Date(), {
addSuffix: true,
})}
</p>
</div>
<div className="flex w-6 items-center justify-center self-stretch">
{!notification.isRead && (
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
)}
</div>
</Link>
);
}
@@ -0,0 +1,92 @@
import { get, set } from "idb-keyval";
import type { NotificationMetadata } from "./NotificationButton";
type ChangelogItem = {
published_at: string;
title: string;
id: string;
url: string;
};
// Note: This saving read notifications in IndexedDB is temporary implementation - This will soon be replaced by a proper API setup
const READ_NOTIFICATIONS_KEY = "thirdweb:read-notifications";
// get set of notification ids marked as read
async function getReadNotificationIds(): Promise<Set<string>> {
const readIds = (await get<string[]>(READ_NOTIFICATIONS_KEY)) || [];
return new Set(readIds);
}
// fetches last 20 posts with tag "changelog" notifications
async function fetchGhostPosts(tag: string) {
const res = await fetch(
`https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=id,title,url,published_at&filter=tag:${tag}&visibility:public&limit=20`,
);
const json = await res.json();
return json.posts as ChangelogItem[];
}
// Clean up the indexedDB storage of read notification IDs
// This is to prevent the storage from growing indefinitely
// Remove notification IDs from storage that are no longer shown to the user
export async function cleanupReadNotifications(params: {
changelogs: NotificationMetadata[];
updates: NotificationMetadata[];
}) {
const { changelogs, updates } = params;
const readIds = await getReadNotificationIds();
// Get all current notification IDs
const currentIds = new Set([
...changelogs.map((item) => item.id),
...updates.map((item) => item.id),
]);
// remove ids that are no longer being displayed
const usedIds = new Set(
Array.from(readIds).filter((id) => currentIds.has(id)),
);
await set(READ_NOTIFICATIONS_KEY, Array.from(usedIds));
}
export async function markNotificationAsRead(id: string) {
const readIds = await getReadNotificationIds();
readIds.add(id);
// Clean up old notification IDs periodically (every 10th notification marked as read)
await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
}
export async function getChangelogNotifications(): Promise<
NotificationMetadata[]
> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("changelog"),
getReadNotificationIds(),
]);
return changelogs.map((item) => ({
id: item.id,
title: item.title,
href: item.url,
isRead: readIds.has(item.id),
createdAt: item.published_at,
}));
}
export async function getInboxNotifications(): Promise<NotificationMetadata[]> {
const [changelogs, readIds] = await Promise.all([
fetchGhostPosts("update"),
getReadNotificationIds(),
]);
return changelogs.map((item) => ({
id: item.id,
title: item.title,
href: item.url,
isRead: readIds.has(item.id),
createdAt: item.published_at,
}));
}
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
type ProjectSelectorMobileMenuButtonProps = {
@@ -14,6 +15,7 @@ type ProjectSelectorMobileMenuButtonProps = {
projects: Project[];
team: Team;
createProject: (team: Team) => void;
client: ThirdwebClient;
};
export function ProjectSelectorMobileMenuButton(
@@ -46,6 +48,7 @@ export function ProjectSelectorMobileMenuButton(
<DynamicHeight>
<ProjectSelectorUI
currentProject={props.currentProject}
client={props.client}
projects={props.projects}
team={props.team}
createProject={() => {
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { CheckIcon, CirclePlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { SearchInput } from "./SearchInput";
export function ProjectSelectorUI(props: {
@@ -17,6 +18,7 @@ export function ProjectSelectorUI(props: {
currentProject: Project | undefined;
team: Team;
createProject: () => void;
client: ThirdwebClient;
}) {
const { projects, currentProject, team } = props;
const [searchProjectTerm, setSearchProjectTerm] = useState("");
@@ -60,8 +62,11 @@ export function ProjectSelectorUI(props: {
>
<Link href={`/team/${team.slug}/${project.slug}`}>
<div className="flex items-center gap-2">
{/* TODO - set Image */}
<ProjectAvatar src="" className="size-6" />
<ProjectAvatar
src={project.image || ""}
className="size-6"
client={props.client}
/>
<span className="truncate"> {project.name}</span>
</div>
{isSelected && (
@@ -9,10 +9,10 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
import { TeamSelectionUI } from "./TeamSelectionUI";
@@ -23,6 +23,7 @@ type TeamSwitcherProps = {
focus: "project-selection" | "team-selection";
createProject: (team: Team) => void;
account: Pick<Account, "email" | "id"> | undefined;
client: ThirdwebClient;
};
export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
@@ -31,7 +32,6 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
const [hoveredTeam, setHoveredTeam] = useState<Team>();
const projectsToShowOfTeam =
hoveredTeam || currentTeam || teamsAndProjects[0]?.team;
const client = useThirdwebClient();
// if we can't find a single team associated with this user - something is really wrong
if (!projectsToShowOfTeam) {
@@ -93,7 +93,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
: undefined
}
account={props.account}
client={client}
client={props.client}
/>
{/* Right */}
@@ -102,6 +102,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
currentProject={props.currentProject}
projects={projectsToShow}
team={projectsToShowOfTeam}
client={props.client}
createProject={() => {
setOpen(false);
props.createProject(projectsToShowOfTeam);
@@ -52,6 +52,11 @@ function Variants(props: {
if (!team1 || !team2 || !team3 || !team3Project) {
return <div> failed to get team and project stubs </div>;
}
const getChangelogsStub = () => Promise.resolve([]);
const getInboxNotificationsStub = () => Promise.resolve([]);
const markNotificationAsReadStub = () => Promise.resolve();
return (
<ThirdwebProvider>
<div className="flex min-h-dvh flex-col gap-6 bg-background py-10">
@@ -70,6 +75,9 @@ function Variants(props: {
id: "1",
}}
client={client}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
</div>
</BadgeContainer>
@@ -89,6 +97,9 @@ function Variants(props: {
connectButton={<ConnectButtonStub />}
createProject={() => {}}
client={client}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
</div>
</BadgeContainer>
@@ -108,6 +119,9 @@ function Variants(props: {
connectButton={<ConnectButtonStub />}
createProject={() => {}}
client={client}
getChangelogNotifications={getChangelogsStub}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
/>
</div>
</BadgeContainer>
@@ -10,6 +10,10 @@ import { SecondaryNav } from "../../../components/Header/SecondaryNav/SecondaryN
import { MobileBurgerMenuButton } from "../../../components/MobileBurgerMenuButton";
import { TeamPlanBadge } from "../../../components/TeamPlanBadge";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
import {
NotificationButtonUI,
type NotificationMetadata,
} from "../NotificationButton/NotificationButton";
import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButton";
import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton";
@@ -26,6 +30,9 @@ export type TeamHeaderCompProps = {
createProject: (team: Team) => void;
client: ThirdwebClient;
accountAddress: string;
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
markNotificationAsRead: (id: string) => Promise<void>;
};
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
@@ -68,6 +75,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
focus="team-selection"
createProject={props.createProject}
account={props.account}
client={props.client}
/>
</div>
@@ -79,8 +87,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
href={`/team/${props.currentTeam.slug}/${props.currentProject.slug}`}
className="flex flex-row items-center gap-2 font-semibold text-sm"
>
{/* TODO - set project avatar image */}
<ProjectAvatar src="" className="size-6" />
<ProjectAvatar
src={props.currentProject.image || ""}
className="size-6"
client={props.client}
/>
{props.currentProject.name}
</Link>
@@ -91,6 +102,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
focus="project-selection"
createProject={props.createProject}
account={props.account}
client={props.client}
/>
</div>
</>
@@ -103,6 +115,9 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
</header>
);
@@ -171,19 +186,28 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
projects={projects}
team={props.currentTeam}
createProject={props.createProject}
client={props.client}
/>
</div>
</>
)}
</div>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account?.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
<div className="flex items-center gap-3">
<NotificationButtonUI
getChangelogs={props.getChangelogNotifications}
getInboxNotifications={props.getInboxNotifications}
markNotificationAsRead={props.markNotificationAsRead}
/>
<MobileBurgerMenuButton
type="loggedIn"
email={props.account.email}
logout={props.logout}
connectButton={props.connectButton}
accountAddress={props.accountAddress}
/>
</div>
</header>
);
}
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateProjectDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { doLogout } from "../../../login/auth-actions";
import {
getChangelogNotifications,
getInboxNotifications,
markNotificationAsRead,
} from "../NotificationButton/fetch-notifications";
import {
type TeamHeaderCompProps,
TeamHeaderDesktopUI,
@@ -58,6 +63,9 @@ export function TeamHeaderLoggedIn(props: {
},
client: getThirdwebClient(),
accountAddress: props.accountAddress,
getChangelogNotifications: getChangelogNotifications,
getInboxNotifications: getInboxNotifications,
markNotificationAsRead: markNotificationAsRead,
};
return (
+2 -5
View File
@@ -1,11 +1,8 @@
import { getTeams } from "@/api/team";
import { getDefaultTeam } from "@/api/team";
import { notFound, redirect } from "next/navigation";
export default async function TeamRootPage() {
// get all teams, then go to the first team
const teams = await getTeams();
const firstTeam = teams?.[0];
const firstTeam = await getDefaultTeam();
if (!firstTeam) {
notFound();
}
@@ -214,11 +214,10 @@ export const MismatchButton = forwardRef<
>
<DialogContent
className={cn(
"z-[10001] gap-0 p-0",
"gap-0 p-0",
dialog === "no-funds" && "max-w-[480px]",
dialog === "pay" && "max-w-[360px] border-none bg-transparent",
)}
dialogOverlayClassName="z-[10000]"
dialogCloseClassName="focus:ring-0"
>
<DynamicHeight>
@@ -190,10 +190,7 @@ export const CmdKSearchModal = (props: {
}
}}
>
<DialogContent
className="z-[10000001] gap-0 p-0"
dialogOverlayClassName="z-[10000000]"
>
<DialogContent className="gap-0 p-0">
{/* Title */}
<DynamicHeight>
<div className="p-6">

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