Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
1cff849b69 Version Packages (#6089)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-28 18:22:36 -08:00
Jonas DanielsandGitHub b5e327e5ec add billingPlanVersion to TeamResponse (#6084) 2025-01-28 18:20:57 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
073b25afd7 Version Packages (#6083)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-28 17:40:13 -08:00
Joaquim VergesandGitHub 4550bb2663 [SDK] feat: Expose Account and Wallet components in React Native (#6082) 2025-01-28 16:29:50 -08:00
Manan TankandGitHub f5f8a40320 [TOOL-3251] Dashboard: Add Rotate Secret Key feature in Project Settings page (#6087) 2025-01-28 14:21:25 -08:00
Joaquim VergesandGitHub 3569763501 [Dashboard] fix: Use API key for in-app wallet analytics (#6086) 2025-01-29 09:44:02 +13:00
Joaquim VergesandGitHub 1401f8d039 [SDK] fix: Auto-connection of in-app wallets in React Native (#6076) 2025-01-29 07:13:09 +13:00
Jonas DanielsandGitHub 1616b7f619 update dependencies (#6079) 2025-01-27 23:08:39 -08:00
saminaandGitHub aa548a359f Nebula docs (#6080) 2025-01-27 19:22:38 -08:00
Jonas DanielsandGitHub eef52dfa7e update plan selection copy and tracking (#6078) 2025-01-27 16:56:30 -08:00
Joaquim VergesandGitHub 19ec571500 chore: Update PR template format (#6077) 2025-01-28 13:13:07 +13:00
Jonas DanielsandGitHub 47dce2e05c remove growth trial eligibility (#6070) 2025-01-27 15:13:51 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
8b4695224c Version Packages (#6075)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-28 12:00:53 +13:00
Joaquim VergesandGitHub 94e2d5b8dd [SDK] fix: Account linking for ecosystem smart wallets (#6074) 2025-01-28 11:44:05 +13:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
40fa323294 Version Packages (#6073)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-28 11:02:56 +13:00
Joaquim VergesandGitHub 2cebb420e6 [SDK] fix: Show link profiles for ecosystem smart wallets (#6072) 2025-01-28 10:59:05 +13:00
Joaquim VergesandGitHub e89ddb24f2 [SDK] fix: Update ecosystem name and admin wallet handling (#6071) 2025-01-28 10:54:27 +13:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
86f205e2aa Version Packages (#6069)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-28 10:14:31 +13:00
Joaquim VergesandGitHub 6c277ae676 [SDK] fix: Respect chain param in in-app wallet connection (#6068) 2025-01-28 09:57:07 +13:00
MananTank 153651df35 [TOOL-3241] Fix previous account shown after logging out in onboarding flow (#6061) 2025-01-27 19:49:51 +00:00
MananTank 8ba460b6fc [NEB-77] Nebula: Various UI updates (#6060) 2025-01-27 18:18:13 +00:00
MananTank 1e61a9edfc [TOOL-3238] Fix Engine backend wallet balances not updating on chain change (#6059) 2025-01-27 17:00:48 +00:00
MananTank d03e687aa9 [TOOL-3231] Show View Project Link after creating a project in Modal (#6055)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces the `teamSlug` prop across multiple components to enhance API key management by associating it with specific teams. This allows for better project handling and user navigation within the dashboard.

### Detailed summary
- Added `teamSlug` prop to `CreateAPIKeyModal` and related components.
- Updated `embed-setup.tsx` to handle `teamSlug` as `undefined`.
- Integrated `teamSlug` in `TeamProjectsPage` and `AccountHeader`.
- Modified `CreateAPIKeyDialog` to include `teamSlug` in props.
- Enhanced `APIKeyDetails` to fetch projects based on `teamSlug`.
- Implemented conditional rendering for "View Project" button based on `teamSlug`.

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

<!-- end pr-codex -->
2025-01-27 09:52:19 +00:00
Joaquim VergesandGitHub e1c0dbe19e [SDK] chore: Increase CJS bundle size limit to 130kb (#6058) 2025-01-27 20:42:56 +13:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
3e7df081d7 Version Packages (#6048)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-27 20:41:53 +13:00
Joaquim VergesandGitHub b6f189c220 [SDK] update dependencies (#6057) 2025-01-27 16:48:24 +13:00
Phillip HoandGitHub fb4d0a26f1 [service-utils] Replace client_id with project_id in usage_v2 (#6050) 2025-01-24 17:02:15 +08:00
Phillip HoandGitHub bc345371ba [service-utils] usage_v2 accepts a project_id field (#6049) 2025-01-24 16:44:26 +08:00
Phillip HoandGitHub 7a8e4e5f86 [service-utils] Export UsageV2 helpers (#6047) 2025-01-24 15:55:41 +08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
6ba3b30b60 Version Packages (#6030)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-24 15:08:14 +13:00
Joaquim VergesandGitHub 5ad442cf0c [SDK] fix: Simplify in-app wallet logout flow (#6046) 2025-01-24 14:49:15 +13:00
Phillip HoandGitHub 5756c74a71 [service-utils] feat: Add usageV2 support (#6021) 2025-01-24 08:52:35 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a1cff7a579 build(deps-dev): bump checkly from 4.15.0 to 4.18.0 (#6045)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-24 13:46:30 +13:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eafaefec20 build(deps-dev): bump vite from 6.0.10 to 6.0.11 in the npm_and_yarn group (#6022)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-24 13:37:31 +13:00
Manan TankandGitHub b81d7a1854 [TOOL-3207] Portal: Delete V4 SDK pages, Setup knip lint, Fix lint config (#6039) 2025-01-24 13:37:03 +13:00
Winston YeoandGitHub 21cc45adc2 Winston/tool 3161 cannot create backend wallets using latest version (#6025) 2025-01-24 13:36:33 +13:00
Joaquim VergesandGitHub a67d342779 [SDK] fix: Improve smart wallet signature verification (#6040) 2025-01-24 13:35:58 +13:00
Joaquim VergesandGitHub 60edce49c0 [SDK] fix: Improve WalletConnect mobile connection handling (#6042) 2025-01-24 13:35:31 +13:00
Joaquim VergesandGitHub 20c0dca958 [Portal] fix: Update useAutoConnect hook reference link (#6043) 2025-01-24 13:35:11 +13:00
Joaquim VergesandGitHub 4b30b847e2 [Dashboard] fix: Use formatted ABI for contract function calls (#6044) 2025-01-24 13:34:47 +13:00
MananTank b2cc17fd1e [TOOL-3220] Dashboard: Reset Project pagination on filter change, SDK: Fix WalletProvider JSDoc (#6041) 2025-01-23 23:58:47 +00:00
MananTank ace1dc5b09 [TOOL-3124] Dashboard: paginate projects, sort by Montly active users (#6027)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the project analytics in the dashboard by changing how project data is retrieved and displayed. It updates the calculation of user connections to reflect monthly active users and introduces pagination for project listings.

### Detailed summary
- Replaced `getProjectsWithTotalWallets` with `getProjectsWithAnalytics`.
- Updated project data structure to include `monthlyActiveUsers` instead of `totalConnections`.
- Introduced pagination in the `TeamProjectsPage` component.
- Changed sorting criteria from `totalConnections` to `monthlyActiveUsers`.
- Updated UI elements to reflect the new data fields.

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

<!-- end pr-codex -->
2025-01-23 12:09:59 +00:00
Joaquim VergesandGitHub 9f79277344 [Dashboard] perf: Optimize chain and contract data processing (#6035) 2025-01-23 13:47:14 +13:00
MananTank 6e0fd205ff [TOOL-2964] Dashboard: Fix crash on unix time converter page for invalid unix time, UI improvements (#6036)
=
2025-01-22 23:58:04 +00:00
MananTank 69b1ee9176 [TOOL-3082] Dashboard: Replace NetworkSelectorButton with SingleNetworkSelector where switching chain in wallet is not required (#6031)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces enhancements to the `SingleNetworkSelector` component and its usage across various files. It adds new props for alignment and positioning, replaces existing network selector buttons, and integrates chain ID handling in several components.

### Detailed summary
- Added `side`, `disableChainId`, and `align` props to `SingleNetworkSelector`.
- Updated `SingleNetworkSelector` to conditionally render the chain ID badge.
- Replaced `NetworkSelectorButton` with `SingleNetworkSelector` in multiple components.
- Integrated `chainId` state management using `useState` in `BackendWalletsSection` and `ImportForm`.
- Updated `BackendWalletsTable` and `BackendWalletBalanceCell` to accept `chainId` as a prop.
- Changed the way active chains are retrieved with `useActiveChainAsDashboardChain` to `useV5DashboardChain`.

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

<!-- end pr-codex -->
2025-01-22 23:48:14 +00:00
MananTank 0a19142a24 [TOOL-3120] Redirect to last visited team page if authenticated (#6028) 2025-01-22 23:39:05 +00:00
MananTank feb4fd0908 [TOOL-3123] Dashboard: Order projects by total wallets (#6026)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the `CopyTextButton` component, optimizing the project fetching logic, and improving project display features by integrating total wallet connections in the dashboard.

### Detailed summary
- Changed `tooltip` type in `CopyTextButton` to `string | undefined`.
- Updated the API call in `getChangelog` to limit results to 7.
- Replaced cookie-based token retrieval with `getAuthToken` in `getProjects`.
- Added `getProjectsWithTotalWallets` to calculate total wallet connections.
- Updated `TeamProjectsPage` to display projects with total connections.
- Modified sorting logic in `TeamProjectsPage` to include total connections.
- Replaced `CopyButton` with `CopyTextButton` in `ProjectCard`.
- Enhanced project display to show total users.

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

<!-- end pr-codex -->
2025-01-22 23:30:11 +00:00
gregfromstl 1d8695eb42 [SDK] Fix: Opt out of 1559 on lumia mainnet (#6032)
<!-- start pr-codex -->

## PR-Codex overview
This PR adds support for the `Lumia Mainnet` in the fee data configuration.

### Detailed summary
- Added a new entry for `Lumia Mainnet` in the fee data array.

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

<!-- end pr-codex -->
2025-01-22 22:34:34 +00:00
Joaquim VergesandGitHub 858dc53a25 [Dashboard] fix: Add error reason to analytics API error logs (#6034) 2025-01-23 11:34:16 +13:00
Joaquim VergesandGitHub 8997fac1b0 [Dashboard] fix: Improve error logging for analytics API failures (#6033) 2025-01-23 11:25:35 +13:00
gregfromstl 403e4224e4 Chore: Remove Jakub from CODEOWNERS (#6029)
<!-- start pr-codex -->

## PR-Codex overview
This PR modifies the `.github/CODEOWNERS` file to remove a specific code owner from the `apps/` directory.

### Detailed summary
- Removed `@jakubkrehel` as a code owner for the `apps/` directory.
- Retained `@thirdweb-dev/developer-tools` and `@thirdweb-dev/core-platform` as code owners for the `apps/` directory.
- `scripts/` code ownership remains unchanged with `@jnsdls` and `@joaquim-verges`.

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

<!-- end pr-codex -->
2025-01-22 20:17:33 +00:00
Phillip HoandGitHub de039ef462 [service-utils] fix: Don't hash cache key (#6023) 2025-01-23 04:16:16 +08:00
Joaquim VergesandGitHub 35fd3bcafa [Dashboard] feat: Add transaction analytics and tracking (#6019) 2025-01-23 08:36:04 +13:00
MananTank 80a22ff45e [TOOL-3173] Portal: Update hero iamge, OG image on landing page (#6017) 2025-01-22 21:46:05 +05:30
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
eefa2ab424 Version Packages (#6018)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-22 17:40:37 +13:00
Joaquim VergesandGitHub ab9a148b75 [SDK] feat: Add transaction tracking for smart wallets (#6020) 2025-01-22 17:12:38 +13:00
gregandGitHub 8bbee03c77 [SDK] Fix: Allow smart wallets for site linking (#6015) 2025-01-21 17:02:11 -08:00
gregandGitHub b38604cce8 [SDK] Fix: Do not remove chain listener on disconnect (#6011) 2025-01-21 15:45:55 -08:00
MananTank 59eda22043 [TOOL-3066] Dashboard: Fix 404 on example asset files, robots.txt, sitemap.xml (#6014) 2025-01-21 21:17:22 +00:00
MananTank 577636c037 [TOOL-3156] Dashboard: Fix localhost engine not loading, fetch engine permission on client side (#6013)
<!-- start pr-codex -->

## PR-Codex overview
This PR enhances the engine access permission handling in the dashboard. It introduces a new component, `EnsureEnginePermission`, which checks permissions before rendering engine details and handles various error states more effectively.

### Detailed summary
- Added `account` to the return object in `getEngineInstancePageMeta.ts`.
- Improved URL handling in `getEngineAccessPermission.ts`.
- Introduced `EnsureEnginePermission` component.
- Replaced direct permission checks in `Layout` with `EnsureEnginePermission`.
- Removed `EngineInstanceLayoutContent` component.

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

<!-- end pr-codex -->
2025-01-21 20:50:21 +00:00
jnsdls 1d844ac5d6 add connect/pay to framer rewrites (#6012)
closes: CORE-708

### TL;DR
Added `/connect/pay` route to Framer rewrites

### What changed?
Added a new route `/connect/pay` to the list of Framer rewrites in the dashboard application

### How to test?
1. Navigate to `/connect/pay` in the dashboard
2. Verify the route resolves correctly
3. Ensure the page loads without any routing errors

### Why make this change?
To enable proper routing and rendering of the new payment page in the Connect section of the dashboard
2025-01-21 19:21:28 +00:00
MananTank c7be92f75b [TOOL-3166] Update portal theming to match dashboard (#6010)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on updating the styling and structure of various components in the `apps/portal` project, enhancing UI consistency and accessibility. It introduces color changes, layout adjustments, and modifies several components' props for better integration.

### Detailed summary
- Deleted `docs-hero.png` and `docs-hero.svg`.
- Updated `fill` attribute in `UnrealEngineIcon.tsx` and `UnityIcon.tsx` to `currentColor`.
- Changed color definitions in `layout.tsx` and several components to use `text-foreground` and `text-muted-foreground`.
- Modified class names in `Paragraph.tsx`, `InlineCode.tsx`, and `Document` components for improved styling.
- Adjusted button classes in `Button.tsx` and `Feedback.tsx` for consistency.
- Refactored links to use `DocLink` for better routing.
- Updated `Callout` component styling to use `bg-card`.
- Enhanced accessibility features across various components by modifying text colors and ensuring proper contrast.
- Modified `tailwind.config.ts` to introduce new color variables for better theme management.
- Improved layout consistency in multiple `page.mdx` files by adjusting grid gaps and spacing.

> The following files were skipped due to too many changes: `apps/portal/src/app/contracts/deploy/overview/page.mdx`, `apps/portal/src/components/others/ScrollShadow/ScrollShadow.tsx`, `apps/portal/src/components/ui/select.tsx`, `apps/portal/src/components/others/Sidebar.tsx`, `apps/portal/src/app/contracts/explore/pre-built-modular/token-drop/page.mdx`, `apps/portal/src/components/others/DocSearch.tsx`, `apps/portal/src/icons/products/nebula/NebulaIcon.tsx`, `apps/portal/src/app/contracts/explore/pre-built-modular/nft-drop/page.mdx`, `apps/portal/src/app/connect/wallet/overview/page.mdx`, `apps/portal/src/app/globals.css`, `apps/portal/src/app/page.tsx`, `apps/portal/src/app/contracts/explore/pre-built-modular/edition-drop/page.mdx`, `apps/portal/src/icons/sdks/ReactIcon.tsx`, `apps/portal/src/app/contracts/explore/pre-built-modular/edition/page.mdx`, `apps/portal/src/app/contracts/explore/pre-built-modular/nft-collection/page.mdx`, `apps/portal/src/app/Header.tsx`

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

<!-- end pr-codex -->
2025-01-21 19:11:53 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>greg
79ed399e56 build(deps-dev): bump @types/react-dom from 19.0.2 to 19.0.3 (#6000)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: greg <[email protected]>
2025-01-21 21:05:25 +13:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ded5e22bb7 build(deps-dev): bump tailwindcss from 3.4.16 to 3.4.17 (#6001)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-21 21:05:10 +13:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5029345461 build(deps-dev): bump vite from 6.0.3 to 6.0.10 (#6002)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-21 21:04:57 +13:00
665 changed files with 9584 additions and 743316 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ packages/eslint-config-thirdweb/ @thirdweb-dev/core-platform @edwardysun
packages/tw-tsconfig/ @thirdweb-dev/core-platform @edwardysun
# apps
apps/ @thirdweb-dev/developer-tools @thirdweb-dev/core-platform @jakubkrehel
apps/ @thirdweb-dev/developer-tools @thirdweb-dev/core-platform
# .github folder + .changeset + turbo config is owned by jonas for now
scripts/ @jnsdls @joaquim-verges
+6 -3
View File
@@ -1,12 +1,15 @@
---
title: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"
---
<!--
## 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.
-->
+5 -1
View File
@@ -27,5 +27,9 @@
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
}
},
"eslint.workingDirectories": [
{ "pattern": "./packages/*/" },
{ "pattern": "./apps/*/" }
]
}
+1
View File
@@ -9,6 +9,7 @@ module.exports = [
// -- connect
"/connect",
"/connect/sign-in",
"/connect/pay",
// -- nebula
"/nebula",
// --insight
+41 -41
View File
@@ -31,29 +31,29 @@
"@marsidev/react-turnstile": "^1.1.0",
"@n8tb1t/use-scroll-position": "^2.0.3",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-alert-dialog": "^1.1.5",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-hover-card": "^1.1.3",
"@radix-ui/react-dialog": "1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-hover-card": "^1.1.5",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tooltip": "1.1.5",
"@sentry/nextjs": "8.45.1",
"@shazow/whatsabi": "^0.18.0",
"@tanstack/react-query": "5.62.16",
"@radix-ui/react-tooltip": "1.1.7",
"@sentry/nextjs": "8.51.0",
"@shazow/whatsabi": "^0.19.0",
"@tanstack/react-query": "5.65.0",
"@tanstack/react-table": "^8.20.6",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "^1.5.2",
"@vercel/og": "^0.6.4",
"abitype": "1.0.7",
"@vercel/og": "^0.6.5",
"abitype": "1.0.8",
"chakra-react-select": "^4.7.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -62,19 +62,19 @@
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "11.15.0",
"framer-motion": "12.0.6",
"fuse.js": "7.0.0",
"input-otp": "^1.4.1",
"ioredis": "^5.4.1",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.468.0",
"next": "15.1.3",
"lucide-react": "0.474.0",
"next": "15.1.6",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
"p-limit": "^6.2.0",
"papaparse": "^5.4.1",
"papaparse": "^5.5.1",
"pluralize": "^8.0.0",
"posthog-js": "1.67.1",
"qrcode": "^1.5.3",
@@ -87,11 +87,11 @@
"react-hook-form": "7.54.2",
"react-markdown": "^9.0.1",
"react-table": "^7.8.0",
"recharts": "2.14.1",
"recharts": "2.15.1",
"remark-gfm": "^4.0.0",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"sonner": "^1.7.1",
"sonner": "^1.7.2",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.18.2",
"tailwind-merge": "^2.6.0",
@@ -103,42 +103,42 @@
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.2",
"@next/bundle-analyzer": "15.1.0",
"@next/eslint-plugin-next": "15.1.3",
"@playwright/test": "1.49.1",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/addon-viewport": "8.4.7",
"@storybook/blocks": "8.4.7",
"@storybook/nextjs": "8.4.7",
"@storybook/react": "8.5.0",
"@storybook/test": "8.4.7",
"@chromatic-com/storybook": "3.2.4",
"@next/bundle-analyzer": "15.1.6",
"@next/eslint-plugin-next": "15.1.6",
"@playwright/test": "1.50.0",
"@storybook/addon-essentials": "8.5.2",
"@storybook/addon-interactions": "8.5.2",
"@storybook/addon-links": "8.5.2",
"@storybook/addon-onboarding": "8.5.2",
"@storybook/addon-viewport": "8.5.2",
"@storybook/blocks": "8.5.2",
"@storybook/nextjs": "8.5.2",
"@storybook/react": "8.5.2",
"@storybook/test": "8.5.2",
"@types/color": "4.2.0",
"@types/node": "22.10.2",
"@types/node": "22.10.10",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"@types/react-table": "^7.7.20",
"@types/spdx-correct": "^3.1.3",
"@types/swagger-ui-react": "^4.18.3",
"@types/swagger-ui-react": "^4.19.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.19",
"checkly": "^4.15.0",
"checkly": "^4.18.0",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"eslint-plugin-storybook": "^0.11.1",
"knip": "5.41.0",
"knip": "5.43.5",
"next-sitemap": "^4.2.3",
"postcss": "8.4.49",
"storybook": "8.4.7",
"tailwindcss": "3.4.16",
"typescript": "5.7.2"
"postcss": "8.5.1",
"storybook": "8.5.2",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
}
}
@@ -1,2 +1,2 @@
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property
1 name description external_url background_color youtube_url additional properties can be added
2 Token 0 Name Token 0 Description https://thirdweb.com #0098EE every row is a property

Before

Width:  |  Height:  |  Size: 881 KiB

After

Width:  |  Height:  |  Size: 881 KiB

+29 -1
View File
@@ -3,6 +3,7 @@ import type {
AnalyticsQueryParams,
InAppWalletStats,
RpcMethodStats,
TransactionStats,
UserOpStats,
WalletStats,
WalletUserStats,
@@ -77,7 +78,10 @@ export async function getUserOpUsage(
});
if (res?.status !== 200) {
console.error("Failed to fetch user ops usage");
const reason = await res?.text();
console.error(
`Failed to fetch user ops usage: ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
@@ -85,6 +89,30 @@ export async function getUserOpUsage(
return json.data as UserOpStats[];
}
export async function getClientTransactions(
params: AnalyticsQueryParams,
): Promise<TransactionStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v1/transactions/client?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch client transactions stats: ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
const json = await res.json();
return json.data as TransactionStats[];
}
export async function getRpcMethodUsage(
params: AnalyticsQueryParams,
): Promise<RpcMethodStats[]> {
+3 -12
View File
@@ -1,7 +1,6 @@
import "server-only";
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { API_SERVER_URL } from "@/constants/env";
import { cookies } from "next/headers";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
export type Project = {
id: string;
@@ -21,11 +20,7 @@ export type Project = {
};
export async function getProjects(teamSlug: string) {
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
const token = await getAuthToken();
if (!token) {
return [];
@@ -46,11 +41,7 @@ export async function getProjects(teamSlug: string) {
}
export async function getProject(teamSlug: string, projectSlug: string) {
const cookiesManager = await cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
const token = await getAuthToken();
if (!token) {
return null;
+1 -1
View File
@@ -25,7 +25,7 @@ export type Team = {
billingPlan: "pro" | "growth" | "free" | "starter";
billingStatus: "validPayment" | (string & {}) | null;
billingEmail: string | null;
growthTrialEligible: boolean | null;
growthTrialEligible: false;
enabledScopes: EnabledTeamScope[];
};
@@ -16,6 +16,8 @@ type Option = { label: string; value: string };
export function MultiNetworkSelector(props: {
selectedChainIds: number[];
onChange: (chainIds: number[]) => void;
disableChainId?: boolean;
className?: string;
}) {
const { allChains, idToChain } = useAllChainsData();
@@ -53,16 +55,24 @@ export function MultiNetworkSelector(props: {
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
<Badge variant="outline" className="gap-2">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
{!props.disableChainId && (
<Badge variant="outline" className="gap-2">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain],
[idToChain, props.disableChainId],
);
return (
@@ -79,6 +89,7 @@ export function MultiNetworkSelector(props: {
disabled={allChains.length === 0}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
/>
);
}
@@ -90,6 +101,9 @@ export function SingleNetworkSelector(props: {
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
}) {
const { allChains, idToChain } = useAllChainsData();
@@ -140,16 +154,19 @@ export function SingleNetworkSelector(props: {
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{chain.name}
{cleanChainName(chain.name)}
</span>
<Badge variant="outline" className="gap-2 max-sm:hidden">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
{!props.disableChainId && (
<Badge variant="outline" className="gap-2 max-sm:hidden">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain],
[idToChain, props.disableChainId],
);
const isLoadingChains = allChains.length === 0;
@@ -162,12 +179,15 @@ export function SingleNetworkSelector(props: {
onValueChange={(chainId) => {
props.onChange(Number(chainId));
}}
closeOnSelect={true}
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
popoverContentClassName={props.popoverContentClassName}
disabled={isLoadingChains}
side={props.side}
align={props.align}
/>
);
}
@@ -31,6 +31,9 @@ interface SelectWithSearchProps
) => boolean;
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
popoverContentClassName?: string;
side?: "left" | "right" | "top" | "bottom";
align?: "center" | "start" | "end";
closeOnSelect?: boolean;
}
export const SelectWithSearch = React.forwardRef<
@@ -48,6 +51,7 @@ export const SelectWithSearch = React.forwardRef<
overrideSearchFn,
popoverContentClassName,
searchPlaceholder,
closeOnSelect,
...props
},
ref,
@@ -107,7 +111,7 @@ export const SelectWithSearch = React.forwardRef<
}, [searchValue]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
<PopoverTrigger asChild>
<Button
ref={ref}
@@ -118,23 +122,24 @@ export const SelectWithSearch = React.forwardRef<
className,
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"text-muted-foreground text-sm",
"truncate text-muted-foreground text-sm",
selectedOption && "text-foreground",
)}
>
{selectedOption?.label || placeholder}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
<ChevronDown className="size-4 cursor-pointer text-muted-foreground" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={cn("z-[10001] p-0", popoverContentClassName)}
align="center"
align={props.align || "center"}
side={props.side}
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
style={{
@@ -174,7 +179,12 @@ export const SelectWithSearch = React.forwardRef<
key={option.value}
role="option"
aria-selected={isSelected}
onClick={() => onValueChange(option.value)}
onClick={() => {
onValueChange(option.value);
if (closeOnSelect) {
setIsPopoverOpen(false);
}
}}
variant="ghost"
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
ref={
@@ -9,7 +9,7 @@ import { ToolTipLabel } from "./tooltip";
export function CopyTextButton(props: {
textToShow: string;
textToCopy: string;
tooltip: string;
tooltip: string | undefined;
className?: string;
iconClassName?: string;
variant?:
@@ -10,7 +10,7 @@ import {
import type { ResultItem } from "app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/metrics/components/StatusCodes";
import type { EngineBackendWalletType } from "lib/engine";
import { useState } from "react";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { useActiveAccount } from "thirdweb/react";
import invariant from "tiny-invariant";
import type { EngineStatus } from "../../../app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table";
import { engineKeys } from "../cache-keys";
@@ -469,12 +469,12 @@ export function useEngineBackendWalletBalance(params: {
instanceUrl: string;
address: string;
authToken: string;
chainId: number;
}) {
const { instanceUrl, address, authToken } = params;
const chainId = useActiveWalletChain()?.id;
const { instanceUrl, address, authToken, chainId } = params;
return useQuery({
queryKey: engineKeys.backendWalletBalance(address, chainId || 1),
queryKey: engineKeys.backendWalletBalance(address, chainId),
queryFn: async () => {
invariant(chainId, "chainId is required");
@@ -4,6 +4,7 @@ import {
toTokens,
toUnits,
} from "thirdweb";
import { getContract } from "thirdweb/contract";
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
import { getContractMetadata } from "thirdweb/extensions/common";
@@ -71,7 +72,10 @@ export async function getClaimPhasesInLegacyFormat(
conditions.map(async (condition) => {
const [currencyMetadata, metadata, contractMetadata] = await Promise.all([
await ERC20Ext.getCurrencyMetadata({
contract: { ...options.contract, address: condition.currency },
contract: getContract({
...options.contract,
address: condition.currency,
}),
}).then((m) => ({
...m,
displayValue: toTokens(condition.pricePerToken, m.decimals),
@@ -197,7 +197,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<a
className="text-link-foreground hover:text-foreground"
download
href="/snapshot-with-maxclaimable.csv"
href="/assets/examples/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
@@ -212,7 +212,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/snapshot-with-overrides.csv"
href="/assets/examples/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
@@ -228,7 +228,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/snapshot.csv"
href="/assets/examples/snapshot.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
@@ -243,7 +243,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/snapshot-with-maxclaimable.csv"
href="/assets/examples/snapshot-with-maxclaimable.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
@@ -262,7 +262,7 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/snapshot-with-overrides.csv"
href="/assets/examples/snapshot-with-overrides.csv"
>
<DownloadIcon className="inline size-3" /> Example
snapshot
@@ -323,6 +323,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
apiKeys.refetch();
}}
enableNebulaServiceByDefault={false}
teamSlug={undefined}
/>
<Alert variant="warning">
@@ -4,13 +4,13 @@ import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Alert, AlertTitle } from "@/components/ui/alert";
import type { Account as TWAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { CircleSlash } from "lucide-react";
import type { ContractOptions } from "thirdweb";
import type { ThirdwebContract } from "thirdweb/contract";
import type { Account } from "thirdweb/wallets";
import { ModuleCard } from "./module-card";
import { useAllModuleContractInfo } from "./moduleContractInfo";
export const InstalledModulesTable = (props: {
contract: ContractOptions;
contract: ThirdwebContract;
installedModules: {
data?: string[];
isPending: boolean;
@@ -15,8 +15,9 @@ import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type Chain,
type ContractOptions,
type ThirdwebClient,
type ThirdwebContract,
getContract,
sendTransaction,
waitForReceipt,
} from "thirdweb";
@@ -47,7 +48,7 @@ type FormData = {
};
type InstallModuleFormProps = {
contract: ContractOptions;
contract: ThirdwebContract;
refetchModules: () => void;
account: Account;
installedModules: {
@@ -180,11 +181,13 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
return Promise.all(
moduleAddress.map(async (address) => {
const result = await resolveImplementation({
client,
address,
chain: contract.chain,
});
const result = await resolveImplementation(
getContract({
client,
address,
chain: contract.chain,
}),
);
if (!result) {
throw new Error("Failed to fetch bytecode for module");
@@ -23,7 +23,7 @@ import { Suspense, useEffect, useState } from "react";
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
import { toast } from "sonner";
import {
type ContractOptions,
type ThirdwebContract,
getContract,
sendTransaction,
waitForReceipt,
@@ -35,7 +35,7 @@ import { useModuleContractInfo } from "./moduleContractInfo";
type ModuleCardProps = {
moduleAddress: string;
contract: ContractOptions;
contract: ThirdwebContract;
onRemoveModule: () => void;
ownerAccount: Account | undefined;
allModuleContractInfo: {
@@ -193,7 +193,11 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
Files <em>must</em> contain one .csv file with an address and
quantity column, if the quantity column is not provided, that
record will be flagged as invalid.
<Link download color="primary.500" href="/airdrop.csv">
<Link
download
color="primary.500"
href="/assets/examples/airdrop.csv"
>
Download an example CSV
</Link>
</li>
@@ -1,29 +0,0 @@
"use client";
import { TabLinks } from "@/components/ui/tabs";
import { useSelectedLayoutSegment } from "next/navigation";
const links: {
name: string;
segment: string;
}[] = [
{ name: "Transaction Simulator", segment: "transaction-simulator" },
{ name: "Wei Converter", segment: "wei-converter" },
{ name: "Hex Converter", segment: "hex-converter" },
{ name: "Unix Time Converter", segment: "unixtime-converter" },
{ name: "Keccak-256 Converter", segment: "keccak256-converter" },
];
export function ToolsTabs() {
const layoutSegment = useSelectedLayoutSegment() || "";
return (
<TabLinks
links={links.map((tab) => ({
name: tab.name,
href: `/tools/${tab.segment}`,
isActive: layoutSegment === tab.segment,
}))}
/>
);
}
@@ -1,5 +1,5 @@
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import type { Metadata } from "next";
import { ToolsTabs } from "./components/tabs";
export const metadata: Metadata = {
title: "thirdweb Blockchain Tools",
@@ -11,13 +11,31 @@ export default function ToolLayout({
children,
}: { children: React.ReactNode }) {
return (
<section className="flex h-full flex-col gap-8">
<main className="container flex-1 pb-20">
<div className="h-14" />
<ToolsTabs />
<div className="h-8" />
<div>{children}</div>
</main>
</section>
<SidebarLayout
sidebarLinks={[
{
label: "Transaction Simulator",
href: "/tools/transaction-simulator",
},
{
label: "Wei Converter",
href: "/tools/wei-converter",
},
{
label: "Hex Converter",
href: "/tools/hex-converter",
},
{
label: "Unix Time Converter",
href: "/tools/unixtime-converter",
},
{
label: "Keccak-256 Converter",
href: "/tools/keccak256-converter",
},
]}
>
{children}
</SidebarLayout>
);
}
@@ -1,118 +1,150 @@
"use client";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatDistanceToNow } from "date-fns";
import { useState } from "react";
import { ShareButton } from "../../components/share";
import {
formatDistanceToNow,
formatISO9075,
formatRFC3339,
formatRFC7231,
fromUnixTime,
} from "date-fns";
import { useMemo, useState } from "react";
export const UnixTimeConverter = () => {
const getCurrentUnixMilliseconds = () => Math.floor(new Date().getTime());
const [unix, setUnix] = useState<number>(getCurrentUnixMilliseconds());
export function UnixTimeConverter() {
const [unixTime, setUnixTime] = useState(() => {
return Math.floor(Date.now() / 1000);
});
// `unix` may be in milliseconds or seconds.
// Treat any value < 100000000000 as seconds, otherwise milliseconds.
const isSeconds = unix < 100000000000;
const date = new Date(isSeconds ? unix * 1000 : unix);
const dateLocal = date.toLocaleString(undefined, {
timeZoneName: "short",
});
const dateUTC = date.toLocaleString(undefined, {
timeZoneName: "short",
timeZone: "UTC",
});
const { date, error } = useMemo(() => {
if (Number.isNaN(unixTime) || !Number.isInteger(unixTime)) {
return {
error: "Invalid Unix Timestamp",
date: null,
};
}
const date = fromUnixTime(unixTime);
if (!isValidDate(date)) {
return {
date: null,
error: "Invalid Unix Timestamp",
};
}
return {
date,
error: null,
};
}, [unixTime]);
return (
<div className="space-y-24">
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Label htmlFor="unix-input" className="min-w-64 text-xl">
Unix Time
</Label>
<Input
id="unix-input"
type="number"
value={unix}
onChange={(e) => {
if (!Number.isNaN(e.target.valueAsNumber)) {
setUnix(e.target.valueAsNumber);
}
}}
placeholder="Enter a Unix time in milliseconds or seconds"
className="p-6 text-xl"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Label className="min-w-64 text-xl">Time (local)</Label>
<div>
<CopyTextButton
textToCopy={dateLocal}
textToShow={dateLocal}
tooltip="Copy"
copyIconPosition="right"
className="font-mono text-xl"
/>
</div>
<p className="text-sm italic">
&larr; {formatDistanceToNow(date, { addSuffix: true })}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Label className="min-w-64 text-xl">Time (UTC)</Label>
<div>
<CopyTextButton
textToCopy={dateUTC}
textToShow={dateUTC}
tooltip="Copy"
copyIconPosition="right"
className="font-mono text-xl"
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<ShareButton
cta="Share on X"
href="https://twitter.com/intent/tweet?text=Easy-to-use%20Unix%20time%20converter%20by%20thirdweb%20%F0%9F%98%8D&url=https%3A%2F%2Fthirdweb.com%2Ftools%2Funixtime-converter"
/>
</div>
</div>
<div className="mx-auto max-w-2xl">
<Card>
<CardHeader className="mb-6 border-b">
<CardTitle className="font-bold text-2xl">
Unix Time Converter
</CardTitle>
</CardHeader>
<div className="space-y-4">
<h1 className="font-bold text-2xl">
Convert Unix time in milliseconds or seconds
</h1>
<CardContent>
<div className="mb-4">
<Label htmlFor="unixTime">Enter Unix Timestamp</Label>
<Input
id="unixTime"
type="number"
value={unixTime}
onChange={(e) => {
setUnixTime(Number(e.target.value));
}}
placeholder="e.g., 1609459200"
className="mt-1"
/>
{error && (
<p className="mt-2 text-destructive-text text-sm">{error}</p>
)}
</div>
<p>
Timestamps are often represented in Unix time for several key reasons:
</p>
<h2 className="font-bold text-md">Simplicity</h2>
<p>
Counts seconds since January 1, 1970, making it easy to understand and
manipulate.
</p>
<h2 className="font-bold text-md">Consistency</h2>
<p>
Provides a uniform time representation across systems, unaffected by
time zones or calendar changes.
</p>
<h2 className="font-bold text-md">Standardization</h2>
<p>
Widely supported in computing, ensuring compatibility across
platforms.
</p>
<h2 className="font-bold text-md">Calculation</h2>
<p>
Allows straightforward arithmetic operations for time differences.
</p>
<h2 className="font-bold text-md">Efficiency</h2>
<p>
Stores time as a single integer, conserving memory and computational
resources.
</p>
<h2 className="font-bold text-md">Interoperability</h2>
<p>Facilitates seamless data exchange between systems and languages.</p>
</div>
<div className="flex flex-col gap-4">
<div>
<Label>Relative Time</Label>
<p className="font-mono text-muted-foreground">
{date
? formatDistanceToNow(date, {
addSuffix: true,
})
: "---"}
</p>
</div>
<div>
<Label>Local Time</Label>
<p className="font-mono text-muted-foreground">
{date ? formatLocal(date) : "---"}
</p>
</div>
<div>
<Label>UTC Time</Label>
<p className="font-mono text-muted-foreground">
{date ? formatUTC(date) : "---"}
</p>
</div>
<div>
<Label>ISO 8601</Label>
<p className="font-mono text-muted-foreground">
{date ? formatISO8601(date) : "---"}
</p>
</div>
<div>
<Label>ISO 9075</Label>
<p className="font-mono text-muted-foreground">
{date ? formatISO9075(date) : "---"}
</p>
</div>
<div>
<Label>RFC 7231</Label>
<p className="font-mono text-muted-foreground">
{date ? formatRFC7231(date) : "---"}
</p>
</div>
<div>
<Label>RFC 3339</Label>
<p className="font-mono text-muted-foreground">
{date ? formatRFC3339(date) : "---"}
</p>
</div>
<div>
<Label>SQL Timestamp</Label>
<p className="font-mono text-muted-foreground">
{date ? formatSQL(date) : "---"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
}
function formatLocal(date: Date): string {
return date.toLocaleString();
}
function formatUTC(date: Date): string {
return date.toUTCString();
}
function isValidDate(date: Date): boolean {
return !Number.isNaN(date.getTime());
}
function formatISO8601(date: Date): string {
return date.toISOString();
}
function formatSQL(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { ClientOnly } from "../../../../components/ClientOnly/ClientOnly";
import { UnixTimeConverter } from "./components/UnixTimeConverter";
export const metadata: Metadata = {
@@ -7,5 +8,9 @@ export const metadata: Metadata = {
};
export default async function Page() {
return <UnixTimeConverter />;
return (
<ClientOnly ssr={null}>
<UnixTimeConverter />
</ClientOnly>
);
}
@@ -70,6 +70,11 @@ export function AccountHeader(props: {
// refresh projects
router.refresh();
}}
teamSlug={
createProjectDialogState.isOpen
? createProjectDialogState.team.slug
: undefined
}
enableNebulaServiceByDefault={
createProjectDialogState.isOpen &&
createProjectDialogState.team.enabledScopes.includes("nebula")
+1 -1
View File
@@ -37,7 +37,7 @@ export const metadata: Metadata = {
siteName: "thirdweb",
images: [
{
url: "https://thirdweb.com/thirdweb.png",
url: "https://thirdweb.com/assets/og-image/thirdweb.png",
width: 1200,
height: 630,
alt: "thirdweb",
+33 -22
View File
@@ -9,7 +9,7 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { useTheme } from "next-themes";
import Link from "next/link";
import { Suspense, lazy, useState } from "react";
import { Suspense, lazy, useEffect, useState } from "react";
import { ConnectEmbed, useActiveWalletConnectionStatus } from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
@@ -154,27 +154,6 @@ function PageContent(props: {
router.replace(props.redirectPath);
}
if (connectionStatus === "connecting") {
return <LoadingCard />;
}
if (connectionStatus !== "connected" || screen.id === "login") {
return <CustomConnectEmbed onLogin={onLogin} />;
}
if (screen.id === "onboarding") {
return (
<Suspense fallback={<LoadingCard />}>
<LazyOnboardingUI
account={screen.account}
onComplete={onComplete}
redirectPath={props.redirectPath}
redirectToCheckout={redirectToCheckout}
/>
</Suspense>
);
}
async function onLogin() {
const account = await getRawAccountAction();
@@ -193,6 +172,38 @@ function PageContent(props: {
}
}
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
// if suddenly disconnected
if (connectionStatus !== "connected" && screen.id !== "login") {
setScreen({ id: "login" });
}
}, [connectionStatus, screen.id]);
if (connectionStatus === "connecting") {
return <LoadingCard />;
}
if (connectionStatus !== "connected" || screen.id === "login") {
return <CustomConnectEmbed onLogin={onLogin} />;
}
if (screen.id === "onboarding") {
return (
<Suspense fallback={<LoadingCard />}>
<LazyOnboardingUI
account={screen.account}
onComplete={onComplete}
redirectPath={props.redirectPath}
redirectToCheckout={redirectToCheckout}
onLogout={() => {
setScreen({ id: "login" });
}}
/>
</Suspense>
);
}
return <LoadingCard />;
}
@@ -27,9 +27,10 @@ export function OnboardingChoosePlan(props: {
billingPlan="starter"
teamSlug={props.teamSlug}
cta={{
title: "Get started for free",
title: "Get Started for free",
tracking: {
category: "account",
label: "starterPlan",
},
}}
redirectPath={props.redirectPath}
@@ -40,15 +41,14 @@ export function OnboardingChoosePlan(props: {
billingPlan="growth"
teamSlug={props.teamSlug}
cta={{
title: "Claim your 1-month free",
hint: "Your free trial will end after 30 days.",
title: "Get Started with Growth",
tracking: {
category: "account",
label: "growthPlan",
},
variant: "default",
}}
canTrialGrowth={props.canTrialGrowth}
canTrialGrowth={false}
highlighted
redirectPath={props.redirectPath}
redirectToCheckout={props.redirectToCheckout}
@@ -14,12 +14,14 @@ type OnboardingGeneralProps = {
account: Account;
onSave: (email: string) => void;
onDuplicate: (email: string) => void;
onLogout: () => void;
};
export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
account,
onSave,
onDuplicate,
onLogout,
}) => {
const [existing, setExisting] = useState(false);
const activeWallet = useActiveWallet();
@@ -27,6 +29,7 @@ export const OnboardingGeneral: React.FC<OnboardingGeneralProps> = ({
async function handleLogout() {
await doLogout();
onLogout();
if (activeWallet) {
disconnect(activeWallet);
}
@@ -21,6 +21,7 @@ type OnboardingScreen =
function OnboardingUI(props: {
account: Account;
onComplete: () => void;
onLogout: () => void;
// path to redirect from stripe
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
@@ -68,6 +69,7 @@ function OnboardingUI(props: {
{screen.id === "onboarding" && (
<OnboardingGeneral
account={account}
onLogout={props.onLogout}
onSave={(email) => {
setUpdatedEmail(email);
setScreen({
@@ -181,6 +181,7 @@ function SessionCard(props: {
<Link
className="before:absolute before:inset-0"
href={`/chat/${props.session.id}`}
prefetch={false}
>
{props.session.title || "Untitled"}
</Link>
@@ -20,7 +20,7 @@ import type { ExecuteConfig, SessionInfo } from "../api/types";
import { newChatPageUrlStore, newSessionsStore } from "../stores";
import { ChatBar } from "./ChatBar";
import { type ChatMessage, Chats } from "./Chats";
import ContextFiltersButton from "./ContextFilters";
import ContextFiltersButton, { ContextFiltersForm } from "./ContextFilters";
import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent";
export function ChatPageContent(props: {
@@ -362,69 +362,92 @@ export function ChatPageContent(props: {
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
const handleUpdateContextFilters = async (
values: ContextFilters | undefined,
) => {
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context filters
if (sessionId) {
await updateSession({
authToken: props.authToken,
config,
sessionId,
contextFilters: values,
});
}
};
return (
<div className="flex grow flex-col overflow-hidden">
<WalletDisconnectedDialog
open={showConnectModal}
onOpenChange={setShowConnectModal}
/>
<header className="flex justify-start border-b bg-background p-4">
<header className="flex justify-between border-b bg-background p-4 xl:hidden">
<ContextFiltersButton
contextFilters={contextFilters}
setContextFilters={setContextFilters}
updateContextFilters={async (values) => {
// if session is not yet created, don't need to update sessions - starting a chat will create a session with the context filters
if (sessionId) {
await updateSession({
authToken: props.authToken,
config,
sessionId,
contextFilters: values,
});
}
}}
updateContextFilters={handleUpdateContextFilters}
/>
</header>
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-6">
{showEmptyState ? (
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
<EmptyStateChatPageContent sendMessage={handleSendMessage} />
</div>
) : (
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
<Chats
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-6 pb-32"
twAccount={props.account}
client={client}
enableAutoScroll={enableAutoScroll}
setEnableAutoScroll={setEnableAutoScroll}
/>
<div className="container max-w-[800px]">
<ChatBar
sendMessage={handleSendMessage}
isChatStreaming={isChatStreaming}
abortChatStream={() => {
chatAbortController?.abort();
setChatAbortController(undefined);
setIsChatStreaming(false);
// if last message is presence, remove it
if (messages[messages.length - 1]?.type === "presence") {
setMessages((prev) => prev.slice(0, -1));
}
}}
/>
<div className="flex grow overflow-hidden">
<div className="relative flex grow flex-col overflow-hidden rounded-lg pb-6">
{showEmptyState ? (
<div className="fade-in-0 container flex max-w-[800px] grow animate-in flex-col justify-center">
<EmptyStateChatPageContent sendMessage={handleSendMessage} />
</div>
</div>
)}
) : (
<div className="fade-in-0 relative z-[0] flex max-h-full flex-1 animate-in flex-col overflow-hidden">
<Chats
messages={messages}
isChatStreaming={isChatStreaming}
authToken={props.authToken}
sessionId={sessionId}
className="min-w-0 pt-6 pb-32"
twAccount={props.account}
client={client}
enableAutoScroll={enableAutoScroll}
setEnableAutoScroll={setEnableAutoScroll}
/>
<p className="mt-4 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
Nebula may make mistakes. Please use with discretion
</p>
<div className="container max-w-[800px]">
<ChatBar
sendMessage={handleSendMessage}
isChatStreaming={isChatStreaming}
abortChatStream={() => {
chatAbortController?.abort();
setChatAbortController(undefined);
setIsChatStreaming(false);
// if last message is presence, remove it
if (messages[messages.length - 1]?.type === "presence") {
setMessages((prev) => prev.slice(0, -1));
}
}}
/>
</div>
</div>
)}
<p className="mt-4 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
Nebula may make mistakes. Please use with discretion
</p>
</div>
<aside className="hidden w-[360px] flex-col border-l bg-card pt-4 xl:flex">
<div className="px-4">
<h3 className="font-semibold text-lg tracking-tight">Context</h3>
<p className="mb-5 text-muted-foreground text-sm">
Provide context to Nebula for your prompts
</p>
</div>
<ContextFiltersForm
contextFilters={contextFilters}
setContextFilters={setContextFilters}
modal={undefined}
updateContextFilters={handleUpdateContextFilters}
formBodyClassName="px-4"
formActionContainerClassName="px-4 border-t-0 pt-0 bg-transparent"
/>
</aside>
</div>
</div>
);
@@ -4,6 +4,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import {
FileCode2Icon,
MessageSquareShareIcon,
MessagesSquareIcon,
SquareDashedBottomCodeIcon,
TextIcon,
@@ -29,8 +31,11 @@ export function ChatSidebar(props: {
return (
<div className="flex h-full flex-col p-2">
<div className="flex items-center justify-start gap-3 p-2 lg:justify-between">
<Link href="/">
<NebulaIcon className="size-8 text-foreground" />
<Link href="/" className="flex items-center gap-2">
<NebulaIcon className="size-8 text-foreground" aria-label="Nebula" />
<span className="font-semibold text-lg tracking-tight">
Playground
</span>
</Link>
<Badge variant="secondary" className="gap-1 py-1">
@@ -48,6 +53,13 @@ export function ChatSidebar(props: {
<div className="h-3" />
<SidebarIconLink
href="https://portal.thirdweb.com/nebula/api-reference"
icon={FileCode2Icon}
label="API Reference"
target="_blank"
/>
<SidebarIconLink
href="/chat/history"
icon={MessagesSquareIcon}
@@ -79,7 +91,14 @@ export function ChatSidebar(props: {
</ScrollShadow>
)}
<div className="mb-3 border-b border-dashed pt-2 pb-3">
<div className="mb-3 border-y border-dashed py-3">
<SidebarIconLink
href="https://docs.google.com/forms/d/e/1FAIpQLSeM3fJRyywihRZUF1fiTNKEpJ_AzAcohRwXPpLr_3zxQ6W-tg/viewform?usp=sharing"
icon={MessageSquareShareIcon}
label="Take our quick survey!"
target="_blank"
/>
<SidebarIconLink
href="https://portal.thirdweb.com/changelog"
icon={TextIcon}
@@ -116,6 +135,7 @@ function SidebarIconLink(props: {
href={props.href}
target={props.target}
className="!justify-start !px-3 w-full gap-2.5 rounded-lg text-left"
prefetch={false}
>
<props.icon className="size-4" />
{props.label}
@@ -8,6 +8,7 @@ import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -15,15 +16,14 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogDescription } from "@radix-ui/react-dialog";
import { useMutation } from "@tanstack/react-query";
import { SlidersHorizontalIcon } from "lucide-react";
import { useState } from "react";
@@ -39,9 +39,6 @@ export default function ContextFiltersButton(props: {
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
}) {
const [isOpen, setIsOpen] = useState(false);
const updateMutation = useMutation({
mutationFn: props.updateContextFilters,
});
const chainIds = props.contextFilters?.chainIds;
const contractAddresses = props.contextFilters?.contractAddresses;
@@ -85,21 +82,22 @@ export default function ContextFiltersButton(props: {
</div>
</Button>
</DialogTrigger>
<DialogContent className="p-0">
<ContextFilterDialogContent
contextFilters={props.contextFilters}
isPending={updateMutation.isPending}
updateFilters={async (data) => {
const promise = updateMutation.mutateAsync(data);
toast.promise(promise, {
success: "Context filters updated",
error: "Failed to update context filters",
});
<DialogContent className="overflow-hidden p-0">
<DialogHeader className="px-6 pt-6 pb-1">
<DialogTitle className="font-semibold text-xl">
Context Filters
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Provide context to Nebula for your prompts
</DialogDescription>
</DialogHeader>
promise.then(() => {
props.setContextFilters(data);
setIsOpen(false);
});
<ContextFiltersForm
contextFilters={props.contextFilters}
updateContextFilters={props.updateContextFilters}
setContextFilters={props.setContextFilters}
modal={{
close: () => setIsOpen(false),
}}
/>
</DialogContent>
@@ -138,11 +136,22 @@ const formSchema = z.object({
walletAddresses: commaSeparateListOfAddresses,
});
function ContextFilterDialogContent(props: {
export function ContextFiltersForm(props: {
contextFilters: ContextFilters | undefined;
updateFilters: (filters: ContextFilters | undefined) => void;
isPending: boolean;
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
setContextFilters: (filters: ContextFilters | undefined) => void;
formBodyClassName?: string;
formActionContainerClassName?: string;
modal:
| {
close: () => void;
}
| undefined;
}) {
const updateMutation = useMutation({
mutationFn: props.updateContextFilters,
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: {
@@ -172,26 +181,37 @@ function ContextFilterDialogContent(props: {
.split(",")
.filter((v) => v.trim());
props.updateFilters({
const data = {
chainIds: chainIdsArray,
contractAddresses: contractAddressesArray,
walletAddresses: walletAddressesArray,
};
const promise = updateMutation.mutateAsync(data);
toast.promise(promise, {
success: "Context filters updated",
error: "Failed to update context filters",
});
promise.then(() => {
props.setContextFilters(data);
props.modal?.close?.();
});
}
return (
<Form {...form}>
<DialogHeader className="px-6 pt-6 pb-1">
<DialogTitle className="font-semibold text-xl">
Context Filters
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Provide context information to Nebula for your prompts
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-5 px-6">
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn("flex w-full grow flex-col")}
>
<div
className={cn(
"flex flex-col gap-5 px-6 pb-6",
props.formBodyClassName,
)}
>
<FormField
control={form.control}
name="chainIds"
@@ -200,6 +220,8 @@ function ContextFilterDialogContent(props: {
<FormLabel>Chain IDs</FormLabel>
<FormControl>
<MultiNetworkSelector
disableChainId
className="bg-background"
selectedChainIds={form
.watch()
.chainIds.split(",")
@@ -226,12 +248,9 @@ function ContextFilterDialogContent(props: {
<AutoResizeTextarea
{...field}
placeholder="0x123..., 0x456..."
className="min-h-[32px] resize-none"
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
/>
</FormControl>
<FormDescription>
Comma separated list of contract addresses
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -247,25 +266,36 @@ function ContextFilterDialogContent(props: {
<AutoResizeTextarea
{...field}
placeholder="0x123..., 0x456..."
className="min-h-[32px] resize-none"
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
/>
</FormControl>
<FormDescription>
Comma separated list of wallet addresses
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-10 flex justify-end gap-3 border-t bg-card p-6">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button className="min-w-28 gap-2" type="submit">
{props.isPending && <Spinner className="size-4" />}
Update
<div
className={cn(
"flex justify-end gap-3 border-t bg-card p-6",
props.formActionContainerClassName,
)}
>
{props.modal && (
<DialogClose asChild>
<Button variant="outline" size="sm">
Cancel
</Button>
</DialogClose>
)}
<Button
className="min-w-24 gap-2"
type="submit"
size="sm"
disabled={updateMutation.isPending}
>
{updateMutation.isPending && <Spinner className="size-4" />}
Update Context
</Button>
</div>
</form>
@@ -10,7 +10,7 @@ export function EmptyStateChatPageContent(props: {
sendMessage: (message: string) => void;
}) {
return (
<div className="py-16">
<div className="py-10 lg:py-16">
<div className="relative py-10">
<FancyBorders />
<div className="flex justify-center">
@@ -1,6 +1,5 @@
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { CodeClient } from "@/components/ui/code/code.client";
import type { Account as TWAccount } from "@3rdweb-sdk/react/hooks/useApi";
@@ -106,7 +106,7 @@ export async function TotalSponsoredChartCardUI({
return (
<CombinedBarChartCard
isCurrency
title={title || "Total Sponsored"}
title={title || "Gas Sponsored"}
chartConfig={chartConfig}
data={timeSeriesData}
activeChart={
@@ -0,0 +1,130 @@
import { cn } from "@/lib/utils";
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import type { TransactionStats } from "types/analytics";
import { EmptyAccountAbstractionChartContent } from "../../../../../components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
import { BarChart } from "../../../components/Analytics/BarChart";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
export async function TransactionsChartCardUI({
data,
aggregatedData,
searchParams,
className,
onlyMainnet,
title,
description,
}: {
data: TransactionStats[];
aggregatedData: TransactionStats[];
searchParams?: { [key: string]: string | string[] | undefined };
className?: string;
onlyMainnet?: boolean;
title?: string;
description?: string;
}) {
const uniqueChainIds = [
...new Set(data.map((item) => item.chainId).filter(Boolean)),
];
const chains = await Promise.all(
uniqueChainIds.map((chainId) =>
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(Number(chainId))).catch(() => undefined),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
for (const item of data) {
const chain = chains.find((c) => c.chainId === Number(item.chainId));
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
if (chain?.testnet) {
existing.testnet += item.count;
} else {
existing.mainnet += item.count;
}
dateMap.set(item.date, existing);
}
// Convert to array and sort by date
const timeSeriesData = Array.from(dateMap.entries())
.map(([date, values]) => ({
date,
mainnet: values.mainnet,
testnet: values.testnet,
total: values.mainnet + values.testnet,
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const processedAggregatedData = {
mainnet: aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.count, 0),
};
const chartConfig = {
mainnet: {
label: "Mainnet Chains",
color: "hsl(var(--chart-1))",
},
testnet: {
label: "Testnet Chains",
color: "hsl(var(--chart-2))",
},
total: {
label: "All Chains",
color: "hsl(var(--chart-3))",
},
};
if (onlyMainnet) {
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
<div className={cn("rounded-lg border p-4 lg:p-6", className)}>
<h3 className="mb-1 font-semibold text-xl tracking-tight">
{title || "Transactions"}
</h3>
<p className="text-muted-foreground"> {description}</p>
<BarChart
chartConfig={chartConfig}
data={filteredData}
activeKey="mainnet"
emptyChartContent={<EmptyAccountAbstractionChartContent />}
/>
</div>
);
}
return (
<CombinedBarChartCard
title={title || "Transactions"}
chartConfig={chartConfig}
data={timeSeriesData}
activeChart={
(searchParams?.client_transactions as keyof typeof chartConfig) ??
"mainnet"
}
queryKey="client_transactions"
existingQueryParams={searchParams}
aggregateFn={(_data, key) => processedAggregatedData[key]}
className={className}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
@@ -1,8 +1,13 @@
import { getProjects } from "@/api/projects";
import { getWalletConnections } from "@/api/analytics";
import { type Project, getProjects } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { Changelog } from "components/dashboard/Changelog";
import { subDays } from "date-fns";
import { redirect } from "next/navigation";
import { TeamProjectsPage } from "./~/projects/TeamProjectsPage";
import {
type ProjectWithAnalytics,
TeamProjectsPage,
} from "./~/projects/TeamProjectsPage";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
@@ -15,12 +20,13 @@ export default async function Page(props: {
}
const projects = await getProjects(params.team_slug);
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={projects} team={team} />
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
</div>
<div className="shrink-0 lg:w-[320px]">
<h2 className="mb-4 font-semibold text-2xl tracking-tight">
@@ -31,3 +37,38 @@ export default async function Page(props: {
</div>
);
}
async function getProjectsWithAnalytics(
projects: Project[],
): Promise<Array<ProjectWithAnalytics>> {
return Promise.all(
projects.map(async (p) => {
try {
const today = new Date();
const thirtyDaysAgo = subDays(today, 30);
const data = await getWalletConnections({
clientId: p.publishableKey,
period: "all",
from: thirtyDaysAgo,
to: today,
});
let uniqueWalletsConnected = 0;
for (const d of data) {
uniqueWalletsConnected += d.uniqueWalletsConnected;
}
return {
...p,
monthlyActiveUsers: uniqueWalletsConnected,
};
} catch {
return {
...p,
monthlyActiveUsers: 0,
};
}
}),
);
}
@@ -1,4 +1,5 @@
import {
getClientTransactions,
getInAppWalletUsage,
getUserOpUsage,
getWalletConnections,
@@ -31,9 +32,10 @@ import { getValidAccount } from "app/account/settings/getAccount";
import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard";
import { Suspense } from "react";
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
// revalidate every 5 minutes
export const revalidate = 300;
export const maxDuration = 300;
type SearchParams = {
usersChart?: string;
@@ -100,6 +102,8 @@ async function OverviewPageContent(props: {
inAppWalletUsage,
userOpUsageTimeSeries,
userOpUsage,
clientTransactionsTimeSeries,
clientTransactions,
] = await Promise.all([
// Aggregated wallet connections
getWalletConnections({
@@ -135,6 +139,19 @@ async function OverviewPageContent(props: {
to: range.to,
period: "all",
}),
// Client transactions
getClientTransactions({
accountId: account.id,
from: range.from,
to: range.to,
period: interval,
}),
getClientTransactions({
accountId: account.id,
from: range.from,
to: range.to,
period: "all",
}),
]);
const isEmpty =
@@ -180,6 +197,14 @@ async function OverviewPageContent(props: {
/>
)}
</div>
{clientTransactions.length > 0 && (
<TransactionsChartCardUI
searchParams={searchParams}
data={clientTransactionsTimeSeries}
aggregatedData={clientTransactions}
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
/>
)}
{userOpUsage.length > 0 ? (
<TotalSponsoredChartCardUI
searchParams={searchParams}
@@ -189,7 +214,7 @@ async function OverviewPageContent(props: {
/>
) : (
<EmptyStateCard
metric="Sponsored Transactions"
metric="Gas Sponsored"
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
/>
)}
@@ -0,0 +1,87 @@
"use client";
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import GenericLoadingPage from "../../../../ecosystem/loading";
import { getEngineAccessPermission } from "../../../_utils/getEngineAccessPermission";
import { EngineErrorPage } from "./EngineErrorPage";
export function EnsureEnginePermission(props: {
engineId: string;
accountId: string;
authToken: string;
children: React.ReactNode;
teamSlug: string;
instance: EngineInstance;
}) {
const { instance } = props;
const rootPath = `/team/${props.teamSlug}/~/engine`;
const permissionQuery = useQuery({
queryKey: [
"engine-permission",
{
instanceUrl: instance.url,
authToken: props.authToken,
},
],
queryFn: () => {
const url = instance.url;
if (!url) {
throw new Error();
}
return getEngineAccessPermission({
authToken: props.authToken,
instanceUrl: url,
});
},
retry: false,
});
if (permissionQuery.isPending) {
return <GenericLoadingPage />;
}
if (!permissionQuery.data) {
return (
<EngineErrorPage rootPath={rootPath}>
Failed to verify access to engine instance
</EngineErrorPage>
);
}
const permission = permissionQuery.data;
if (!permission.ok) {
if (permission.status === 404) {
return (
<EngineErrorPage rootPath={rootPath}>
<p> Engine instance not found </p>
</EngineErrorPage>
);
}
if (permission.fetchFailed) {
return (
<EngineErrorPage rootPath={rootPath}>
<p> Engine instance could not be reached </p>
</EngineErrorPage>
);
}
if (permission.status === 401 || permission.status === 500) {
return (
<EngineErrorPage rootPath={rootPath}>
<div>
<p>You are not an admin for this Engine instance. </p>
<p> Contact the owner to add your wallet as an admin</p>
</div>
</EngineErrorPage>
);
}
}
return <div>{props.children}</div>;
}
@@ -7,10 +7,10 @@ import Link from "next/link";
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../login/loginRedirect";
import { getEngineAccessPermission } from "../../_utils/getEngineAccessPermission";
import { getEngineInstance } from "../../_utils/getEngineInstance";
import { EngineErrorPage } from "./_components/EngineErrorPage";
import { EngineSidebarLayout } from "./_components/EnginePageLayout";
import { EnsureEnginePermission } from "./_components/EnsureEnginePermission";
import { EngineVersionBadge } from "./_components/version";
export default async function Layout(props: {
@@ -49,78 +49,28 @@ export default async function Layout(props: {
);
}
const permission = await getEngineAccessPermission({
authToken,
instanceUrl: instance.url,
});
return (
<EngineSidebarLayout engineId={params.engineId} teamSlug={params.team_slug}>
<EngineInstanceLayoutContent
<EnsureEnginePermission
teamSlug={params.team_slug}
accountId={account.id}
authToken={authToken}
engineId={params.engineId}
instance={instance}
permission={permission}
rootPath={engineRootLayoutPath}
team_slug={params.team_slug}
>
{props.children}
</EngineInstanceLayoutContent>
<div>
<EngineInstanceHeader
instance={instance}
rootPath={engineRootLayoutPath}
teamSlug={params.team_slug}
/>
{props.children}
</div>
</EnsureEnginePermission>
</EngineSidebarLayout>
);
}
function EngineInstanceLayoutContent(props: {
permission: {
ok: boolean;
status: number;
};
instance: EngineInstance;
rootPath: string;
children: React.ReactNode;
team_slug: string;
}) {
const { instance, permission, rootPath, team_slug } = props;
if (!permission.ok) {
if (permission.status === 404) {
return (
<EngineErrorPage rootPath={rootPath}>
<p> Engine Instance Not Found </p>
</EngineErrorPage>
);
}
if (permission.status === 500) {
return (
<EngineErrorPage rootPath={rootPath}>
<p> Engine Instance Could Not Be Reached </p>
</EngineErrorPage>
);
}
if (permission.status === 401) {
return (
<EngineErrorPage rootPath={rootPath}>
<div>
<p>You are not an admin for this Engine instance. </p>
<p> Contact the owner to add your wallet as an admin</p>
</div>
</EngineErrorPage>
);
}
}
return (
<div>
<EngineInstanceHeader
instance={instance}
rootPath={rootPath}
teamSlug={team_slug}
/>
{props.children}
</div>
);
}
function EngineInstanceHeader(props: {
instance: EngineInstance;
rootPath: string;
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { FormItem } from "@/components/ui/form";
import { Skeleton } from "@/components/ui/skeleton";
import {
type BackendWallet,
useEngineBackendWalletBalance,
@@ -34,8 +35,9 @@ import { type ColumnDef, createColumnHelper } from "@tanstack/react-table";
import { ChainIcon } from "components/icons/ChainIcon";
import { TWTable } from "components/shared/TWTable";
import { useTrack } from "hooks/analytics/useTrack";
import { useAllChainsData } from "hooks/chains/allChains";
import { EngineBackendWalletOptions } from "lib/engine";
import { useActiveChainAsDashboardChain } from "lib/v5-adapter";
import { useV5DashboardChain } from "lib/v5-adapter";
import {
DownloadIcon,
PencilIcon,
@@ -49,7 +51,6 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { getAddress } from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import invariant from "tiny-invariant";
import { FormHelperText, FormLabel, LinkButton, Text } from "tw-components";
import { prettyPrintCurrency } from "./utils";
@@ -59,6 +60,7 @@ interface BackendWalletsTableProps {
isPending: boolean;
isFetched: boolean;
authToken: string;
chainId: number;
}
interface BackendWalletDashboard extends BackendWallet {
@@ -72,35 +74,37 @@ interface BackendWalletBalanceCellProps {
instanceUrl: string;
address: string;
authToken: string;
chainId: number;
}
const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
instanceUrl,
address,
authToken,
chainId,
}) => {
const { data: backendWalletBalance } = useEngineBackendWalletBalance({
instanceUrl: instanceUrl,
address,
authToken,
chainId,
});
const chain = useActiveChainAsDashboardChain();
if (!chain || !backendWalletBalance) {
return;
const chain = useV5DashboardChain(chainId);
if (!backendWalletBalance) {
return <Skeleton className="h-5 w-32 rounded-lg" />;
}
const balanceDisplay = prettyPrintCurrency({
amount: backendWalletBalance.displayValue,
symbol: backendWalletBalance.symbol,
symbol: backendWalletBalance.symbol || chain.nativeCurrency?.symbol,
});
const balanceComponent = (
<Text fontWeight={backendWalletBalance.value === "0" ? "light" : "bold"}>
{balanceDisplay}
</Text>
<div className="text-muted-foreground">{balanceDisplay}</div>
);
const explorer = chain?.explorers?.[0];
const explorer = chain.blockExplorers?.[0];
if (!explorer) {
return balanceComponent;
}
@@ -123,6 +127,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
isPending,
isFetched,
authToken,
chainId,
}) => {
const editDisclosure = useDisclosure();
const receiveDisclosure = useDisclosure();
@@ -163,13 +168,14 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
instanceUrl={instanceUrl}
address={address}
authToken={authToken}
chainId={chainId}
/>
);
},
id: "balance",
}),
];
}, [instanceUrl, authToken]);
}, [instanceUrl, authToken, chainId]);
const [selectedBackendWallet, setSelectedBackendWallet] =
useState<BackendWallet>();
@@ -242,6 +248,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
disclosure={sendDisclosure}
instanceUrl={instanceUrl}
authToken={authToken}
chainId={chainId}
/>
)}
{selectedBackendWallet && deleteDisclosure.isOpen && (
@@ -417,24 +424,28 @@ const SendFundsModal = ({
disclosure,
instanceUrl,
authToken,
chainId,
}: {
fromWallet: BackendWallet;
backendWallets: BackendWallet[];
disclosure: UseDisclosureReturn;
instanceUrl: string;
authToken: string;
chainId: number;
}) => {
const chain = useActiveChainAsDashboardChain();
const form = useForm<SendFundsInput>();
const sendTokens = useEngineSendTokens({
instanceUrl,
authToken,
});
const { idToChain } = useAllChainsData();
const { data: backendWalletBalance } = useEngineBackendWalletBalance({
instanceUrl,
address: fromWallet.address,
authToken,
chainId: chainId,
});
const chain = idToChain.get(chainId);
const toWalletDisclosure = useDisclosure();
if (!backendWalletBalance) {
@@ -442,11 +453,9 @@ const SendFundsModal = ({
}
const onSubmit = async (data: SendFundsInput) => {
invariant(chain, "chain is required");
const promise = sendTokens.mutateAsync(
{
chainId: chain.chainId,
chainId: chainId,
fromAddress: fromWallet.address,
toAddress: data.toAddress,
amount: data.amount,
@@ -1,11 +1,13 @@
"use client";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import {
type EngineInstance,
useEngineBackendWallets,
useEngineWalletConfig,
} from "@3rdweb-sdk/react/hooks/useEngine";
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
import Link from "next/link";
import { useState } from "react";
import { useActiveWalletChain } from "thirdweb/react";
import { BackendWalletsTable } from "./backend-wallets-table";
import { CreateBackendWalletButton } from "./create-backend-wallet-button";
import { ImportBackendWalletButton } from "./import-backend-wallet-button";
@@ -43,6 +45,10 @@ function BackendWalletsSection(props: {
authToken: string;
}) {
const { instance, teamSlug, authToken } = props;
const activeWalletChain = useActiveWalletChain();
const [_chainId, setChainId] = useState<number>();
const chainId = _chainId || activeWalletChain?.id || 1;
const backendWallets = useEngineBackendWallets({
instanceUrl: instance.url,
authToken,
@@ -105,9 +111,14 @@ function BackendWalletsSection(props: {
<div className="flex justify-end">
<div className="flex items-center gap-2">
<span className="text-sm">Show balance for</span>
{/* TODO - Replace with simple network selector - there's no need for user to switch chain */}
<div className="flex flex-row">
<NetworkSelectorButton />
<SingleNetworkSelector
chainId={chainId}
onChange={setChainId}
className="min-w-40 max-w-52 lg:max-w-60"
popoverContentClassName="!w-[80vw] md:!w-[500px]"
align="end"
/>
</div>
</div>
</div>
@@ -120,6 +131,7 @@ function BackendWalletsSection(props: {
isPending={backendWallets.isPending}
isFetched={backendWallets.isFetched}
authToken={authToken}
chainId={chainId}
/>
</section>
);
@@ -3,9 +3,9 @@ export const prettyPrintCurrency = ({
symbol,
}: {
amount?: string | number;
symbol: string;
symbol?: string;
}): string => {
const amountNumber =
typeof amount === "string" ? Number.parseFloat(amount) : (amount ?? 0);
return `${amountNumber.toFixed(6)} ${symbol}`;
return `${amountNumber.toFixed(6)} ${symbol || ""}`;
};
@@ -1,9 +1,17 @@
"use client";
// can not fetch engine permission on server - instance url can be localhost url
export async function getEngineAccessPermission(params: {
authToken: string;
instanceUrl: string;
}) {
try {
const res = await fetch(`${params.instanceUrl}auth/permissions/get-all`, {
const instanceUrl = params.instanceUrl.endsWith("/")
? params.instanceUrl
: `${params.instanceUrl}/`;
const res = await fetch(`${instanceUrl}auth/permissions/get-all`, {
method: "GET",
headers: {
"Content-Type": "application/json",
@@ -19,6 +27,7 @@ export async function getEngineAccessPermission(params: {
return {
ok: false,
status: 500,
fetchFailed: true,
};
}
}
@@ -30,5 +30,5 @@ export async function engineInstancePageHandler(params: {
notFound();
}
return { instance, authToken };
return { instance, authToken, account };
}
@@ -3,7 +3,8 @@
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
import { CopyButton } from "@/components/ui/CopyButton";
import { PaginationButtons } from "@/components/pagination-buttons";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -11,6 +12,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -19,51 +25,90 @@ import {
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { ChevronDownIcon, PlusIcon, SearchIcon } from "lucide-react";
import {
ChevronDownIcon,
EllipsisVerticalIcon,
PlusIcon,
SearchIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useMemo, useState } from "react";
type SortById = "name" | "createdAt";
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
export type ProjectWithAnalytics = Project & {
monthlyActiveUsers: number;
};
export function TeamProjectsPage(props: {
projects: Project[];
projects: ProjectWithAnalytics[];
team: Team;
}) {
const { projects } = props;
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<SortById>("createdAt");
const [sortBy, setSortBy] = useState<SortById>("monthlyActiveUsers");
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const router = useDashboardRouter();
let projectsToShow = !searchTerm
? projects
: projects.filter(
(project) =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.publishableKey
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
const sortedProjects = useMemo(() => {
let _projectsToShow = !searchTerm
? projects
: projects.filter(
(project) =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.publishableKey
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
if (sortBy === "name") {
projectsToShow = projectsToShow.sort((a, b) =>
a.name.localeCompare(b.name),
);
} else if (sortBy === "createdAt") {
projectsToShow = projectsToShow.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
if (sortBy === "name") {
_projectsToShow = _projectsToShow.sort((a, b) =>
a.name.localeCompare(b.name),
);
} else if (sortBy === "createdAt") {
_projectsToShow = _projectsToShow.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
} else if (sortBy === "monthlyActiveUsers") {
_projectsToShow = _projectsToShow.sort(
(a, b) => b.monthlyActiveUsers - a.monthlyActiveUsers,
);
}
return _projectsToShow;
}, [searchTerm, sortBy, projects]);
const pageSize = 8;
const [page, setPage] = useState(1);
const paginatedProjects = sortedProjects.slice(
(page - 1) * pageSize,
page * pageSize,
);
const showPagination = sortedProjects.length > pageSize;
const totalPages = Math.ceil(sortedProjects.length / pageSize);
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={setSearchTerm} />
<SearchInput
value={searchTerm}
onValueChange={(v) => {
setSearchTerm(v);
setPage(1);
}}
/>
<div className="flex gap-4">
<SelectBy value={sortBy} onChange={setSortBy} />
<SelectBy
value={sortBy}
onChange={(v) => {
setSortBy(v);
setPage(1);
}}
/>
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
@@ -74,23 +119,33 @@ export function TeamProjectsPage(props: {
<div className="h-6" />
{/* Projects */}
{projectsToShow.length === 0 ? (
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
<div className="flex flex-col items-center">
<p className="mb-5 text-center">No projects created</p>
<Button
className="gap-2"
onClick={() => setIsCreateProjectDialogOpen(true)}
variant="outline"
>
<PlusIcon className="size-4" />
Create a Project
</Button>
</div>
</div>
{paginatedProjects.length === 0 ? (
<>
{searchTerm !== "" ? (
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
<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 flex-col items-center">
<p className="mb-5 text-center">No projects created</p>
<Button
className="gap-2"
onClick={() => setIsCreateProjectDialogOpen(true)}
variant="outline"
>
<PlusIcon className="size-4" />
Create a Project
</Button>
</div>
</div>
)}
</>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{projectsToShow.map((project) => {
{paginatedProjects.map((project) => {
return (
<ProjectCard
key={project.id}
@@ -102,9 +157,20 @@ export function TeamProjectsPage(props: {
</div>
)}
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
<LazyCreateAPIKeyDialog
open={isCreateProjectDialogOpen}
onOpenChange={setIsCreateProjectDialogOpen}
teamSlug={props.team.slug}
onCreateAndComplete={() => {
// refresh projects
router.refresh();
@@ -118,7 +184,7 @@ export function TeamProjectsPage(props: {
}
function ProjectCard(props: {
project: Project;
project: ProjectWithAnalytics;
team_slug: string;
}) {
const { project, team_slug } = props;
@@ -130,34 +196,51 @@ function ProjectCard(props: {
{/* TODO - set image */}
<ProjectAvatar className="size-10 rounded-full" src="" />
<div className="flex-grow flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<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>
<CopyButton
text={project.publishableKey}
iconClassName="z-10 size-3"
className="!h-auto !w-auto -translate-x-1 p-2 hover:bg-secondary"
/>
</div>
<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 text-muted-foreground text-sm">
{truncate(project.publishableKey, 32)}
<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 truncate(str: string, stringLimit: number) {
return str.length > stringLimit ? `${str.slice(0, stringLimit)}...` : str;
}
function SearchInput(props: {
value: string;
onValueChange: (value: string) => void;
@@ -209,10 +292,11 @@ function SelectBy(props: {
value: SortById;
onChange: (value: SortById) => void;
}) {
const values: SortById[] = ["name", "createdAt"];
const values: SortById[] = ["name", "createdAt", "monthlyActiveUsers"];
const valueToLabel: Record<SortById, string> = {
name: "Name",
createdAt: "Creation Date",
monthlyActiveUsers: "Monthly Active Users",
};
return (
@@ -223,7 +307,12 @@ function SelectBy(props: {
}}
>
<SelectTrigger className="min-w-[200px] bg-card capitalize">
Sort by {valueToLabel[props.value]}
<div className="flex items-center gap-1.5">
<span className="!hidden lg:!inline text-muted-foreground">
Sort by
</span>
{valueToLabel[props.value]}
</div>
</SelectTrigger>
<SelectContent>
{values.map((value) => (
@@ -0,0 +1,119 @@
import { defineChain, getContract } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { TransactionsChartCardUI } from "../../../(team)/_components/TransactionsCard";
import { getThirdwebClient } from "../../../../../../@/constants/thirdweb.server";
import { fetchDashboardContractMetadata } from "../../../../../../@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
import type { TransactionStats } from "../../../../../../types/analytics";
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
export function TransactionsChartsUI({
data,
aggregatedData,
searchParams,
}: {
data: TransactionStats[];
aggregatedData: TransactionStats[];
searchParams: { [key: string]: string | string[] | undefined };
}) {
return (
<>
<TransactionsChartCardUI
searchParams={searchParams}
data={data}
aggregatedData={aggregatedData}
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
/>
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
<ChainDistributionCard data={aggregatedData} />
<ContractDistributionCard data={aggregatedData} />
</div>
</>
);
}
async function ChainDistributionCard({ data }: { data: TransactionStats[] }) {
const reducedData = await Promise.all(
Object.entries(
data.reduce(
(acc, curr) => {
acc[curr.chainId] = (acc[curr.chainId] || 0) + curr.count;
return acc;
},
{} as Record<string, number>,
),
)
.sort((a, b) => b[1] - a[1])
.slice(0, 10) // only top ten
.map(async ([key, value]) => {
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(key));
const chainMeta = await getChainMetadata(chain).catch(() => undefined);
return {
label: chainMeta?.slug || chain.id.toString(),
value,
link: `/${chain.id}`,
};
}),
);
const aggregateFn = () => new Set(data.map((d) => `${d.chainId}`)).size;
return (
<PieChartCard title="Chains" data={reducedData} aggregateFn={aggregateFn} />
);
}
async function ContractDistributionCard({
data,
}: { data: TransactionStats[] }) {
const reducedData = await Promise.all(
Object.entries(
data
.filter((d) => d.contractAddress)
.reduce(
(acc, curr) => {
acc[`${curr.chainId}:${curr.contractAddress}`] =
(acc[`${curr.chainId}:${curr.contractAddress}`] || 0) +
curr.count;
return acc;
},
{} as Record<string, number>,
),
)
.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,
};
}),
);
const aggregateFn = () =>
new Set(
data
.filter((d) => d.contractAddress)
.map((d) => `${d.chainId}:${d.contractAddress}`),
).size;
return (
<PieChartCard
title="Contracts"
data={reducedData}
aggregateFn={aggregateFn}
/>
);
}
@@ -0,0 +1,50 @@
import { LoadingChartState } from "components/analytics/empty-chart-state";
import { Suspense } from "react";
import type { AnalyticsQueryParams } from "types/analytics";
import { getClientTransactions } from "../../../../../../@/api/analytics";
import { TransactionsChartsUI } from "./TransactionCharts";
export function TransactionsCharts(
props: AnalyticsQueryParams & {
searchParams: { [key: string]: string | string[] | undefined };
},
) {
return (
// TODO: Add better LoadingChartState
<Suspense
fallback={
<div className="h-[400px]">
<LoadingChartState />
</div>
}
>
<TransactionsChartCardAsync {...props} />
</Suspense>
);
}
async function TransactionsChartCardAsync(
props: AnalyticsQueryParams & {
searchParams: { [key: string]: string | string[] | undefined };
},
) {
const [data, aggregatedData] = await Promise.all([
getClientTransactions(props),
getClientTransactions({
...props,
period: "all",
}),
]);
if (!aggregatedData.length) {
return null;
}
return (
<TransactionsChartsUI
data={data}
aggregatedData={aggregatedData}
searchParams={props.searchParams}
/>
);
}
@@ -1,5 +1,8 @@
import type { Range } from "components/analytics/date-range-selector";
import { InAppWalletAnalytics } from "components/embedded-wallets/Analytics";
import { notFound } from "next/navigation";
import { getProject } from "../../../../../../@/api/projects";
import { getAPIKeyForProjectId } from "../../../../../api/lib/getAPIKeys";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -27,9 +30,19 @@ export default async function Page(props: {
? (searchParams.interval as "day" | "week")
: "week";
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
notFound();
}
const apiKey = await getAPIKeyForProjectId(project.id);
if (!apiKey) {
notFound();
}
return (
<InAppWalletAnalytics
clientId={params.project_slug}
clientId={apiKey.key}
interval={interval}
range={range as Range}
/>
@@ -34,6 +34,7 @@ import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChar
import { EmptyState } from "../../components/Analytics/EmptyState";
import { PieChartCard } from "../../components/Analytics/PieChartCard";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
interface PageParams {
team_slug: string;
@@ -49,6 +50,8 @@ interface PageProps {
searchParams: Promise<PageSearchParams>;
}
export const maxDuration = 300;
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
props.params,
@@ -174,12 +177,6 @@ async function ProjectAnalytics(props: {
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
<RpcMethodBarChartCard
from={range.from}
to={range.to}
period={interval}
clientId={project.publishableKey}
/>
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
{walletConnections.status === "fulfilled" &&
walletConnections.value.length > 0 ? (
@@ -200,6 +197,13 @@ async function ProjectAnalytics(props: {
/>
)}
</div>
<TransactionsCharts
searchParams={searchParams}
from={range.from}
to={range.to}
period={interval}
clientId={project.publishableKey}
/>
{userOpUsageTimeSeries.status === "fulfilled" &&
userOpUsage.status === "fulfilled" &&
userOpUsage.value.length > 0 ? (
@@ -216,6 +220,12 @@ async function ProjectAnalytics(props: {
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
/>
)}
<RpcMethodBarChartCard
from={range.from}
to={range.to}
period={interval}
clientId={project.publishableKey}
/>
</div>
);
}
@@ -417,7 +427,7 @@ async function TotalSponsoredCard({
return (
<CombinedBarChartCard
isCurrency
title="Total Sponsored"
title="Gas Sponsored"
chartConfig={chartConfig}
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0"
data={timeSeriesData}
@@ -60,6 +60,16 @@ function Story() {
payConfig: "/payConfig",
}}
onKeyUpdated={undefined}
rotateSecretKey={async () => {
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",
},
};
}}
showNebulaSettings={false}
/>
@@ -1,12 +1,21 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -14,13 +23,14 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { ApiKey, UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi";
import {
useRevokeApiKey,
useUpdateApiKey,
} from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import type { UseMutationResult } from "@tanstack/react-query";
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
import { SERVICES } from "@thirdweb-dev/service-utils";
import {
type ServiceName,
@@ -28,8 +38,13 @@ import {
} from "@thirdweb-dev/service-utils";
import { format } from "date-fns";
import { useTrack } from "hooks/analytics/useTrack";
import { ExternalLinkIcon } from "lucide-react";
import {
CircleAlertIcon,
ExternalLinkIcon,
RefreshCcwIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { type UseFormReturn, useForm } from "react-hook-form";
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
import { toast } from "sonner";
@@ -47,11 +62,20 @@ type EditProjectUIPaths = {
afterDeleteRedirectTo: string;
};
type RotateSecretKeyAPIReturnType = {
data: {
secret: string;
secretMasked: string;
secretHash: string;
};
};
export function ProjectGeneralSettingsPage(props: {
apiKey: ApiKey;
paths: EditProjectUIPaths;
onKeyUpdated: (() => void) | undefined;
showNebulaSettings: boolean;
projectId: string;
}) {
const updateMutation = useUpdateApiKey();
const deleteMutation = useRevokeApiKey();
@@ -64,6 +88,24 @@ export function ProjectGeneralSettingsPage(props: {
paths={props.paths}
onKeyUpdated={props.onKeyUpdated}
showNebulaSettings={props.showNebulaSettings}
rotateSecretKey={async () => {
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
pathname: "/v2/keys/rotate-secret-key",
method: "POST",
body: JSON.stringify({
projectId: props.projectId,
}),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(res.error);
}
return res.data;
}}
/>
);
}
@@ -84,6 +126,7 @@ interface EditApiKeyProps {
paths: EditProjectUIPaths;
onKeyUpdated: (() => void) | undefined;
showNebulaSettings: boolean;
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
}
type UpdateAPIForm = UseFormReturn<ProjectSettingsPageFormSchema>;
@@ -216,7 +259,10 @@ export const ProjectGeneralSettingsPageUI: React.FC<EditApiKeyProps> = (
handleSubmit={handleSubmit}
/>
<APIKeyDetails apiKey={apiKey} />
<APIKeyDetails
apiKey={apiKey}
rotateSecretKey={props.rotateSecretKey}
/>
<AllowedDomainsSetting
form={form}
@@ -609,10 +655,13 @@ function EnabledServicesSetting(props: {
function APIKeyDetails({
apiKey,
rotateSecretKey,
}: {
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
apiKey: ApiKey;
}) {
const { createdAt, updatedAt, lastAccessedAt } = apiKey;
const [secretKeyMasked, setSecretKeyMasked] = useState(apiKey.secretMasked);
return (
<div className="flex flex-col gap-6 rounded-lg border border-border bg-card px-4 py-6 lg:px-6">
@@ -632,7 +681,7 @@ function APIKeyDetails({
</div>
{/* NOTE: for very old api keys the secret might be `null`, if that's the case we skip it */}
{apiKey.secretMasked && (
{secretKeyMasked && (
<div>
<h3>Secret Key</h3>
<p className="mb-2 text-muted-foreground text-sm">
@@ -641,8 +690,17 @@ function APIKeyDetails({
the time of creation for the full secret key.
</p>
<div className="max-w-[350px] rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
{apiKey.secretMasked}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm lg:w-[350px]">
{secretKeyMasked}
</div>
<RotateSecretKeyButton
rotateSecretKey={rotateSecretKey}
onSuccess={(data) => {
setSecretKeyMasked(data.data.secretMasked);
}}
/>
</div>
</div>
)}
@@ -726,3 +784,214 @@ function DeleteProject(props: {
/>
);
}
function RotateSecretKeyButton(props: {
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [isModalCloseAllowed, setIsModalCloseAllowed] = useState(true);
return (
<Dialog
open={isOpen}
onOpenChange={(v) => {
if (!isModalCloseAllowed) {
return;
}
setIsOpen(v);
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
className="h-auto gap-2 rounded-lg bg-background px-4 py-3"
onClick={() => setIsOpen(true)}
>
<RefreshCcwIcon className="size-4" />
Rotate Secret Key
</Button>
</DialogTrigger>
<DialogContent
className="overflow-hidden p-0"
dialogCloseClassName={cn(!isModalCloseAllowed && "hidden")}
>
<RotateSecretKeyModalContent
rotateSecretKey={props.rotateSecretKey}
closeModal={() => {
setIsOpen(false);
setIsModalCloseAllowed(true);
}}
disableModalClose={() => setIsModalCloseAllowed(false)}
onSuccess={props.onSuccess}
/>
</DialogContent>
</Dialog>
);
}
type RotateSecretKeyScreen =
| { id: "initial" }
| { id: "save-newkey"; secretKey: string };
function RotateSecretKeyModalContent(props: {
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
closeModal: () => void;
disableModalClose: () => void;
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
}) {
const [screen, setScreen] = useState<RotateSecretKeyScreen>({
id: "initial",
});
if (screen.id === "save-newkey") {
return (
<SaveNewKeyScreen
secretKey={screen.secretKey}
closeModal={props.closeModal}
/>
);
}
if (screen.id === "initial") {
return (
<RotateSecretKeyInitialScreen
rotateSecretKey={props.rotateSecretKey}
onSuccess={(data) => {
props.disableModalClose();
props.onSuccess(data);
setScreen({ id: "save-newkey", secretKey: data.data.secret });
}}
closeModal={props.closeModal}
/>
);
}
return null;
}
function RotateSecretKeyInitialScreen(props: {
rotateSecretKey: () => Promise<RotateSecretKeyAPIReturnType>;
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
closeModal: () => void;
}) {
const [isConfirmed, setIsConfirmed] = useState(false);
const rotateKeyMutation = useMutation({
mutationFn: props.rotateSecretKey,
onSuccess: (data) => {
props.onSuccess(data);
},
onError: (err) => {
console.error(err);
toast.error("Failed to rotate secret key");
},
});
return (
<div>
<div className="flex flex-col p-6">
<DialogHeader>
<DialogTitle>Rotate Secret Key</DialogTitle>
</DialogHeader>
<div className="h-6" />
<Alert variant="destructive">
<CircleAlertIcon className="size-5" />
<AlertTitle>Current secret key will stop working</AlertTitle>
<AlertDescription>
Rotating the secret key will invalidate the current secret key and
generate a new one. This action is irreversible.
</AlertDescription>
</Alert>
<div className="h-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={isConfirmed}
onCheckedChange={(v) => setIsConfirmed(!!v)}
/>
I understand the consequences of rotating the secret key
</CheckboxWithLabel>
</div>
<div className="flex justify-end gap-3 border-t bg-card p-6">
<Button variant="outline" onClick={props.closeModal}>
Close
</Button>
<Button
variant="destructive"
className="gap-2"
disabled={!isConfirmed || rotateKeyMutation.isPending}
onClick={() => {
rotateKeyMutation.mutate();
}}
>
{rotateKeyMutation.isPending ? (
<Spinner className="size-4" />
) : (
<RefreshCcwIcon className="size-4" />
)}
Rotate Secret Key
</Button>
</div>
</div>
);
}
function SaveNewKeyScreen(props: {
secretKey: string;
closeModal: () => void;
}) {
const [isSecretStored, setIsSecretStored] = useState(false);
return (
<div className="flex min-w-0 flex-col">
<div className="flex flex-col p-6">
<DialogHeader>
<DialogTitle>Save New Secret Key</DialogTitle>
</DialogHeader>
<div className="h-6" />
<CopyTextButton
textToCopy={props.secretKey}
className="!h-auto w-full justify-between bg-card px-3 py-3 font-mono"
textToShow={props.secretKey}
copyIconPosition="right"
tooltip="Copy Secret Key"
/>
<div className="h-4" />
<Alert variant="destructive">
<AlertTitle>Do not share or expose your secret key</AlertTitle>
<AlertDescription>
<div className="mb-5">
Secret keys cannot be recovered. If you lose your secret key, you
will need to rotate the secret key or create a new Project.
</div>
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={isSecretStored}
onCheckedChange={(v) => {
setIsSecretStored(!!v);
}}
/>
I confirm that I've securely stored my secret key
</CheckboxWithLabel>
</AlertDescription>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card p-6">
<Button
variant="outline"
className="gap-2"
disabled={!isSecretStored}
onClick={props.closeModal}
>
Close
</Button>
</div>
</div>
);
}
@@ -9,9 +9,10 @@ export function ProjectGeneralSettingsPageForTeams(props: {
team: Team;
project_slug: string;
apiKey: ApiKey;
projectId: string;
}) {
const router = useDashboardRouter();
const { team, project_slug, apiKey } = props;
const { team, project_slug, apiKey, projectId } = props;
const projectLayout = `/team/${team.slug}/${project_slug}`;
// TODO - add a Project Image form field on this page
@@ -19,6 +20,7 @@ export function ProjectGeneralSettingsPageForTeams(props: {
return (
<ProjectGeneralSettingsPage
apiKey={apiKey}
projectId={projectId}
paths={{
aaConfig: `${projectLayout}/connect/account-abstraction/settings`,
inAppConfig: `${projectLayout}/connect/in-app-wallets/settings`,
@@ -31,6 +31,7 @@ export default async function Page(props: {
<ProjectGeneralSettingsPageForTeams
apiKey={apiKey}
project_slug={project_slug}
projectId={project.id}
team={team}
/>
);
@@ -2,6 +2,7 @@ import { getTeamBySlug } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { redirect } from "next/navigation";
import { TWAutoConnect } from "../../components/autoconnect";
import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage";
export default async function RootTeamLayout(props: {
children: React.ReactNode;
@@ -19,6 +20,7 @@ export default async function RootTeamLayout(props: {
<div className="flex grow flex-col">{props.children}</div>
<TWAutoConnect />
<AppFooter />
<SaveLastVisitedTeamPage />
</div>
);
}
@@ -4,6 +4,7 @@ import {
CardFooter,
CardHeader,
} from "@/components/ui/card";
import Link from "next/link";
import { PieChart } from "./PieChart";
import { Stat } from "./Stat";
@@ -11,6 +12,7 @@ type ChartData = {
value: number;
label: string;
fill?: string;
link?: string;
};
export function PieChartCard({
title,
@@ -22,11 +24,11 @@ export function PieChartCard({
aggregateFn?: (data: ChartData[]) => number;
}) {
const processedData = (() => {
if (data.length <= 9) return data;
// Sort by value descending
const sorted = [...data].sort((a, b) => b.value - a.value);
if (sorted.length <= 9) return sorted;
// Take top 9
const top10 = sorted.slice(0, 9).map((item) => ({
...item,
@@ -62,13 +64,27 @@ export function PieChartCard({
<CardFooter className="no-scrollbar flex max-w-full justify-center p-6 pt-0 max-md:overflow-x-auto">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2.5">
{processedData.map(
({ label, fill }: { label: string; fill?: string }) => (
({
label,
fill,
link,
}: { label: string; fill?: string; link?: string }) => (
<div key={fill} className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ background: fill }}
/>
<span className="text-muted-foreground text-xs">{label}</span>
{link ? (
<Link
href={link}
target="_blank"
className="text-muted-foreground text-xs hover:text-link-foreground"
>
{label}
</Link>
) : (
<span className="text-muted-foreground text-xs">{label}</span>
)}
</div>
),
)}
@@ -65,6 +65,11 @@ export function TeamHeaderLoggedIn(props: {
<LazyCreateAPIKeyDialog
open={createProjectDialogState.isOpen}
teamSlug={
createProjectDialogState.isOpen
? createProjectDialogState.team.slug
: undefined
}
onOpenChange={() =>
setCreateProjectDialogState({
isOpen: false,
@@ -0,0 +1,17 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { setCookie } from "../../../../lib/cookie";
import { LAST_VISITED_TEAM_PAGE_PATH } from "./consts";
export function SaveLastVisitedTeamPage() {
const pathname = usePathname();
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setCookie(LAST_VISITED_TEAM_PAGE_PATH, pathname);
}, [pathname]);
return null;
}
@@ -0,0 +1,3 @@
// Only keep constants here - this file is imported in middleware too
export const LAST_VISITED_TEAM_PAGE_PATH = "last-visited-team-page-path";
@@ -25,7 +25,7 @@ import { CircleAlertIcon, ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
import { ZERO_ADDRESS } from "thirdweb";
import { ZERO_ADDRESS, getContract } from "thirdweb";
import type { FetchDeployMetadataResult } from "thirdweb/contract";
import {
deployContractfromDeployMetadata,
@@ -567,11 +567,13 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
const contractAddr = await deployMutation.mutateAsync(formData);
// send verification request - no need to await
verifyContract({
address: contractAddr,
chain: walletChain,
client: thirdwebClient,
});
verifyContract(
getContract({
address: contractAddr,
chain: walletChain,
client: thirdwebClient,
}),
);
trackEvent({
category: "custom-contract",
@@ -1,4 +1,6 @@
"use client";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
@@ -24,7 +26,6 @@ import {
useAllContractList,
} from "@3rdweb-sdk/react/hooks/useRegistry";
import { zodResolver } from "@hookform/resolvers/zod";
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
import { useChainSlug } from "hooks/chains/chainSlug";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
@@ -82,17 +83,22 @@ const importFormSchema = z.object({
message: "Invalid contract address",
},
),
chainId: z.coerce.number(),
});
function ImportForm() {
const router = useDashboardRouter();
const chainId = useActiveWalletChain()?.id;
const chainSlug = useChainSlug(chainId || 1);
const activeChainId = useActiveWalletChain()?.id;
const [isRedirecting, setIsRedirecting] = useState(false);
const form = useForm({
resolver: zodResolver(importFormSchema),
values: {
contractAddress: "",
chainId: activeChainId || 1,
},
});
const chainSlug = useChainSlug(form.watch("chainId"));
const addToDashboard = useAddContractMutation();
const address = useActiveAccount()?.address;
const registry = useAllContractList(address);
@@ -107,9 +113,7 @@ function ImportForm() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
if (!chainId) {
throw new Error("No chain ID");
}
const { chainId } = data;
let contractAddress: string;
try {
@@ -199,7 +203,11 @@ function ImportForm() {
<div className="h-3" />
<div>
<Label className="mb-3 inline-block">Network</Label>
<NetworkSelectorButton />
<SingleNetworkSelector
chainId={form.watch("chainId")}
onChange={(v) => form.setValue("chainId", v)}
side="top"
/>
</div>
<div className="h-8" />
@@ -14,7 +14,7 @@ import {
Input,
} from "@chakra-ui/react";
import { useMutation } from "@tanstack/react-query";
import type { AbiFunction, AbiParameter } from "abitype";
import { type AbiFunction, type AbiParameter, formatAbiItem } from "abitype";
import { TransactionButton } from "components/buttons/TransactionButton";
import { SolidityInput } from "contract-ui/components/solidity-inputs";
import { camelToTitle } from "contract-ui/components/solidity-inputs/helpers";
@@ -28,13 +28,12 @@ import {
type ThirdwebContract,
prepareContractCall,
readContract,
resolveMethod,
simulateTransaction,
toSerializableTransaction,
toWei,
} from "thirdweb";
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
import { parseAbiParams, stringify } from "thirdweb/utils";
import { parseAbiParams, stringify, toFunctionSelector } from "thirdweb/utils";
import {
Button,
Card,
@@ -135,7 +134,12 @@ interface InteractiveAbiFunctionProps {
twAccount: Account | undefined;
}
function useAsyncRead(contract: ThirdwebContract, functionName: string) {
function useAsyncRead(contract: ThirdwebContract, abiFunction: AbiFunction) {
const formattedAbi = formatAbiItem({
...abiFunction,
type: "function",
// biome-ignore lint/suspicious/noExplicitAny: FIXME
} as any);
return useMutation({
mutationFn: async ({
args,
@@ -144,7 +148,8 @@ function useAsyncRead(contract: ThirdwebContract, functionName: string) {
const params = parseAbiParams(types, args);
return readContract({
contract,
method: resolveMethod(functionName),
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing
method: formattedAbi as any,
params,
});
},
@@ -156,21 +161,32 @@ function useSimulateTransaction() {
return useMutation({
mutationFn: async ({
contract,
functionName,
abiFunction,
params,
value,
}: {
contract: ThirdwebContract;
functionName: string;
abiFunction: AbiFunction;
params: unknown[];
value?: bigint;
}) => {
if (!from) {
return toast.error("No account connected");
}
const formattedAbi = formatAbiItem({
...abiFunction,
type: "function",
// biome-ignore lint/suspicious/noExplicitAny: FIXME
} as any);
console.log(
"formattedAbi",
formattedAbi,
toFunctionSelector(abiFunction),
);
const transaction = prepareContractCall({
contract,
method: resolveMethod(functionName),
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing
method: formattedAbi as any,
params,
value,
});
@@ -179,10 +195,16 @@ function useSimulateTransaction() {
simulateTransaction({
from,
transaction,
}).catch((e) => {
console.error("Error simulating transaction", e);
throw e;
}),
toSerializableTransaction({
from,
transaction,
}).catch((e) => {
console.error("Error serializing transaction", e);
throw e;
}),
]);
return `--- ✅ Simulation succeeded ---
@@ -257,7 +279,7 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
data: readData,
isPending: readLoading,
error: readError,
} = useAsyncRead(contract, abiFunction.name);
} = useAsyncRead(contract, abiFunction);
const txSimulation = useSimulateTransaction();
@@ -311,12 +333,18 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
if (!abiFunction.name) {
return toast.error("Cannot detect function name");
}
const formattedAbi = formatAbiItem({
...abiFunction,
type: "function",
// biome-ignore lint/suspicious/noExplicitAny: FIXME
} as any);
const types = abiFunction.inputs.map((o) => o.type);
const formatted = formatContractCall(d.params);
const params = parseAbiParams(types, formatted);
const transaction = prepareContractCall({
contract,
method: resolveMethod(abiFunction.name),
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing
method: formattedAbi as any,
params,
value: d.value ? toWei(d.value) : undefined,
});
@@ -334,7 +362,7 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
txSimulation.mutate({
contract,
params,
functionName: abiFunction.name,
abiFunction,
value: d.value ? toWei(d.value) : undefined,
});
});
@@ -47,7 +47,7 @@ export async function Changelog() {
async function getChangelog() {
const res = await fetch(
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=10",
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=7",
);
const json = await res.json();
return json.posts as ChangelogItem[];
@@ -20,7 +20,10 @@ export async function InAppWalletAnalytics({
from: range.from,
to: range.to,
period: interval,
}).catch(() => null);
}).catch((error) => {
console.error(error);
return [];
});
return (
<div>
@@ -57,20 +57,18 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
}, [validTeamPlan]);
const growthCardCta: CtaLink | undefined = useMemo(() => {
const trialTitle = "Claim your 1-month free";
switch (validTeamPlan) {
// free > growth
case "free": {
return {
label: team.growthTrialEligible ? trialTitle : "Get started",
label: "Get started",
};
}
// starter > growth
case "starter": {
return {
label: team.growthTrialEligible ? trialTitle : "Upgrade",
label: "Upgrade",
};
}
@@ -87,7 +85,7 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
};
}
}
}, [team, validTeamPlan]);
}, [validTeamPlan]);
const proCta: CtaLink | undefined = useMemo(() => {
// pro > pro
@@ -138,20 +136,16 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
title: growthCardCta.label,
tracking: {
category: "account",
label: team.growthTrialEligible
? "claimGrowthTrial"
: "growthPlan",
label: "growthPlan",
},
variant: "default",
hint: team.growthTrialEligible
? "Your free trial will end after 30 days."
: undefined,
hint: undefined,
}
: undefined
}
canTrialGrowth={team.growthTrialEligible || false}
canTrialGrowth={false}
// upsell growth plan if user is on free plan
highlighted={validTeamPlan === "free"}
highlighted={validTeamPlan === "free" || validTeamPlan === "starter"}
teamSlug={team.slug}
redirectToCheckout={redirectToCheckout}
/>
@@ -1,10 +1,8 @@
import { redirectToBillingPortal, redirectToCheckout } from "@/actions/billing";
import type { Team } from "@/api/team";
import type { TeamSubscription } from "@/api/team-subscription";
import {} from "@/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import {} from "@chakra-ui/react";
import { AlertCircleIcon } from "lucide-react";
import Link from "next/link";
import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard";
@@ -53,6 +53,7 @@ function Story(props: {
createKeyMutation={mutation}
prefill={props.prefill}
enableNebulaServiceByDefault={false}
teamSlug="foo"
/>
<Button
@@ -1,3 +1,5 @@
import { apiServerProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
@@ -23,6 +25,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import {
type ApiKey,
type CreateKeyInput,
@@ -30,10 +33,10 @@ import {
} from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogDescription } from "@radix-ui/react-dialog";
import type { UseMutationResult } from "@tanstack/react-query";
import { type UseMutationResult, useQuery } from "@tanstack/react-query";
import { SERVICES } from "@thirdweb-dev/service-utils";
import { useTrack } from "hooks/analytics/useTrack";
import { ArrowLeftIcon } from "lucide-react";
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -54,6 +57,7 @@ export type CreateAPIKeyDialogProps = {
onCreateAndComplete?: () => void;
prefill?: CreateAPIKeyPrefillOptions;
enableNebulaServiceByDefault: boolean;
teamSlug: string | undefined;
};
const CreateAPIKeyDialog = (props: CreateAPIKeyDialogProps) => {
@@ -78,6 +82,7 @@ export const CreateAPIKeyDialogUI = (props: {
>;
prefill?: CreateAPIKeyPrefillOptions;
enableNebulaServiceByDefault: boolean;
teamSlug: string | undefined;
}) => {
const [screen, setScreen] = useState<
{ id: "create" } | { id: "api-details"; key: ApiKey }
@@ -97,7 +102,7 @@ export const CreateAPIKeyDialogUI = (props: {
}}
>
<DialogContent
className="z-[10001] p-0"
className="z-[10001] overflow-hidden p-0"
dialogOverlayClassName="z-[10000]"
dialogCloseClassName={screen.id === "api-details" ? "hidden" : ""}
>
@@ -116,6 +121,7 @@ export const CreateAPIKeyDialogUI = (props: {
{screen.id === "api-details" && (
<APIKeyDetails
apiKey={screen.key}
teamSlug={props.teamSlug}
onComplete={() => {
onOpenChange(false);
setScreen({ id: "create" });
@@ -402,9 +408,35 @@ function DomainsAlert(props: {
function APIKeyDetails(props: {
apiKey: ApiKey;
onComplete: () => void;
teamSlug: string | undefined;
}) {
const { apiKey } = props;
const [secretStored, setSecretStored] = useState(false);
const router = useDashboardRouter();
// get the project.slug for the apiKey to render "View Project" button
const projectQuery = useQuery({
queryKey: ["project", props.teamSlug, apiKey.id],
queryFn: async () => {
const res = await apiServerProxy<{
result: Project[];
}>({
method: "GET",
pathname: `/v1/teams/${props.teamSlug}/projects`,
});
if (!res.ok) {
throw new Error(res.error);
}
const projects = res.data.result;
const project = projects.find((p) => p.publishableKey === apiKey.key);
return project || null;
},
enabled: !!props.teamSlug,
});
const projectSlug = projectQuery.data?.slug;
return (
<div>
@@ -454,7 +486,7 @@ function APIKeyDetails(props: {
<AlertDescription>
<div className="mb-5">
Secret keys cannot be recovered. If you lose your secret key, you
will need to create a project
will need to rotate the secret key or create a new Project.
</div>
<CheckboxWithLabel className="text-foreground">
<Checkbox
@@ -474,10 +506,31 @@ function APIKeyDetails(props: {
type="button"
onClick={props.onComplete}
disabled={!secretStored}
variant="outline"
className="min-w-28 gap-2"
>
Complete
{props.teamSlug ? "Close" : "Complete"}
</Button>
{props.teamSlug && (
<Button
onClick={() => {
if (!projectSlug) {
return;
}
router.push(`/team/${props.teamSlug}/${projectSlug}`);
}}
disabled={!secretStored || !projectSlug}
className="min-w-28 gap-2"
>
View Project
{projectQuery.isPending ? (
<Spinner className="size-4" />
) : (
<ExternalLinkIcon className="size-4" />
)}
</Button>
)}
</DialogFooter>
</div>
);
@@ -63,7 +63,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/example.csv"
href="/assets/examples/example.csv"
>
Download example.csv
</a>
@@ -71,7 +71,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/example.json"
href="/assets/examples/example.json"
>
Download example.json
</a>
@@ -113,7 +113,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/example-with-ipfs.csv"
href="/assets/examples/example-with-ipfs.csv"
>
Download example.csv
</a>
@@ -125,7 +125,7 @@ export const UploadStep: React.FC<UploadStepProps> = ({
<a
download
className="text-link-foreground hover:text-foreground"
href="/example-with-maps.csv"
href="/assets/examples/example-with-maps.csv"
>
Download example.csv
</a>
@@ -24,6 +24,13 @@ export async function fetchAnalytics(
decodeURIComponent(value),
);
}
// client id DEBUG OVERRIDE
// ANALYTICS_SERVICE_URL.searchParams.delete("clientId");
// ANALYTICS_SERVICE_URL.searchParams.delete("accountId");
// ANALYTICS_SERVICE_URL.searchParams.append(
// "clientId",
// "...",
// );
return fetch(ANALYTICS_SERVICE_URL, {
...init,
+25
View File
@@ -0,0 +1,25 @@
"use client";
export function getCookie(name: string) {
try {
const value = document.cookie;
const cookies = value.split(";");
for (const c of cookies) {
const [_name, _val] = c.trim().split("=");
if (_name === name && _val) {
return decodeURIComponent(_val);
}
}
} catch {
// ignore
}
return undefined;
}
export function setCookie(name: string, value: string, days = 365) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};path=/;expires=${date.toUTCString()}`;
}
+8 -5
View File
@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { getAddress } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { isValidENSName } from "thirdweb/utils";
import { LAST_VISITED_TEAM_PAGE_PATH } from "./app/team/components/last-visited-page/consts";
import { defineDashboardChain } from "./lib/defineDashboardChain";
// ignore assets, api - only intercept page routes
@@ -17,8 +18,9 @@ export const config = {
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - assets/
* - sitemap.xml, sitemap-0.xml, robots.txt, favicon.ico, some favicon images
*/
"/((?!api|_next/static|_next/image|favicon.ico|assets).*)",
"/((?!api|_next/static|_next/image|favicon.ico|assets|robots.txt|\\.xml|\\.png).*)",
],
};
@@ -53,7 +55,6 @@ export async function middleware(request: NextRequest) {
: null;
// utm collection
// NOTE: this is not working for pages with rewrites in next.config.js - (framer pages)
// if user is already signed in - don't bother capturing utm params
if (!authCookie) {
const searchParamsEntries = request.nextUrl.searchParams.entries();
@@ -95,13 +96,15 @@ export async function middleware(request: NextRequest) {
}
}
// remove '/' in front and then split by '/'
// if it's the homepage and we have an auth cookie, redirect to the dashboard
if (paths.length === 1 && paths[0] === "" && authCookie) {
const lastVisitedTeamPagePath = request.cookies.get(
LAST_VISITED_TEAM_PAGE_PATH,
)?.value;
return redirect(
request,
"/team",
lastVisitedTeamPagePath || "/team",
cookiesToSet
? {
cookiesToSet,
@@ -1,5 +1,6 @@
import { type Store, useStore } from "@/lib/reactive";
import { useEffect } from "react";
import { getCookie, setCookie } from "../lib/cookie";
const loadedStoreKeys = new Set<string>();
@@ -51,27 +52,3 @@ export function SyncStoreToCookies<T>(props: {
return null;
}
function getCookie(name: string) {
try {
const value = document.cookie;
const cookies = value.split(";");
for (const c of cookies) {
const [_name, _val] = c.trim().split("=");
if (_name === name && _val) {
return decodeURIComponent(_val);
}
}
} catch {
// ignore
}
return undefined;
}
function setCookie(name: string, value: string, days = 365) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};path=/;expires=${date.toUTCString()}`;
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
billingEmail: "[email protected]",
growthTrialEligible: true,
growthTrialEligible: false,
enabledScopes: [
"pay",
"storage",
+7
View File
@@ -28,6 +28,13 @@ export interface UserOpStats {
chainId?: string;
}
export interface TransactionStats {
date: string;
chainId: number;
contractAddress?: string;
count: number;
}
export interface RpcMethodStats {
date: string;
evmMethod: string;
+18 -18
View File
@@ -13,28 +13,28 @@
"fix": "eslint ./src --fix"
},
"dependencies": {
"@abstract-foundation/agw-client": "^1.0.1",
"@abstract-foundation/agw-react": "^1.0.1",
"@abstract-foundation/agw-client": "^1.3.0",
"@abstract-foundation/agw-react": "^1.4.2",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-dialog": "1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "1.1.5",
"@tanstack/react-query": "5.62.16",
"@thirdweb-dev/engine": "^0.0.17",
"@radix-ui/react-tooltip": "1.1.7",
"@tanstack/react-query": "5.65.0",
"@thirdweb-dev/engine": "^0.0.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "0.468.0",
"next": "15.1.3",
"lucide-react": "0.474.0",
"next": "15.1.6",
"next-themes": "^0.4.4",
"prettier": "3.3.3",
"react": "19.0.0",
@@ -48,15 +48,15 @@
"use-debounce": "^10.0.4"
},
"devDependencies": {
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"@types/node": "22.10.10",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"eslint": "8.57.0",
"eslint-config-next": "15.1.4",
"eslint-config-next": "15.1.6",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"postcss": "8.4.49",
"tailwindcss": "3.4.16",
"postcss": "8.5.1",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.7.2"
"typescript": "5.7.3"
}
}
@@ -1,5 +1,4 @@
"use client";
import { useState } from "react";
import { getContract } from "thirdweb";
import { zkSyncSepolia } from "thirdweb/chains";
@@ -15,6 +14,12 @@ import { shortenHex } from "thirdweb/utils";
import { THIRDWEB_CLIENT } from "../../lib/client";
import { WALLETS } from "../../lib/constants";
// const chain = abstractTestnet;
// const editionDropContract = getContract({
// client: THIRDWEB_CLIENT,
// address: "0x8A24a7Df38fA5fCCcFD1259e90Fb6996fDdfcADa",
// chain,
// });
const chain = zkSyncSepolia;
const editionDropContract = getContract({
client: THIRDWEB_CLIENT,
@@ -83,6 +88,7 @@ export function SponsoredTxZksyncPreview() {
metadata: nft?.metadata,
}}
onError={(error) => {
console.error("error", error);
alert(`Error: ${error.message}`);
}}
onClick={() => {
@@ -11,7 +11,7 @@ const getEcosystem = () => {
return "ecosystem.catlovers";
}
// prod ecosystem
return "ecosystem.new-age";
return "ecosystem.thirdweb-engs";
};
export function EcosystemConnectEmbed(
@@ -1,5 +1,4 @@
"use client";
import {
useActiveAccount,
useActiveWallet,
@@ -39,7 +38,9 @@ export function HooksPreview() {
</>
) : (
<Button variant="default" onClick={connect}>
Connect with Metamask
{connectMutation.isConnecting
? "Connecting..."
: "Connect with Metamask"}
</Button>
)}
</div>
+11 -3
View File
@@ -5,12 +5,20 @@
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@next/next/recommended",
"plugin:storybook/recommended"
"plugin:mdx/recommended",
"plugin:tailwindcss/recommended"
],
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "off",
"svg-jsx/camel-case-dash": "error"
}
"svg-jsx/camel-case-dash": "error",
"tailwindcss/classnames-order": "off"
},
"overrides": [
{
"files": ["*.ts", "*.js", "*.tsx", "*.jsx"],
"extends": ["biome"]
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"next": true,
"project": ["src/**"],
"ignoreBinaries": ["only-allow", "biome"],
"ignore": ["src/components/ui/**"],
"ignoreDependencies": [
"@thirdweb-dev/chains",
"@thirdweb-dev/wallets",
"thirdweb",
"@types/mdx"
],
"entry": [
"next.config.{js,ts,cjs,mjs}",
"{instrumentation,middleware}.{js,ts}",
"app/global-error.{js,jsx,ts,tsx}",
"app/**/{error,layout,loading,not-found,page,template,default}.{js,jsx,ts,tsx,mdx}",
"app/**/route.{js,jsx,ts,tsx}",
"app/{manifest,sitemap,robots}.{js,ts}",
"app/**/{icon,apple-icon}.{js,jsx,ts,tsx}",
"app/**/{opengraph,twitter}-image.{js,jsx,ts,tsx}",
"pages/**/*.{js,jsx,ts,tsx}",
"src/{instrumentation,middleware}.{js,ts}",
"src/app/global-error.{js,jsx,ts,tsx}",
"src/app/**/{error,layout,loading,not-found,page,template,default}.{js,jsx,ts,tsx,mdx}",
"src/app/**/route.{js,jsx,ts,tsx}",
"src/app/{manifest,sitemap,robots}.{js,ts}",
"src/app/**/{icon,apple-icon}.{js,jsx,ts,tsx}",
"src/app/**/{opengraph,twitter}-image.{js,jsx,ts,tsx}",
"src/pages/**/*.{js,jsx,ts,tsx}",
"scripts/*.ts"
]
}
+20 -19
View File
@@ -10,31 +10,32 @@
"build": "next build",
"postbuild": "pnpm run extract-search-data && pnpm next-sitemap",
"start": "next start",
"lint": "biome check ./src && eslint ./src",
"lint": "biome check ./src && knip && eslint ./src",
"fix": "biome check ./src --fix && eslint ./src --fix",
"create-index": "pnpm tsx scripts/createEmptySearchIndex.ts",
"extract-search-data": "pnpm tsx scripts/extractSearchData.ts"
"extract-search-data": "pnpm tsx scripts/extractSearchData.ts",
"typecheck": "tsc --noEmit",
"knip": "knip"
},
"dependencies": {
"@dirtycajunrice/klee": "^1.0.6",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "15.1.0",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-select": "^2.1.3",
"@next/mdx": "15.1.6",
"@radix-ui/react-dialog": "1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@tanstack/react-query": "5.62.16",
"@tanstack/react-query": "5.65.0",
"@tryghost/content-api": "^1.11.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
"escape-string-regexp": "^5.0.0",
"flexsearch": "^0.7.43",
"github-slugger": "^2.0.0",
"lucide-react": "0.468.0",
"next": "15.1.3",
"lucide-react": "0.474.0",
"next": "15.1.6",
"nextjs-toploader": "^1.6.12",
"node-html-parser": "^6.1.13",
"posthog-js": "1.67.1",
@@ -43,7 +44,6 @@
"react-dom": "19.0.0",
"react-html-parser": "2.0.2",
"remark-gfm": "3.0.1",
"semver": "^7.6.0",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"tailwind-merge": "^2.6.0",
@@ -53,11 +53,12 @@
"typedoc-better-json": "0.9.4"
},
"devDependencies": {
"@next/eslint-plugin-next": "15.1.6",
"@types/flexsearch": "^0.7.6",
"@types/mdx": "^2.0.13",
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"@types/node": "22.10.10",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"@types/react-html-parser": "^2.0.6",
"@types/tryghost__content-api": "^1.3.16",
"@typescript-eslint/eslint-plugin": "7.14.1",
@@ -65,14 +66,14 @@
"autoprefixer": "^10.4.19",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-config-next": "15.1.4",
"eslint-plugin-mdx": "^3.1.5",
"eslint-plugin-svg-jsx": "^1.2.4",
"eslint-plugin-tailwindcss": "^3.17.5",
"eslint-plugin-tailwindcss": "^3.18.0",
"knip": "5.43.5",
"next-sitemap": "^4.2.3",
"postcss": "8.4.49",
"tailwindcss": "3.4.16",
"postcss": "8.5.1",
"tailwindcss": "3.4.17",
"tsx": "4.19.2",
"typescript": "5.7.2"
"typescript": "5.7.3"
}
}
+21 -41
View File
@@ -1,6 +1,11 @@
// @ts-check
import { unrealEngineRedirects } from "./src/app/unreal-engine/redirects.mjs";
const unrealEngineRedirects = {
"/unreal/:path*": "/unreal-engine/:path*",
"/unreal-engine/cpp/wallet-handle": "/unreal-engine/cpp/wallet-handles",
"/unreal-engine/blueprints/private-key-wallet":
"unreal-engine/blueprints/in-app-wallet",
};
const reactRedirects = {
"/react": "/react/v4",
@@ -981,6 +986,20 @@ const v5RestructuredRedirects = {
"/typescript/v5/react-native/:path*": "/react-native/v5/:path*",
};
const v4ToV5Redirects = {
"/typescript/v4": "/typescript/v5",
"/typescript/v4/:path*": "/typescript/v5",
"/react/v4": "/react/v5",
"/react/v4/:path*": "/react/v5",
"/react-native/v0": "/react-native/v5",
"/react-native/v0/:path*": "/react-native/v5",
"/wallet-sdk/:path*": "/connect",
"/storage-sdk/v2": "/typescript/v5/storage",
"/storage-sdk/v2/:path*": "/typescript/v5/storage",
"/unity/v4": "/unity/v5",
"/unity/v4/:path*": "/unity/v5",
};
/**
* @type {import('next').NextConfig['redirects']}
*/
@@ -999,18 +1018,7 @@ export const redirects = async () => {
...createRedirects(otherRedirects),
...createRedirects(v5RestructuredRedirects),
...createRedirects(unrealEngineRedirects),
// references docs
latestReference("react", "v4"),
latestReference("react-native", "v0"),
latestReference("typescript", "v5"),
latestReference("wallets", "v2"),
latestReference("storage", "v2"),
// sdk docs
latestSDK("react", "v4"),
latestSDK("react-native", "v0"),
latestSDK("typescript", "v5"),
latestSDK("wallet-sdk", "v2"),
latestSDK("storage-sdk", "v2"),
...createRedirects(v4ToV5Redirects),
];
};
@@ -1026,31 +1034,3 @@ function createRedirects(linkMap, permanent = true) {
}
return redirects;
}
/**
*
* @param {string} pkg
* @param {string} latestVersion
* @returns
*/
function latestReference(pkg, latestVersion) {
return {
source: `/references/${pkg}/latest/:path*`,
destination: `/references/${pkg}/${latestVersion}/:path*`,
permanent: false,
};
}
/**
*
* @param {string} pkg
* @param {string} latestVersion
* @returns
*/
function latestSDK(pkg, latestVersion) {
return {
source: `/${pkg}/latest/:path*`,
destination: `/${pkg}/${latestVersion}/:path*`,
permanent: false,
};
}
+52 -47
View File
@@ -9,14 +9,20 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import clsx from "clsx";
import { ChevronDownIcon, Menu } from "lucide-react";
import Image from "next/image";
import { ChevronDownIcon, Menu, TableOfContentsIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { GithubIcon } from "../components/Document/GithubButtonLink";
import { CustomAccordion } from "../components/others/CustomAccordion";
import { ThemeSwitcher } from "../components/others/theme/ThemeSwitcher";
import {
DotNetIcon,
ReactIcon,
TypeScriptIcon,
UnityIcon,
UnrealEngineIcon,
} from "../icons";
import { ThirdwebIcon } from "../icons/thirdweb";
const links = [
@@ -65,42 +71,45 @@ const toolLinks = [
},
];
export const connectLinks = [
export const connectLinks: Array<{
name: string;
href: string;
icon: React.FC<{ className?: string }>;
}> = [
{
name: "Overview",
href: "/connect",
icon: "/icons/navbar/nav-icon-dashboard.svg",
icon: TableOfContentsIcon,
},
{
name: "TypeScript",
href: "/typescript/v5",
icon: "/icons/navbar/nav-icon-typescript.svg",
icon: TypeScriptIcon,
},
{
name: "React",
href: "/react/v5",
icon: "/icons/navbar/nav-icon-react.svg",
icon: ReactIcon,
},
{
name: "React Native",
href: "/react-native/v5",
icon: "/icons/navbar/nav-icon-react.svg",
// icon: "/icons/navbar/nav-icon-react-native.svg",
icon: ReactIcon,
},
{
name: ".NET",
href: "/dotnet",
icon: "/icons/navbar/nav-icon-dotnet.svg",
icon: DotNetIcon,
},
{
name: "Unity",
href: "/unity",
icon: "/icons/navbar/nav-icon-unity.svg",
icon: UnityIcon,
},
{
name: "Unreal Engine",
href: "/unreal-engine",
icon: "/icons/navbar/nav-icon-unreal-engine.svg",
icon: UnrealEngineIcon,
},
] as const;
@@ -123,7 +132,7 @@ export function Header() {
const [showBurgerMenu, setShowBurgerMenu] = useState(false);
return (
<header className="flex w-full items-center border-b bg-b-900">
<header className="flex w-full items-center border-b bg-background">
<div className="container flex items-center justify-between gap-6 p-4 xl:justify-start">
<Link
className="flex items-center gap-2"
@@ -132,7 +141,7 @@ export function Header() {
title="thirdweb Docs"
>
<ThirdwebIcon className="size-8" />
<span className="font-bold text-[23px] text-f-100 leading-none tracking-tight">
<span className="font-bold text-[23px] text-foreground leading-none tracking-tight">
Docs
</span>
</Link>
@@ -145,7 +154,7 @@ export function Header() {
<Link
href="https://github.com/thirdweb-dev"
target="_blank"
className="text-f-100"
className="text-foreground"
>
<GithubIcon className="mx-3 size-6" />
</Link>
@@ -164,7 +173,7 @@ export function Header() {
className={clsx(
"grow gap-5",
!showBurgerMenu ? "hidden xl:flex" : "flex",
"fade-in-20 slide-in-from-top-3 fixed inset-0 top-sticky-top-height animate-in flex-col overflow-auto bg-b-800 p-6",
"fade-in-20 slide-in-from-top-3 fixed inset-0 top-sticky-top-height animate-in flex-col overflow-auto bg-card p-6",
"xl:static xl:animate-none xl:flex-row xl:justify-between xl:bg-transparent xl:p-0",
)}
>
@@ -227,7 +236,7 @@ export function Header() {
<Link
href="https://github.com/thirdweb-dev"
target="_blank"
className="hidden text-f-300 transition-colors hover:text-f-100 xl:block"
className="hidden text-muted-foreground transition-colors hover:text-foreground xl:block"
>
<GithubIcon className="mx-2 size-6" />
</Link>
@@ -241,7 +250,11 @@ export function Header() {
function DropdownLinks(props: {
onLinkClick?: () => void;
category: string;
links: readonly { name: string; href: string; icon?: string }[];
links: readonly {
name: string;
href: string;
icon?: React.FC<{ className?: string }>;
}[];
}) {
return (
<>
@@ -251,15 +264,15 @@ function DropdownLinks(props: {
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="inline-flex items-center gap-1 p-0 font-medium text-f-300 text-sm hover:bg-transparent hover:text-f-100"
className="inline-flex items-center gap-1 p-0 font-medium text-muted-foreground text-sm hover:bg-transparent hover:text-foreground"
>
{props.category}
<ChevronDownIcon className="size-4 text-f-300 opacity-70" />
<ChevronDownIcon className="size-4 text-muted-foreground opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="flex flex-col gap-1 bg-b-800 p-3"
className="flex flex-col gap-1 bg-card p-3"
style={{
minWidth: "150px",
}}
@@ -267,26 +280,24 @@ function DropdownLinks(props: {
{props.links.map((info) => {
return (
<DropdownMenuItem asChild key={info.name}>
<Link
href={info.href}
target={info.href.startsWith("http") ? "_blank" : ""}
prefetch={false}
<div
className={clsx(
"flex cursor-pointer font-medium text-f-200",
"hover:bg-b-600 hover:text-f-100",
"relative flex cursor-pointer gap-2 font-medium text-foreground",
"hover:bg-accent",
)}
>
{info.icon && (
<Image
src={info.icon}
width="20"
height="20"
alt=""
className="mr-2"
/>
<info.icon className="size-5 text-foreground" />
)}
{info.name}
</Link>
<Link
prefetch={false}
target={info.href.startsWith("http") ? "_blank" : ""}
href={info.href}
className="before:absolute before:inset-0"
>
{info.name}
</Link>
</div>
</DropdownMenuItem>
);
})}
@@ -300,7 +311,7 @@ function DropdownLinks(props: {
chevronPosition="right"
containerClassName="border-none"
trigger={props.category}
triggerContainerClassName="py-0 text-base font-medium text-f-300"
triggerContainerClassName="py-0 text-base font-medium text-muted-foreground"
>
<div className="pt-2">
<div className="flex flex-col gap-4 border-l-2 pt-3 pl-4">
@@ -327,7 +338,7 @@ function NavLink(props: {
href: string;
name: string;
onClick?: () => void;
icon?: string;
icon?: React.FC<{ className?: string }>;
}) {
const pathname = usePathname();
return (
@@ -336,20 +347,14 @@ function NavLink(props: {
onClick={props.onClick}
target={props.href.startsWith("http") ? "_blank" : ""}
className={clsx(
"font-medium text-base transition-colors hover:text-f-100 xl:text-sm",
pathname === props.href ? "text-f-100" : "text-f-300 ",
props.icon ? "flex flex-row gap-2" : "",
"font-medium text-base transition-colors hover:text-foreground xl:text-sm",
pathname === props.href ? "text-foreground" : "text-muted-foreground ",
props.icon ? "flex flex-row gap-3" : "",
)}
>
{props.icon ? (
<>
<Image
src={props.icon}
width="40"
height="40"
className="size-7"
alt=""
/>
<props.icon className="size-6 text-muted-foreground" />
<span className="my-auto">{props.name}</span>
</>
) : (
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

-404
View File
@@ -1,404 +0,0 @@
<svg width="564" height="466" viewBox="0 0 564 466" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_f_5802_15050)">
<ellipse cx="281.863" cy="233.013" rx="151.841" ry="102.898" fill="url(#paint0_radial_5802_15050)"/>
</g>
<g opacity="0.35">
<rect x="58.9673" y="330.246" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<g filter="url(#filter1_i_5802_15050)">
<path d="M74.7568 345.181C74.7568 343.674 75.9786 342.452 77.4858 342.452H96.7809C98.2881 342.452 99.5099 343.674 99.5099 345.181V372.471C99.5099 373.978 98.2881 375.2 96.7809 375.2H77.4858C75.9786 375.2 74.7568 373.978 74.7568 372.471V345.181Z" fill="#EBA4D2" fill-opacity="0.8"/>
</g>
<path d="M104.975 361.787C106.188 362.487 106.188 364.238 104.975 364.938L94.188 371.166C92.9751 371.866 91.459 370.991 91.459 369.59L91.459 357.135C91.459 355.735 92.9751 354.859 94.188 355.56L104.975 361.787Z" fill="url(#paint1_linear_5802_15050)"/>
<path d="M81.6768 349.729L92.5926 349.73" stroke="white" stroke-width="2.72896" stroke-linecap="round"/>
<path d="M81.6768 355.187L87.1347 355.187" stroke="white" stroke-width="2.72896" stroke-linecap="round"/>
</g>
<rect x="156.126" y="330.246" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7" clip-path="url(#clip0_5802_15050)">
<path d="M181.555 345.706C182.955 343.28 186.457 343.28 187.857 345.706L200.672 367.901C202.072 370.327 200.321 373.359 197.52 373.359H171.891C169.09 373.359 167.34 370.327 168.74 367.901L181.555 345.706Z" fill="url(#paint2_linear_5802_15050)"/>
<g filter="url(#filter2_i_5802_15050)">
<path d="M183.129 361.336C183.829 360.124 185.58 360.124 186.28 361.336L191.638 370.616C192.338 371.829 191.463 373.345 190.062 373.345H179.347C177.946 373.345 177.071 371.829 177.771 370.616L183.129 361.336Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
</g>
<rect x="253.285" y="330.246" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<rect x="265.491" y="369.74" width="27.2874" height="19.3754" rx="3.63861" transform="rotate(-90 265.491 369.74)" fill="url(#paint3_linear_5802_15050)"/>
<g filter="url(#filter3_bi_5802_15050)">
<path d="M265.489 353.368C265.489 351.358 267.118 349.729 269.128 349.729H294.598C296.608 349.729 298.237 351.358 298.237 353.368V371.561C298.237 373.57 296.608 375.2 294.598 375.2H269.128C267.118 375.2 265.489 373.57 265.489 371.561V353.368Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<path d="M293.329 360.098V360.098C292.022 361.405 292.022 363.523 293.329 364.83V364.83" stroke="white" stroke-width="2.72896" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<rect x="350.444" y="330.246" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<path d="M384.754 353.081L384.754 349.161C384.754 345.996 382.189 343.431 379.024 343.431V343.431C375.859 343.431 373.293 345.996 373.293 349.161L373.293 353.081" stroke="#03B29E" stroke-width="3.63861" stroke-linecap="round"/>
<g filter="url(#filter4_bi_5802_15050)">
<path d="M366.134 353.12C366.262 351.715 367.44 350.639 368.851 350.639H389.135C390.545 350.639 391.723 351.712 391.853 353.116L393.541 371.309C393.689 372.909 392.43 374.29 390.823 374.29H367.193C365.588 374.29 364.33 372.911 364.475 371.313L366.134 353.12Z" fill="#C7FFF8" fill-opacity="0.8"/>
</g>
</g>
<rect x="447.602" y="330.246" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<path d="M461.016 351.597V346.08C461.016 343.006 464.592 341.317 466.966 343.271L490.025 362.242C490.865 362.934 491.352 363.964 491.352 365.052V371.366C491.352 374.474 487.705 376.151 485.345 374.128L462.286 354.359C461.48 353.668 461.016 352.659 461.016 351.597Z" fill="url(#paint4_linear_5802_15050)"/>
<g filter="url(#filter5_bi_5802_15050)">
<path d="M491.352 351.597V346.08C491.352 343.006 487.776 341.317 485.402 343.271L462.343 362.242C461.503 362.934 461.016 363.964 461.016 365.052V371.366C461.016 374.474 464.663 376.151 467.023 374.128L490.082 354.359C490.888 353.668 491.352 352.659 491.352 351.597Z" fill="#EBA4D2" fill-opacity="0.8"/>
</g>
</g>
<rect x="400.854" y="207.691" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<path d="M418.038 222.097C418.038 220.589 419.259 219.368 420.767 219.368H438.401C440.563 219.368 441.866 221.762 440.693 223.578L423.059 250.869C421.582 253.154 418.038 252.108 418.038 249.388V222.097Z" fill="url(#paint5_linear_5802_15050)"/>
<g filter="url(#filter6_bi_5802_15050)">
<path d="M441.147 222.097C441.147 220.589 439.925 219.368 438.418 219.368H420.784C418.622 219.368 417.319 221.762 418.492 223.578L436.126 250.869C437.602 253.154 441.147 252.108 441.147 249.388V222.097Z" fill="#EBA4D2" fill-opacity="0.8"/>
</g>
</g>
<rect x="498.013" y="207.691" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<circle cx="518.601" cy="237.181" r="8.38164" fill="url(#paint6_linear_5802_15050)"/>
<g filter="url(#filter7_bi_5802_15050)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M542.969 227.83C542.968 223.449 537.889 219.897 531.624 219.897C525.359 219.897 520.28 223.449 520.28 227.831C520.28 227.846 520.28 227.862 520.28 227.877V244.71C520.28 244.711 520.28 244.711 520.28 244.711C520.28 249.093 525.359 252.645 531.624 252.645C537.89 252.645 542.969 249.093 542.969 244.711C542.969 244.711 542.969 244.711 542.969 244.71V227.83H542.969Z" fill="#BFB5FF" fill-opacity="0.8"/>
</g>
<path d="M524.19 232.272V232.272C528.675 235.329 534.573 235.329 539.058 232.272V232.272" stroke="white" stroke-width="2.72896" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M524.19 240.941V240.941C528.675 243.997 534.573 243.997 539.058 240.941V240.941" stroke="white" stroke-width="2.72896" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<rect x="59.6479" y="84.6064" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<rect x="75.9883" y="96.8121" width="24.4847" height="16.9754" rx="2.72052" fill="url(#paint7_linear_5802_15050)"/>
<g filter="url(#filter8_bi_5802_15050)">
<path d="M71.8535 107.728C71.8535 105.719 73.4826 104.09 75.4921 104.09H100.962C102.972 104.09 104.601 105.719 104.601 107.728V125.922C104.601 127.931 102.972 129.56 100.962 129.56H75.4921C73.4826 129.56 71.8535 127.931 71.8535 125.922V107.728Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<path d="M77.3789 109.474L88.6171 117.734L99.3498 109.474" stroke="white" stroke-width="2.72896" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<rect x="156.807" y="84.6064" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<path fill-rule="evenodd" clip-rule="evenodd" d="M175.891 104.594C176.746 105.448 177.904 105.928 179.112 105.928H183.599C183.599 105.928 183.599 105.928 183.599 105.928V105.928C183.599 105.928 183.599 105.928 183.599 105.928H191.796C193.004 105.928 194.162 105.448 195.016 104.594C195.87 103.74 196.35 102.582 196.35 101.374C196.35 100.166 195.87 99.0079 195.016 98.1539C194.162 97.2999 193.004 96.8201 191.796 96.8201C190.039 96.7894 188.317 97.6421 186.854 99.2668C186.67 99.472 186.49 99.6884 186.316 99.9153C185.894 100.466 185.014 100.466 184.591 99.9153C184.417 99.6884 184.238 99.472 184.053 99.2667C182.591 97.642 180.869 96.7894 179.112 96.82C177.904 96.82 176.746 97.2998 175.891 98.1539C175.037 99.0079 174.558 100.166 174.558 101.374C174.558 102.582 175.037 103.74 175.891 104.594Z" fill="#03B39E"/>
<g filter="url(#filter9_bi_5802_15050)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M172.711 104.068C171.311 104.068 170.176 105.203 170.176 106.604V109.984C170.176 111.385 171.311 112.52 172.711 112.52H173.507V125.198C173.507 126.598 174.642 127.733 176.043 127.733H194.742C196.143 127.733 197.278 126.598 197.278 125.198V112.52H198.067C199.467 112.52 200.602 111.385 200.602 109.984V106.604C200.602 105.203 199.467 104.068 198.067 104.068H172.711Z" fill="#C7FFF8" fill-opacity="0.8"/>
</g>
</g>
<rect x="351.124" y="84.6064" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7" clip-path="url(#clip1_5802_15050)">
<path d="M377.828 112.325C378.66 110.884 380.74 110.884 381.572 112.325L389.663 126.339C390.495 127.78 389.455 129.582 387.79 129.582H371.609C369.945 129.582 368.905 127.78 369.737 126.339L377.828 112.325Z" fill="url(#paint8_linear_5802_15050)"/>
<g filter="url(#filter10_bi_5802_15050)">
<circle cx="379.701" cy="110.002" r="13.19" fill="#EBA4D2" fill-opacity="0.8"/>
</g>
</g>
<rect x="253.965" y="84.6064" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<path fill-rule="evenodd" clip-rule="evenodd" d="M287.912 113.184C287.912 113.182 287.911 113.181 287.91 113.181C287.833 113.184 287.756 113.186 287.679 113.186C284.468 113.186 281.864 110.582 281.864 107.371C281.864 104.16 284.468 101.556 287.679 101.556C289.775 101.556 291.613 102.666 292.636 104.33C292.902 104.763 293.44 104.977 293.941 104.882C294.195 104.834 294.458 104.809 294.726 104.809C297.04 104.809 298.915 106.684 298.915 108.997C298.915 111.311 297.04 113.186 294.726 113.186C294.71 113.186 294.693 113.186 294.677 113.186H287.915C287.913 113.186 287.912 113.185 287.912 113.184V113.184Z" fill="url(#paint9_linear_5802_15050)"/>
<g filter="url(#filter11_bi_5802_15050)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M271.961 125.785C271.943 125.785 271.924 125.785 271.906 125.785C268.738 125.785 266.169 123.217 266.169 120.049C266.169 116.881 268.738 114.313 271.906 114.313V114.313C271.96 114.313 272.003 114.265 271.998 114.211C271.969 113.925 271.954 113.634 271.954 113.34C271.954 108.402 276.157 104.398 281.342 104.398C285.917 104.398 289.727 107.516 290.56 111.642C290.672 112.193 291.205 112.58 291.766 112.537C291.936 112.524 292.108 112.517 292.281 112.517C295.945 112.517 298.915 115.487 298.915 119.151C298.915 122.815 295.945 125.785 292.281 125.785C292.157 125.785 292.033 125.782 291.91 125.775C291.833 125.782 291.755 125.785 291.676 125.785H272.011C271.994 125.785 271.978 125.785 271.961 125.785Z" fill="#BFB5FF" fill-opacity="0.8"/>
</g>
</g>
<rect x="448.283" y="84.6064" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<rect x="476.263" y="122.203" width="24.4847" height="16.9754" rx="2.72052" transform="rotate(-90 476.263 122.203)" fill="url(#paint10_linear_5802_15050)"/>
<g filter="url(#filter12_bi_5802_15050)">
<path d="M469.922 130.015C470.897 130.616 472.098 130.616 473.072 130.015C476.115 128.137 482.502 123.446 482.502 117.158V110.05C482.502 108.533 481.561 107.175 480.141 106.643L472.775 103.88C471.951 103.572 471.043 103.572 470.22 103.88L462.853 106.643C461.433 107.175 460.492 108.533 460.492 110.05V117.158C460.492 123.446 466.88 128.137 469.922 130.015Z" fill="#B9DDFF" fill-opacity="0.8"/>
</g>
<path d="M471.493 117.557C473.205 117.557 474.592 116.169 474.592 114.458C474.592 112.746 473.205 111.359 471.493 111.359C469.782 111.359 468.395 112.746 468.395 114.458C468.395 116.169 469.782 117.557 471.493 117.557Z" fill="white"/>
<path d="M471.492 116.215C472.093 116.215 472.581 116.718 472.581 117.339V121.833C472.581 122.453 472.094 122.956 471.492 122.956C470.892 122.956 470.404 122.454 470.404 121.833V117.339C470.404 116.719 470.891 116.215 471.492 116.215Z" fill="white"/>
</g>
<rect x="13" y="207.162" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<rect x="32.4844" y="219.368" width="18.1931" height="18.1931" rx="2.72896" fill="url(#paint11_linear_5802_15050)"/>
<g filter="url(#filter13_bi_5802_15050)">
<rect x="25.2041" y="233.922" width="32.7476" height="18.1931" rx="2.72896" fill="#EBA4D2" fill-opacity="0.8"/>
</g>
</g>
<rect x="110.159" y="207.162" width="57.1589" height="57.1589" rx="13.842" fill="#1E2128"/>
<g opacity="0.7">
<rect x="138.743" y="236.652" width="16.3711" height="16.3711" rx="2.72896" fill="url(#paint12_linear_5802_15050)"/>
<g filter="url(#filter14_i_5802_15050)">
<path d="M126.002 246.657C123.992 246.657 122.363 245.028 122.363 243.019L122.363 223.007C122.363 220.997 123.992 219.368 126.002 219.368L146.014 219.368C148.024 219.368 149.653 220.997 149.653 223.007L149.653 243.019C149.653 245.028 148.024 246.657 146.014 246.657L126.002 246.657Z" fill="#BFB5FF" fill-opacity="0.8"/>
</g>
<path d="M128.013 230.648C130.148 228.555 133.018 227.383 136.007 227.383C138.996 227.383 141.866 228.555 144.001 230.648" stroke="white" stroke-width="2.72896" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132.011 234.644C133.079 233.598 134.514 233.012 136.008 233.012C137.503 233.012 138.938 233.598 140.005 234.644" stroke="white" stroke-width="2.72896" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M136.008 238.642H136.018" stroke="white" stroke-width="3.63861" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
<g opacity="0.6" filter="url(#filter15_f_5802_15050)">
<rect x="167.317" y="119.989" width="231.512" height="231.512" rx="54.285" fill="url(#paint13_radial_5802_15050)"/>
<rect x="167.317" y="119.989" width="231.512" height="231.512" rx="54.285" fill="url(#paint14_radial_5802_15050)" style="mix-blend-mode:color-dodge"/>
</g>
<g filter="url(#filter16_d_5802_15050)">
<rect x="178.462" y="131.136" width="209.218" height="209.218" rx="50.666" fill="url(#paint15_linear_5802_15050)"/>
<rect x="178.236" y="130.91" width="209.671" height="209.671" rx="50.8921" stroke="url(#paint16_linear_5802_15050)" stroke-width="0.452375" stroke-linecap="round" stroke-linejoin="round" style="mix-blend-mode:soft-light"/>
<g clip-path="url(#clip2_5802_15050)">
<path d="M321.267 201.49C321.959 201.635 322.612 201.927 323.182 202.346C323.825 202.85 324.317 203.519 324.606 204.282C326.415 208.81 328.228 213.335 330.047 217.858L336.107 232.96C337.165 235.604 338.216 238.24 339.29 240.876C339.518 241.414 339.636 241.993 339.636 242.578C339.636 243.162 339.518 243.741 339.29 244.279C336.219 251.934 333.145 259.595 330.069 267.26C329.819 267.978 329.373 268.612 328.782 269.091C328.191 269.57 327.478 269.874 326.723 269.971C325.79 270.145 324.826 269.992 323.992 269.539C323.159 269.086 322.507 268.36 322.146 267.483C320.417 263.239 318.725 258.979 317.018 254.72C314.116 247.506 311.221 240.288 308.334 233.064C305.755 226.635 303.179 220.211 300.605 213.792C299.748 211.662 298.92 209.517 298.026 207.395C297.78 206.82 297.663 206.198 297.685 205.573C297.706 204.948 297.864 204.336 298.149 203.779C298.433 203.222 298.837 202.734 299.331 202.35C299.825 201.966 300.397 201.695 301.007 201.557C301.082 201.557 301.149 201.512 301.224 201.49H321.267Z" fill="white"/>
<path d="M321.267 201.49C321.959 201.635 322.612 201.927 323.182 202.346C323.825 202.85 324.317 203.519 324.606 204.282C326.415 208.81 328.228 213.335 330.047 217.858L336.107 232.96C337.165 235.604 338.216 238.24 339.29 240.876C339.518 241.414 339.636 241.993 339.636 242.578C339.636 243.162 339.518 243.741 339.29 244.279C336.219 251.934 333.145 259.595 330.069 267.26C329.819 267.978 329.373 268.612 328.782 269.091C328.191 269.57 327.478 269.874 326.723 269.971C325.79 270.145 324.826 269.992 323.992 269.539C323.159 269.086 322.507 268.36 322.146 267.483C320.417 263.239 318.725 258.979 317.018 254.72C314.116 247.506 311.221 240.288 308.334 233.064C305.755 226.635 303.179 220.211 300.605 213.792C299.748 211.662 298.92 209.517 298.026 207.395C297.78 206.82 297.663 206.198 297.685 205.573C297.706 204.948 297.864 204.336 298.149 203.779C298.433 203.222 298.837 202.734 299.331 202.35C299.825 201.966 300.397 201.695 301.007 201.557C301.082 201.557 301.149 201.512 301.224 201.49H321.267Z" fill="url(#paint17_linear_5802_15050)"/>
<path d="M250.039 201.49C250.27 201.557 250.494 201.609 250.717 201.683C251.297 201.864 251.828 202.172 252.273 202.584C252.718 202.996 253.065 203.503 253.289 204.066C255.276 208.976 257.249 213.886 259.207 218.796C261.165 223.706 263.153 228.671 265.17 233.69L268.099 240.973C268.319 241.491 268.432 242.048 268.432 242.611C268.432 243.174 268.319 243.731 268.099 244.249C267.011 246.975 265.918 249.705 264.82 252.441L258.805 267.402C258.489 268.179 257.949 268.845 257.254 269.316C256.558 269.787 255.739 270.04 254.899 270.045C254.013 270.058 253.145 269.797 252.412 269.299C251.68 268.8 251.119 268.088 250.807 267.26C248.943 262.561 247.08 257.87 245.172 253.178L237.532 234.144L229.765 214.782C228.789 212.34 227.812 209.89 226.783 207.447C226.349 206.43 226.328 205.284 226.725 204.251C227.121 203.219 227.904 202.381 228.908 201.914C229.274 201.766 229.65 201.644 230.033 201.549L250.039 201.49Z" fill="url(#paint18_linear_5802_15050)"/>
<path d="M303.999 243.251C303.463 245.098 302.68 246.841 301.935 248.643C299.912 253.702 297.877 258.763 295.83 263.827C295.36 265.026 294.883 266.143 294.429 267.312C294.104 268.132 293.532 268.831 292.792 269.313C292.053 269.794 291.181 270.035 290.299 270C289.457 269.978 288.641 269.705 287.955 269.217C287.268 268.729 286.743 268.048 286.446 267.26C283.494 259.883 280.537 252.505 277.576 245.128C275.022 238.783 272.458 232.424 269.884 226.049C267.399 219.809 264.914 213.568 262.43 207.328C262.189 206.758 262.077 206.141 262.101 205.523C262.125 204.904 262.285 204.298 262.569 203.748C262.854 203.198 263.256 202.718 263.747 202.34C264.238 201.963 264.806 201.698 265.411 201.564L265.657 201.49H285.685L285.872 201.549C286.577 201.698 287.234 202.023 287.779 202.494C288.324 202.966 288.74 203.569 288.987 204.245C290.15 207.149 291.318 210.056 292.491 212.965C295.547 220.591 298.603 228.216 301.659 235.842C302.464 237.853 303.358 239.826 304.014 241.889L303.999 243.251Z" fill="white"/>
<path d="M303.999 243.251C303.463 245.098 302.68 246.841 301.935 248.643C299.912 253.702 297.877 258.763 295.83 263.827C295.36 265.026 294.883 266.143 294.429 267.312C294.104 268.132 293.532 268.831 292.792 269.313C292.053 269.794 291.181 270.035 290.299 270C289.457 269.978 288.641 269.705 287.955 269.217C287.268 268.729 286.743 268.048 286.446 267.26C283.494 259.883 280.537 252.505 277.576 245.128C275.022 238.783 272.458 232.424 269.884 226.049C267.399 219.809 264.914 213.568 262.43 207.328C262.189 206.758 262.077 206.141 262.101 205.523C262.125 204.904 262.285 204.298 262.569 203.748C262.854 203.198 263.256 202.718 263.747 202.34C264.238 201.963 264.806 201.698 265.411 201.564L265.657 201.49H285.685L285.872 201.549C286.577 201.698 287.234 202.023 287.779 202.494C288.324 202.966 288.74 203.569 288.987 204.245C290.15 207.149 291.318 210.056 292.491 212.965C295.547 220.591 298.603 228.216 301.659 235.842C302.464 237.853 303.358 239.826 304.014 241.889L303.999 243.251Z" fill="url(#paint19_linear_5802_15050)"/>
</g>
</g>
<defs>
<filter id="filter0_f_5802_15050" x="0.0224609" y="0.114868" width="563.682" height="465.796" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="65" result="effect1_foregroundBlur_5802_15050"/>
</filter>
<filter id="filter1_i_5802_15050" x="74.7568" y="342.452" width="24.7529" height="32.9294" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_5802_15050"/>
</filter>
<filter id="filter2_i_5802_15050" x="177.525" y="360.427" width="14.3594" height="13.1003" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_5802_15050"/>
</filter>
<filter id="filter3_bi_5802_15050" x="261.851" y="346.091" width="40.0248" height="32.7474" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter4_bi_5802_15050" x="360.825" y="347" width="36.3661" height="30.9282" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.181931"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter5_bi_5802_15050" x="457.378" y="338.796" width="37.6132" height="39.8534" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter6_bi_5802_15050" x="414.412" y="215.729" width="30.3739" height="40.0315" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter7_bi_5802_15050" x="516.641" y="216.259" width="29.9662" height="40.0245" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter8_bi_5802_15050" x="68.2149" y="100.451" width="40.0248" height="32.7474" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter9_bi_5802_15050" x="166.537" y="100.43" width="37.704" height="30.9424" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter10_bi_5802_15050" x="362.873" y="93.1735" width="33.6571" height="33.6571" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter11_bi_5802_15050" x="262.531" y="100.76" width="40.0233" height="28.6641" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter12_bi_5802_15050" x="456.854" y="100.01" width="29.2875" height="34.0945" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter13_bi_5802_15050" x="21.5655" y="230.283" width="40.0248" height="25.4702" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.81931"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5802_15050" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.363861"/>
<feGaussianBlur stdDeviation="0.0909653"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_5802_15050"/>
</filter>
<filter id="filter14_i_5802_15050" x="122.363" y="219.368" width="27.2896" height="27.7923" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.502798"/>
<feGaussianBlur stdDeviation="0.251399"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_5802_15050"/>
</filter>
<filter id="filter15_f_5802_15050" x="131.127" y="83.7988" width="303.892" height="303.892" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="18.095" result="effect1_foregroundBlur_5802_15050"/>
</filter>
<filter id="filter16_d_5802_15050" x="171.942" y="124.616" width="222.258" height="222.258" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="3.03377"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow_5802_15050"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5802_15050" result="shape"/>
</filter>
<radialGradient id="paint0_radial_5802_15050" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(281.863 233.013) rotate(95.2763) scale(279.846 421.701)">
<stop stop-color="#9835C7"/>
<stop offset="0.291667" stop-color="#612A90"/>
<stop offset="1" stop-color="#612A90" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint1_linear_5802_15050" x1="89.3673" y1="344.191" x2="113.05" y2="372.126" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4009F"/>
<stop offset="1" stop-color="#F856C8"/>
</linearGradient>
<linearGradient id="paint2_linear_5802_15050" x1="168.973" y1="374.872" x2="202.648" y2="359.697" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<linearGradient id="paint3_linear_5802_15050" x1="287.37" y1="377.856" x2="266.823" y2="386.631" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<linearGradient id="paint4_linear_5802_15050" x1="449.332" y1="373.002" x2="497.525" y2="342.699" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4009F"/>
<stop offset="1" stop-color="#F856C8"/>
</linearGradient>
<linearGradient id="paint5_linear_5802_15050" x1="409.142" y1="247.097" x2="446.861" y2="224.548" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4009F"/>
<stop offset="1" stop-color="#F856C8"/>
</linearGradient>
<linearGradient id="paint6_linear_5802_15050" x1="513.027" y1="242.537" x2="544.35" y2="231.713" gradientUnits="userSpaceOnUse">
<stop stop-color="#3F2DAF"/>
<stop offset="1" stop-color="#917FFB"/>
</linearGradient>
<linearGradient id="paint7_linear_5802_15050" x1="95.6202" y1="103.923" x2="77.321" y2="111.926" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<linearGradient id="paint8_linear_5802_15050" x1="355.507" y1="132.221" x2="390.758" y2="102.335" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4009F"/>
<stop offset="1" stop-color="#F856C8"/>
</linearGradient>
<linearGradient id="paint9_linear_5802_15050" x1="284.72" y1="111.087" x2="313.1" y2="96.7089" gradientUnits="userSpaceOnUse">
<stop stop-color="#3F2DAF"/>
<stop offset="1" stop-color="#917FFB"/>
</linearGradient>
<linearGradient id="paint10_linear_5802_15050" x1="495.895" y1="129.315" x2="477.595" y2="137.318" gradientUnits="userSpaceOnUse">
<stop stop-color="#2567FF"/>
<stop offset="1" stop-color="#22A7FF"/>
</linearGradient>
<linearGradient id="paint11_linear_5802_15050" x1="25.4775" y1="234.769" x2="48.9417" y2="214.876" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4009F"/>
<stop offset="1" stop-color="#F856C8"/>
</linearGradient>
<linearGradient id="paint12_linear_5802_15050" x1="141.485" y1="250.069" x2="172.075" y2="239.498" gradientUnits="userSpaceOnUse">
<stop stop-color="#3F2DAF"/>
<stop offset="1" stop-color="#917FFB"/>
</linearGradient>
<radialGradient id="paint13_radial_5802_15050" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(167.317 351.501) rotate(-45) scale(163.704 229.969)">
<stop stop-color="#E81D72"/>
<stop offset="1" stop-color="#FF0000" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint14_radial_5802_15050" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(182.872 208.686) rotate(7.14187) scale(217.646 129.025)">
<stop stop-color="#A46CFF"/>
<stop offset="1" stop-color="#9E00FF"/>
</radialGradient>
<linearGradient id="paint15_linear_5802_15050" x1="178.462" y1="131.136" x2="387.68" y2="340.354" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E2128"/>
<stop offset="1" stop-color="#101011"/>
</linearGradient>
<linearGradient id="paint16_linear_5802_15050" x1="256.499" y1="76.4413" x2="316.163" y2="308.232" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint17_linear_5802_15050" x1="309.284" y1="198.496" x2="339.494" y2="269.775" gradientUnits="userSpaceOnUse">
<stop stop-color="#DE35A0"/>
<stop offset="1" stop-color="#9120AC"/>
</linearGradient>
<linearGradient id="paint18_linear_5802_15050" x1="238.054" y1="198.495" x2="268.249" y2="269.797" gradientUnits="userSpaceOnUse">
<stop stop-color="#DE35A0"/>
<stop offset="1" stop-color="#9120AC"/>
</linearGradient>
<linearGradient id="paint19_linear_5802_15050" x1="273.689" y1="198.497" x2="303.89" y2="269.731" gradientUnits="userSpaceOnUse">
<stop stop-color="#DE35A0"/>
<stop offset="1" stop-color="#9120AC"/>
</linearGradient>
<clipPath id="clip0_5802_15050">
<rect width="43.6633" height="43.6633" fill="white" transform="translate(162.875 336.994)"/>
</clipPath>
<clipPath id="clip1_5802_15050">
<rect width="43.6633" height="43.6633" fill="white" transform="translate(357.87 91.3542)"/>
</clipPath>
<clipPath id="clip2_5802_15050">
<rect width="113.17" height="68.5107" fill="white" transform="translate(226.485 201.49)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

@@ -19,5 +19,3 @@ export const ignoreHeadings = new Set(
"example",
].map((t) => t.toLowerCase()),
);
export const minimumParagraphlength = 20;

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