Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
fecd3a6c16 Version Packages (#6205)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-07 20:07:21 -08:00
Phillip HoandGitHub 8aeb4d6308 chore: Export sources array (#6206) 2025-02-08 12:00:43 +08:00
Joaquim VergesandGitHub f1cd2539d1 [SDK] chore: Update coinbase/wallet-sdk (#6204) 2025-02-07 19:31:39 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
a322e16853 Version Packages (#6184)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-07 16:10:52 -08:00
0xFirekeeper e50d77d28f Docs up to .NET 2.17.1 / Unity 5.17.1 (#6200)
Closes TOOL-2991
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces enhancements to the `sidebar` structure and API documentation, adds new wallet authentication options, and refines existing content for clarity. It also includes new RPC overrides and updates to the `InAppWallet` and `EcosystemWallet` functionalities.

### Detailed summary
- Added `rpcOverrides` example to `page.mdx`.
- Updated `sidebar.tsx` to include "API Reference" and "Blockchain API" sections.
- Replaced "Blockchain API" with "Nebula AI" in `sidebar.tsx`.
- Enhanced `EcosystemWallet` and `InAppWallet` functionalities with new login options.
- Clarified documentation on wallet connections and authentication methods.
- Added examples for `SiweExternal` login process.
- Updated various sections in `page.mdx` for better clarity and detail.

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

<!-- end pr-codex -->
2025-02-07 21:06:12 +00:00
MananTank 7f3b91da37 [TOOL-3346] Portal: Add Nebula icon support in dynamic OG image generation (#6199) 2025-02-07 21:02:35 +00:00
MananTank 41d50443d9 [TOOL-3328] Dashboard: Show error message in toast on Email submission fail in onboaridng flow (#6186)
<!-- start pr-codex -->

## PR-Codex overview
This PR enhances error handling in the `AccountForm` component by improving the way errors are processed and displayed to the user.

### Detailed summary
- Replaced the `onError` parameter's type from `Error` to a more generic `error`.
- Added console logging for errors.
- Implemented specific error handling for duplicate email and invalid email address cases.
- Integrated `toast` notifications for user-friendly error messages.

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

<!-- end pr-codex -->
2025-02-07 20:40:27 +00:00
catalyst17andcatalyst17 a8ac98bd49 feat: allowedToCreateChains in TeamResponse (#6172)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on adding the `canCreatePublicChains` property to the `mocks.ts` and modifying its type in `api.ts` to enhance the API's capabilities regarding public chain creation.

### Detailed summary
- Added `canCreatePublicChains: false` to the object in `mocks.ts`.
- Updated the type of `canCreatePublicChains` in `api.ts` to `boolean | null`.

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

<!-- end pr-codex -->
2025-02-07 11:52:05 -08:00
YashandGitHub 0781da9dca [SDK] Check bytecode prefix (#6196) 2025-02-07 09:52:00 -08:00
MananTank c5dfad8dbb [TOOL-3298] Playground: Add Insight playground (#6185)
<!-- start pr-codex -->

## PR-Codex overview
This PR introduces several enhancements and new features to the `playground-web` application, including a new `Spinner` component, improved layout for the `APIHeader`, and various updates to forms, UI components, and hooks for better functionality and usability.

### Detailed summary
- Added `isProd` export in `env.ts`.
- Introduced `Spinner` component in `Spinner.tsx` with CSS animations.
- Updated `Layout` to include `APIHeader` with title and links.
- Enhanced `RenderCode` to support new scrollable container class.
- Added `Anchor` component with improved link handling.
- Modified `CodeClient` to accept new props for scrollable container.
- Implemented `useShowMore` hook for dynamic item display.
- Updated `tailwind.config.ts` for new color schemes.
- Refactored `AppSidebar` to accept dynamic links.
- Introduced `fetchAllBlueprints` function for sidebar link generation.
- Added `Alert`, `Tooltip`, and `Table` components with improved styling.
- Enhanced `SelectWithSearch` for better option handling and search functionality.
- Updated various components to use client-side rendering (`"use client"`).
- Improved error handling and loading states in image components.
- Added new utility functions for better link and chain handling.

> The following files were skipped due to too many changes: `apps/playground-web/src/components/ui/select.tsx`, `apps/playground-web/src/app/insight/[blueprint_slug]/blueprint-playground.client.tsx`, `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-02-07 15:29:21 +00:00
Joaquim VergesandGitHub 9663079062 [SDK] feat: Add session keys to smart wallet options (#6194) 2025-02-07 04:37:28 -08:00
Joaquim VergesandGitHub 1f6bb7c329 [SDK] feat: Add fiat value display to buy token input (#6193) 2025-02-07 04:19:24 -08:00
Joaquim VergesandGitHub 30e13e6b91 [SDK] feat: Add editable token amount input to buy screen (#6192) 2025-02-07 03:54:22 -08:00
Joaquim VergesandGitHub 2dfc245d44 [SDK] feat: Add provider selection to fiat onramp (#6190) 2025-02-07 01:41:47 -08:00
jnsdls 64d7bf358f prefer explicit clientId over secretKey computation (#6188)
TOOL-0000

### TL;DR

Prioritize explicitly passed `clientId` over computed values when creating a Thirdweb client.

### What changed?

Modified the client initialization logic to prefer explicitly passed `clientId` parameters over computing them from `secretKey`. Only falls back to computing the `clientId` from `secretKey` when no explicit `clientId` is provided.

### How to test?

1. Create a Thirdweb client with both `clientId` and `secretKey` parameters
2. Verify that the provided `clientId` is used instead of computing one from the `secretKey`
3. Create a client with only `secretKey` and verify the `clientId` is computed correctly

### Why make this change?

To give developers more control over client configuration by respecting explicitly provided parameters rather than always computing values from the secret key. This allows for more flexible client setup scenarios where the computed `clientId` might not be desired.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the behavior of the `createThirdwebClient()` function to prioritize the explicitly provided `clientId` over deriving it from the `secretKey`. It also modifies the corresponding test to reflect this new behavior.

### Detailed summary
- Updated `createThirdwebClient()` to prefer `clientId` over computed value from `secretKey`.
- Changed test description to indicate `clientId` is not ignored when `secretKey` is provided.
- Updated test expectation to check that `clientId` is equal to the explicitly provided value instead of the computed one.

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

<!-- end pr-codex -->
2025-02-07 08:28:29 +00:00
jnsdls a6e9c97c6f add redirect from /connect/pay to /connect/universal-bridge (#6189)
fixes: TOOL-0000
2025-02-07 08:07:35 +00:00
Joaquim VergesandGitHub ee57ded902 [SDK] fix: Requery allowances on quote screen return (#6187) 2025-02-06 21:00:01 -08:00
MananTank 6d3947ab0c [TOOL-3314] Playground: Fix Next.js errors in Headless UI example pages (#6175)
Fixed "Can not import server (async) components in client components" error

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new preview components for wallet, NFT, token, and account functionalities in the `playground-web` application. It enhances code modularity by separating previews into distinct files, improving readability and maintainability.

### Detailed summary
- Added `wallet-previews.tsx` with previews for `WalletIcon`, `WalletName`, and formatting options.
- Created `nft-previews.tsx` with previews for `NFTMedia`, `NFTName`, and `NFTDescription`.
- Introduced `token-previews.tsx` with previews for `TokenIcon`, `TokenName`, and `TokenSymbol`.
- Developed `account-previews.tsx` for `AccountAddress`, `AccountName`, and `AccountBalance` with various configurations.
- Updated example files (`wallet-examples.tsx`, `nft-examples.tsx`, `token-examples.tsx`, `account-examples.tsx`) to utilize new preview components, enhancing code clarity.

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

<!-- end pr-codex -->
2025-02-06 20:10:27 +00:00
MananTank 632f1d138f [TOOL-3299] Playground: Update Sidebar, Minor improvements and fixes (#6174) 2025-02-06 20:10:26 +00:00
Phillip HoandGitHub 18f0e575c4 [service-utils] Update list of usageV2 sources (#6183) 2025-02-06 17:39:33 +08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
e732b11e45 Version Packages (#6166)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-06 00:46:47 -08:00
Joaquim VergesandGitHub f77165e2d1 [SDK] fix: Skip swap approvals and use local gas prices (#6182) 2025-02-06 00:36:31 -08:00
Phillip HoandGitHub dfd64d2896 [service-utils] Expose more kafka configs, set max in-flight requests (#6181) 2025-02-06 11:55:23 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4e703fdb5d build(deps-dev): bump vitest from 3.0.4 to 3.0.5 in the npm_and_yarn group (#6167)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-05 14:07:21 -08:00
Aaron RamirezandGitHub af88935680 [Portal Documentation] Fix: Typo Fix (#6156)
Signed-off-by: Aaron Ramirez <[email protected]>
2025-02-05 14:06:09 -08:00
gregandGitHub c50c82aacd fix: remove pay framer page (#6178) 2025-02-05 14:02:23 -08:00
MananTank 0fa8354dec [TOOL-3318] Dashboard: Add Upload Account Avatar feature in account settings page (#6179) 2025-02-05 20:44:06 +00:00
gregfromstl 769a927627 [Dashboard] Feature: add universal-bridge framer page (#6176)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on modifying the routes in the `framer-rewrites.js` file for the dashboard application, specifically adding and updating certain paths.

### Detailed summary
- Added the route `"/connect/universal-bridge"`.
- Updated the existing route list by retaining `"/connect/pay"`, `"/connect/account-abstraction"`, and `"/nebula"`.
- Removed the comment indicating `// --insight`.

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

<!-- end pr-codex -->
2025-02-05 18:53:54 +00:00
saminacodes ceff873cba [Portal] Add video and update prompt guide (#6163)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `Nebula` documentation by adding new video tutorials and reorganizing content related to supported chains. It also expands the action descriptions in the `prompt-guide` to include bridging and swapping currencies.

### Detailed summary
- Added `Grid` and `ArticleCard` components to introduce video tutorials on using `Nebula`.
- Moved the section on supported chains to follow the new video tutorials.
- Expanded action descriptions in the `prompt-guide` to include "Bridge & Swap" functionality.

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

<!-- end pr-codex -->
2025-02-05 18:44:49 +00:00
arcoraven 9a432af1cf fix: Tweak 404 page (#6173)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the content of the `NotFoundPage` component to enhance the user experience by changing the messaging displayed when a page is not found.

### Detailed summary
- Changed the message from "Looks like web3 can't be found here." to "Looks like you're lost!".
- Updated the text from "Try our" to "Find live content on our".
- Removed "instead" and added a period after the `TrackedLinkTW` component.

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

<!-- end pr-codex -->
2025-02-05 14:35:21 +00:00
Phillip HoandGitHub ee6bd4f898 [service-utils] fix: export kafka producer (#6171) 2025-02-05 14:03:50 +08:00
Phillip HoandGitHub ccf6329810 [service-utils] Add kafka producer to emit raw messages (#6170) 2025-02-05 12:36:46 +08:00
Phillip HoandGitHub 821caa63d7 fix: Fix usageV2 imports, allow sdk source (#6164) 2025-02-05 11:31:22 +08:00
kumaryash90 09e396d856 [SDK] Update implementations (#6162)
TOOL-3283

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `MarketplaceV3` address in the `zksync` implementation file, likely reflecting a new deployment or contract version.

### Detailed summary
- Added a new `MarketplaceV3` address: `0x4027561E163a420c4e5Db46E07EBd581CAa8Bb62` at line 2741.
- Removed the previous `MarketplaceV3` address: `0x9EB0830B0b10010F2a53383517A7D0B75531Bb1b`.

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

<!-- end pr-codex -->
2025-02-05 01:26:13 +00:00
jnsdls 9573b2f9d2 move account abstraction page to connect directory (#6161)
closes: TOOL-3311
2025-02-04 22:39:11 +00:00
MananTank b004a07e5d [NEB-76] Dashboard: Add Nebula service in chainlist, Update all product icons, links (#6158)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new `Nebula` product to the application, updates several links to point directly to the `thirdweb` site, and refines various UI components for better styling and functionality.

### Detailed summary
- Added `nebula` to the `products` array in `chain.ts`.
- Updated links in `products` to `thirdweb.com` for several items.
- Added `className` to `<TableHeader>` for styling.
- Refined styling and classes in several components, including `<Star>` and `<ProductIcon>`.
- Removed unused functions related to link generation.
- Updated SVG icons to improve accessibility and presentation.

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

<!-- end pr-codex -->
2025-02-04 22:08:01 +00:00
MananTank 2396b4e461 [NEB-87] Dashboard, Docs - Use updated Nebula API (#6157)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the API's context handling by replacing the `context_filter` with a simplified `context` structure, changing types, and cleaning up related components to improve clarity and functionality in the codebase.

### Detailed summary
- Changed `description` type from `string` to `React.ReactNode` in `APIParameter`.
- Replaced `nebulaContextFilterPathParameter` and `nebulaExecuteConfigPathParameter` with `nebulaContextParameter`.
- Updated JSON structures to use `context` instead of `context_filter`.
- Modified related API response types and request parameters to align with the new context structure.
- Refactored components to use `NebulaContext` instead of `ContextFilters`.
- Adjusted context handling in various components and functions for consistency.

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

<!-- end pr-codex -->
2025-02-04 21:59:22 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
ca6c913361 Version Packages (#6132)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-03 22:41:38 -08:00
MananTank cf166e4c04 [TOOL-3216] Dashboard: Add Faucet Refill, Fix toast colors on light mode (#6147)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the UI and functionality of the dashboard by updating theme management, enhancing the `FaucetButton` component, and integrating a new modal for refilling the faucet. It also modifies color schemes for buttons and introduces a loading state.

### Detailed summary
- Removed `<Toaster richColors />` from `RootLayout`.
- Added `<ToasterSetup />` component in `AppRouterProviders` for dynamic theming.
- Updated button color schemes in `sdk-component-theme.ts`.
- Enhanced `FaucetButton` with loading states and a new modal for refilling.
- Introduced `SendFundsToFaucetModalButton` and `SendFundsToFaucetModalContent` components.
- Added form validation for the amount in the faucet refill modal.

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

<!-- end pr-codex -->
2025-02-04 01:03:52 +00:00
MananTank a51192ffbd [TOOL-3055] Dashboard: Add Refresh Balances button for engine backend wallets (#6153)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the `BackendWalletsTable` component by improving the balance display functionality, adding a refresh feature, and optimizing some data fetching mechanisms.

### Detailed summary
- Updated `backendWalletBalance` keys in `cache-keys.ts`.
- Introduced `backendWalletBalanceAll` function.
- Added `isFetching` state to balance fetching logic.
- Changed balance skeleton size.
- Replaced `LinkButton` with `Link` for external navigation.
- Added a refresh button with tooltip for balance updates.
- Included `queryClient` in dependencies for memoized columns.

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

<!-- end pr-codex -->
2025-02-04 01:00:27 +00:00
MananTank 6fd36aabc7 [TOOL-3286] Dashboard: Add Feedback button on header, Add more links in mobile burger menu (#6145) 2025-02-03 19:43:05 +00:00
kumaryash90 08cc48910d [SDK] Update implementation addr extraction logic (#6093)
TOOL-3258

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for EIP-7702 by adding functionality to extract the implementation address from a specific bytecode pattern. It includes a new utility function and a corresponding test case.

### Detailed summary
- Added EIP-7702 support in `extractMinimalProxyImplementationAddress.ts` to extract the implementation address from bytecode.
- Implemented a check for bytecode length and prefix.
- Added a test case in `extractMinimalProxyImplementationAddress.test.ts` to validate the new functionality.

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

<!-- end pr-codex -->
2025-02-03 17:56:14 +00:00
MananTank f03ded300f [TOOL-3276] SDK: Fix JSDoc comments of download function (#6133)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on updating the import path for the `createThirdwebClient` function from `@thirdweb-dev/sdk` to `thirdweb` in the `download.ts` file.

### Detailed summary
- Changed import of `createThirdwebClient` from `@thirdweb-dev/sdk` to `thirdweb` in multiple instances within the `download.ts` file.

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

<!-- end pr-codex -->
2025-02-02 03:27:49 +00:00
kumaryash90 b651bae4ba Update addresses (#6141)
TOOL-3279

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining type imports in `usageV2.ts`, adding a new address in `implementations.ts`, and simplifying the `package.json` formatting for better readability.

### Detailed summary
- Changed import style in `usageV2.ts` to use `import type`.
- Added a new address entry in `implementations.ts`.
- Reformatted `typesVersions` and `files` sections in `package.json` for improved clarity.

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

<!-- end pr-codex -->
2025-02-02 01:19:51 +00:00
Phillip HoandGitHub 66833a832c fix: usageV2 API endpoint (#6140) 2025-01-31 20:20:58 -08:00
Alec AnanianandGitHub 9d5828eeab [SDK] Fix: skip factory entrypoint lookup for ZKsync chains (#6116) 2025-01-31 17:10:33 -08:00
Jonas DanielsandGitHub b191658fcd update login script URL to HTTPS (#6138) 2025-01-31 16:26:11 -08:00
Jonas DanielsandGitHub a6b7e8d818 update dependencies (#6137) 2025-01-31 16:23:07 -08:00
Phillip HoandGitHub f4f93e6353 [service-utils] ESM imports for compression (#6139) 2025-01-31 16:17:41 -08:00
gregfromstl b693b78645 [SDK] Fix: Switch underlying admin wallet with ecosystem smart account (#6119)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the functionality of smart wallets in the `thirdweb` ecosystem, ensuring they correctly trigger chain switches and improving related wallet checks and configurations.

### Detailed summary
- Fixed smart wallets to trigger chain switch on admin wallets.
- Updated `smartWalletGetCapabilities` to accept a generic `Wallet` type.
- Implemented `isSmartWallet` function to check for smart wallet status.
- Modified `getSmartWallet` to throw an error for non-smart wallets.
- Refactored wallet checks in `createConnectionManager`.
- Added tests for `isSmartWallet` and `getSmartWallet`.

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

<!-- end pr-codex -->
2025-01-31 22:21:58 +00:00
jnsdls daceeed2ec remove deprecated NFT embed functionality (#6136)
fixes: CORE-720
2025-01-31 22:18:32 +00:00
Jonas DanielsandGitHub 5f7c726917 add JWT signing and redirect for login flow (#6129) 2025-01-31 13:44:37 -08:00
Jonas DanielsandGitHub 2405456e5f add knip dependency to login app (#6135) 2025-01-31 13:03:26 -08:00
Phillip HoandGitHub a76e6534c4 [service-utils] switch to lz4 for compression lib (#6131) 2025-01-31 10:41:53 -08:00
Phillip HoandGitHub 0fb586db52 [service-utils] fix: use lz4js for usageV2 (#6130) 2025-01-31 10:22:48 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
f8ebbe54d9 Version Packages (#6118)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 09:17:07 -08:00
Phillip HoandGitHub ca756819db [service-utils] CJS import for kafkajs-lz4 (#6127) 2025-01-30 17:29:44 -08:00
Jonas DanielsandGitHub 7217e774b2 update login permissions UI and styling (#6128) 2025-01-30 17:26:07 -08:00
Jonas DanielsandGitHub 5de72c5ecd fix arrow function in login script (#6126) 2025-01-30 17:05:46 -08:00
Jonas DanielsandGitHub 60c5c26583 add thirdweb login client script (#6125) 2025-01-30 16:57:22 -08:00
Jonas DanielsandGitHub dc74c3b4b9 add Suspense wrapper to root layout (#6124) 2025-01-30 16:15:42 -08:00
Jonas DanielsandGitHub 69a921d01f tw:login (#6122) 2025-01-30 16:09:20 -08:00
Phillip HoandGitHub 9abdb92028 [service-utils] import LZ4 compression for usageV2 (#6123) 2025-01-30 15:59:08 -08:00
Phillip HoandGitHub 6ed2fd13de [service-utils] fix: Add LZ4 package for usageV2 compression (#6121) 2025-01-30 14:55:37 -08:00
Phillip HoandGitHub a22457a69c [service-utils] Expose broker settings for usageV2 (#6120) 2025-01-30 12:38:52 -08:00
gregfromstl ae675db8c8 [Wagmi Adapter] Fix: use smart account chain in getProvider (#6117)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on fixing the `getProvider` function in the `wagmi-adapter` package to improve the autoconnection behavior with the account factory by enhancing the chain selection logic.

### Detailed summary
- Updated `getProvider` function to include `args.smartAccount?.chain?.id` in chain selection.
- Changed the logic for defining `chain` to prioritize `params?.chainId`, followed by the smart account's chain ID, the last stored chain ID, and defaulting to `1`.

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

<!-- end pr-codex -->
2025-01-30 19:17:54 +00:00
MananTank 54c9333fc9 [TOOL-3268] Dashboard: Fix in-app wallet settings page form submit not working (#6115) 2025-01-30 17:12:59 +00:00
MananTank 541cf0b3e9 [TOOL-3259] Dashboard: Fix Claimable Module UI crashing on for ERC20 token currency set in claim condition (#6114)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `ClaimableModule` component by integrating the `useQuery` hook for fetching currency decimals and refining the logic for determining when to fetch this data.

### Detailed summary
- Added `useQuery` to fetch currency decimals based on the `claimConditionCurrency`.
- Refactored the logic to check if currency decimals should be fetched.
- Updated the conditional rendering of fetched data in the `ClaimableModule`.
- Made the "Add Claim Condition" button disabled based on `props.isOwnerAccount`.

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

<!-- end pr-codex -->
2025-01-30 15:25:38 +00:00
215 changed files with 8190 additions and 4580 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ module.exports = [
// -- connect
"/connect",
"/connect/sign-in",
"/connect/pay",
"/connect/account-abstraction",
"/connect/universal-bridge",
// -- nebula
"/nebula",
// --insight
+5 -5
View File
@@ -46,9 +46,9 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tooltip": "1.1.7",
"@sentry/nextjs": "8.52.0",
"@sentry/nextjs": "8.53.0",
"@shazow/whatsabi": "^0.19.0",
"@tanstack/react-query": "5.65.1",
"@tanstack/react-query": "5.66.0",
"@tanstack/react-table": "^8.20.6",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "^1.6.0",
@@ -91,7 +91,7 @@
"remark-gfm": "^4.0.0",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"sonner": "^1.7.3",
"sonner": "^1.7.4",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.18.3",
"tailwind-merge": "^2.6.0",
@@ -106,7 +106,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",
"@playwright/test": "1.50.1",
"@storybook/addon-essentials": "8.5.2",
"@storybook/addon-interactions": "8.5.2",
"@storybook/addon-links": "8.5.2",
@@ -117,7 +117,7 @@
"@storybook/react": "8.5.2",
"@storybook/test": "8.5.2",
"@types/color": "4.2.0",
"@types/node": "22.10.10",
"@types/node": "22.13.0",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
+12
View File
@@ -331,6 +331,18 @@ async function redirects() {
destination: "/templates/:slug",
permanent: false,
},
// redirect /account-abstraction to /connect/account-abstraction
{
source: "/account-abstraction",
destination: "/connect/account-abstraction",
permanent: false,
},
// redirect /connect/pay to /connect/universal-bridge
{
source: "/connect/pay",
destination: "/connect/universal-bridge",
permanent: false,
},
// PREVIOUS CAMPAIGNS
{
source: "/unlimited-wallets",
@@ -5,6 +5,7 @@ import { API_SERVER_URL } from "../constants/env";
export async function updateAccount(values: {
name?: string;
email?: string;
image?: string | null;
}) {
const token = await getAuthToken();
@@ -95,7 +95,8 @@ export const engineKeys = {
backendWallet: (address: string, chainId: number) =>
["backendWallet", address, chainId] as const,
backendWalletBalance: (address: string, chainId: number) =>
[...engineKeys.backendWallet(address, chainId), "balance"] as const,
["backendWallet", "balance", address, chainId] as const,
backendWalletBalanceAll: () => ["backendWallet", "balance"] as const,
corsUrls: (instance: string) =>
[...engineKeys.all, instance, "corsUrls"] as const,
ipAllowlist: (instance: string) =>
@@ -39,6 +39,7 @@ export type Account = {
id: string;
isStaff: boolean;
creatorWalletAddress: string;
image?: string | null;
name?: string;
email?: string;
advancedEnabled: boolean;
@@ -548,41 +549,6 @@ export function useResendEmailConfirmation() {
});
}
export function useApiKeys(props: {
isLoggedIn: boolean;
}) {
const address = useActiveAccount()?.address;
return useQuery({
queryKey: apiKeys.keys(address || ""),
queryFn: async () => {
type Result = {
data: ApiKey[];
error?: { message: string };
};
const res = await apiServerProxy<Result>({
pathname: "/v1/keys",
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error(res.error);
}
const json = res.data;
if (json.error) {
throw new Error(json.error.message);
}
return json.data;
},
enabled: !!address && props.isLoggedIn,
});
}
export function useCreateApiKey() {
const address = useActiveAccount()?.address;
const queryClient = useQueryClient();
@@ -1,13 +1,32 @@
"use client";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
THIRDWEB_ENGINE_FAUCET_WALLET,
TURNSTILE_SITE_KEY,
} from "@/constants/env";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { Turnstile } from "@marsidev/react-turnstile";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
@@ -17,9 +36,21 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { toUnits } from "thirdweb";
import {
type ThirdwebClient,
prepareTransaction,
toUnits,
toWei,
} from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
import type { Chain } from "thirdweb/chains";
import {
useActiveAccount,
useActiveWalletChain,
useSendTransaction,
useSwitchActiveWalletChain,
useWalletBalance,
} from "thirdweb/react";
import { z } from "zod";
import { isOnboardingComplete } from "../../../../../../login/onboarding/isOnboardingRequired";
@@ -137,6 +168,35 @@ export function FaucetButton({
const form = useForm<z.infer<typeof claimFaucetSchema>>();
// loading state
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
return (
<Button variant="outline" className="w-full gap-2">
Checking Faucet <Spinner className="size-3" />
</Button>
);
}
// faucet is empty
if (isFaucetEmpty) {
return (
<div className="w-full">
<div className="mb-3 text-center text-muted-foreground text-sm">
Faucet is empty right now
</div>
<SendFundsToFaucetModalButton
chain={definedChain}
isLoggedIn={!!twAccount}
client={client}
chainMeta={chain}
onFaucetRefill={() => {
faucetWalletBalanceQuery.refetch();
}}
/>
</div>
);
}
// Force users to log in to claim the faucet
if (!address || !twAccount) {
return (
@@ -164,24 +224,6 @@ export function FaucetButton({
);
}
// loading state
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
return (
<Button variant="outline" className="w-full gap-2">
Checking Faucet <Spinner className="size-3" />
</Button>
);
}
// faucet is empty
if (isFaucetEmpty) {
return (
<Button variant="outline" disabled className="!opacity-100 w-full">
Faucet is empty right now
</Button>
);
}
// Can not claim
if (canClaimFaucetQuery.data && canClaimFaucetQuery.data.canClaim === false) {
return (
@@ -250,3 +292,165 @@ export function FaucetButton({
</div>
);
}
const faucetFormSchema = z.object({
amount: z.coerce.number().refine((value) => value > 0, {
message: "Amount must be greater than 0",
}),
});
function SendFundsToFaucetModalButton(props: {
chain: Chain;
isLoggedIn: boolean;
client: ThirdwebClient;
chainMeta: ChainMetadata;
onFaucetRefill: () => void;
}) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="default" className="w-full">
Refill Faucet
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Refill Faucet</DialogTitle>
<DialogDescription>Send funds to faucet wallet</DialogDescription>
</DialogHeader>
<SendFundsToFaucetModalContent {...props} />
</DialogContent>
</Dialog>
);
}
function SendFundsToFaucetModalContent(props: {
chain: Chain;
isLoggedIn: boolean;
client: ThirdwebClient;
chainMeta: ChainMetadata;
onFaucetRefill: () => void;
}) {
const account = useActiveAccount();
const activeChain = useActiveWalletChain();
const switchActiveWalletChain = useSwitchActiveWalletChain();
const sendTxMutation = useSendTransaction({
payModal: false,
});
const switchChainMutation = useMutation({
mutationFn: async () => {
await switchActiveWalletChain(props.chain);
},
});
const form = useForm<z.infer<typeof faucetFormSchema>>({
resolver: zodResolver(faucetFormSchema),
defaultValues: {
amount: 0.1,
},
});
function onSubmit(values: z.infer<typeof faucetFormSchema>) {
const sendNativeTokenTx = prepareTransaction({
chain: props.chain,
client: props.client,
to: THIRDWEB_ENGINE_FAUCET_WALLET,
value: toWei(values.amount.toString()),
});
const promise = sendTxMutation.mutateAsync(sendNativeTokenTx);
toast.promise(promise, {
success: `Sent ${values.amount} ${props.chainMeta.nativeCurrency.symbol} to faucet`,
error: `Failed to send ${values.amount} ${props.chainMeta.nativeCurrency.symbol} to faucet`,
});
promise.then(() => {
props.onFaucetRefill();
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex min-w-0 flex-col gap-5"
>
<div className="min-w-0">
<p className="mb-2 text-foreground text-sm"> Faucet Wallet </p>
<CopyTextButton
copyIconPosition="right"
variant="outline"
className="w-full justify-between bg-card py-2 font-mono"
textToCopy={THIRDWEB_ENGINE_FAUCET_WALLET}
textToShow={THIRDWEB_ENGINE_FAUCET_WALLET}
tooltip={undefined}
/>
</div>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type="number"
className="h-auto bg-card text-2xl md:text-2xl [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<div className="-translate-y-1/2 absolute top-1/2 right-4 text-muted-foreground text-sm">
{props.chainMeta.nativeCurrency.symbol}
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!account && (
<CustomConnectWallet
chain={props.chain}
loginRequired={false}
isLoggedIn={props.isLoggedIn}
connectButtonClassName="!w-full"
detailsButtonClassName="!w-full"
/>
)}
{account && activeChain && (
<div>
{activeChain.id === props.chain.id ? (
<Button
key="submit"
type="submit"
className="mt-4 w-full gap-2"
disabled={sendTxMutation.isPending}
>
{sendTxMutation.isPending && <Spinner className="size-4" />}
Send funds to faucet
</Button>
) : (
<Button
key="switch"
className="mt-4 w-full gap-2"
disabled={switchChainMutation.isPending}
onClick={() => switchChainMutation.mutate()}
>
Switch to {props.chainMeta.name}{" "}
{switchChainMutation.isPending && (
<Spinner className="size-4" />
)}
</Button>
)}
</div>
)}
</form>
</Form>
);
}
@@ -39,12 +39,6 @@ export function getContractPageSidebarLinks(data: {
href: `${layoutPrefix}/events`,
exactMatch: true,
},
{
label: "Embed",
href: `${layoutPrefix}/embed`,
hide: data.metadata.embedType === null,
exactMatch: true,
},
{
label: "Analytics",
href: `${layoutPrefix}/analytics`,
@@ -1,35 +0,0 @@
"use client";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type { ThirdwebContract } from "thirdweb";
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
import { EmbedSetup } from "./embed-setup";
export function EmbedSetupClient(props: {
contract: ThirdwebContract;
twAccount: Account | undefined;
}) {
const metadataQuery = useContractPageMetadata(props.contract);
if (metadataQuery.isPending) {
return <LoadingPage />;
}
if (metadataQuery.isError) {
return <ErrorPage />;
}
if (metadataQuery.data.embedType === null) {
return <RedirectToContractOverview contract={props.contract} />;
}
return (
<EmbedSetup
contract={props.contract}
ercOrMarketplace={metadataQuery.data.embedType}
twAccount={props.twAccount}
/>
);
}
@@ -1,613 +0,0 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CodeClient } from "@/components/ui/code/code.client";
import {
type Account,
useApiKeys,
useCreateApiKey,
} from "@3rdweb-sdk/react/hooks/useApi";
import { Flex, FormControl, Input, Select } from "@chakra-ui/react";
import { LazyCreateAPIKeyDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { useTrack } from "hooks/analytics/useTrack";
import { useAllChainsData } from "hooks/chains/allChains";
import { AlertCircleIcon } from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import type { StoredChain } from "stores/chainStores";
import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { useActiveAccount } from "thirdweb/react";
import { Card, FormHelperText, FormLabel, Heading, Text } from "tw-components";
interface EmbedSetupProps {
contract: ThirdwebContract;
ercOrMarketplace: string;
twAccount: Account | undefined;
}
// MAKE SURE THIS IS v1 embed hashes!!
const IPFS_URI = "bafybeigdie2yyiazou7grjowoevmuip6akk33nqb55vrpezqdwfssrxyfy";
const ERC721_IPFS_URI =
"bafybeicd3qfzelz4su7ng6n523virdsgobrc5pcbarhwqv3dj3drh645pi";
interface IframeSrcOptions {
clientId: string;
chain: string;
tokenId?: string;
listingId?: string;
listingType?: string;
directListingId?: string;
englishAuctionId?: string;
relayUrl?: string;
theme?: string;
primaryColor?: string;
secondaryColor?: string;
biconomyApiKey?: string;
biconomyApiId?: string;
}
const colorOptions = [
"purple",
"blue",
"orange",
"pink",
"green",
"red",
"teal",
"cyan",
"yellow",
];
const isValidUrl = (url: string | undefined) => {
if (!url) {
return false;
}
try {
new URL(url);
return true;
} catch {
return false;
}
};
const buildIframeSrc = (
contract: ThirdwebContract,
ercOrMarketplace?: string,
options?: IframeSrcOptions,
): string => {
const contractPath =
ercOrMarketplace === "erc721" ? "" : `${ercOrMarketplace}.html`;
const contractEmbedHash =
ercOrMarketplace === "erc721" ? ERC721_IPFS_URI : IPFS_URI;
if (!contract || !options || !contractEmbedHash) {
return "";
}
const {
clientId,
chain,
tokenId,
listingId,
listingType,
directListingId,
englishAuctionId,
relayUrl,
theme,
primaryColor,
secondaryColor,
biconomyApiKey,
biconomyApiId,
} = options;
const url = new URL(
`https://embed.ipfscdn.io/ipfs/${contractEmbedHash}/${contractPath}`,
);
url.searchParams.append("contract", contract.address);
url.searchParams.append("chain", chain);
url.searchParams.append("clientId", clientId);
if (tokenId !== undefined && ercOrMarketplace === "erc1155") {
url.searchParams.append("tokenId", tokenId.toString());
}
if (listingId !== undefined && ercOrMarketplace === "marketplace") {
url.searchParams.append("listingId", listingId.toString());
}
if (
directListingId !== undefined &&
ercOrMarketplace === "marketplace-v3" &&
listingType === "direct-listing"
) {
url.searchParams.append("directListingId", directListingId.toString());
}
if (
englishAuctionId !== undefined &&
ercOrMarketplace === "marketplace-v3" &&
listingType === "english-auction"
) {
url.searchParams.append("englishAuctionId", englishAuctionId.toString());
}
if (isValidUrl(relayUrl)) {
url.searchParams.append("relayUrl", relayUrl || "");
}
if (biconomyApiKey) {
url.searchParams.append("biconomyApiKey", biconomyApiKey);
}
if (biconomyApiId) {
url.searchParams.append("biconomyApiId", biconomyApiId);
}
if (theme) {
url.searchParams.append("theme", theme);
}
if (primaryColor && primaryColor !== "default") {
url.searchParams.append("primaryColor", primaryColor);
}
if (secondaryColor && secondaryColor !== "orange") {
url.searchParams.append("secondaryColor", secondaryColor);
}
return url.toString();
};
// taken from @thirdweb-dev /chains
// @internal
function minimizeChain(
chain: ChainMetadata,
): Pick<
ChainMetadata,
| "name"
| "chain"
| "rpc"
| "nativeCurrency"
| "shortName"
| "chainId"
| "testnet"
| "slug"
| "icon"
> {
const [firstRpc] = chain.rpc;
return {
name: chain.name,
chain: chain.chain,
rpc: [firstRpc || ""],
nativeCurrency: chain.nativeCurrency,
shortName: chain.shortName,
chainId: chain.chainId,
testnet: chain.testnet,
slug: chain.slug,
icon: chain.icon,
};
}
// taken from @thirdweb-dev /chains
// @internal
type ChainConfiguration = {
rpc?: string | string[];
};
function configureChain(
chain: ChainMetadata,
chainConfig: ChainConfiguration,
): ChainMetadata {
let additionalRPCs: string[] = [];
if (chainConfig?.rpc) {
if (typeof chainConfig.rpc === "string") {
additionalRPCs = [chainConfig.rpc];
} else {
additionalRPCs = chainConfig.rpc;
}
}
// prepend additional RPCs to the chain's RPCs
return { ...chain, rpc: [...additionalRPCs, ...chain.rpc] };
}
export const EmbedSetup: React.FC<EmbedSetupProps> = ({
contract,
ercOrMarketplace,
twAccount,
}) => {
const trackEvent = useTrack();
const { theme } = useTheme();
const apiKeys = useApiKeys({
isLoggedIn: !!twAccount,
});
const createKeyMutation = useCreateApiKey();
const validApiKey = (apiKeys.data || []).find(
(apiKey) =>
(apiKey.domains.includes("*") ||
apiKey.domains.includes("embed.ipfscdn.io") ||
apiKey.domains.includes("*.ipfscdn.io")) &&
(apiKey.services || [])
.find((service) => service.name === "storage")
?.actions.includes("read") &&
!!(apiKey.services || []).find((service) => service.name === "rpc"),
);
const chainId = contract.chain.id;
const { idToChain } = useAllChainsData();
const chain: StoredChain = (chainId ? idToChain.get(chainId) : undefined) || {
name: "Unknown Chain",
chainId: chainId || -1,
rpc: [],
chain: "unknown",
nativeCurrency: {
name: "Ethereum",
symbol: "ETH",
decimals: 18,
},
shortName: "unknown",
slug: "unknown",
testnet: false,
stackType: "",
};
const { register, watch } = useForm<{
rpcUrl: string;
relayUrl: string;
tokenId: string;
listingId: string;
listingType: string;
directListingId: string;
englishAuctionId: string;
theme: string;
primaryColor: string;
secondaryColor: string;
biconomyApiKey: string;
biconomyApiId: string;
gasless: string;
}>({
defaultValues: {
rpcUrl: chain?.rpc[0],
tokenId: "0",
listingId: "0",
directListingId: "0",
englishAuctionId: "0",
theme: theme === "light" ? "light" : "dark",
primaryColor: "purple",
secondaryColor: "orange",
},
reValidateMode: "onChange",
});
const configuredChainWithNewRpc = configureChain(chain, {
rpc: watch("rpcUrl"),
});
const minimizedChain = minimizeChain(configuredChainWithNewRpc);
const iframeSrc = buildIframeSrc(contract, ercOrMarketplace, {
chain: JSON.stringify(minimizedChain),
clientId: validApiKey?.key || "",
...watch(),
});
const embedCode = useMemo(
() =>
`<iframe
src="${iframeSrc}"
width="100%"
height="750px"
style="max-width:100%;"
frameborder="0"
></iframe>`,
[iframeSrc],
);
const [showCreateAPIKeyModal, setShowCreateAPIKeyModal] = useState(false);
const activeAccount = useActiveAccount();
const pathname = usePathname();
return (
<div>
<LazyCreateAPIKeyDialog
prefill={{
name: "Embed API Key",
domains: "embed.ipfscdn.io",
}}
open={showCreateAPIKeyModal}
onOpenChange={setShowCreateAPIKeyModal}
onCreateAndComplete={() => {
trackEvent({
category: "api-keys",
action: "create",
label: "success",
fromEmbed: true,
});
apiKeys.refetch();
}}
enableNebulaServiceByDefault={false}
teamSlug={undefined}
/>
<Alert variant="warning">
<AlertCircleIcon className="size-5" />
<AlertTitle>Deprecated</AlertTitle>
<AlertDescription className="leading-relaxed">
<span>
thirdweb NFT Embeds are deprecated and not actively maintained
</span>{" "}
<br />
<span>
Use the{" "}
<Link
href="https://github.com/thirdweb-example/nft-minting-template"
target="_blank"
className="underline underline-offset-2 hover:text-foreground"
>
{" "}
NFT Minting Template
</Link>{" "}
or build a custom NFT minting app using{" "}
<Link
href="https://portal.thirdweb.com/react/v5"
className="underline underline-offset-2 hover:text-foreground"
>
thirdweb React SDK components
</Link>{" "}
instead
</span>
</AlertDescription>
</Alert>
<div className="h-8" />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<h2 className="mb-3 font-semibold text-xl tracking-tight">
Configuration
</h2>
<Card className="flex w-full flex-col gap-5 bg-card">
{ercOrMarketplace === "marketplace" ? (
<FormControl>
<FormLabel>Listing ID</FormLabel>
<Input type="number" {...register("listingId")} />
<FormHelperText>
The listing ID the embed should display
</FormHelperText>
</FormControl>
) : null}
{ercOrMarketplace === "marketplace-v3" ? (
<FormControl>
<FormLabel>Listing type</FormLabel>
<Select {...register("listingType")}>
<option value="direct-listing">Direct Listing</option>
<option value="english-auction">English Auction</option>
</Select>
<FormHelperText>
The type of listing the embed should display
</FormHelperText>
</FormControl>
) : null}
{ercOrMarketplace === "marketplace-v3" &&
watch("listingType") === "direct-listing" ? (
<FormControl>
<FormLabel>Direct Listing ID</FormLabel>
<Input type="number" {...register("directListingId")} />
<FormHelperText>
The direct listing ID the embed should display
</FormHelperText>
</FormControl>
) : null}
{ercOrMarketplace === "marketplace-v3" &&
watch("listingType") === "english-auction" ? (
<FormControl>
<FormLabel>English Auction ID</FormLabel>
<Input type="number" {...register("englishAuctionId")} />
<FormHelperText>
The english auction ID the embed should display
</FormHelperText>
</FormControl>
) : null}
{ercOrMarketplace === "erc1155" ? (
<FormControl>
<FormLabel>Token ID</FormLabel>
<Input type="number" {...register("tokenId")} />
<FormHelperText>
The token ID the embed should display
</FormHelperText>
</FormControl>
) : null}
<FormControl>
<FormLabel>Client ID</FormLabel>
{!activeAccount ? (
<Button asChild className="w-full">
<Link
href={`/login${
pathname ? `?next=${encodeURIComponent(pathname)}` : ""
}`}
>
Sign in to create a client ID
</Link>
</Button>
) : validApiKey ? (
<Input
readOnly
disabled
value={`${validApiKey?.name} - ${validApiKey?.key}`}
/>
) : (
<Button
className="w-full"
onClick={() => {
trackEvent({
category: "api-keys",
action: "create",
label: "attempt",
fromEmbed: true,
});
setShowCreateAPIKeyModal(true);
}}
disabled={createKeyMutation.isPending}
>
Create Client ID
</Button>
)}
<FormHelperText>
You need a client ID to use embeds.{" "}
<Link
href="https://portal.thirdweb.com/account/api-keys"
className="text-link-foreground hover:text-foreground"
target="_blank"
>
Learn more
</Link>
.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>RPC Url</FormLabel>
<Input type="url" {...register("rpcUrl")} />
<FormHelperText>
RPC the embed should use to connect to the blockchain.
</FormHelperText>
</FormControl>
{ercOrMarketplace === "marketplace" ||
ercOrMarketplace === "marketplace-v3" ? null : (
<FormControl gap={4}>
<Heading size="title.sm" mb={2}>
Gasless
</Heading>
<Select {...register("gasless")} mb={4}>
<option value="false">Disabled</option>
<option value="openZeppelin">OpenZeppelin Relayer</option>
<option value="biconomy">Biconomy Relayer</option>
</Select>
{watch("gasless") === "openZeppelin" && (
<FormControl>
<FormLabel>OpenZeppelin Relayer URL</FormLabel>
<Input type="url" {...register("relayUrl")} />
</FormControl>
)}
{watch("gasless") === "biconomy" && (
<Flex gap={4} flexDir="column">
<FormControl>
<FormLabel>Biconomy API key</FormLabel>
<Input type="url" {...register("biconomyApiKey")} />
</FormControl>
<FormControl>
<FormLabel>Biconomy API ID</FormLabel>
<Input type="url" {...register("biconomyApiId")} />
</FormControl>
</Flex>
)}
<FormHelperText>
A relayer can be used to make the transaction gasless for the
end user.{" "}
<Link
target="_blank"
className="text-link-foreground hover:text-foreground"
href="https://blog.thirdweb.com/guides/setup-gasless-transactions"
>
Learn more
</Link>
</FormHelperText>
</FormControl>
)}
<FormControl>
<FormLabel>Theme</FormLabel>
<Select {...register("theme")}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">User system</option>
</Select>
<FormHelperText>
Selecting system will make it so the embed would change
depending on the user system&apos;s preferences.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Primary Color</FormLabel>
<Select {...register("primaryColor")}>
{ercOrMarketplace === "erc721" && (
<option value="default">Default</option>
)}
{colorOptions.map((color) => (
<option key={color} value={color}>
{color[0]?.toUpperCase() + color.substring(1)}
</option>
))}
</Select>
<FormHelperText>
Used for the main actions button backgrounds.
</FormHelperText>
</FormControl>
{ercOrMarketplace === "marketplace" ||
ercOrMarketplace === "marketplace-v3" ? (
<FormControl>
<FormLabel>Secondary Color</FormLabel>
<Select {...register("secondaryColor")}>
{colorOptions.map((color) => (
<option key={color} value={color}>
{color[0]?.toUpperCase() + color.substring(1)}
</option>
))}
</Select>
<FormHelperText>
Use for secondary actions (like when the user is connected to
the wrong network)
</FormHelperText>
</FormControl>
) : null}
</Card>
</div>
<div>
<h2 className="mb-3 font-semibold text-xl tracking-tight">
Embed Code
</h2>
<CodeClient
code={embedCode}
lang="html"
onCopy={() => {
trackEvent({
category: "embed",
action: "click",
label: "copy-code",
address: contract.address,
chainId,
});
}}
/>
</div>
</div>
<div className="h-8" />
<h2 className="mb-3 font-semibold text-xl tracking-tight">Preview</h2>
<div className="overflow-hidden rounded-lg border border-border">
{!validApiKey ? (
<Text>You need to create a client ID to use embeds</Text>
) : iframeSrc ? (
<iframe
title="thirdweb embed"
src={iframeSrc}
width="100%"
height="750px"
frameBorder="0"
/>
) : null}
</div>
</div>
);
};
@@ -1,42 +0,0 @@
import { notFound, redirect } from "next/navigation";
import { localhost } from "thirdweb/chains";
import { getRawAccount } from "../../../../../account/settings/getAccount";
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
import { EmbedSetupClient } from "./EmbedSetup.client";
import { EmbedSetup } from "./embed-setup";
export default async function Page(props: {
params: Promise<{
contractAddress: string;
chain_id: string;
}>;
}) {
const params = await props.params;
const info = await getContractPageParamsInfo(params);
if (!info) {
notFound();
}
const account = await getRawAccount();
const { contract } = info;
if (contract.chain.id === localhost.id) {
return <EmbedSetupClient contract={contract} twAccount={account} />;
}
const { embedType } = await getContractPageMetadata(contract);
if (embedType === null) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return (
<EmbedSetup
contract={contract}
ercOrMarketplace={embedType}
twAccount={account}
/>
);
}
@@ -26,6 +26,7 @@ import { ToolTipLabel } from "@/components/ui/tooltip";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { TransactionButton } from "components/buttons/TransactionButton";
import { addDays, fromUnixTime } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
@@ -119,22 +120,39 @@ function ClaimableModule(props: ModuleInstanceProps) {
claimConditionQuery.data?.startTimestamp === 0 &&
claimConditionQuery.data?.endTimestamp === 0;
const currencyContract = getContract({
address: claimConditionQuery.data?.currency || "",
chain: props.contract.chain,
client: props.contract.client,
});
const claimConditionCurrency = claimConditionQuery.data?.currency;
const shouldFetchTokenDecimals =
claimConditionQuery.data &&
claimConditionQuery.data?.currency !== ZERO_ADDRESS;
const shouldFetchCurrencyDecimals =
!!claimConditionCurrency && claimConditionCurrency !== ZERO_ADDRESS;
const currencyDecimalsQuery = useReadContract(decimals, {
contract: currencyContract,
queryOptions: {
enabled: shouldFetchTokenDecimals,
const currencyDecimalsQuery = useQuery({
queryKey: [
"currency-decimals",
{
contractAddress: props.contract.address,
chainId: props.contract.chain.id,
claimConditionCurrency,
},
],
queryFn: async () => {
if (!claimConditionCurrency) {
throw new Error();
}
const currencyContract = getContract({
address: claimConditionCurrency,
chain: props.contract.chain,
client: props.contract.client,
});
const decimalsVal = await decimals({
contract: currencyContract,
});
return decimalsVal;
},
enabled: shouldFetchCurrencyDecimals,
});
const tokenDecimalsQuery = useReadContract(decimals, {
contract: contract,
queryOptions: {
@@ -265,7 +283,7 @@ function ClaimableModule(props: ModuleInstanceProps) {
// claim conditions data is present
claimConditionQuery.data &&
// token decimals is fetched if it should be fetched
(shouldFetchTokenDecimals ? currencyDecimalsQuery.isFetched : true)
(shouldFetchCurrencyDecimals ? currencyDecimalsQuery.isFetched : true)
? {
claimCondition: claimConditionQuery.data,
currencyDecimals: currencyDecimalsQuery.data,
@@ -276,7 +294,7 @@ function ClaimableModule(props: ModuleInstanceProps) {
tokenId,
isLoading:
claimConditionQuery.isLoading ||
(!!shouldFetchTokenDecimals && currencyDecimalsQuery.isLoading),
(!!shouldFetchCurrencyDecimals && currencyDecimalsQuery.isLoading),
}}
isOwnerAccount={!!ownerAccount}
name={props.contractInfo.name}
@@ -565,6 +583,7 @@ function ClaimConditionSection(props: {
onClick={() => setAddClaimConditionButtonClicked(true)}
variant="outline"
className="w-full"
disabled={!props.isOwnerAccount}
>
Add Claim Condition
</Button>
@@ -166,7 +166,7 @@ export async function ChainsData(props: {
) : props.activeView === "table" ? (
<TableContainer>
<Table>
<TableHeader>
<TableHeader className="z-0">
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Chain ID</TableHead>
@@ -83,14 +83,14 @@ export async function ChainListRow({
<TableCell>
<div className="flex w-[520px] flex-row items-center gap-14 ">
<div className="z-10 flex items-center gap-7">
<div className="z-10 flex items-center gap-4">
{products.map((p) => {
return (
<ProductIcon
key={p.name}
icon={p.icon}
label={p.name}
href={pidToHref(p.id)}
href={p.link}
isEnabled={enabledServices.includes(p.id)}
/>
);
@@ -102,32 +102,6 @@ export async function ChainListRow({
);
}
function pidToHref(pid: (typeof products)[number]["id"]) {
switch (pid) {
case "account-abstraction": {
return "https://portal.thirdweb.com/connect/account-abstraction/overview";
}
case "contracts": {
return "https://portal.thirdweb.com/contracts";
}
case "connect-sdk": {
return "https://portal.thirdweb.com/connect";
}
case "engine": {
return "https://portal.thirdweb.com/engine";
}
case "pay": {
return "https://portal.thirdweb.com/connect/pay/overview";
}
case "rpc-edge": {
return "https://portal.thirdweb.com/infrastructure/rpc-edge/overview";
}
case "insight": {
return "https://portal.thirdweb.com/insight";
}
}
}
function ProductIcon(props: {
icon: React.FC<{ className?: string }>;
label: string;
@@ -148,8 +122,15 @@ function ProductIcon(props: {
<Link
href={props.href}
target={props.href.startsWith("http") ? "_blank" : undefined}
className="group rounded-lg p-2 hover:bg-accent"
>
<props.icon className={cn("size-8", !props.isEnabled && "grayscale")} />
<props.icon
className={cn(
"size-6 text-foreground group-hover:text-link-foreground",
!props.isEnabled &&
"text-muted-foreground opacity-50 group-hover:opacity-100",
)}
/>
</Link>
</ToolTipLabel>
);
@@ -106,9 +106,8 @@ export function StarButton(props: {
) : (
<Star
className={cn(
"size-6 transition-all",
"size-6 text-foreground transition-all",
props.iconClassName,
isPreferred ? "text-yellow-400" : "text-foreground",
)}
fill={isPreferred ? "currentColor" : "none"}
strokeWidth="1px"
@@ -7,85 +7,15 @@ export function ConnectSDKIcon(props: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>Connect SDK</title>
<rect
x="3"
y="17.9988"
width="14.9988"
height="10.6499"
rx="2"
transform="rotate(-90 3 17.9988)"
fill="url(#paint0_linear_195_4047)"
/>
<g filter="url(#filter0_bi_195_4047)">
<path
d="M3 9C3 7.89543 3.89543 7 5 7H19C20.1046 7 21 7.89543 21 9V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V9Z"
fill="#B9DDFF"
fillOpacity="0.8"
/>
</g>
<path
d="M18.3004 12.6995V12.6995C17.5822 13.4177 17.5822 14.5822 18.3004 15.3004V15.3004"
stroke="white"
strokeWidth="1.5"
d="M16 8.00007V4.50073C16 3.66899 16 3.25312 15.8248 2.99755C15.6717 2.77425 15.4346 2.62257 15.1678 2.57715C14.8623 2.52517 14.4847 2.69944 13.7295 3.04799L4.85901 7.14206C4.18551 7.45291 3.84875 7.60834 3.60211 7.84939C3.38406 8.06249 3.21762 8.32262 3.1155 8.6099C3 8.93486 3 9.30575 3 10.0475V15.0001M16.5 14.5001H16.51M3 11.2001L3 17.8001C3 18.9202 3 19.4802 3.21799 19.9081C3.40973 20.2844 3.71569 20.5903 4.09202 20.7821C4.51984 21.0001 5.07989 21.0001 6.2 21.0001H17.8C18.9201 21.0001 19.4802 21.0001 19.908 20.7821C20.2843 20.5903 20.5903 20.2844 20.782 19.9081C21 19.4802 21 18.9202 21 17.8001V11.2001C21 10.08 21 9.51992 20.782 9.09209C20.5903 8.71577 20.2843 8.40981 19.908 8.21806C19.4802 8.00007 18.9201 8.00007 17.8 8.00007L6.2 8.00007C5.0799 8.00007 4.51984 8.00007 4.09202 8.21806C3.7157 8.40981 3.40973 8.71577 3.21799 9.09209C3 9.51991 3 10.08 3 11.2001ZM17 14.5001C17 14.7762 16.7761 15.0001 16.5 15.0001C16.2239 15.0001 16 14.7762 16 14.5001C16 14.2239 16.2239 14.0001 16.5 14.0001C16.7761 14.0001 17 14.2239 17 14.5001Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<filter
id="filter0_bi_195_4047"
x="1"
y="5"
width="22"
height="18"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_195_4047"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_195_4047"
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.2" />
<feGaussianBlur stdDeviation="0.05" />
<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_195_4047"
/>
</filter>
<linearGradient
id="paint0_linear_195_4047"
x1="15.026"
y1="22.4602"
x2="3.73188"
y2="27.2831"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2567FF" />
<stop offset="1" stopColor="#22A7FF" />
</linearGradient>
</defs>
</svg>
);
}
@@ -1,98 +1,21 @@
export function ContractIcon(props: { className?: string }) {
return (
<svg
className={props.className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>Contract</title>
<g>
<rect
x="3"
y="21"
width="14.9988"
height="10.6499"
rx="1.5"
transform="rotate(-90 3 21)"
fill="url(#paint0_linear_281_45147)"
/>
<g filter="url(#filter0_bi_281_45147)">
<path
d="M7.39429 4.5C7.39429 3.67157 8.06586 3 8.89429 3H19.5C20.3285 3 21 3.67157 21 4.5V19.5C21 20.3284 20.3285 21 19.5 21H8.89429C8.06586 21 7.39429 20.3284 7.39429 19.5V4.5Z"
fill="#EBA4D2"
fillOpacity="0.8"
/>
</g>
<path
d="M11.1971 7L17.1971 7.0001"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M11.1971 10L14.1971 10.0001"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
/>
</g>
<defs>
<filter
id="filter0_bi_281_45147"
x="5.39429"
y="1"
width="17.6057"
height="22"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_281_45147"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_281_45147"
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.2" />
<feGaussianBlur stdDeviation="0.05" />
<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_281_45147"
/>
</filter>
<linearGradient
id="paint0_linear_281_45147"
x1="-2.77663"
y1="30.016"
x2="10.9305"
y2="13.6496"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4009F" />
<stop offset="1" stopColor="#F856C8" />
</linearGradient>
</defs>
<path
d="M14 11H8M10 15H8M16 7H8M20 6.8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H15.2C16.8802 2 17.7202 2 18.362 2.32698C18.9265 2.6146 19.3854 3.07354 19.673 3.63803C20 4.27976 20 5.11984 20 6.8Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -7,77 +7,15 @@ export function EngineIcon(props: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>Engine</title>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.9523 11.9988C14.9523 11.9981 14.9518 11.9975 14.951 11.9975C14.9089 11.9992 14.8665 12 14.8239 12C13.0588 12 11.6278 10.5691 11.6278 8.8039C11.6278 7.03873 13.0588 5.60779 14.8239 5.60779C15.9762 5.60779 16.986 6.21756 17.5484 7.13202C17.695 7.37046 17.9908 7.48805 18.2658 7.43583C18.4057 7.40926 18.5501 7.39536 18.6978 7.39536C19.9694 7.39536 21.0001 8.42614 21.0001 9.69767C21.0001 10.9692 19.9694 12 18.6979 12C18.6979 12 18.6979 12 18.6979 12C18.6979 12 18.6979 12 18.6978 12H14.9536C14.9529 12 14.9523 11.9994 14.9523 11.9988Z"
fill="url(#paint0_linear_195_3943)"
d="M6.5 19C4.01472 19 2 16.9853 2 14.5C2 12.1564 3.79151 10.2313 6.07974 10.0194C6.54781 7.17213 9.02024 5 12 5C14.9798 5 17.4522 7.17213 17.9203 10.0194C20.2085 10.2313 22 12.1564 22 14.5C22 16.9853 19.9853 19 17.5 19C13.1102 19 10.3433 19 6.5 19Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g filter="url(#filter0_bi_195_3943)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.18764 18.9251C6.17601 18.9253 6.16437 18.9253 6.15271 18.9253C4.41138 18.9253 2.99976 17.5137 2.99976 15.7724C2.99976 14.031 4.41138 12.6194 6.15271 12.6194C6.18301 12.6194 6.20685 12.5932 6.20379 12.563C6.18785 12.4057 6.1797 12.2463 6.1797 12.085C6.1797 9.37046 8.48997 7.16992 11.3398 7.16992C13.8545 7.16992 15.949 8.88324 16.407 11.1512C16.4682 11.4541 16.7615 11.6671 17.0697 11.6434C17.1634 11.6361 17.2581 11.6325 17.3536 11.6325C19.3675 11.6325 21 13.265 21 15.2789C21 17.2928 19.3675 18.9253 17.3536 18.9253C17.285 18.9253 17.2169 18.9234 17.1493 18.9197C17.1063 18.9234 17.0628 18.9253 17.0188 18.9253H6.20985C6.20243 18.9253 6.19503 18.9252 6.18764 18.9251Z"
fill="#BFB5FF"
fillOpacity="0.8"
/>
</g>
<defs>
<filter
id="filter0_bi_195_3943"
x="0.999756"
y="5.16992"
width="22.0003"
height="15.7554"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_195_3943"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_195_3943"
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.2" />
<feGaussianBlur stdDeviation="0.05" />
<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_195_3943"
/>
</filter>
<linearGradient
id="paint0_linear_195_3943"
x1="13.1976"
y1="10.8463"
x2="28.7967"
y2="2.94276"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3F2DAF" />
<stop offset="1" stopColor="#917FFB" />
</linearGradient>
</defs>
</svg>
);
}
@@ -7,47 +7,15 @@ export function InsightIcon(props: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>Insight</title>
<circle
cx="8.11963"
cy="12.9889"
r="4.505"
fill="url(#paint0_linear_1095_479)"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.2163 7.97235C21.2163 7.96914 21.2164 7.96592 21.2164 7.96271C21.2164 5.60751 18.4863 3.69824 15.1186 3.69824C11.7509 3.69824 9.02089 5.60751 9.02089 7.96271C9.02089 7.96324 9.02089 7.96378 9.02089 7.96431H9.02087V17.0369C9.02087 17.0372 9.02087 17.0374 9.02087 17.0376C9.02137 19.3925 11.7512 21.3014 15.1186 21.3014C18.486 21.3014 21.2158 19.3925 21.2163 17.0376C21.2163 17.0374 21.2163 17.0372 21.2163 17.0369V7.97235Z"
fill="#06B6D4"
/>
<path
d="M11.1232 10.3516V10.3516C13.5336 11.9944 16.704 11.9944 19.1145 10.3515V10.3515"
stroke="white"
strokeWidth="1.5"
d="M20.5 7.27783L12 12.0001M12 12.0001L3.49997 7.27783M12 12.0001L12 21.5001M21 12.0001V7.94153C21 7.59889 21 7.42757 20.9495 7.27477C20.9049 7.13959 20.8318 7.01551 20.7354 6.91082C20.6263 6.79248 20.4766 6.70928 20.177 6.54288L12.777 2.43177C12.4934 2.27421 12.3516 2.19543 12.2015 2.16454C12.0685 2.13721 11.9315 2.13721 11.7986 2.16454C11.6484 2.19543 11.5066 2.27421 11.223 2.43177L3.82297 6.54288C3.52345 6.70928 3.37368 6.79248 3.26463 6.91082C3.16816 7.01551 3.09515 7.13959 3.05048 7.27477C3 7.42757 3 7.59889 3 7.94153V16.0586C3 16.4013 3 16.5726 3.05048 16.7254C3.09515 16.8606 3.16816 16.9847 3.26463 17.0893C3.37368 17.2077 3.52346 17.2909 3.82297 17.4573L11.223 21.5684C11.5066 21.726 11.6484 21.8047 11.7986 21.8356C11.9315 21.863 12.0685 21.863 12.2015 21.8356C12.3516 21.8047 12.4934 21.726 12.777 21.5684L13 21.4445M7.5 4.50008L16.5 9.50008M22 21.5001L21 20.5001M22 18.0001C22 19.6569 20.6569 21.0001 19 21.0001C17.3431 21.0001 16 19.6569 16 18.0001C16 16.3432 17.3431 15.0001 19 15.0001C20.6569 15.0001 22 16.3432 22 18.0001Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11.1232 15.0107V15.0107C13.5336 16.6536 16.704 16.6535 19.1145 15.0107V15.0107"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<linearGradient
id="paint0_linear_1095_479"
x1="8.11963"
y1="16.3861"
x2="-1.14166"
y2="3.67577"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#155E75" />
<stop offset="1" stopColor="#27B0DB" />
</linearGradient>
</defs>
</svg>
);
}
@@ -7,79 +7,15 @@ export function PayIcon(props: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>Pay</title>
<circle cx="15.9849" cy="16" r="5" fill="url(#paint0_linear_585_2)" />
<g filter="url(#filter0_bi_585_2)">
<path
d="M3 4.5C3 3.67157 3.67157 3 4.5 3H17.5C18.3284 3 19 3.67157 19 4.5V13.5C19 14.3284 18.3284 15 17.5 15H4.5C3.67157 15 3 14.3284 3 13.5V4.5Z"
fill="#B9DDFF"
fillOpacity="0.8"
/>
</g>
<rect
x="4.98955"
y="9.44861"
width="3.54886"
height="3.54886"
rx="1"
fill="white"
<path
d="M22 10H2M2 8.2L2 15.8C2 16.9201 2 17.4802 2.21799 17.908C2.40973 18.2843 2.71569 18.5903 3.09202 18.782C3.51984 19 4.07989 19 5.2 19L18.8 19C19.9201 19 20.4802 19 20.908 18.782C21.2843 18.5903 21.5903 18.2843 21.782 17.908C22 17.4802 22 16.9201 22 15.8V8.2C22 7.0799 22 6.51984 21.782 6.09202C21.5903 5.7157 21.2843 5.40974 20.908 5.21799C20.4802 5 19.9201 5 18.8 5L5.2 5C4.0799 5 3.51984 5 3.09202 5.21799C2.7157 5.40973 2.40973 5.71569 2.21799 6.09202C2 6.51984 2 7.07989 2 8.2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M3.00316 5.99185H19" stroke="white" />
<defs>
<filter
id="filter0_bi_585_2"
x="1"
y="1"
width="20"
height="16"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_585_2"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_585_2"
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.2" />
<feGaussianBlur stdDeviation="0.05" />
<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_585_2"
/>
</filter>
<linearGradient
id="paint0_linear_585_2"
x1="19.003"
y1="15.1892"
x2="10.8494"
y2="17.6614"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2567FF" />
<stop offset="1" stopColor="#22A7FF" />
</linearGradient>
</defs>
</svg>
);
}
@@ -7,92 +7,15 @@ export function RPCIcon(props: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>RPC</title>
<rect
x="12.0015"
y="12.5007"
width="8.99854"
height="8.99854"
rx="1.5"
fill="url(#paint0_linear_195_3994)"
/>
<g filter="url(#filter0_i_195_3994)">
<path
d="M5 18C3.89543 18 3 17.1046 3 16L3 5C3 3.89543 3.89543 3 5 3L16 3C17.1046 3 18 3.89543 18 5L18 16C18 17.1046 17.1046 18 16 18L5 18Z"
fill="#BFB5FF"
fillOpacity="0.8"
/>
</g>
<path
d="M6.10608 9.20007C7.27946 8.04993 8.85702 7.4057 10.5001 7.4057C12.1431 7.4057 13.7207 8.04993 14.8941 9.20007"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.30289 11.3971C8.88958 10.822 9.67836 10.4999 10.4999 10.4999C11.3214 10.4999 12.1102 10.822 12.6969 11.3971"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.5 13.5941H10.5058"
stroke="white"
d="M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20M6 19C6 19.5523 5.55228 20 5 20C4.44772 20 4 19.5523 4 19C4 18.4477 4.44772 18 5 18C5.55228 18 6 18.4477 6 19Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<filter
id="filter0_i_195_3994"
x="3"
y="3"
width="15"
height="15.2764"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="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.276368" />
<feGaussianBlur stdDeviation="0.138184" />
<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_195_3994"
/>
</filter>
<linearGradient
id="paint0_linear_195_3994"
x1="13.5087"
y1="19.8752"
x2="30.3227"
y2="14.0648"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3F2DAF" />
<stop offset="1" stopColor="#917FFB" />
</linearGradient>
</defs>
</svg>
);
}
@@ -7,86 +7,15 @@ export function SmartAccountIcon(props: { className?: string }) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
role="presentation"
>
<title>Smart Account</title>
<rect
x="11.6693"
y="16.9566"
width="13.4583"
height="9.33069"
rx="1.49536"
transform="rotate(-90 11.6693 16.9566)"
fill="url(#paint0_linear_195_3925)"
/>
<g filter="url(#filter0_bi_195_3925)">
<path
d="M8.18321 21.2503C8.7189 21.581 9.37914 21.581 9.91484 21.2503C11.5871 20.2182 15.098 17.6396 15.098 14.1832V10.2763C15.098 9.44263 14.5809 8.69639 13.8003 8.40366L9.75127 6.88528C9.2985 6.71549 8.79955 6.71549 8.34678 6.88528L4.29775 8.40366C3.51715 8.69639 3 9.44263 3 10.2763V14.1832C3 17.6396 6.51095 20.2182 8.18321 21.2503Z"
fill="#B9DDFF"
fillOpacity="0.8"
/>
</g>
<path
d="M9.04903 14.4024C9.98975 14.4024 10.7524 13.6398 10.7524 12.6991C10.7524 11.7583 9.98975 10.9957 9.04903 10.9957C8.10831 10.9957 7.3457 11.7583 7.3457 12.6991C7.3457 13.6398 8.10831 14.4024 9.04903 14.4024Z"
fill="white"
d="M13 7L11.8845 4.76892C11.5634 4.1268 11.4029 3.80573 11.1634 3.57116C10.9516 3.36373 10.6963 3.20597 10.4161 3.10931C10.0992 3 9.74021 3 9.02229 3H5.2C4.0799 3 3.51984 3 3.09202 3.21799C2.71569 3.40973 2.40973 3.71569 2.21799 4.09202C2 4.51984 2 5.0799 2 6.2V7M2 7H17.2C18.8802 7 19.7202 7 20.362 7.32698C20.9265 7.6146 21.3854 8.07354 21.673 8.63803C22 9.27976 22 10.1198 22 11.8V16.2C22 17.8802 22 18.7202 21.673 19.362C21.3854 19.9265 20.9265 20.3854 20.362 20.673C19.7202 21 18.8802 21 17.2 21H6.8C5.11984 21 4.27976 21 3.63803 20.673C3.07354 20.3854 2.6146 19.9265 2.32698 19.362C2 18.7202 2 17.8802 2 16.2V7ZM12 17.5C12 17.5 15 16.0701 15 13.9252V11.4229L12.8124 10.6412C12.2868 10.4529 11.712 10.4529 11.1864 10.6412L9 11.4229V13.9252C9 16.0701 12 17.5 12 17.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.04906 13.6652C9.3792 13.6652 9.6472 13.9415 9.6472 14.2828V16.7528C9.6472 17.0937 9.37954 17.3704 9.04906 17.3704C8.71893 17.3704 8.45093 17.094 8.45093 16.7528V14.2828C8.45093 13.9419 8.71859 13.6652 9.04906 13.6652Z"
fill="white"
/>
<defs>
<filter
id="filter0_bi_195_3925"
x="1"
y="4.75793"
width="16.098"
height="18.7404"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_195_3925"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_195_3925"
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.2" />
<feGaussianBlur stdDeviation="0.05" />
<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_195_3925"
/>
</filter>
<linearGradient
id="paint0_linear_195_3925"
x1="22.4602"
y1="20.8654"
x2="12.4019"
y2="25.2643"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2567FF" />
<stop offset="1" stopColor="#22A7FF" />
</linearGradient>
</defs>
</svg>
);
}
@@ -1,3 +1,4 @@
import { NebulaIcon } from "../../../../nebula-app/(app)/icons/NebulaIcon";
import type { ChainSupportedService } from "../../types/chain";
import { ConnectSDKIcon } from "./icons/ConnectSDKIcon";
import { ContractIcon } from "./icons/ContractIcon";
@@ -13,14 +14,14 @@ export const products = [
id: "contracts",
icon: ContractIcon,
description: "Pre-built contracts & deployment tools",
link: "https://portal.thirdweb.com/contracts",
link: "https://thirdweb.com/contracts",
},
{
name: "Connect SDK",
id: "connect-sdk",
icon: ConnectSDKIcon,
description: "Client-side SDKs for wallets and blockchain interactions",
link: "https://portal.thirdweb.com/connect",
link: "https://thirdweb.com/connect",
},
{
name: "RPC Edge",
@@ -34,7 +35,7 @@ export const products = [
id: "engine",
icon: EngineIcon,
description: "Backend server that reads, writes, and deploys contracts",
link: "https://portal.thirdweb.com/engine",
link: "https://thirdweb.com/engine",
},
{
name: "Account Abstraction",
@@ -55,7 +56,14 @@ export const products = [
id: "insight",
icon: InsightIcon,
description: "Query, transform and analyze blockchain data",
link: "https://portal.thirdweb.com/insight",
link: "https://thirdweb.com/insight",
},
{
name: "Nebula",
id: "nebula",
icon: NebulaIcon,
description: "The most powerful AI for interacting with the blockchain",
link: "https://thirdweb.com/nebula",
},
] satisfies Array<{
name: string;
@@ -5,6 +5,7 @@ export type ChainSupportedService =
| "connect-sdk"
| "engine"
| "account-abstraction"
| "nebula"
| "pay"
| "rpc-edge"
| "chainsaw"
@@ -33,9 +33,9 @@ const PackageIcon: React.FC = () => (
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,.5)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="16.5" y1="9.4" x2="7.5" y2="4.21" />
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
@@ -53,9 +53,9 @@ const FileTextIcon: React.FC = () => (
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,.5)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
@@ -74,9 +74,9 @@ const CalendarIcon: React.FC = () => (
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,.5)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
@@ -94,9 +94,9 @@ const VersionIcon: React.FC = () => (
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,.5)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
@@ -1,207 +0,0 @@
import { Box, Container, Flex } from "@chakra-ui/react";
import { ChakraNextImage } from "components/Image";
import { LandingDynamicSelector } from "components/landing-pages/dynamic-selector";
import { LandingEndCTA } from "components/landing-pages/end-cta";
import { LandingGuidesShowcase } from "components/landing-pages/guide-showcase";
import { LandingHeroWithSideImage } from "components/landing-pages/hero-with-side-image";
import { LandingShowcaseImage } from "components/landing-pages/showcase-image";
import type { Metadata } from "next";
import { Heading, Text } from "tw-components";
// images
import smartWalletMiniImage from "../../../../public/assets/product-icons/smart-wallet.png";
import accountAbstractionImage from "../../../../public/assets/product-pages/smart-wallet/account-abstraction.png";
import batchTxImage from "../../../../public/assets/product-pages/smart-wallet/batch-txns.png";
import dashboardImage from "../../../../public/assets/product-pages/smart-wallet/dashboard.png";
import desktopHero from "../../../../public/assets/product-pages/smart-wallet/desktop-hero.png";
import fullyProgrammaticImage from "../../../../public/assets/product-pages/smart-wallet/full-programmability.png";
import getStartedImage from "../../../../public/assets/product-pages/smart-wallet/get-started.png";
import invisibleWalletsImage from "../../../../public/assets/product-pages/smart-wallet/invisible-wallet.png";
import managedInfrastructureImage from "../../../../public/assets/product-pages/smart-wallet/managed-infrastructure.png";
import mobileHero from "../../../../public/assets/product-pages/smart-wallet/mobile-hero.png";
import pairAnyWalletImage from "../../../../public/assets/product-pages/smart-wallet/pair-any-wallet.png";
import smartContractsImage from "../../../../public/assets/product-pages/smart-wallet/smart-contracts.png";
import uiComponentsImage from "../../../../public/assets/product-pages/smart-wallet/ui-components.png";
import chooseContractImage from "../../../../public/assets/product-pages/smart-wallet/which-contract.png";
import { getAbsoluteUrl } from "../../../lib/vercel-utils";
const GUIDES = [
{
title: "The Quick-Start Guide to Account Abstraction",
image: getStartedImage,
link: "https://portal.thirdweb.com/wallets/smart-wallet/get-started",
},
{
title: "Choosing Between Simple, Managed, & Dynamic Smart Accounts",
image: chooseContractImage,
link: "https://blog.thirdweb.com/smart-contract-deep-dive-building-smart-wallets-for-individuals-and-teams/",
},
{
title: "How to Enable Batch Transactions with Account Abstraction",
image: batchTxImage,
link: "https://blog.thirdweb.com/guides/how-to-batch-transactions-with-the-thirdweb-sdk/",
},
];
const TRACKING_CATEGORY = "smart-wallet-landing";
const title = "The Complete Account Abstraction Toolkit";
const description =
"Add account abstraction to your web3 app & unlock powerful features for seamless onboarding, customizable transactions, & maximum security. Learn more.";
export const metadata: Metadata = {
title,
description,
openGraph: {
title,
description,
images: [
{
url: `${getAbsoluteUrl()}/assets/og-image/smart-wallet.png`,
width: 1200,
height: 630,
},
],
},
};
export default function Page() {
return (
<Container
maxW="container.page"
as={Flex}
flexDir="column"
gap={{ base: "80px", md: "120px" }}
>
<LandingHeroWithSideImage
miniTitle="Powered by ERC-4337"
title="The complete toolkit for"
titleWithGradient="Account Abstraction"
subtitle="Transform your app's user experience with signless transactions, multi-signature security, account recovery and more."
trackingCategory={TRACKING_CATEGORY}
ctaLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
contactUsTitle="Book Demo"
gradient="linear(to-r, #4490FF, #4490FF)"
image={desktopHero}
mobileImage={mobileHero}
miniImage={smartWalletMiniImage}
/>
<LandingDynamicSelector
title="What can you build with account abstraction?"
blackToWhiteTitle=""
TRACKING_CATEGORY={TRACKING_CATEGORY}
items={[
{
title: "Transform your apps UX",
description:
"Infinitely-customizable wallet transactions — with spend limits, batch transactions, and a signless & gasless combo for web2 users.",
Component: (
<ChakraNextImage
src={invisibleWalletsImage}
alt=""
borderRadius="lg"
/>
),
},
{
title: "Instant onboarding for every user",
description:
"Auth for the most popular web3 wallets and web2 login flows — with just an email, phone number, social account, or passkeys.",
Component: (
<ChakraNextImage
src={pairAnyWalletImage}
alt=""
borderRadius="lg"
/>
),
},
{
title: "Enterprise-grade security",
description:
"Wallet recovery, 2FA, and multi-signature support for ultimate peace of mind — for users & teams.",
Component: (
<ChakraNextImage
src={fullyProgrammaticImage}
alt=""
borderRadius="lg"
/>
),
},
]}
/>
<Flex flexDir="column" gap={6} alignItems="center">
<Heading
as="h2"
size="display.md"
textAlign="center"
px={{ base: 2, md: 0 }}
>
An all-in-one solution for
<Box bgGradient="linear(to-r, #3385FF, #7BB0FF)" bgClip="text">
Account Abstraction
</Box>
</Heading>
<Text textAlign="center" size="body.xl" w={{ base: "100%", md: "60%" }}>
Implement account abstraction into any web3 app — with a best-in-class
SDK, full wallet customizability, and managed infrastructure.
</Text>
</Flex>
<LandingShowcaseImage
miniTitle="Smart Contracts"
titleWithGradient="Fast Account Abstraction"
title="factory deployment."
gradient="linear(to-r, #3385FF, #7BB0FF)"
description="Pre-built, audited, & customizable factory contracts with options for simple, managed, or dynamic permissions — deployable to any EVM network."
image={smartContractsImage}
/>
<LandingShowcaseImage
miniTitle="SDK"
titleWithGradient="Integrate account abstraction"
title="into your web3 apps."
gradient="linear(to-r, #3385FF, #7BB0FF)"
description="A best-in-class SDK with everything you need to build apps powered by account abstraction & generate accounts for your users — with full ERC-4337 compatibility."
image={accountAbstractionImage}
imagePosition="left"
/>
<LandingShowcaseImage
miniTitle="UI Components"
titleWithGradient="Drag and drop"
title="UI Components."
gradient="linear(to-r, #3385FF, #7BB0FF)"
description="The easiest way to give accounts to your users with account abstraction — with UI components that are purpose-built for account abstraction & compatible with any existing ERC-4337 account, out of the box."
image={uiComponentsImage}
/>
<LandingShowcaseImage
miniTitle="Managed Infrastructure"
titleWithGradient="Support millions"
title="of users."
gradient="linear(to-r, #3385FF, #7BB0FF)"
description="Managed infrastructure with everything you need to scale to millions of users — handling bundlers, paymasters, & all of the complexity so you can focus on building your app."
image={managedInfrastructureImage}
imagePosition="left"
/>
<LandingShowcaseImage
miniTitle="Dashboard & Analytics"
titleWithGradient="Manage accounts"
title="& view onchain analytics."
gradient="linear(to-r, #3385FF, #7BB0FF)"
description="A single source of truth to view accounts, manage signers, and view balances for your web3 app — complete with onchain analytics."
image={dashboardImage}
/>
<LandingGuidesShowcase
title="Get started with Account Abstraction"
category={TRACKING_CATEGORY}
description="See our quick-start guides to implement account abstraction into your web3 app"
solution="Account Abstraction"
guides={GUIDES}
/>
<LandingEndCTA
title="Add account abstraction"
titleWithGradient="to your web3 app."
trackingCategory={TRACKING_CATEGORY}
ctaLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
gradient="linear(to-r, #3385FF, #7BB0FF)"
/>
</Container>
);
}
@@ -17,7 +17,7 @@ export type AccountHeaderCompProps = {
connectButton: React.ReactNode;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
createProject: (team: Team) => void;
account: Pick<Account, "email" | "id"> | undefined;
account: Pick<Account, "email" | "id" | "image"> | undefined;
client: ThirdwebClient;
};
@@ -41,10 +41,9 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
href="/account"
className="flex flex-row items-center gap-2 font-normal text-sm"
>
{/* TODO - set account Image */}
<GradientAvatar
id={props.account?.id || "default"}
src={""}
src={props.account?.image || ""}
className="size-6"
client={props.client}
/>
@@ -90,10 +89,9 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
"flex flex-row items-center gap-2 font-normal text-foreground text-sm",
)}
>
{/* TODO - set account image */}
<GradientAvatar
id={props.account?.id}
src={props.account ? "" : undefined}
src={props.account?.image || ""}
className="size-6"
client={props.client}
/>
@@ -5,6 +5,7 @@ import { updateAccount } from "@/actions/updateAccount";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
export function AccountSettingsPage(props: {
@@ -23,9 +24,25 @@ export function AccountSettingsPage(props: {
</div>
<div className="container max-w-[950px] grow pt-8 pb-20">
<AccountSettingsPageUI
// TODO - remove hide props these when these fields are functional
hideAvatar
hideDeleteAccount
client={props.client}
updateAccountAvatar={async (file) => {
let uri: string | undefined = undefined;
if (file) {
// upload to IPFS
uri = await upload({
client: props.client,
files: [file],
});
}
await updateAccount({
image: uri,
});
router.refresh();
}}
account={props.account}
updateEmailWithOTP={async (otp) => {
const res = await confirmEmailWithOTP(otp);
@@ -1,4 +1,5 @@
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Toaster } from "sonner";
@@ -33,6 +34,8 @@ export const Mobile: Story = {
},
};
const client = getThirdwebClient();
function Variants() {
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
const [sendEmailFails, setSendEmailFails] = useState(false);
@@ -74,6 +77,10 @@ function Variants() {
? new Date().toISOString()
: undefined,
}}
client={client}
updateAccountAvatar={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
updateEmailWithOTP={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (emailConfirmationFails) {
@@ -30,6 +30,7 @@ import {
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -39,30 +40,35 @@ import { EllipsisIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { z } from "zod";
import { FileInput } from "../../../components/shared/FileInput";
type MinimalAccount = Pick<
Account,
"name" | "email" | "emailConfirmedAt" | "unconfirmedEmail"
"name" | "email" | "emailConfirmedAt" | "unconfirmedEmail" | "image"
>;
export function AccountSettingsPageUI(props: {
account: MinimalAccount;
// TODO - remove hide props these when these fields are functional
hideAvatar?: boolean;
hideDeleteAccount?: boolean;
sendEmail: (email: string) => Promise<void>;
updateName: (name: string) => Promise<void>;
updateEmailWithOTP: (otp: string) => Promise<void>;
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
client: ThirdwebClient;
}) {
return (
<div className="flex flex-col gap-8">
{!props.hideAvatar && <AccountAvatarFormControl />}
<AccountNameFormControl
name={props.account.name || ""}
updateName={(name) => props.updateName(name)}
/>
<AccountAvatarFormControl
updateAccountAvatar={props.updateAccountAvatar}
avatar={props.account.image || undefined}
client={props.client}
/>
<AccountEmailFormControl
email={props.account.email || ""}
status={props.account.emailConfirmedAt ? "verified" : "unverified"}
@@ -75,18 +81,24 @@ export function AccountSettingsPageUI(props: {
);
}
function AccountAvatarFormControl() {
function AccountAvatarFormControl(props: {
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
avatar: string | undefined;
client: ThirdwebClient;
}) {
const accountAvatarUrl = resolveSchemeWithErrorHandler({
client: props.client,
uri: props.avatar,
});
const [avatar, setAvatar] = useState<File>();
// TODO - implement
const updateAvatarMutation = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
mutationFn: (_avatar: File | undefined) => {
return props.updateAccountAvatar(_avatar);
},
});
function handleSave() {
const promises = updateAvatarMutation.mutateAsync();
const promises = updateAvatarMutation.mutateAsync(avatar);
toast.promise(promises, {
success: "Account avatar updated successfully",
error: "Failed to update account avatar",
@@ -118,6 +130,7 @@ function AccountAvatarFormControl() {
setValue={setAvatar}
className="w-20 rounded-full lg:w-28"
disableHelperText
fileUrl={accountAvatarUrl}
/>
</div>
</SettingsCard>
@@ -30,19 +30,27 @@ export function SecondaryNavLinks() {
<ResourcesDropdownButton />
<Link
href="https://portal.thirdweb.com"
className="text-muted-foreground text-sm hover:text-foreground"
target="_blank"
href="https://thirdweb.com/support"
>
Docs
</Link>
<Link
target="_blank"
href="/support"
className="text-muted-foreground text-sm hover:text-foreground"
>
Support
</Link>
<Link
href="https://portal.thirdweb.com/"
className="text-muted-foreground text-sm hover:text-foreground"
target="_blank"
href="https://feedback.thirdweb.com"
className="text-muted-foreground text-sm hover:text-foreground"
>
Docs
Feedback
</Link>
</div>
);
@@ -18,7 +18,7 @@ import type { ThirdwebClient } from "thirdweb";
export function AccountButton(props: {
logout: () => void;
connectButton: React.ReactNode;
account?: Pick<Account, "email" | "id">;
account?: Pick<Account, "email" | "id" | "image">;
client: ThirdwebClient;
}) {
const { setTheme, theme } = useTheme();
@@ -35,11 +35,10 @@ export function AccountButton(props: {
>
{/* Don't remove the div */}
<div>
{/* TODO - set account image */}
<GradientAvatar
id={props.account?.id || "default"}
src={""}
className="size-9"
src={props.account?.image || ""}
className="size-9 border"
client={props.client}
/>
</div>
@@ -159,6 +159,34 @@ export function MobileBurgerMenuButton(
</div>
<div className="mt-auto">
<div className="flex flex-col gap-5">
<Link
target="_blank"
href="https://portal.thirdweb.com"
className="text-muted-foreground hover:text-foreground "
>
Docs
</Link>
<Link
target="_blank"
href="/support"
className="text-muted-foreground hover:text-foreground "
>
Support
</Link>
<Link
target="_blank"
href="https://feedback.thirdweb.com"
className="text-muted-foreground hover:text-foreground "
>
Feedback
</Link>
</div>
<div className="h-6" />
<Separator />
<div className="h-6" />
@@ -25,8 +25,8 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
accentText: "hsl(var(--link-foreground))",
accentButtonBg: "hsl(var(--primary))",
accentButtonText: "hsl(var(--primary-foreground))",
primaryButtonBg: "hsl(var(--background))",
primaryButtonText: "hsl(var(--foreground))",
primaryButtonBg: "hsl(var(--inverted))",
primaryButtonText: "hsl(var(--inverted-foreground))",
secondaryButtonText: "hsl(var(--secondary-foreground))",
tooltipBg: "hsl(var(--popover))",
tooltipText: "hsl(var(--popover-foreground))",
-2
View File
@@ -1,5 +1,4 @@
import "@/styles/globals.css";
import { Toaster } from "@/components/ui/sonner";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
@@ -82,7 +81,6 @@ export default function RootLayout({
<EnsureValidConnectedWalletLoginServer />
</Suspense>
</AppRouterProviders>
<Toaster richColors />
<DashboardRouterTopProgressBar />
<NextTopLoader
color="hsl(var(--primary))"
+12 -4
View File
@@ -55,7 +55,15 @@ export function LoginAndOnboardingPage(props: {
<ThirdwebMiniLogo className="size-7 md:size-8" />
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Link
href="https://portal.thirdweb.com/"
className="px-2 text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Docs
</Link>
<Link
href="/support"
target="_blank"
@@ -65,11 +73,11 @@ export function LoginAndOnboardingPage(props: {
</Link>
<Link
href="https://portal.thirdweb.com/"
className="px-2 text-muted-foreground text-sm hover:text-foreground"
target="_blank"
href="https://feedback.thirdweb.com"
className="px-2 text-muted-foreground text-sm hover:text-foreground"
>
Docs
Feedback
</Link>
<ColorModeToggle />
@@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTrack } from "hooks/analytics/useTrack";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type AccountValidationSchema,
accountValidationSchema,
@@ -81,15 +82,21 @@ export const AccountForm: React.FC<AccountFormProps> = ({
data,
});
},
onError: (err) => {
const error = err as Error;
onError: (error) => {
console.error(error);
if (
onDuplicateError &&
error?.message.match(/email address already exists/)
) {
onDuplicateError(values.email);
return;
} else if (error.message.includes("INVALID_EMAIL_ADDRESS")) {
toast.error("Invalid Email Address");
} else {
toast.error(error.message || "Failed to update account");
}
trackEvent({
category: "account",
action: "update",
@@ -2,42 +2,33 @@ import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
// TODO - copy the source of this library to dashboard
import { stream } from "fetch-event-stream";
import type { NebulaTxData } from "../components/Chats";
import type { ExecuteConfig } from "./types";
export type ContextFilters = {
chainIds?: string[];
contractAddresses?: string[];
walletAddresses?: string[];
export type NebulaContext = {
chainIds: string[] | null;
walletAddress: string | null;
};
export async function promptNebula(params: {
message: string;
sessionId: string;
config: ExecuteConfig | null;
authToken: string;
handleStream: (res: ChatStreamedResponse) => void;
abortController: AbortController;
contextFilters: undefined | ContextFilters;
context: undefined | NebulaContext;
}) {
const body: Record<string, string | boolean | object> = {
message: params.message,
user_id: "default-user",
session_id: params.sessionId,
stream: true,
session_id: params.sessionId,
};
if (params.contextFilters) {
body.context_filter = {
chain_ids: params.contextFilters.chainIds || [],
contract_addresses: params.contextFilters.contractAddresses || [],
wallet_addresses: params.contextFilters.walletAddresses || [],
if (params.context) {
body.context = {
chain_ids: params.context.chainIds || [],
wallet_address: params.context.walletAddress,
};
}
if (params.config) {
body.execute_config = params.config;
}
const events = await stream(`${NEXT_PUBLIC_NEBULA_URL}/chat`, {
method: "POST",
headers: {
@@ -1,33 +1,23 @@
import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env";
import { fetchWithAuthToken } from "../../../../utils/fetchWithAuthToken";
import type { ContextFilters } from "./chat";
import type { NebulaContext } from "./chat";
import type {
DeletedSessionInfo,
ExecuteConfig,
SessionInfo,
TruncatedSessionInfo,
UpdatedSessionInfo,
} from "./types";
// TODO - get the spec for return types on /session POST and PUT
export async function createSession(params: {
authToken: string;
config: ExecuteConfig | null;
contextFilters: ContextFilters | undefined;
context: NebulaContext | undefined;
}) {
const body: Record<string, string | boolean | object> = {
can_execute: !!params.config,
};
if (params.config) {
body.execute_config = params.config;
}
const body: Record<string, string | boolean | object> = {};
if (params.contextFilters) {
body.context_filter = {
chain_ids: params.contextFilters.chainIds || [],
contract_addresses: params.contextFilters.contractAddresses || [],
wallet_addresses: params.contextFilters.walletAddresses || [],
if (params.context) {
body.context = {
chain_ids: params.context.chainIds || [],
wallet_address: params.context.walletAddress,
};
}
@@ -48,22 +38,15 @@ export async function createSession(params: {
export async function updateSession(params: {
authToken: string;
config: ExecuteConfig | null;
sessionId: string;
contextFilters: ContextFilters | undefined;
contextFilters: NebulaContext | undefined;
}) {
const body: Record<string, string | boolean | object> = {
can_execute: !!params.config,
};
if (params.config) {
body.execute_config = params.config;
}
const body: Record<string, string | boolean | object> = {};
if (params.contextFilters) {
body.context_filter = {
body.context = {
chain_ids: params.contextFilters.chainIds || [],
contract_addresses: params.contextFilters.contractAddresses || [],
wallet_addresses: params.contextFilters.walletAddresses || [],
wallet_address: params.contextFilters.walletAddress,
};
}
@@ -1,28 +1,6 @@
type EngineConfig = {
mode: "engine";
engine_url: string;
engine_authorization_token: string;
engine_backend_wallet_address: string;
};
type SessionKeyConfig = {
mode: "session_key";
smart_account_address: string;
smart_account_factory_address: string;
smart_account_session_key: string;
};
type ClientConfig = {
mode: "client";
signer_wallet_address: string;
};
export type ExecuteConfig = EngineConfig | SessionKeyConfig | ClientConfig;
type SessionContextFilter = {
chain_ids: string[] | null;
contract_addresses: string[] | null;
wallet_addresses: string[] | null;
wallet_address: string | null;
};
export type SessionInfo = {
@@ -31,7 +9,6 @@ export type SessionInfo = {
modal_name: string;
archive_at: string | null;
can_execute: boolean;
execute_config: ExecuteConfig | null;
created_at: string;
deleted_at: string | null;
history: Array<{
@@ -43,7 +20,7 @@ export type SessionInfo = {
archived_at: string | null;
title: string | null;
is_public: boolean | null;
context_filter: SessionContextFilter | null;
context: SessionContextFilter | null;
// memory
// action: array<object> | null; <-- type of this is not available on https://nebula-api.thirdweb-dev.com/docs#/default/get_session_session__session_id__get
};
@@ -52,8 +29,7 @@ export type UpdatedSessionInfo = {
title: string;
modal_name: string;
account_id: string;
execute_config: ExecuteConfig | null;
context_filter: SessionContextFilter | null;
context: SessionContextFilter | null;
};
export type DeletedSessionInfo = {
@@ -12,11 +12,11 @@ import { useThirdwebClient } from "@/constants/thirdweb.client";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { type ContextFilters, promptNebula } from "../api/chat";
import { type NebulaContext, promptNebula } from "../api/chat";
import { createSession, updateSession } from "../api/session";
import type { ExecuteConfig, SessionInfo } from "../api/types";
import type { SessionInfo } from "../api/types";
import { newChatPageUrlStore, newSessionsStore } from "../stores";
import { ChatBar } from "./ChatBar";
import { type ChatMessage, Chats } from "./Chats";
@@ -81,27 +81,26 @@ export function ChatPageContent(props: {
useState(false);
const [contextFilters, _setContextFilters] = useState<
ContextFilters | undefined
NebulaContext | undefined
>(() => {
const contextFilterRes = props.session?.context_filter;
const value: ContextFilters = {
chainIds: contextFilterRes?.chain_ids || undefined,
contractAddresses: contextFilterRes?.contract_addresses || undefined,
walletAddresses: contextFilterRes?.wallet_addresses || undefined,
const contextRes = props.session?.context;
const value: NebulaContext = {
chainIds: contextRes?.chain_ids || null,
walletAddress: contextRes?.wallet_address || null,
};
return value;
});
const setContextFilters = useCallback((v: ContextFilters | undefined) => {
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
_setContextFilters(v);
setHasUserUpdatedContextFilters(true);
}, []);
const isNewSession = !props.session;
// if this is a new session, user has not manually updated context filters
// update the context filters to the current user's wallet address and chain id
// if this is a new session, user has not manually updated context
// update the context to the current user's wallet address and chain id
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isNewSession || hasUserUpdatedContextFilters) {
@@ -109,11 +108,16 @@ export function ChatPageContent(props: {
}
_setContextFilters((_contextFilters) => {
const updatedContextFilters: ContextFilters = _contextFilters
? { ..._contextFilters }
: {};
const updatedContextFilters: NebulaContext = _contextFilters
? {
..._contextFilters,
}
: {
chainIds: [],
walletAddress: null,
};
updatedContextFilters.walletAddresses = address ? [address] : [];
updatedContextFilters.walletAddress = address || null;
updatedContextFilters.chainIds = activeChain
? [activeChain.id.toString()]
: [];
@@ -122,15 +126,6 @@ export function ChatPageContent(props: {
});
}, [address, isNewSession, hasUserUpdatedContextFilters, activeChain]);
const config: ExecuteConfig | null = useMemo(() => {
return address
? {
mode: "client",
signer_wallet_address: address,
}
: null;
}, [address]);
const [sessionId, _setSessionId] = useState<string | undefined>(
props.session?.id,
);
@@ -163,12 +158,11 @@ export function ChatPageContent(props: {
const initSession = useCallback(async () => {
const session = await createSession({
authToken: props.authToken,
config,
contextFilters,
context: contextFilters,
});
setSessionId(session.id);
return session;
}, [config, contextFilters, props.authToken, setSessionId]);
}, [contextFilters, props.authToken, setSessionId]);
const handleSendMessage = useCallback(
async (message: string) => {
@@ -221,7 +215,6 @@ export function ChatPageContent(props: {
abortController,
message: message,
sessionId: currentSessionId,
config: config,
authToken: props.authToken,
handleStream(res) {
if (abortController.signal.aborted) {
@@ -308,7 +301,7 @@ export function ChatPageContent(props: {
}
}
},
contextFilters: contextFilters,
context: contextFilters,
});
} catch (error) {
if (abortController.signal.aborted) {
@@ -338,7 +331,6 @@ export function ChatPageContent(props: {
[
sessionId,
contextFilters,
config,
props.authToken,
messages.length,
initSession,
@@ -363,13 +355,12 @@ export function ChatPageContent(props: {
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
const handleUpdateContextFilters = async (
values: ContextFilters | undefined,
values: NebulaContext | 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 session is not yet created, don't need to update sessions - starting a chat will create a session with the context
if (sessionId) {
await updateSession({
authToken: props.authToken,
config,
sessionId,
contextFilters: values,
});
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import type { ContextFilters } from "../api/chat";
import type { NebulaContext } from "../api/chat";
import ContextFiltersButton from "./ContextFilters";
const meta = {
@@ -37,6 +37,7 @@ function Story() {
<Variant
contextFilters={{
chainIds: ["137"],
walletAddress: null,
}}
label="1 chain"
/>
@@ -44,57 +45,25 @@ function Story() {
<Variant
contextFilters={{
chainIds: ["137", "10", "421614"],
walletAddress: null,
}}
label="Few chains"
/>
<Variant
contextFilters={{
contractAddresses: ["0x1E51e33F9838A5a043E099C60409f62aA564272f"],
}}
label="1 contract"
/>
<Variant
contextFilters={{
contractAddresses: [
"0x1E51e33F9838A5a043E099C60409f62aA564272f",
"0xF61c8d5492139b40af09bDB353733d5F0a348aCf",
],
}}
label="Few contracts"
/>
<Variant
contextFilters={{
walletAddresses: ["0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"],
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
chainIds: null,
}}
label="1 wallet"
/>
<Variant
contextFilters={{
walletAddresses: [
"0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
"0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
],
}}
label="Few wallets"
/>
<Variant
contextFilters={{
chainIds: ["137", "10", "421614"],
contractAddresses: [
"0x1E51e33F9838A5a043E099C60409f62aA564272f",
"0xF61c8d5492139b40af09bDB353733d5F0a348aCf",
],
walletAddresses: [
"0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
"0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
],
walletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
}}
label="chains + wallets + contracts"
label="chains + wallet"
/>
<Toaster richColors />
</div>
@@ -103,10 +72,10 @@ function Story() {
function Variant(props: {
label: string;
contextFilters: ContextFilters | undefined;
contextFilters: NebulaContext | undefined;
}) {
const [contextFilters, setContextFilters] = useState<
ContextFilters | undefined
NebulaContext | undefined
>(props.contextFilters);
return (
<BadgeContainer label={props.label}>
@@ -114,7 +83,7 @@ function Variant(props: {
contextFilters={contextFilters}
setContextFilters={setContextFilters}
updateContextFilters={async (values) => {
console.log("Updating context filters", values);
console.log("Updating context", values);
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
@@ -21,7 +21,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { AutoResizeTextarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
@@ -31,52 +31,34 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { isAddress } from "thirdweb";
import { z } from "zod";
import type { ContextFilters } from "../api/chat";
import type { NebulaContext } from "../api/chat";
export default function ContextFiltersButton(props: {
contextFilters: ContextFilters | undefined;
setContextFilters: (filters: ContextFilters | undefined) => void;
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
contextFilters: NebulaContext | undefined;
setContextFilters: (filters: NebulaContext | undefined) => void;
updateContextFilters: (filters: NebulaContext | undefined) => Promise<void>;
}) {
const [isOpen, setIsOpen] = useState(false);
const chainIds = props.contextFilters?.chainIds;
const contractAddresses = props.contextFilters?.contractAddresses;
const walletAddresses = props.contextFilters?.walletAddresses;
const walletAddress = props.contextFilters?.walletAddress;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="max-w-full gap-2">
<SlidersHorizontalIcon className="size-3.5 shrink-0 text-muted-foreground" />
Context Filters
Context
<div className="flex gap-1 overflow-hidden">
{chainIds && chainIds.length > 0 && (
<Badge className="gap-1 truncate" variant="secondary">
Chain
<span className="max-sm:hidden">{chainIds.join(", ")}</span>
</Badge>
)}
{contractAddresses && contractAddresses.length > 0 && (
<Badge className="gap-1 truncate" variant="secondary">
Contract
<span className="max-sm:hidden">
{contractAddresses
.map((add) => `${add.trim().slice(0, 6)}..`)
.join(", ")}
</span>
</Badge>
)}
{walletAddresses && walletAddresses.length > 0 && (
{walletAddress && (
<Badge className="gap-1 truncate" variant="secondary">
Wallet
<span className="max-sm:hidden">
{walletAddresses
.map((add) => `${add.trim().slice(0, 6)}..`)
.join(", ")}
</span>
</Badge>
)}
</div>
@@ -84,9 +66,7 @@ export default function ContextFiltersButton(props: {
</DialogTrigger>
<DialogContent className="overflow-hidden p-0">
<DialogHeader className="px-6 pt-6 pb-1">
<DialogTitle className="font-semibold text-xl">
Context Filters
</DialogTitle>
<DialogTitle className="font-semibold text-xl">Context</DialogTitle>
<DialogDescription className="text-muted-foreground">
Provide context to Nebula for your prompts
</DialogDescription>
@@ -105,17 +85,21 @@ export default function ContextFiltersButton(props: {
);
}
const commaSeparateListOfAddresses = z.string().refine(
(s) => {
if (s.trim() === "") {
return true;
}
return s.split(",").every((s) => isAddress(s.trim()));
},
{
message: "Must be a comma-separated list of valid addresses",
},
);
const optionalAddressSchema = z
.string()
.transform((v) => v.trim())
.optional()
.refine(
(s) => {
if (!s) {
return true;
}
return isAddress(s);
},
{
message: "Must be a valid addresses",
},
);
const formSchema = z.object({
chainIds: z.string().refine(
@@ -132,14 +116,13 @@ const formSchema = z.object({
message: "Chain IDs must be a comma-separated list of integers",
},
),
contractAddresses: commaSeparateListOfAddresses,
walletAddresses: commaSeparateListOfAddresses,
walletAddress: optionalAddressSchema,
});
export function ContextFiltersForm(props: {
contextFilters: ContextFilters | undefined;
updateContextFilters: (filters: ContextFilters | undefined) => Promise<void>;
setContextFilters: (filters: ContextFilters | undefined) => void;
contextFilters: NebulaContext | undefined;
updateContextFilters: (filters: NebulaContext | undefined) => Promise<void>;
setContextFilters: (filters: NebulaContext | undefined) => void;
formBodyClassName?: string;
formActionContainerClassName?: string;
modal:
@@ -158,40 +141,26 @@ export function ContextFiltersForm(props: {
chainIds: props.contextFilters?.chainIds
? props.contextFilters.chainIds.join(",")
: "",
contractAddresses: props.contextFilters?.contractAddresses
? props.contextFilters.contractAddresses.join(",")
: "",
walletAddresses: props.contextFilters?.walletAddresses
? props.contextFilters.walletAddresses.join(",")
: "",
walletAddress: props.contextFilters?.walletAddress || "",
},
reValidateMode: "onChange",
});
function onSubmit(values: z.infer<typeof formSchema>) {
const { chainIds, contractAddresses, walletAddresses } = values;
const { chainIds, walletAddress } = values;
const chainIdsArray = chainIds.split(",").filter((id) => id.trim());
const contractAddressesArray = contractAddresses
.split(",")
.filter((v) => v.trim());
const walletAddressesArray = walletAddresses
.split(",")
.filter((v) => v.trim());
const data = {
chainIds: chainIdsArray,
contractAddresses: contractAddressesArray,
walletAddresses: walletAddressesArray,
walletAddress: walletAddress || null,
};
const promise = updateMutation.mutateAsync(data);
toast.promise(promise, {
success: "Context filters updated",
error: "Failed to update context filters",
success: "Context updated",
error: "Failed to update context",
});
promise.then(() => {
@@ -228,7 +197,6 @@ export function ContextFiltersForm(props: {
.filter(Boolean)
.map(Number)}
onChange={(values) => {
console.log("values", values);
form.setValue("chainIds", values.join(","));
}}
/>
@@ -240,33 +208,15 @@ export function ContextFiltersForm(props: {
<FormField
control={form.control}
name="contractAddresses"
name="walletAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Contract Addresses</FormLabel>
<FormLabel>Wallet Address</FormLabel>
<FormControl>
<AutoResizeTextarea
<Input
{...field}
placeholder="0x123..., 0x456..."
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="walletAddresses"
render={({ field }) => (
<FormItem>
<FormLabel>Wallet Addresses</FormLabel>
<FormControl>
<AutoResizeTextarea
{...field}
placeholder="0x123..., 0x456..."
className="!leading-relaxed min-h-[52px] resize-none md:text-xs"
placeholder="0x123.."
className="md:text-xs md:placeholder:text-xs"
/>
</FormControl>
<FormMessage />
@@ -61,7 +61,7 @@ export function NebulaAccountButton(props: {
<div className="shrink-0">
<GradientAvatar
id={props.account?.id || "default"}
src={""}
src={props.account?.image || ""}
className="size-9 rounded-lg"
client={client}
/>
@@ -88,7 +88,7 @@ export function NebulaAccountButton(props: {
<div className="shrink-0">
<GradientAvatar
id={props.account?.id || "default"}
src={""}
src={props.account?.image || ""}
className="size-9 rounded-full"
client={client}
/>
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1,8 +1,9 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { ThemeProvider, useTheme } from "next-themes";
import { useEffect, useMemo } from "react";
import { Toaster } from "sonner";
import {
ThirdwebProvider,
useActiveAccount,
@@ -31,6 +32,7 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
enableSystem={false}
defaultTheme="dark"
>
<ToasterSetup />
<SanctionedAddressesChecker>
{props.children}
</SanctionedAddressesChecker>
@@ -40,6 +42,11 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
);
}
function ToasterSetup() {
const { theme } = useTheme();
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
}
function SyncChainDefinitionsToConnectionManager() {
const { allChainsV5 } = useAllChainsData();
const connectionManager = useConnectionManager();
@@ -6,6 +6,8 @@ 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 { ToolTipLabel } from "@/components/ui/tooltip";
import { engineKeys } from "@3rdweb-sdk/react";
import {
type BackendWallet,
useEngineBackendWalletBalance,
@@ -30,7 +32,7 @@ import {
type UseDisclosureReturn,
useDisclosure,
} from "@chakra-ui/react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { type ColumnDef, createColumnHelper } from "@tanstack/react-table";
import { ChainIcon } from "components/icons/ChainIcon";
import { TWTable } from "components/shared/TWTable";
@@ -40,18 +42,21 @@ import { EngineBackendWalletOptions } from "lib/engine";
import { useV5DashboardChain } from "lib/v5-adapter";
import {
DownloadIcon,
ExternalLinkIcon,
PencilIcon,
RefreshCcwIcon,
TrashIcon,
TriangleAlertIcon,
UploadIcon,
} from "lucide-react";
import Link from "next/link";
import QRCode from "qrcode";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { getAddress } from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { FormHelperText, FormLabel, LinkButton, Text } from "tw-components";
import { FormHelperText, FormLabel, Text } from "tw-components";
import { prettyPrintCurrency } from "./utils";
interface BackendWalletsTableProps {
@@ -83,16 +88,17 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
authToken,
chainId,
}) => {
const { data: backendWalletBalance } = useEngineBackendWalletBalance({
instanceUrl: instanceUrl,
address,
authToken,
chainId,
});
const { data: backendWalletBalance, isFetching } =
useEngineBackendWalletBalance({
instanceUrl: instanceUrl,
address,
authToken,
chainId,
});
const chain = useV5DashboardChain(chainId);
if (!backendWalletBalance) {
return <Skeleton className="h-5 w-32 rounded-lg" />;
if (!backendWalletBalance || isFetching) {
return <Skeleton className="h-6 w-36 rounded-lg" />;
}
const balanceDisplay = prettyPrintCurrency({
@@ -100,9 +106,7 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
symbol: backendWalletBalance.symbol || chain.nativeCurrency?.symbol,
});
const balanceComponent = (
<div className="text-muted-foreground">{balanceDisplay}</div>
);
const balanceComponent = <span>{balanceDisplay}</span>;
const explorer = chain.blockExplorers?.[0];
if (!explorer) {
@@ -110,14 +114,14 @@ const BackendWalletBalanceCell: React.FC<BackendWalletBalanceCellProps> = ({
}
return (
<LinkButton
variant="ghost"
isExternal
size="xs"
<Link
target="_blank"
href={`${explorer.url}/address/${address}`}
className="inline-flex items-center gap-2.5 rounded-lg px-2 py-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
{balanceComponent}
</LinkButton>
<ExternalLinkIcon className="size-4" />
</Link>
);
};
@@ -133,6 +137,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
const receiveDisclosure = useDisclosure();
const sendDisclosure = useDisclosure();
const deleteDisclosure = useDisclosure();
const queryClient = useQueryClient();
const columns = useMemo(() => {
return [
@@ -160,7 +165,31 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
},
}),
columnHelper.accessor("address", {
header: "Balance",
header: () => (
<div className="flex w-[180px] items-center gap-1.5">
Balance
<ToolTipLabel
label="Refresh Balance"
contentClassName="capitalize font-normal tracking-normal leading-normal"
>
<Button
className="z-20 h-auto p-1.5 [&[data-pending='true']_svg]:animate-spin"
variant="ghost"
size="sm"
onClick={async (e) => {
const buttonEl = e.currentTarget;
buttonEl.setAttribute("data-pending", "true");
await queryClient.invalidateQueries({
queryKey: engineKeys.backendWalletBalanceAll(),
});
buttonEl.setAttribute("data-pending", "false");
}}
>
<RefreshCcwIcon className="size-4" />
</Button>
</ToolTipLabel>
</div>
),
cell: (cell) => {
const address = cell.getValue();
return (
@@ -175,7 +204,7 @@ export const BackendWalletsTable: React.FC<BackendWalletsTableProps> = ({
id: "balance",
}),
];
}, [instanceUrl, authToken, chainId]);
}, [instanceUrl, authToken, chainId, queryClient]);
const [selectedBackendWallet, setSelectedBackendWallet] =
useState<BackendWallet>();
@@ -7,7 +7,7 @@ import { getTeamSettingsLinks } from "./getTeamSettingsLinks";
export function TeamSettingsSidebar(props: {
team: Team;
account: Pick<Account, "id"> | undefined;
account: Pick<Account, "id" | "image"> | undefined;
client: ThirdwebClient;
}) {
const teamLinks = getTeamSettingsLinks(props.team.slug);
@@ -34,7 +34,7 @@ export function TeamSettingsSidebar(props: {
team={props.team}
titleAvatarIcon={{
id: props.account?.id,
src: "", // TODO - set account image
src: props.account?.image || "",
}}
client={props.client}
/>
@@ -60,7 +60,7 @@ function RenderLinkGroup(props: {
<div className="flex items-center gap-1.5 px-4">
<GradientAvatar
src={props.titleAvatarIcon.src}
className="size-4"
className="size-[18px]"
id={props.titleAvatarIcon.id}
client={props.client}
/>
@@ -1,24 +1,22 @@
"use client";
import type { Team } from "@/api/team";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { TeamGeneralSettingsPageUI } from "./TeamGeneralSettingsPageUI";
import { updateTeam } from "./updateTeam";
export function TeamGeneralSettingsPage(props: {
team: Team;
authToken: string;
client: ThirdwebClient;
}) {
const router = useDashboardRouter();
const client = useThirdwebClient();
return (
<TeamGeneralSettingsPageUI
team={props.team}
client={client}
client={props.client}
updateTeamField={async (teamValue) => {
await updateTeam({
teamId: props.team.id,
@@ -38,7 +36,7 @@ export function TeamGeneralSettingsPage(props: {
if (file) {
// upload to IPFS
uri = await upload({
client: getThirdwebClient(props.authToken),
client: props.client,
files: [file],
});
}
@@ -188,7 +188,7 @@ function TeamAvatarFormControl(props: {
disabled: false,
isPending: updateTeamAvatarMutation.isPending,
}}
noPermissionText={undefined} // TODO
noPermissionText={undefined}
errorText={undefined}
>
<div className="flex flex-row gap-4 md:justify-between">
@@ -1,4 +1,5 @@
import { getTeamBySlug } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { notFound } from "next/navigation";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { TeamGeneralSettingsPage } from "./general/TeamGeneralSettingsPage";
@@ -14,5 +15,7 @@ export default async function Page(props: {
notFound();
}
return <TeamGeneralSettingsPage team={team} authToken={token} />;
return (
<TeamGeneralSettingsPage team={team} client={getThirdwebClient(token)} />
);
}
@@ -19,7 +19,7 @@ export function TeamSelectionUI(props: {
currentTeam: Team | undefined;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
upgradeTeamLink: string | undefined;
account: Pick<Account, "email" | "id"> | undefined;
account: Pick<Account, "email" | "id" | "image"> | undefined;
client: ThirdwebClient;
}) {
const { setHoveredTeam, currentTeam, teamsAndProjects } = props;
@@ -52,9 +52,8 @@ export function TeamSelectionUI(props: {
asChild
>
<Link href="/account">
{/* TODO set Image src */}
<GradientAvatar
src={""}
src={props.account?.image || ""}
className="size-4"
id={props.account?.id}
client={props.client}
@@ -417,14 +417,9 @@ function AuthEndpointFields(props: {
canEditAdvancedFeatures: boolean;
}) {
const { form, canEditAdvancedFeatures } = props;
const customHeaderFields = useFieldArray({
control: form.control,
name: "customAuthEndpoint.customHeaders",
});
const expandCustomAuthEndpointField =
form.watch("customAuthEndpoint")?.authEndpoint !== undefined &&
canEditAdvancedFeatures;
form.watch("customAuthEndpoint") !== undefined && canEditAdvancedFeatures;
return (
<div>
@@ -464,88 +459,107 @@ function AuthEndpointFields(props: {
}}
/>
</SwitchContainer>
<AdvancedConfigurationContainer
show={expandCustomAuthEndpointField}
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<FormField
control={form.control}
name="customAuthEndpoint.authEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Endpoint</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-auth-verifier"
/>
</FormControl>
<FormDescription>
Enter the URL of your server where we will send the user payload
for verification
</FormDescription>
<FormMessage />
</FormItem>
)}
{/* useFieldArray used on this component - it creates empty customAuthEndpoint.customHeaders array on mount */}
{/* So only mount if expandCustomAuthEndpointField is true */}
{expandCustomAuthEndpointField && (
<AuthEndpointFieldsContent
form={form}
canEditAdvancedFeatures={canEditAdvancedFeatures}
/>
)}
</div>
);
}
<div>
<Label className="mb-3 inline-block">Custom Headers</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, customHeaderIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.value`,
)}
/>
<Button
variant="outline"
aria-label="Remove header"
onClick={() => {
customHeaderFields.remove(customHeaderIdx);
}}
className="!w-auto px-3"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
function AuthEndpointFieldsContent(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
canEditAdvancedFeatures: boolean;
}) {
const { form } = props;
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
const customHeaderFields = useFieldArray({
control: form.control,
name: "customAuthEndpoint.customHeaders",
});
<p className="mt-3 text-muted-foreground text-sm">
Set custom headers to be sent along the request with the payload to
the authentication endpoint above. This can be used to verify the
incoming requests
</p>
return (
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<FormField
control={form.control}
name="customAuthEndpoint.authEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Endpoint</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-auth-verifier"
/>
</FormControl>
<FormDescription>
Enter the URL of your server where we will send the user payload
for verification
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label className="mb-3 inline-block">Custom Headers</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, customHeaderIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.value`,
)}
/>
<Button
variant="outline"
aria-label="Remove header"
onClick={() => {
customHeaderFields.remove(customHeaderIdx);
}}
className="!w-auto px-3"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
</AdvancedConfigurationContainer>
<p className="mt-3 text-muted-foreground text-sm">
Set custom headers to be sent along the request with the payload to
the authentication endpoint above. This can be used to verify the
incoming requests
</p>
</div>
</div>
);
}
@@ -1,62 +0,0 @@
"use client";
import {
Box,
Flex,
GridItem,
SimpleGrid,
useBreakpointValue,
} from "@chakra-ui/react";
import { ChakraNextImage } from "components/Image";
import type { StaticImageData } from "next/image";
import { Heading, Text } from "tw-components";
interface LandingShowcaseImageProps {
miniTitle?: string;
title: string;
titleWithGradient?: string;
gradient?: string;
description: string;
image: StaticImageData;
imagePosition?: "left" | "right";
}
export const LandingShowcaseImage: React.FC<LandingShowcaseImageProps> = ({
miniTitle,
title,
titleWithGradient,
gradient,
description,
image,
imagePosition = "right",
}) => {
const isMobile = useBreakpointValue({ base: true, md: false });
return (
<SimpleGrid gap={8} columns={{ base: 1, md: 2 }}>
<GridItem order={imagePosition === "left" && !isMobile ? 2 : 1}>
<Flex flexDir="column" justifyContent="center">
{miniTitle && (
<Text
size="body.lg"
as="span"
textTransform="uppercase"
fontWeight="bold"
>
{miniTitle}
</Text>
)}
<Heading pb={4} size="display.sm">
<Box as="span" bgGradient={gradient} bgClip="text">
{titleWithGradient}
</Box>
{title}
</Heading>
{description && <Text size="body.xl">{description}</Text>}
</Flex>
</GridItem>
<GridItem order={imagePosition === "left" && !isMobile ? 1 : 2}>
<ChakraNextImage src={image} alt="" borderRadius="xl" />
</GridItem>
</SimpleGrid>
);
};
@@ -13,15 +13,14 @@ export function NotFoundPage() {
<p className="text-center font-bold text-3xl tracking-tighter md:text-5xl">
<span className="block"> Uh oh. </span>
<span className="block">Looks like web3</span>
<span className="block">can't be found here.</span>
<span className="block">Looks like you're lost!</span>
</p>
<div className="h-12" />
<div>
<p className="text-center text-muted-foreground text-xl leading-7">
Try our{" "}
Find live content on our{" "}
<TrackedLinkTW
category="page-not-found"
label="homepage"
@@ -47,8 +46,8 @@ export function NotFoundPage() {
className="text-foreground hover:underline"
>
developer portal
</TrackedLinkTW>{" "}
instead
</TrackedLinkTW>
.
</p>
</div>
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+31
View File
@@ -0,0 +1,31 @@
# thirdweb-login
## 0.1.4
### Patch Changes
- Updated dependencies [[`f1cd253`](https://github.com/thirdweb-dev/js/commit/f1cd2539d1be15eb18807b3f5f2b90509e3d58cf)]:
- thirdweb@5.88.1
## 0.1.3
### Patch Changes
- Updated dependencies [[`1f6bb7c`](https://github.com/thirdweb-dev/js/commit/1f6bb7c3294d70648b120a6a6a6cba13302a84fc), [`30e13e6`](https://github.com/thirdweb-dev/js/commit/30e13e6b9176265a2f4eddfa53578889abbcb750), [`2dfc245`](https://github.com/thirdweb-dev/js/commit/2dfc245d44dde86e42f6c799305db707316432aa), [`ee57ded`](https://github.com/thirdweb-dev/js/commit/ee57ded902cb69da6fc171599a4a90776e650149), [`9663079`](https://github.com/thirdweb-dev/js/commit/966307906212ac99dc0a2a9be88e514c920d39c4), [`64d7bf3`](https://github.com/thirdweb-dev/js/commit/64d7bf358fe2014b684688d41d525a75e47f1b82)]:
- thirdweb@5.88.0
## 0.1.2
### Patch Changes
- Updated dependencies [[`f77165e`](https://github.com/thirdweb-dev/js/commit/f77165e2d1dd13a1887604c3431bd49b9bd67f28)]:
- thirdweb@5.87.4
## 0.1.1
### Patch Changes
- [#6137](https://github.com/thirdweb-dev/js/pull/6137) [`a6b7e8d`](https://github.com/thirdweb-dev/js/commit/a6b7e8d81868b5f32f1c8b7ff093bb1f06c734ca) Thanks [@jnsdls](https://github.com/jnsdls)! - updated dependencies
- Updated dependencies [[`a6b7e8d`](https://github.com/thirdweb-dev/js/commit/a6b7e8d81868b5f32f1c8b7ff093bb1f06c734ca), [`9d5828e`](https://github.com/thirdweb-dev/js/commit/9d5828eeab201960a720744ca3a59c85a0d8e548), [`b693b78`](https://github.com/thirdweb-dev/js/commit/b693b78645e2b214a5f8be0eec6d335d569ceb8c), [`08cc489`](https://github.com/thirdweb-dev/js/commit/08cc48910df351d068c1ce224d4102f40cb1dce1)]:
- thirdweb@5.87.3
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"next": true,
"ignore": ["public/**"],
"project": ["src/**"],
"ignoreBinaries": ["only-allow", "biome"],
"ignoreDependencies": ["thirdweb", "eslint-config-next"]
}
+28
View File
@@ -0,0 +1,28 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
async headers() {
return [
{
source: "/api/:path*",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "*", // Set your origin
},
{
key: "Access-Control-Allow-Methods",
value: "GET, POST, PUT, DELETE, OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
];
},
};
export default nextConfig;
+36
View File
@@ -0,0 +1,36 @@
{
"name": "thirdweb-login",
"version": "0.1.4",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"format": "biome format ./src --write",
"lint": "biome check ./src && knip && eslint ./src",
"fix": "biome check ./src --fix && eslint ./src --fix",
"typecheck": "tsc --noEmit",
"knip": "knip"
},
"dependencies": {
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"server-only": "^0.0.1",
"thirdweb": "workspace:*"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "22.13.0",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"knip": "5.43.6",
"postcss": "8.5.1",
"postcss-load-config": "^6.0.1",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
+19
View File
@@ -0,0 +1,19 @@
<svg width="903" height="549" viewBox="0 0 903 549" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M475.061 0.925537C480.587 2.07406 485.804 4.39614 490.358 7.73528C495.457 11.7723 499.362 17.1238 501.656 23.2159C516.137 59.447 530.618 95.6561 545.099 131.843C561.124 172.089 577.229 212.341 593.414 252.601C601.849 273.709 610.241 294.839 618.807 315.903C620.629 320.212 621.568 324.844 621.568 329.523C621.568 334.202 620.629 338.834 618.807 343.142L545.208 526.743C540.335 538.895 531.506 546.449 518.526 548.42C502.377 550.851 487.933 542.88 482.098 528.713C468.287 494.752 454.782 460.682 441.168 426.633L371.94 253.433C351.442 202.036 330.915 150.652 310.359 99.2836C303.542 82.2264 296.942 65.0816 289.818 48.1558C287.856 43.5536 286.929 38.5755 287.1 33.5741C287.272 28.5726 288.539 23.6702 290.812 19.2139C293.084 14.7576 296.307 10.8566 300.251 7.78696C304.196 4.71736 308.766 2.55433 313.637 1.45105C314.227 1.31967 314.796 1.10071 315.364 0.925537H475.061Z" fill="url(#paint0_linear_4033_153464)"/>
<path d="M188.239 0.925964C190.052 1.42958 191.866 1.8675 193.636 2.4806C198.245 3.9207 202.472 6.37753 206.008 9.67041C209.544 12.9633 212.298 17.0085 214.068 21.5085C229.875 60.7466 245.609 100.014 261.27 139.31C277.12 178.928 292.97 218.553 308.821 258.185C316.571 277.615 324.358 297.037 332.181 316.451C333.932 320.609 334.834 325.076 334.834 329.589C334.834 334.102 333.932 338.569 332.181 342.727C323.44 364.536 314.699 386.337 305.958 408.131C289.933 448.026 273.908 487.921 257.882 527.816C255.367 534.028 251.065 539.351 245.525 543.109C239.985 546.867 233.455 548.891 226.765 548.924C219.677 549.049 212.723 546.977 206.856 542.991C200.988 539.005 196.493 533.299 193.986 526.656C179.17 489.06 164.048 451.573 149.057 414.043C128.968 363.229 108.776 312.452 88.4822 261.711C67.8243 210.065 47.1738 158.411 26.5305 106.751C18.7073 87.1973 10.9497 67.6001 3.01724 48.0686C-0.448593 39.9305 -0.616668 30.7602 2.54861 22.5001C5.71389 14.2399 11.9639 7.53886 19.9747 3.81627C22.881 2.63941 25.8679 1.67348 28.9124 0.925964L188.239 0.925964Z" fill="url(#paint1_linear_4033_153464)"/>
<path d="M902.82 334.858C898.45 349.638 891.894 363.586 886.322 377.841C870.195 418.393 853.98 458.923 837.678 499.431C833.941 508.715 830.139 517.977 826.511 527.305C823.958 533.679 819.554 539.138 813.87 542.976C808.185 546.815 801.482 548.855 794.628 548.834C787.773 548.813 781.083 546.731 775.422 542.857C769.761 538.984 765.391 533.497 762.877 527.108L692.119 350.142L630.932 197.284C610.959 147.375 591 97.4735 571.056 47.5791C569.16 43.017 568.286 38.0935 568.495 33.156C568.704 28.2184 569.992 23.387 572.267 19.0024C574.543 14.6177 577.751 10.7867 581.664 7.77934C585.578 4.77199 590.103 2.66159 594.919 1.59695C595.575 1.44368 596.208 1.22472 596.864 1.02765H756.562C757.064 1.18092 757.545 1.37799 758.048 1.48747C763.649 2.65867 768.864 5.22722 773.211 8.95523C777.558 12.6832 780.896 17.4502 782.916 22.8144C792.21 46.1705 801.52 69.4608 810.843 92.6855C835.245 153.674 859.625 214.662 883.983 275.651C890.386 291.723 897.51 307.51 902.755 324.02L902.82 334.858Z" fill="url(#paint2_linear_4033_153464)"/>
<defs>
<linearGradient id="paint0_linear_4033_153464" x1="262.328" y1="45.4188" x2="647.023" y2="429.344" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF00A8"/>
<stop offset="1" stop-color="#6200C6"/>
</linearGradient>
<linearGradient id="paint1_linear_4033_153464" x1="-24.342" y1="45.2878" x2="360.265" y2="429.125" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF00A8"/>
<stop offset="1" stop-color="#6200C6"/>
</linearGradient>
<linearGradient id="paint2_linear_4033_153464" x1="543.762" y1="45.28" x2="928.392" y2="429.14" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF00A8"/>
<stop offset="1" stop-color="#6200C6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+193
View File
@@ -0,0 +1,193 @@
// biome-ignore lint/complexity/useArrowFunction: This is a self-executing function, we do not want to use an arrow function here.
(function () {
const globalSetup = getSetup();
const JWT_KEY = "tw.login:jwt";
const CODE_KEY = "tw.login:code";
// check if redirected first, this sets up the logged in state if it was from redirect
const result = parseURL(new URL(window.location));
if (
result &&
result.length === 2 &&
result[1] === localStorage.getItem(CODE_KEY)
) {
// reset the URL
window.location.hash = "";
window.location.search = "";
// write the jwt to local storage
localStorage.setItem(JWT_KEY, result[0]);
}
// always reset the code
localStorage.removeItem(CODE_KEY);
const jwt = localStorage.getItem(JWT_KEY);
if (jwt) {
// handle logged in state
handleIsLoggedIn();
} else {
// handle not logged in state
handleNotLoggedIn();
}
function handleIsLoggedIn() {
window.thirdweb = {
isLoggedIn: true,
getUser: async () => {
const res = await fetch(`${globalSetup.baseUrl}/api/user`, {
headers: {
Authorization: `Bearer ${localStorage.getItem(JWT_KEY)}`,
},
});
return res.json();
},
logout: () => {
window.localStorage.removeItem(JWT_KEY);
window.location.reload();
},
};
renderFloatingBubble(true);
}
function handleNotLoggedIn() {
window.thirdweb = { login: onLogin, isLoggedIn: false };
renderFloatingBubble(false);
}
function onLogin() {
const code = window.crypto.getRandomValues(new Uint8Array(16)).join("");
localStorage.setItem(CODE_KEY, code);
// redirect to the login page
const redirect = new URL(globalSetup.baseUrl);
redirect.searchParams.set("code", code);
redirect.searchParams.set("clientId", globalSetup.clientId);
redirect.searchParams.set(
"redirect",
window.location.origin + window.location.pathname,
);
window.location.href = redirect.href;
}
// utils
function getSetup() {
const el = document.currentScript;
if (!el) {
throw new Error("Could not find script element");
}
const baseUrl = new URL(el.src).origin;
const dataset = el.dataset;
const clientId = dataset.clientId;
const theme = dataset.theme || "dark";
if (!clientId) {
throw new Error("Missing client-id");
}
return { clientId, baseUrl, theme };
}
/**
* @param {URL} url
* @returns null | [string, string]
*/
function parseURL(url) {
try {
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
const code = url.searchParams.get("code");
if (!hash || !code) {
return null;
}
return [hash, code];
} catch {
// if this fails, invalid data -> return null
return null;
}
}
async function renderFloatingBubble(loggedIn) {
const el = document.createElement("div");
el.id = "tw-floating-bubble";
el.style.position = "fixed";
el.style.bottom = "24px";
el.style.right = "24px";
el.style.zIndex = "1000";
el.style.width = "138px";
el.style.height = "40px";
el.style.backgroundColor =
globalSetup.theme === "dark" ? "#131418" : "#ffffff";
el.style.color = globalSetup.theme === "dark" ? "white" : "black";
el.style.borderRadius = "8px";
el.style.placeItems = "center";
el.style.fontSize = loggedIn ? "12px" : "12px";
el.style.cursor = "pointer";
el.style.overflow = "hidden";
el.style.boxShadow = "1px 1px 10px rgba(0, 0, 0, 0.5)";
el.style.display = "flex";
el.style.alignItems = "center";
el.style.justifyContent = "space-around";
el.style.fontFamily = "sans-serif";
el.style.gap = "8px";
el.style.padding = "0px 8px";
el.onclick = () => {
if (loggedIn) {
window.thirdweb.logout();
} else {
window.thirdweb.login();
}
};
el.innerHTML = loggedIn ? await renderBlobbie() : renderThirdwebLogo();
document.body.appendChild(el);
}
function renderThirdwebLogo() {
const el = document.createElement("img");
el.src = `${globalSetup.baseUrl}/logo.svg`;
el.style.height = "16px";
el.style.objectFit = "contain";
el.style.flexShrink = "0";
el.style.marginLeft = "-4px";
return `${el.outerHTML} <span>Login</span><span></span>`;
}
async function renderBlobbie() {
const address = (await window.thirdweb.getUser()).address;
function hexToNumber(hex) {
if (typeof hex !== "string")
throw new Error(`hex string expected, got ${typeof hex}`);
return hex === "" ? _0n : BigInt(`0x${hex}`);
}
const COLOR_OPTIONS = [
["#fca5a5", "#b91c1c"],
["#fdba74", "#c2410c"],
["#fcd34d", "#b45309"],
["#fde047", "#a16207"],
["#a3e635", "#4d7c0f"],
["#86efac", "#15803d"],
["#67e8f9", "#0e7490"],
["#7dd3fc", "#0369a1"],
["#93c5fd", "#1d4ed8"],
["#a5b4fc", "#4338ca"],
["#c4b5fd", "#6d28d9"],
["#d8b4fe", "#7e22ce"],
["#f0abfc", "#a21caf"],
["#f9a8d4", "#be185d"],
["#fda4af", "#be123c"],
];
const colors =
COLOR_OPTIONS[
Number(hexToNumber(address.slice(2, 4))) % COLOR_OPTIONS.length
];
const el = document.createElement("div");
el.style.backgroundImage = `radial-gradient(ellipse at left bottom, ${colors[0]}, ${colors[1]})`;
el.style.width = "24px";
el.style.height = "24px";
el.style.borderRadius = "50%";
el.style.flexShrink = "0";
return `${el.outerHTML}<span>${address.slice(0, 6)}...${address.slice(-4)}</span><span></span>`;
}
})();
+1
View File
@@ -0,0 +1 @@
!function(){let e=function e(){let t=document.currentScript;if(!t)throw Error("Could not find script element");let a=new URL(t.src).origin,n=t.dataset,l=n.clientId,s=n.theme||"dark";if(!l)throw Error("Missing client-id");return{clientId:l,baseUrl:a,theme:s}}(),t="tw.login:jwt",a="tw.login:code",n=function e(t){try{let a=t.hash.startsWith("#")?t.hash.slice(1):t.hash,n=t.searchParams.get("code");if(!a||!n)return null;return[a,n]}catch{return null}}(new URL(window.location));n&&2===n.length&&n[1]===localStorage.getItem(a)&&(window.location.hash="",window.location.search="",localStorage.setItem(t,n[0])),localStorage.removeItem(a);let l=localStorage.getItem(t);function s(){let t=window.crypto.getRandomValues(new Uint8Array(16)).join("");localStorage.setItem(a,t);let n=new URL(e.baseUrl);n.searchParams.set("code",t),n.searchParams.set("clientId",e.clientId),n.searchParams.set("redirect",window.location.origin+window.location.pathname),window.location.href=n.href}async function i(t){let a=document.createElement("div");a.id="tw-floating-bubble",a.style.position="fixed",a.style.bottom="24px",a.style.right="24px",a.style.zIndex="1000",a.style.width="138px",a.style.height="40px",a.style.backgroundColor="dark"===e.theme?"#131418":"#ffffff",a.style.color="dark"===e.theme?"white":"black",a.style.borderRadius="8px",a.style.placeItems="center",a.style.fontSize="12px",a.style.cursor="pointer",a.style.overflow="hidden",a.style.boxShadow="1px 1px 10px rgba(0, 0, 0, 0.5)",a.style.display="flex",a.style.alignItems="center",a.style.justifyContent="space-around",a.style.fontFamily="sans-serif",a.style.gap="8px",a.style.padding="0px 8px",a.onclick=()=>{t?window.thirdweb.logout():window.thirdweb.login()},a.innerHTML=t?await r():function t(){let a=document.createElement("img");return a.src=`${e.baseUrl}/logo.svg`,a.style.height="16px",a.style.objectFit="contain",a.style.flexShrink="0",a.style.marginLeft="-4px",`${a.outerHTML} <span>Login</span><span></span>`}(),document.body.appendChild(a)}async function r(){let e=(await window.thirdweb.getUser()).address,t=[["#fca5a5","#b91c1c"],["#fdba74","#c2410c"],["#fcd34d","#b45309"],["#fde047","#a16207"],["#a3e635","#4d7c0f"],["#86efac","#15803d"],["#67e8f9","#0e7490"],["#7dd3fc","#0369a1"],["#93c5fd","#1d4ed8"],["#a5b4fc","#4338ca"],["#c4b5fd","#6d28d9"],["#d8b4fe","#7e22ce"],["#f0abfc","#a21caf"],["#f9a8d4","#be185d"],["#fda4af","#be123c"],],a=t[Number(function e(t){if("string"!=typeof t)throw Error(`hex string expected, got ${typeof t}`);return""===t?_0n:BigInt(`0x${t}`)}(e.slice(2,4)))%t.length],n=document.createElement("div");return n.style.backgroundImage=`radial-gradient(ellipse at left bottom, ${a[0]}, ${a[1]})`,n.style.width="24px",n.style.height="24px",n.style.borderRadius="50%",n.style.flexShrink="0",`${n.outerHTML}<span>${e.slice(0,6)}...${e.slice(-4)}</span><span></span>`}l?(window.thirdweb={isLoggedIn:!0,async getUser(){let a=await fetch(`${e.baseUrl}/api/user`,{headers:{Authorization:`Bearer ${localStorage.getItem(t)}`}});return a.json()},logout(){window.localStorage.removeItem(t),window.location.reload()}},i(!0)):(window.thirdweb={login:s,isLoggedIn:!1},i(!1))}();
+33
View File
@@ -0,0 +1,33 @@
import { type NextRequest, NextResponse } from "next/server";
import { verifyJWT } from "../../authorization/jwt";
export const GET = async (req: NextRequest) => {
const jwt = req.headers.get("Authorization")?.split("Bearer ")[1];
if (!jwt) {
return NextResponse.json(
{
message: "No JWT provided",
},
{
status: 401,
},
);
}
try {
const verifiedPayload = await verifyJWT(jwt);
return NextResponse.json({
address: verifiedPayload.sub,
});
} catch (e) {
console.error("failed", e);
return NextResponse.json(
{
message: "Invalid JWT",
},
{
status: 401,
},
);
}
};
+58
View File
@@ -0,0 +1,58 @@
"server only";
import "server-only";
import { createThirdwebClient } from "thirdweb";
import { verifyEOASignature } from "thirdweb/auth";
import { decodeJWT, encodeJWT, stringify } from "thirdweb/utils";
import { privateKeyToAccount, randomPrivateKey } from "thirdweb/wallets";
const client = createThirdwebClient({
clientId: "e9ba48c289e0cc3d06a23bfd370cc111",
});
const privateKey = process.env.THIRDWEB_ADMIN_PRIVATE_KEY || randomPrivateKey();
if (!privateKey) {
throw new Error("Missing THIRDWEB_ADMIN_PRIVATE_KEY");
}
const serverAccount = privateKeyToAccount({
client,
privateKey: privateKey,
});
export async function signJWT(data: {
address: string;
sessionKeySignerAddress: string;
code: string;
}) {
"use server";
return await encodeJWT({
account: serverAccount,
payload: {
iss: serverAccount.address,
sub: data.address,
aud: data.sessionKeySignerAddress,
exp: new Date(Date.now() + 1000 * 60 * 60),
nbf: new Date(),
iat: new Date(),
jti: data.code,
},
});
}
export async function verifyJWT(jwt: string) {
const { payload, signature } = decodeJWT(jwt);
console.log("payload", payload);
console.log("signature", signature);
// verify the signature
const verified = await verifyEOASignature({
message: stringify(payload),
signature,
address: serverAccount.address,
});
if (!verified) {
throw new Error("Invalid JWT signature");
}
return payload;
}
@@ -0,0 +1,186 @@
"use client";
import { useSearchParams } from "next/navigation";
import { createThirdwebClient, getContract } from "thirdweb";
import { baseSepolia } from "thirdweb/chains";
import { addSessionKey, isActiveSigner } from "thirdweb/extensions/erc4337";
import {
AccountAddress,
AccountBlobbie,
AccountProvider,
ConnectEmbed,
TransactionButton,
useActiveAccount,
} from "thirdweb/react";
import { isContractDeployed, shortenAddress } from "thirdweb/utils";
const sessionKeySignerAddress = "0x6f700ba0258886411D2536399624EAa7158d1742";
export function LoginPageInner({
generateJWT,
}: {
generateJWT: (opts: {
address: string;
sessionKeySignerAddress: string;
code: string;
}) => Promise<string>;
}) {
const account = useActiveAccount();
const searchParams = useSearchParams();
const code = searchParams.get("code");
// const clientId = searchParams.get("clientId");
const redirect = searchParams.get("redirect");
const client = createThirdwebClient({
clientId: "e9ba48c289e0cc3d06a23bfd370cc111",
});
return (
<div className="grid h-full w-full place-items-center">
{account ? (
<div
className="flex flex-col gap-4 rounded-lg border p-12"
style={{ borderColor: "rgba(255, 255, 255, 0.1)" }}
>
<AccountProvider address={account.address} client={client}>
<div
className="flex flex-row items-center gap-2 rounded-md border p-4"
style={{ borderColor: "rgba(255, 255, 255, 0.1)" }}
>
<AccountBlobbie className="h-12 w-12 rounded-full" />
<div className="flex flex-col gap-0">
<AccountAddress formatFn={shortenAddress} />
</div>
</div>
</AccountProvider>
<hr className="opacity-10" />
<div className="flex flex-col gap-0">
<h1 className="font-bold text-2xl">Grant Permissions</h1>
<p className="opacity-70">
<strong>App.xyz</strong> is asking you to grant it the following
permissions:
</p>
</div>
<hr className="opacity-10" />
<ul>
<li>
<span className="mr-4">✅</span>Read your wallet address.
</li>
</ul>
<hr className="opacity-10" />
<ul>
<li>
<span className="mr-4">⚠️</span>Interact with <strong>all</strong>{" "}
contracts on <strong>Base Sepolia</strong>
</li>
<li>
<span className="mr-4">⚠️</span>Spend <strong>all</strong> your
funds on <strong>Base Sepolia</strong>
</li>
<li>
<span className="mr-4">📆</span>Granted until{" "}
<strong>02/02/2025</strong>
</li>
</ul>
<hr className="opacity-10" />
<div className="flex flex-row gap-4">
<button
type="button"
className="w-[50%] rounded-lg border p-2 hover:bg-gray-950"
style={{ borderColor: "rgba(255, 255, 255, 0.1)" }}
onClick={() => {
window.location.href = redirect || "";
}}
>
Deny
</button>
<TransactionButton
className="!w-[50%]"
transaction={async () => {
if (!account) {
throw new Error("No account found");
}
if (!code) {
throw new Error("No code found");
}
if (!redirect) {
throw new Error("No redirect found");
}
const accountContract = getContract({
address: account.address,
// hard coded for now
chain: baseSepolia,
client,
});
let hasSessionKey = false;
// check if already added
const accountDeployed =
await isContractDeployed(accountContract);
if (accountDeployed) {
hasSessionKey = await isActiveSigner({
contract: accountContract,
signer: sessionKeySignerAddress,
});
}
// if not added, send tx to add the session key
if (!hasSessionKey) {
return addSessionKey({
account,
contract: accountContract,
sessionKeyAddress: sessionKeySignerAddress,
// hard coded for now
permissions: { approvedTargets: "*" },
});
}
throw "already-added";
}}
onError={async (e) => {
if (!code) {
throw new Error("No code found");
}
if (!redirect) {
throw new Error("No redirect found");
}
if (typeof e === "string" && e === "already-added") {
// sign jwt
const jwt = await generateJWT({
address: account.address,
sessionKeySignerAddress,
code,
});
// redirect
window.location.href = `${redirect}?code=${code}#${jwt}`;
} else {
console.error(e);
}
}}
onTransactionConfirmed={async () => {
if (!code) {
throw new Error("No code found");
}
if (!redirect) {
throw new Error("No redirect found");
}
// sign jwt
const jwt = await generateJWT({
address: account.address,
sessionKeySignerAddress,
code,
});
// redirect
window.location.href = `${redirect}?code=${code}#${jwt}`;
}}
>
Approve
</TransactionButton>
</div>
</div>
) : (
<ConnectEmbed
client={client}
accountAbstraction={{ chain: baseSepolia, sponsorGas: true }}
/>
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+163
View File
@@ -0,0 +1,163 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
/* scroll-behavior: smooth; */
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
font-feature-settings: "rlig" 1, "calt" 0;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
font-variation-settings: normal;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
@layer base {
:root {
--radius: 0.5rem;
--sticky-top-height: 70px;
}
:root,
[data-theme="dark"] {
/* bg - neutral */
--background: 0 0% 0%;
--card: 0 0% 3.92%;
--popover: 0 0% 0%;
--secondary: 0 0% 11%;
--muted: 0 0% 11%;
--accent: 0 0% 11%;
--inverted: 0 0% 100%;
/* bg - colorful */
--primary: 221 83% 54%;
--destructive: 360 72% 51%;
/* Text */
--foreground: 0 0% 98%;
--card-foreground: 0 0% 98%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 98%;
--muted-foreground: 0 0% 63%;
--accent-foreground: 0 0% 98%;
--destructive-foreground: 0 0% 100%;
--link-foreground: 215.88 100% 65%;
--warning-text: 38 92% 50%;
--destructive-text: 360 72% 55%;
--success-text: 142 75% 50%;
--inverted-foreground: 0 0% 0%;
/* Borders */
--border: 0 0% 15%;
--active-border: 0 0% 22%;
--ring: 0 0% 30%;
--input: 0 0% 15%;
}
[data-theme="light"] {
/* bg - neutral */
--background: 0 0% 98%;
--popover: 0 0% 100%;
--card: 0 0% 100%;
--secondary: 0 0% 90%;
--muted: 0 0% 93%;
--accent: 0 0% 93%;
--inverted: 0 0 0%;
/* bg - colorful */
--primary: 221 83% 54%;
--destructive: 360 72% 51%;
/* Text */
--foreground: 0 0% 4%;
--card-foreground: 0 0% 4%;
--popover-foreground: 240 10% 4%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 4%;
--muted-foreground: 0 0% 40%;
--accent-foreground: 0 0% 9%;
--destructive-foreground: 0 0% 100%;
--inverted-foreground: 0 0% 100%;
--link-foreground: 221.21deg 83.19% 53.33%;
--success-text: 142.09 70.56% 35.29%;
--warning-text: 38 92% 40%;
--destructive-text: 357.15deg 100% 68.72%;
/* Borders */
--border: 0 0% 85%;
--active-border: 0 0% 70%;
--input: 0 0% 85%;
--ring: 0 0% 80%;
}
}
/* If no data-theme is added in body, its in dark theme */
body:not([data-theme="light"]) .light-only {
display: none;
}
[data-theme="light"] .dark-only {
display: none;
}
code span {
color: var(--code-dark-color);
}
body[data-theme="light"] code span {
color: var(--code-light-color);
}
/* @layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
} */
.styled-scrollbar::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
}
@media (max-width: 640px) {
.styled-scrollbar::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.styled-scrollbar::-webkit-scrollbar-thumb {
border-radius: 0.5rem;
transition: color 200ms ease;
background: var(--border);
}
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--foreground));
}
.styled-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
button {
-webkit-tap-highlight-color: transparent;
}
::selection {
background: hsl(var(--foreground));
color: hsl(var(--background));
}
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { Fira_Code, Inter } from "next/font/google";
import { Suspense } from "react";
import "./globals.css";
import { Providers } from "./providers";
const sansFont = Inter({
subsets: ["latin"],
variable: "--font-sans",
weight: "variable",
});
const monoFont = Fira_Code({
subsets: ["latin"],
variable: "--font-mono",
weight: "variable",
});
export const metadata: Metadata = {
title: "login with thirdweb",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full w-full bg-black text-white">
<body
className={`${sansFont.variable} ${monoFont.variable} h-full w-full font-sans antialiased`}
>
<Providers>
<Suspense>{children}</Suspense>
</Providers>
</body>
</html>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { signJWT } from "./authorization/jwt";
import { LoginPageInner } from "./components/LoginPageInner";
export default function Page() {
return <LoginPageInner generateJWT={signJWT} />;
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { ThirdwebProvider } from "thirdweb/react";
export const Providers = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <ThirdwebProvider>{children}</ThirdwebProvider>;
};
+18
View File
@@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;
+11
View File
@@ -0,0 +1,11 @@
<html>
<body>
<h1>Test Page</h1>
<!-- load the script -->
<script src="http://localhost:3000/twl.js" data-client-id="testfoo1234"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+12 -6
View File
@@ -10,11 +10,13 @@
"prelint": "biome check ./src",
"lint": "eslint ./src",
"prefix": "biome check ./src --fix",
"fix": "eslint ./src --fix"
"fix": "eslint ./src --fix",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@abstract-foundation/agw-client": "^1.3.0",
"@abstract-foundation/agw-react": "^1.4.3",
"@abstract-foundation/agw-client": "^1.4.0",
"@abstract-foundation/agw-react": "^1.5.0",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "1.1.5",
@@ -29,26 +31,30 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "1.1.7",
"@tanstack/react-query": "5.65.1",
"@tanstack/react-query": "5.66.0",
"@thirdweb-dev/engine": "^0.0.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "0.474.0",
"next": "15.1.6",
"next-themes": "^0.4.4",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
"prettier": "3.3.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "7.54.2",
"react-pick-color": "^2.0.0",
"server-only": "^0.0.1",
"shiki": "1.27.0",
"tailwind-merge": "^2.6.0",
"thirdweb": "workspace:*",
"timeago.js": "^4.0.2",
"use-debounce": "^10.0.4"
"use-debounce": "^10.0.4",
"zod": "3.24.1"
},
"devDependencies": {
"@types/node": "22.10.10",
"@types/node": "22.13.0",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"eslint": "8.57.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+18 -18
View File
@@ -1,34 +1,34 @@
import thirdwebIconSrc from "@/../public/thirdweb.svg";
import { Sidebar } from "@/components/ui/sidebar";
import { Sidebar, type SidebarLink } from "@/components/ui/sidebar";
import Image from "next/image";
import Link from "next/link";
import { navLinks } from "./navLinks";
import { ScrollShadow } from "../components/ui/ScrollShadow/ScrollShadow";
import { otherLinks } from "./otherLinks";
export function AppSidebar() {
export function AppSidebar(props: {
links: SidebarLink[];
}) {
return (
<div className="sticky top-0 z-10 hidden h-dvh w-[300px] flex-col border-border/50 border-r-2 xl:flex">
<div className="px-6 pt-6">
<Link
className="flex items-center gap-2"
href="/"
aria-label="thirdweb Docs"
title="thirdweb Docs"
>
<Image src={thirdwebIconSrc} className="size-6" alt="" />
<div className="z-10 hidden h-dvh w-[300px] flex-col border-border/50 border-r-2 xl:flex">
<div className="border-b px-6 py-5">
<div className="flex items-center gap-2">
<Image src={thirdwebIconSrc} className="size-6" alt="thirdweb" />
<span className="font-bold text-lg leading-none tracking-tight">
Playground
</span>
</Link>
</div>
</div>
<div className="h-5" />
<div className="px-6">
<Sidebar links={navLinks} />
<div className="relative flex max-h-full flex-1 flex-col overflow-hidden">
<ScrollShadow
className="grow pr-4 pl-6"
scrollableClassName="max-h-full pt-6"
>
<Sidebar links={props.links} />
</ScrollShadow>
</div>
<div className="mt-auto flex flex-col gap-4 p-6">
<div className="mt-auto flex flex-col gap-4 border-t px-6 py-6">
{otherLinks.map((link) => {
return (
<Link
+52 -40
View File
@@ -5,12 +5,14 @@ import { MenuIcon, XIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { ScrollShadow } from "../components/ui/ScrollShadow/ScrollShadow";
import { Button } from "../components/ui/button";
import { Sidebar } from "../components/ui/sidebar";
import { navLinks } from "./navLinks";
import { Sidebar, type SidebarLink } from "../components/ui/sidebar";
import { otherLinks } from "./otherLinks";
export function MobileHeader() {
export function MobileHeader(props: {
links: SidebarLink[];
}) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
@@ -26,43 +28,53 @@ export function MobileHeader() {
}, [isOpen]);
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<header
className="sticky top-0 z-10 flex justify-between gap-4 border-b bg-background px-4 py-4 xl:hidden "
onClick={(e) => {
if (e.target instanceof HTMLAnchorElement) {
setIsOpen(false);
}
}}
>
<Link
className="flex items-center gap-3"
href="/"
aria-label="thirdweb Docs"
title="thirdweb Docs"
>
<Image src={thirdwebIconSrc} className="size-7" alt="" />
<span className="font-bold text-xl leading-none tracking-tight">
Playground
</span>
</Link>
<Button
variant="outline"
className="!h-auto p-2"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{!isOpen ? (
<MenuIcon className="size-6" />
) : (
<XIcon className="size-6" />
)}
</Button>
<>
<header className="sticky top-0 z-10 flex justify-between gap-4 border-b bg-background px-4 py-4 xl:hidden">
<Link
className="flex items-center gap-3"
href="/"
aria-label="thirdweb Docs"
title="thirdweb Docs"
>
<Image src={thirdwebIconSrc} className="size-7" alt="" />
<span className="font-bold text-xl leading-none tracking-tight">
Playground
</span>
</Link>
<Button
variant="outline"
className="!h-auto p-2"
onClick={() => {
setIsOpen((v) => !v);
}}
>
{!isOpen ? (
<MenuIcon className="size-6" />
) : (
<XIcon className="size-6" />
)}
</Button>
</header>
{isOpen && (
<div className="fade-in-0 slide-in-from-top-5 fixed top-[75px] right-0 bottom-0 left-0 z-50 flex animate-in flex-col gap-6 overflow-auto bg-background p-6 duration-200">
<Sidebar links={navLinks} />
<div className="mt-auto flex flex-col gap-4">
<div
className="fade-in-0 slide-in-from-top-5 fixed top-[75px] right-0 bottom-0 left-0 z-50 flex animate-in flex-col bg-background duration-200"
onClickCapture={(e) => {
if (e.target instanceof HTMLElement && e.target.closest("a")) {
setIsOpen(false);
}
}}
>
<div className="relative flex max-h-full flex-1 flex-col overflow-hidden">
<ScrollShadow
className="grow px-6"
scrollableClassName="max-h-full pt-6"
>
<Sidebar links={props.links} />
</ScrollShadow>
</div>
<div className="mt-auto flex flex-col gap-4 border-t px-6 py-6">
{otherLinks.map((link) => {
return (
<Link
@@ -78,6 +90,6 @@ export function MobileHeader() {
</div>
</div>
)}
</header>
</>
);
}
@@ -1,7 +1,7 @@
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
arbitrumSepolia,
baseSepolia,
@@ -25,21 +25,26 @@ import { cn } from "../../../../lib/utils";
import { CodeGen } from "../components/CodeGen";
import type { ConnectPlaygroundOptions } from "../components/types";
type Tab = "modal" | "button" | "code";
export function RightSection(props: {
connectOptions: ConnectPlaygroundOptions;
tab?: string;
}) {
const router = useRouter();
const previewTab = useMemo(
() =>
["modal", "button", "code"].includes(props.tab || "")
? (props.tab as "modal" | "button" | "code")
: "modal",
[props.tab],
);
const setPreviewTab = (tab: "modal" | "button" | "code") => {
router.push(`/connect/sign-in?tab=${tab}`);
};
const pathname = usePathname();
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
return props.tab === "code"
? "code"
: props.tab === "button"
? "button"
: "modal";
});
function setPreviewTab(tab: "modal" | "button" | "code") {
_setPreviewTab(tab);
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
}
const { connectOptions } = props;
const wallet = useActiveWallet();
const account = useActiveAccount();
@@ -134,7 +134,7 @@ function WalletButton(props: {
>
<span className="flex items-center gap-3">
<Img
src={walletImage.data || ""}
src={walletImage.data}
alt=""
className="size-7 rounded-lg"
loading="lazy"
+26 -1
View File
@@ -20,6 +20,11 @@
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--destructive-text: 357.15deg 100% 68.72%;
--success-text: 142.09 70.56% 35.29%;
--warning-text: 38 92% 40%;
--inverted-foreground: 0 0% 100%;
--inverted: 0 0% 4%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
@@ -35,7 +40,7 @@
.dark {
--background: 240deg 2% 11%;
--foreground: 0 0% 98%;
--card: 240deg 2% 11%;
--card: 240deg 2% 13%;
--card-foreground: 0 0% 98%;
--popover: 240deg 2% 11%;
--popover-foreground: 0 0% 98%;
@@ -49,7 +54,13 @@
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--destructive-text: 360 72% 55%;
--warning-text: 38 92% 50%;
--success-text: 142 75% 50%;
--border: 240deg 2% 20%;
--active-border: 240deg 2% 25%;
--inverted-foreground: 0 0% 0%;
--inverted: 0 0% 100%;
--input: 240deg 2% 20%;
--ring: 240deg 2% 30%;
--chart-1: 220 70% 50%;
@@ -79,3 +90,17 @@ html.dark .shiki span {
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar,
.no-scrollbar *::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar,
.no-scrollbar * {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
@@ -0,0 +1,39 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ChainMetadata } from "thirdweb/chains";
async function fetchChainsFromApi() {
const res = await fetch("https://api.thirdweb.com/v1/chains");
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data as ChainMetadata[];
}
export function useAllChainsData() {
const query = useQuery({
queryKey: ["all-chains"],
queryFn: async () => {
const idToChain = new Map<number, ChainMetadata>();
const chains = await fetchChainsFromApi();
for (const c of chains) {
idToChain.set(c.chainId, c);
}
return {
allChains: chains,
idToChain,
};
},
});
return {
isPending: query.isLoading,
data: query.data || { allChains: [], idToChain: new Map() },
};
}
@@ -0,0 +1,37 @@
"use client";
import { useCallback, useState } from "react";
/**
*
* @internal
*/
export function useShowMore<T extends HTMLElement>(
initialItemsToShow: number,
itemsToAdd: number,
) {
// start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more
const [itemsToShow, setItemsToShow] = useState(initialItemsToShow);
const lastItemRef = useCallback(
(node: T) => {
if (!node) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items
}
},
{ threshold: 1 },
);
observer.observe(node);
// when the node is removed from the DOM, observer will be disconnected automatically by the browser
},
[itemsToAdd],
);
return { itemsToShow, lastItemRef };
}
@@ -0,0 +1,886 @@
"use client";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { CodeClient, CodeLoading } from "@/components/code/code.client";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
CheckIcon,
CopyIcon,
InfoIcon,
PlayIcon,
} from "lucide-react";
import type { OpenAPIV3 } from "openapi-types";
import { useEffect, useMemo, useState } from "react";
import {
type ControllerRenderProps,
type UseFormReturn,
useForm,
} from "react-hook-form";
import { z } from "zod";
import { isProd } from "../../../lib/env";
import type { BlueprintParameter, BlueprintPathMetadata } from "../utils";
export function BlueprintPlayground(props: {
metadata: BlueprintPathMetadata;
backLink: string;
clientId: string;
path: string;
supportedChainIds: number[];
}) {
const [abortController, setAbortController] =
useState<AbortController | null>(null);
const requestMutation = useMutation({
mutationFn: async (url: string) => {
const controller = new AbortController();
setAbortController(controller);
const start = performance.now();
try {
const res = await fetch(url, {
signal: controller.signal,
});
return {
status: res.status,
data: await res.text(),
time: performance.now() - start,
};
} catch (e) {
const time = performance.now() - start;
if (e instanceof Error) {
return {
data: e.message,
time: time,
};
}
return {
data: "Failed to fetch",
time: time,
};
}
},
});
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
return (
<BlueprintPlaygroundUI
backLink={props.backLink}
clientId={props.clientId}
metadata={props.metadata}
isPending={requestMutation.isPending}
onRun={(url) => {
requestMutation.mutate(url);
}}
response={
abortController?.signal.aborted ? undefined : requestMutation.data
}
abortRequest={() => {
if (abortController) {
// just abort it - don't set a new controller
abortController.abort();
}
}}
domain={`https://insight.${thirdwebDomain}.com`}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
);
}
function modifyParametersForPlayground(_parameters: BlueprintParameter[]) {
const parameters = [..._parameters];
// make chain query param required - its not required in open api spec - because it either has to be set in subdomain or as a query param
const chainIdParameter = parameters.find((p) => p.name === "chain");
if (chainIdParameter) {
chainIdParameter.required = true;
}
// remove the client id parameter if it is present - we will always replace the parameter with project's client id
const clientIdParameterIndex = parameters.findIndex(
(p) => p.name === "clientId",
);
if (clientIdParameterIndex !== -1) {
parameters.splice(clientIdParameterIndex, 1);
}
return parameters;
}
export function BlueprintPlaygroundUI(props: {
backLink: string;
isPending: boolean;
onRun: (url: string) => void;
response:
| {
time: number;
data: undefined | string;
status?: number;
}
| undefined;
clientId: string;
abortRequest: () => void;
domain: string;
path: string;
metadata: BlueprintPathMetadata;
supportedChainIds: number[];
}) {
const parameters = useMemo(() => {
const filteredParams = props.metadata.parameters?.filter(
isOpenAPIV3ParameterObject,
);
return modifyParametersForPlayground(filteredParams || []);
}, [props.metadata.parameters]);
const formSchema = useMemo(() => {
return createParametersFormSchema(parameters);
}, [parameters]);
const defaultValues = useMemo(() => {
const values: Record<string, string | number> = {};
for (const param of parameters) {
if (param.name === "chain") {
values.chain = "1";
} else if (
param.schema &&
"type" in param.schema &&
param.schema.default
) {
values[param.name] = param.schema.default;
} else {
values[param.name] = "";
}
}
return values;
}, [parameters]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues,
});
function onSubmit(values: z.infer<typeof formSchema>) {
const url = createBlueprintUrl({
parameters: parameters,
values: values,
clientId: props.clientId,
domain: props.domain,
path: props.path,
intent: "run",
});
props.onRun(url);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex grow flex-col">
<div className="flex grow flex-col pb-20">
<div className="flex grow flex-col overflow-hidden rounded-xl border bg-card">
<PlaygroundHeader
domain={props.domain}
parameters={parameters}
path={props.path}
isPending={props.isPending}
getFormValues={() => form.getValues()}
clientId={props.clientId}
/>
<div className="grid grow grid-cols-1 lg:grid-cols-2">
<div className="flex max-h-[500px] grow flex-col max-sm:border-b lg:max-h-[740px] lg:border-r">
<RequestConfigSection
domain={props.domain}
parameters={parameters}
path={props.path}
form={form}
supportedChainIds={props.supportedChainIds}
/>
</div>
<div className="flex h-[500px] grow flex-col lg:h-[740px]">
<ResponseSection
isPending={props.isPending}
response={props.response}
abortRequest={props.abortRequest}
/>
</div>
</div>
</div>
</div>
</div>
</form>
</Form>
);
}
function PlaygroundHeader(props: {
parameters: BlueprintParameter[];
isPending: boolean;
getFormValues: () => Record<string, string>;
clientId: string;
domain: string;
path: string;
}) {
const [hasCopied, setHasCopied] = useState(false);
return (
<div className="border-b px-4 py-4 lg:flex lg:justify-center lg:py-3">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 overflow-hidden rounded-xl border bg-muted/50 p-2 lg:justify-center">
{/* copy url */}
<Button
variant="ghost"
className="hover:accent h-auto w-auto p-1"
onClick={() => {
setHasCopied(true);
const url = createBlueprintUrl({
clientId: props.clientId,
parameters: props.parameters,
domain: props.domain,
path: props.path,
values: props.getFormValues(),
intent: "copy",
});
setTimeout(() => {
setHasCopied(false);
}, 500);
navigator.clipboard.writeText(url);
}}
>
{hasCopied ? (
<CheckIcon className="size-4 text-green-500" />
) : (
<CopyIcon className="size-4" />
)}
</Button>
{/* vertical line */}
<div className="h-6 w-[1px] bg-border" />
{/* domain + path */}
<div className="flex items-center gap-1.5 overflow-hidden">
<div className="rounded-xl bg-muted-foreground/10 px-2.5 py-1 font-mono text-xs lg:text-sm">
<span className="hidden lg:inline">{props.domain}</span>
<span className="lg:hidden">...</span>
</div>
<div className="truncate font-mono text-xs lg:text-sm">
{props.path}
</div>
</div>
{/* Run */}
<Button
className="ml-2 hidden h-auto w-auto gap-1.5 p-1 px-2 lg:flex"
disabled={props.isPending}
type="submit"
>
{props.isPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
Run
</Button>
</div>
<Button
size="sm"
className="gap-2 rounded-lg lg:hidden"
type="submit"
disabled={props.isPending}
>
{props.isPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
Run
</Button>
</div>
</div>
);
}
function RequestConfigSection(props: {
parameters: BlueprintParameter[];
form: ParametersForm;
domain: string;
path: string;
supportedChainIds: number[];
}) {
const { pathVariables, queryParams, filterQueryParams } = useMemo(() => {
const pathVariables: OpenAPIV3.ParameterObject[] = [];
const queryParams: OpenAPIV3.ParameterObject[] = [];
const filterQueryParams: OpenAPIV3.ParameterObject[] = [];
for (const param of props.parameters) {
if (param.in === "path") {
pathVariables.push(param);
}
if (param.in === "query") {
if (param.name.startsWith("filter_")) {
filterQueryParams.push(param);
} else {
queryParams.push(param);
}
}
}
return {
pathVariables,
queryParams,
filterQueryParams,
};
}, [props.parameters]);
const showError =
!props.form.formState.isValid &&
props.form.formState.isDirty &&
props.form.formState.isSubmitted;
return (
<div className="flex grow flex-col overflow-hidden">
<div className="flex min-h-[60px] items-center justify-between gap-2 border-b p-4 text-sm">
<div className="flex items-center gap-2">
<ArrowUpRightIcon className="size-5" />
Request
</div>
{showError && <Badge variant="destructive">Invalid Request</Badge>}
</div>
<ScrollShadow className="flex-1" scrollableClassName="max-h-full">
{pathVariables.length > 0 && (
<ParameterSection
parameters={pathVariables}
className="border-b"
title="Path Variables"
form={props.form}
domain={props.domain}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
)}
{queryParams.length > 0 && (
<ParameterSection
className="border-b"
parameters={queryParams}
title="Query Parameters"
form={props.form}
domain={props.domain}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
)}
{filterQueryParams.length > 0 && (
<ParameterSection
parameters={filterQueryParams}
title="Filter Query Parameters"
form={props.form}
domain={props.domain}
path={props.path}
supportedChainIds={props.supportedChainIds}
/>
)}
</ScrollShadow>
</div>
);
}
type ParametersForm = UseFormReturn<{
[x: string]: string | number;
}>;
function ParameterSection(props: {
parameters: BlueprintParameter[];
title: string;
form: ParametersForm;
domain: string;
path: string;
supportedChainIds: number[];
className?: string;
}) {
const url = `${props.domain}${props.path}`;
return (
<div className={cn("p-4 py-6", props.className)}>
<h3 className="mb-3 font-medium text-sm"> {props.title} </h3>
<div className="overflow-hidden rounded-lg border">
{props.parameters.map((param, i) => {
const description =
param.schema && "type" in param.schema
? param.schema.description
: undefined;
const example =
param.schema && "type" in param.schema
? param.schema.example
: undefined;
const exampleToShow =
typeof example === "string" || typeof example === "number"
? example
: undefined;
const showTip = description !== undefined || example !== undefined;
const hasError = !!props.form.formState.errors[param.name];
const placeholder = url.includes(`{${param.name}}`)
? `{${param.name}}`
: url.includes(`:${param.name}`)
? `:${param.name}`
: "Value";
return (
<FormField
key={param.name}
control={props.form.control}
name={param.name}
render={({ field }) => (
<FormItem
className={cn(
"space-y-0",
i !== props.parameters.length - 1 && "border-b",
)}
>
<div
key={param.name}
className={cn(
"grid items-center",
param.name === "chain"
? "grid-cols-1 lg:grid-cols-2"
: "grid-cols-2",
)}
>
<div className="flex h-full flex-row flex-wrap items-center justify-between gap-1 border-r px-3 py-2">
<div className="font-medium font-mono text-sm">
{param.name}
</div>
{param.required && (
<Badge
className="px-2 text-muted-foreground"
variant="secondary"
>
Required
</Badge>
)}
</div>
<div className="relative">
{param.name === "chain" ? (
<SingleNetworkSelector
chainId={
field.value ? Number(field.value) : undefined
}
onChange={(chainId) => {
field.onChange({
target: { value: chainId.toString() },
});
}}
className="rounded-none border-0 border-t lg:border-none"
popoverContentClassName="min-w-[calc(100vw-20px)] lg:min-w-[500px]"
chainIds={
props.supportedChainIds.length > 0
? props.supportedChainIds
: undefined
}
/>
) : (
<>
<ParameterInput
param={param}
field={field}
showTip={showTip}
hasError={hasError}
placeholder={placeholder}
/>
{showTip && (
<ToolTipLabel
hoverable
contentClassName="max-w-[100vw] break-all"
label={
<div className="flex flex-col gap-2">
{description && (
<p className="text-foreground">
{description}
</p>
)}
{exampleToShow !== undefined && (
<div>
<p className="mb-1 text-muted-foreground">
Example:{" "}
<span className="font-mono">
{exampleToShow}
</span>
</p>
</div>
)}
</div>
}
>
<Button
asChild
variant="ghost"
className="-translate-y-1/2 absolute top-1/2 right-2 hidden h-auto w-auto p-1.5 text-muted-foreground opacity-50 hover:opacity-100 lg:flex"
>
<div>
<InfoIcon className="size-4" />
</div>
</Button>
</ToolTipLabel>
)}
</>
)}
</div>
</div>
<FormMessage className="mt-0 border-destructive-text border-t px-3 py-2" />
</FormItem>
)}
/>
);
})}
</div>
</div>
);
}
function ParameterInput(props: {
param: OpenAPIV3.ParameterObject;
field: ControllerRenderProps<
{
[x: string]: string | number;
},
string
>;
showTip: boolean;
hasError: boolean;
placeholder: string;
}) {
const { param, field, showTip, hasError, placeholder } = props;
if (param.schema && "type" in param.schema && param.schema.enum) {
const { value, onChange, ...restField } = field;
return (
<Select
{...restField}
value={value.toString()}
onValueChange={(v) => {
onChange({ target: { value: v } });
}}
>
<SelectTrigger
className={cn(
"border-none bg-transparent pr-10 font-mono focus:ring-0 focus:ring-offset-0",
value === "" && "text-muted-foreground",
)}
chevronClassName="hidden"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent className="font-mono">
{param.schema.enum.map((val) => {
return (
<SelectItem value={val} key={val}>
{val}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
return (
<Input
{...field}
className={cn(
"h-auto truncate rounded-none border-0 bg-transparent py-3 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0",
showTip && "lg:pr-10",
hasError && "text-destructive-text",
)}
placeholder={placeholder}
/>
);
}
function formatMilliseconds(ms: number) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
function ResponseSection(props: {
isPending: boolean;
response:
| { data: undefined | string; status?: number; time: number }
| undefined;
abortRequest: () => void;
}) {
const formattedData = useMemo(() => {
if (!props.response?.data) return undefined;
try {
return JSON.stringify(JSON.parse(props.response.data), null, 2);
} catch {
return props.response.data;
}
}, [props.response]);
return (
<div className="flex h-full grow flex-col">
<div className="flex min-h-[60px] items-center justify-between gap-2 border-b p-4 text-sm">
<div className="flex items-center gap-2">
<ArrowDownLeftIcon className="size-5" />
Response
{props.isPending && <ElapsedTimeCounter />}
{props.response?.time && !props.isPending && (
<span className="text-muted-foreground">
{formatMilliseconds(props.response.time)}
</span>
)}
</div>
{!props.isPending && props.response?.status && (
<Badge
variant={
props.response.status >= 200 && props.response.status < 300
? "success"
: "destructive"
}
>
{props.response.status}
</Badge>
)}
</div>
{props.isPending && (
<div className="flex grow flex-col items-center justify-center gap-4">
<Spinner className="size-14 text-muted-foreground" />
<Button variant="ghost" onClick={props.abortRequest} size="sm">
Cancel
</Button>
</div>
)}
{!props.isPending && !props.response && (
<div className="flex grow flex-col items-center justify-center p-4">
<div>
<div className="flex justify-center">
<div className="rounded-xl border p-1">
<div className="rounded-lg border bg-card p-1.5">
<PlayIcon className="size-5 text-muted-foreground" />
</div>
</div>
</div>
<p className="mt-3 text-center">Click Run to start a request</p>
</div>
</div>
)}
{!props.isPending && props.response && (
<CodeClient
loader={<CodeLoading />}
lang="json"
code={formattedData || ""}
className="rounded-none border-none bg-transparent"
scrollableContainerClassName="h-full"
scrollableClassName="h-full"
// shadowColor="hsl(var(--muted)/50%)"
/>
)}
</div>
);
}
function openAPIV3ParamToZodFormSchema(
schema: BlueprintParameter["schema"],
isRequired: boolean,
): z.ZodTypeAny | undefined {
if (!schema) {
return;
}
if ("anyOf" in schema) {
const anyOf = schema.anyOf;
if (!anyOf) {
return;
}
const anySchemas = anyOf
.map((s) => openAPIV3ParamToZodFormSchema(s, isRequired))
.filter((x) => !!x);
// @ts-expect-error - Its ok, z.union is expecting tuple type but we have array
return z.union(anySchemas);
}
if (!("type" in schema)) {
return;
}
// if enum values
const enumValues = schema.enum;
if (enumValues) {
const enumSchema = z.enum(
// @ts-expect-error - Its correct
enumValues,
);
if (isRequired) {
return enumSchema;
}
return enumSchema.or(z.literal(""));
}
switch (schema.type) {
case "integer": {
const intSchema = z.coerce
.number({
message: "Must be an integer",
})
.int({
message: "Must be an integer",
});
return isRequired
? intSchema.min(1, {
message: "Required",
})
: intSchema.optional();
}
case "number": {
const numberSchema = z.coerce.number();
return isRequired
? numberSchema.min(1, {
message: "Required",
})
: numberSchema.optional();
}
case "boolean": {
const booleanSchema = z.coerce.boolean();
return isRequired ? booleanSchema : booleanSchema.optional();
}
// everything else - just accept it as a string;
default: {
const stringSchema = z.string();
return isRequired
? stringSchema.min(1, {
message: "Required",
})
: stringSchema.optional();
}
}
}
function createParametersFormSchema(parameters: BlueprintParameter[]) {
const shape: z.ZodRawShape = {};
for (const param of parameters) {
const paramSchema = openAPIV3ParamToZodFormSchema(
param.schema,
!!param.required,
);
if (paramSchema) {
shape[param.name] = paramSchema;
} else {
shape[param.name] = param.required
? z.string().min(1, { message: "Required" })
: z.string();
}
}
return z.object(shape);
}
function createBlueprintUrl(options: {
parameters: BlueprintParameter[];
values: Record<string, string>;
clientId: string;
domain: string;
path: string;
intent: "copy" | "run";
}) {
const { parameters, domain, path, values, clientId } = options;
let url = `${domain}${path}`;
// loop over the values and replace {x} or :x with the actual values for paths
// and add query parameters
const pathVariables = parameters.filter((param) => param.in === "path");
const queryParams = parameters.filter((param) => param.in === "query");
for (const parameter of pathVariables) {
const value = values[parameter.name];
if (value) {
url = url.replace(`{${parameter.name}}`, value);
url = url.replace(`:${parameter.name}`, value);
}
}
const searchParams = new URLSearchParams();
for (const parameter of queryParams) {
const value = values[parameter.name];
if (value) {
searchParams.append(parameter.name, value);
}
}
// add client Id search param
if (options.intent === "run") {
searchParams.append("clientId", clientId);
} else {
searchParams.append("clientId", "YOUR_THIRDWEB_CLIENT_ID");
}
if (searchParams.toString()) {
url = `${url}?${searchParams.toString()}`;
}
return url;
}
function ElapsedTimeCounter() {
const [ms, setMs] = useState(0);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const internal = 100;
const id = setInterval(() => {
setMs((prev) => prev + internal);
}, internal);
return () => clearInterval(id);
}, []);
return (
<span className="fade-in-0 animate-in text-muted-foreground text-sm duration-300">
{formatMilliseconds(ms)}
</span>
);
}
function isOpenAPIV3ParameterObject(
x: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject,
): x is OpenAPIV3.ParameterObject {
return !("$ref" in x);
}
@@ -0,0 +1,77 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { redirect } from "next/navigation";
import { THIRDWEB_CLIENT } from "../../../lib/client";
import { fetchBlueprintSpec } from "../utils";
import { BlueprintPlayground } from "./blueprint-playground.client";
export default async function Page(props: {
params: Promise<{
blueprint_slug: string;
}>;
searchParams: Promise<{ path: string }>;
}) {
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
// invalid url
if (!searchParams.path) {
redirect("/insight");
}
const [blueprintSpec] = await Promise.all([
fetchBlueprintSpec({
blueprintId: params.blueprint_slug,
}),
]);
const pathMetadata = blueprintSpec.openapiJson.paths[searchParams.path]?.get;
// invalid url
if (!pathMetadata) {
redirect("/insight");
}
const supportedChainIds =
blueprintSpec.openapiJson.servers?.[0]?.variables?.chainId?.enum?.map(
Number,
) || [];
const title = pathMetadata.summary || "";
return (
<div>
<Breadcrumbs />
<h1 className="mt-3 mb-6 font-semibold text-2xl tracking-tight lg:text-3xl">
{title}
</h1>
<BlueprintPlayground
key={searchParams.path}
metadata={pathMetadata}
backLink={"/insight"}
clientId={THIRDWEB_CLIENT.clientId}
path={searchParams.path}
supportedChainIds={supportedChainIds}
/>
</div>
);
}
function Breadcrumbs() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/insight">Insight Blueprints</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbList>
</Breadcrumb>
);
}
@@ -0,0 +1,21 @@
import type React from "react";
import { APIHeader } from "../../components/blocks/APIHeader";
export default function Layout(props: {
children: React.ReactNode;
}) {
return (
<div>
<APIHeader
title="Insight"
description={
<>Simple & customizable endpoints for querying rich blockchain data</>
}
docsLink="https://portal.thirdweb.com/insight"
heroLink="/insight-hero.avif"
/>
{props.children}
</div>
);
}
@@ -0,0 +1,86 @@
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import {} from "lucide-react";
import Link from "next/link";
import { fetchAllBlueprints } from "./utils";
export default async function Page() {
const blueprints = await fetchAllBlueprints();
return (
<div className="pb-20">
<h2 className="mb-2 font-semibold text-2xl tracking-tight">Blueprints</h2>
<p className="mb-5 text-muted-foreground">
A blueprint is an API that provides access to on-chain data in a
user-friendly format. <br /> There is no need for ABIs, decoding, RPC,
or web3 knowledge to fetch blockchain data.{" "}
<Link
href="https://portal.thirdweb.com/insight/blueprints"
target="_blank"
className="underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:decoration-solid"
>
Learn more about Insight Blueprints{" "}
</Link>
</p>
<div className="flex flex-col gap-8">
{blueprints.map((blueprint) => {
const paths = Object.keys(blueprint.openapiJson.paths);
return (
<BlueprintSection
key={blueprint.id}
blueprintId={blueprint.id}
title={blueprint.name}
blueprints={paths.map((pathName) => {
const pathObj = blueprint.openapiJson.paths[pathName];
if (!pathObj) {
throw new Error(`Path not found: ${pathName}`);
}
return {
name: pathObj.get?.summary || "Unknown",
link: `/insight/${blueprint.id}?path=${pathName}`,
};
})}
/>
);
})}
</div>
</div>
);
}
function BlueprintSection(props: {
title: string;
blueprintId: string;
blueprints: { name: string; link: string }[];
}) {
return (
<div className="overflow-hidden rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b bg-accent/20 px-6 py-4">
<h2 className="font-semibold text-lg tracking-tight">{props.title}</h2>
</div>
<Table>
<TableBody>
{props.blueprints.map((item) => (
<TableRow
key={item.link}
className="group hover:bg-accent/50"
linkBox
>
<TableCell>
<span className="flex items-center gap-3">
<Link
href={item.link}
className="before:absolute before:inset-0"
>
{item.name}
</Link>
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,71 @@
import "server-only";
import type { OpenAPIV3 } from "openapi-types";
import { isProd } from "../../lib/env";
type BlueprintListItem = {
id: string;
name: string;
description: string;
slug: string;
};
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
async function fetchBlueprintList() {
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/blueprints`,
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch blueprints: ${text}`);
}
const json = (await res.json()) as { data: BlueprintListItem[] };
return json.data;
}
export type BlueprintParameter = OpenAPIV3.ParameterObject;
export type BlueprintPathMetadata = OpenAPIV3.PathItemObject;
type BlueprintSpec = {
id: string;
name: string;
description: string;
openapiJson: OpenAPIV3.Document;
};
export async function fetchBlueprintSpec(params: {
blueprintId: string;
}) {
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/blueprints/${params.blueprintId}`,
);
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch blueprint: ${text}`);
}
const json = (await res.json()) as { data: BlueprintSpec };
return json.data;
}
export async function fetchAllBlueprints() {
// fetch list
const blueprintSpecs = await fetchBlueprintList();
// fetch all blueprints
const blueprints = await Promise.all(
blueprintSpecs.map((spec) =>
fetchBlueprintSpec({
blueprintId: spec.id,
}),
),
);
return blueprints;
}
+16 -9
View File
@@ -6,7 +6,9 @@ import Script from "next/script";
import { AppSidebar } from "./AppSidebar";
import { Providers } from "./providers";
import "./globals.css";
import NextTopLoader from "nextjs-toploader";
import { MobileHeader } from "./MobileHeader";
import { getSidebarLinks } from "./navLinks";
const sansFont = Inter({
subsets: ["latin"],
@@ -26,11 +28,12 @@ export const metadata: Metadata = {
description: "thirdweb playground",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const sidebarLinks = await getSidebarLinks();
return (
<html lang="en" suppressHydrationWarning>
<head>
@@ -49,14 +52,18 @@ export default function RootLayout({
monoFont.variable,
)}
>
<MobileHeader />
<div className="relative">
<div className="flex flex-col lg:flex-row">
<AppSidebar />
<div className="flex grow flex-col">
<div className="container relative grow px-4 md:px-6">
<Providers>{children}</Providers>
</div>
<NextTopLoader
color="hsl(var(--foreground))"
height={2}
shadow={false}
showSpinner={false}
/>
<MobileHeader links={sidebarLinks} />
<div className="flex flex-col lg:h-dvh lg:flex-row">
<AppSidebar links={sidebarLinks} />
<div className="flex grow flex-col lg:overflow-auto">
<div className="container relative grow px-4 md:px-6">
<Providers>{children}</Providers>
</div>
</div>
</div>
+153 -117
View File
@@ -1,140 +1,176 @@
import type { SidebarLink } from "../components/ui/sidebar";
import { fetchAllBlueprints } from "./insight/utils";
export const navLinks: SidebarLink[] = [
export const staticSidebarLinks: SidebarLink[] = [
{
name: "Sign in",
expanded: true,
name: "Connect",
isCollapsible: false,
links: [
{
name: "Button",
href: "/connect/sign-in/button",
name: "Connect Wallet",
expanded: false,
links: [
{
name: "Button",
href: "/connect/sign-in/button",
},
{
name: "Embed",
href: "/connect/sign-in/embed",
},
{
name: "Headless",
href: "/connect/sign-in/headless",
},
],
},
{
name: "Embed",
href: "/connect/sign-in/embed",
name: "Account Abstraction",
expanded: false,
links: [
{
name: "Connect",
href: "/connect/account-abstraction/connect",
},
{
name: "Sponsor Gas",
href: "/connect/account-abstraction/sponsor",
},
{
name: "Native AA (zkSync)",
href: "/connect/account-abstraction/native-aa",
},
],
},
{
name: "Headless",
href: "/connect/sign-in/headless",
},
],
},
{
name: "Account Abstraction",
expanded: false,
links: [
{
name: "Connect",
href: "/connect/account-abstraction/connect",
name: "In-App Wallet",
expanded: false,
links: [
{
name: "Any Auth",
href: "/connect/in-app-wallet",
},
{
name: "Ecosystems",
href: "/connect/in-app-wallet/ecosystem",
},
{
name: "Sponsor Gas",
href: "/connect/in-app-wallet/sponsor",
},
],
},
{
name: "Sponsor Gas",
href: "/connect/account-abstraction/sponsor",
name: "Pay",
expanded: false,
links: [
{
name: "Top up",
href: "/connect/pay",
},
{
name: "Commerce",
href: "/connect/pay/commerce",
},
{
name: "Transactions",
href: "/connect/pay/transactions",
},
],
},
{
name: "Native AA (zkSync)",
href: "/connect/account-abstraction/native-aa",
},
],
},
{
name: "In-App Wallet",
expanded: false,
links: [
{
name: "Any Auth",
href: "/connect/in-app-wallet",
name: "Auth",
href: "/connect/auth",
},
{
name: "Ecosystems",
href: "/connect/in-app-wallet/ecosystem",
name: "Social",
href: "/connect/social",
},
{
name: "Sponsor Gas",
href: "/connect/in-app-wallet/sponsor",
},
],
},
{
name: "Pay",
expanded: false,
links: [
{
name: "Top up",
href: "/connect/pay",
name: "Blockchain API",
href: "/connect/blockchain-api",
},
{
name: "Commerce",
href: "/connect/pay/commerce",
},
{
name: "Transactions",
href: "/connect/pay/transactions",
},
],
},
{
name: "Engine",
expanded: false,
links: [
{
name: "Airdrop",
href: "/engine/airdrop",
},
{
name: "Minting",
href: "/engine/minting",
},
{
name: "Webhooks",
href: "/engine/webhooks",
},
// {
// name: "Session Keys",
// href: "/engine/account-abstraction/session-keys",
// },
// {
// name: "Smart Backend Wallets",
// href: "/engine/account-abstraction/smart-backend-wallets",
// },
],
},
{
name: "Auth",
href: "/connect/auth",
},
{
name: "Social",
href: "/connect/social",
},
{
name: "Blockchain API",
href: "/connect/blockchain-api",
},
{
name: "Headless UI",
expanded: true,
links: [
{
name: "Account",
href: "/connect/ui",
},
{
name: "NFT",
href: "/connect/ui/nft",
},
{
name: "Token",
href: "/connect/ui/token",
},
{
name: "Chain",
href: "/connect/ui/chain",
},
{
name: "Wallet",
href: "/connect/ui/wallet",
name: "Headless UI",
expanded: false,
links: [
{
name: "Account",
href: "/connect/ui",
},
{
name: "NFT",
href: "/connect/ui/nft",
},
{
name: "Token",
href: "/connect/ui/token",
},
{
name: "Chain",
href: "/connect/ui/chain",
},
{
name: "Wallet",
href: "/connect/ui/wallet",
},
],
},
],
},
];
const engineSidebarLinks: SidebarLink = {
name: "Engine",
isCollapsible: false,
expanded: false,
links: [
{
name: "Airdrop",
href: "/engine/airdrop",
},
{
name: "Minting",
href: "/engine/minting",
},
{
name: "Webhooks",
href: "/engine/webhooks",
},
],
};
export async function getSidebarLinks() {
const insightBlueprints = await fetchAllBlueprints();
const insightLinks: SidebarLink[] = insightBlueprints.map((blueprint) => {
const paths = Object.keys(blueprint.openapiJson.paths);
return {
name: blueprint.name,
expanded: false,
links: paths.map((pathName) => {
const pathObj = blueprint.openapiJson.paths[pathName];
if (!pathObj) {
throw new Error(`Path not found: ${pathName}`);
}
return {
name: pathObj.get?.summary || pathName,
href: `/insight/${blueprint.id}?path=${pathName}`,
};
}),
};
});
const sidebarLinks: SidebarLink[] = [
...staticSidebarLinks,
{
name: "Insight",
isCollapsible: false,
expanded: false,
links: insightLinks,
},
engineSidebarLinks,
];
return sidebarLinks;
}
+2 -92
View File
@@ -1,95 +1,5 @@
import {
PayIcon,
WalletsAuthIcon,
WalletsConnectIcon,
WalletsInAppIcon,
WalletsSmartIcon,
} from "@/icons";
import { CodeIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
export default function Page() {
return (
<main className="container flex-1">
<WalletsSection />
</main>
);
}
function WalletsSection() {
return (
<section className="my-12">
<h2 className="break-words font-semibold text-2xl tracking-tight md:text-3xl">
Connect
</h2>
<div className="my-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<ArticleCardIndex
href="/connect/sign-in"
title="Sign In"
description="Integrate 500+ web3 wallets, in-app wallets, and smart accounts"
icon={WalletsConnectIcon}
/>
<ArticleCardIndex
href="/connect/account-abstraction"
title="Account abstraction"
description="Complete toolkit for Account Abstraction"
icon={WalletsSmartIcon}
/>
<ArticleCardIndex
title="In-App Wallet"
description="Onboard users with an email, social logins, passkey or your own auth system"
href="/connect/in-app-wallet"
icon={WalletsInAppIcon}
/>
<ArticleCardIndex
href="/connect/auth"
title="Auth"
description="Authenticate users with their wallets"
icon={WalletsAuthIcon}
/>
<ArticleCardIndex
href="/connect/pay"
title="Pay"
description="Add fiat and cross-chain crypto payments in your apps"
icon={PayIcon}
/>
<ArticleCardIndex
href="/connect/blockchain-api"
title="Blockchain API"
description="Performant, and reliable blockchain API"
icon={CodeIcon}
/>
<ArticleCardIndex
href="/connect/ui"
title="Headless UI"
description="Modular & composable set of UIs for your web3 applications"
icon={CodeIcon}
/>
</div>
</section>
);
}
/***
* This component is only for the index page
*/
function ArticleCardIndex(props: {
title: string;
description: string;
href: string;
icon?: React.FC<{ className?: string }>;
}) {
return (
<Link
href={props.href}
className="flex min-h-32 items-center gap-4 rounded-lg border p-4 transition-colors hover:border-ring hover:bg-ring/20"
>
{props.icon && <props.icon className="size-10 shrink-0" />}
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{props.title}</h3>
<p className="font-medium text-muted-foreground">{props.description}</p>
</div>
</Link>
);
redirect("/connect/sign-in/button");
}
@@ -0,0 +1,42 @@
"use client";
import { cn } from "@/lib/utils";
import { type ReactNode, useEffect, useState } from "react";
interface ClientOnlyProps {
/**
* Use this to server render a skeleton or loading state
*/
ssr: ReactNode;
className?: string;
children: ReactNode;
}
export const ClientOnly: React.FC<ClientOnlyProps> = ({
children,
ssr,
className,
}) => {
const hasMounted = useIsClientMounted();
if (!hasMounted) {
return <> {ssr} </>;
}
return (
<div className={cn("fade-in-0 animate-in duration-300", className)}>
{children}
</div>
);
};
function useIsClientMounted() {
const [hasMounted, setHasMounted] = useState(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setHasMounted(true);
}, []);
return hasMounted;
}
@@ -10,7 +10,7 @@ export function APIHeader(props: {
}) {
return (
<div
className="mb-12 flex flex-col-reverse items-center justify-between gap-6 rounded-3xl px-6 py-8 lg:flex-row lg:px-14 lg:py-14"
className="mb-10 flex flex-col-reverse items-center justify-between gap-6 rounded-3xl px-6 py-8 lg:flex-row lg:px-10"
style={{
background:
"linear-gradient(to top, hsl(var(--secondary)/80%), transparent)",
@@ -21,7 +21,7 @@ export function APIHeader(props: {
<h1 className="mb-2 font-bold text-4xl tracking-tighter md:text-5xl">
{props.title}
</h1>
<p className="mb-5 max-w-[700px] text-balance text-muted-foreground">
<p className="mb-7 max-w-[700px] text-balance text-muted-foreground leading-relaxed">
{props.description}
</p>
@@ -0,0 +1,48 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { cn } from "@/lib/utils";
import { Img } from "../ui/Img";
const fallbackChainIcon =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHZpZXdCb3g9IjAgMCA5NiA5NiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTY4LjE1MTkgNzUuNzM3MkM2Mi4yOTQzIDc5Ljk5MyA1NS4yMzk3IDgyLjI4NTIgNDcuOTk5MyA4Mi4yODUyQzQwLjc1ODkgODIuMjg1MiAzMy43MDQzIDc5Ljk5MyAyNy44NDY2IDc1LjczNzJNNjMuMDI5MSAxNy4xODM3QzY5LjUzNjggMjAuMzU3NyA3NC44NzI2IDI1LjUxMDQgNzguMjcxOCAzMS45MDMzQzgxLjY3MDkgMzguMjk2MiA4Mi45NTkgNDUuNjAxMiA4MS45NTEzIDUyLjc3MTFNMTQuMDQ3NiA1Mi43NzA4QzEzLjAzOTkgNDUuNjAwOCAxNC4zMjggMzguMjk1OSAxNy43MjcxIDMxLjkwM0MyMS4xMjYzIDI1LjUxMDEgMjYuNDYyMSAyMC4zNTczIDMyLjk2OTggMTcuMTgzM000Ni4wNTk4IDI5LjM2NzVMMjkuMzY3MyA0Ni4wNkMyOC42ODg1IDQ2LjczODkgMjguMzQ5IDQ3LjA3ODMgMjguMjIxOCA0Ny40Njk3QzI4LjExIDQ3LjgxNCAyOC4xMSA0OC4xODQ5IDI4LjIyMTggNDguNTI5MkMyOC4zNDkgNDguOTIwNiAyOC42ODg1IDQ5LjI2MDEgMjkuMzY3MyA0OS45MzlMNDYuMDU5OCA2Ni42MzE0QzQ2LjczODcgNjcuMzEwMyA0Ny4wNzgxIDY3LjY0OTcgNDcuNDY5NSA2Ny43NzY5QzQ3LjgxMzggNjcuODg4OCA0OC4xODQ3IDY3Ljg4ODggNDguNTI5IDY3Ljc3NjlDNDguOTIwNCA2Ny42NDk3IDQ5LjI1OTkgNjcuMzEwMyA0OS45Mzg4IDY2LjYzMTRMNjYuNjMxMiA0OS45MzlDNjcuMzEwMSA0OS4yNjAxIDY3LjY0OTUgNDguOTIwNiA2Ny43NzY3IDQ4LjUyOTJDNjcuODg4NiA0OC4xODQ5IDY3Ljg4ODYgNDcuODE0IDY3Ljc3NjcgNDcuNDY5N0M2Ny42NDk1IDQ3LjA3ODMgNjcuMzEwMSA0Ni43Mzg5IDY2LjYzMTIgNDYuMDZMNDkuOTM4OCAyOS4zNjc1QzQ5LjI1OTkgMjguNjg4NyA0OC45MjA0IDI4LjM0OTIgNDguNTI5IDI4LjIyMkM0OC4xODQ3IDI4LjExMDIgNDcuODEzOCAyOC4xMTAyIDQ3LjQ2OTUgMjguMjIyQzQ3LjA3ODEgMjguMzQ5MiA0Ni43Mzg3IDI4LjY4ODcgNDYuMDU5OCAyOS4zNjc1WiIgc3Ryb2tlPSIjNDA0MDQwIiBzdHJva2Utd2lkdGg9IjYuODU3MTQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K";
import { resolveScheme } from "thirdweb/storage";
import { THIRDWEB_CLIENT } from "../../lib/client";
type ImageProps = React.ComponentProps<"img">;
type ChainIconProps = ImageProps & {
ipfsSrc?: string;
};
export const ChainIcon = ({ ipfsSrc, ...restProps }: ChainIconProps) => {
const src = ipfsSrc ? replaceIpfsUrl(ipfsSrc) : fallbackChainIcon;
return (
<Img
{...restProps}
// render different image element if src changes to avoid showing old image while loading new one
key={src}
className={cn("object-contain", restProps.className)}
src={src}
loading={restProps.loading || "lazy"}
alt=""
fallback={<img src={fallbackChainIcon} alt="" />}
skeleton={<div className="animate-pulse rounded-full bg-border" />}
/>
);
};
function replaceIpfsUrl(uri: string) {
try {
// eslint-disable-next-line no-restricted-syntax
return resolveScheme({
uri,
client: THIRDWEB_CLIENT,
});
} catch (err) {
console.error("error resolving ipfs url", uri, err);
return uri;
}
}
@@ -0,0 +1,112 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { useCallback, useMemo } from "react";
import { useAllChainsData } from "../../app/hooks/chains";
import { ChainIcon } from "./ChainIcon";
import { SelectWithSearch } from "./select-with-search";
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
}
type Option = { label: string; value: string };
export function SingleNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
}) {
const { data } = useAllChainsData();
const { allChains, idToChain } = data;
const chainsToShow = useMemo(() => {
if (!props.chainIds) {
return allChains;
}
const chainIdSet = new Set(props.chainIds);
return allChains.filter((chain) => chainIdSet.has(chain.chainId));
}, [allChains, props.chainIds]);
const options = useMemo(() => {
return chainsToShow.map((chain) => {
return {
label: chain.name,
value: String(chain.chainId),
};
});
}, [chainsToShow]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[idToChain],
);
const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return option.label;
}
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>
{!props.disableChainId && (
<Badge variant="outline" className="gap-2 max-sm:hidden">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
);
const isLoadingChains = allChains.length === 0;
return (
<SelectWithSearch
searchPlaceholder="Search by Name or Chain ID"
value={String(props.chainId)}
options={options}
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}
/>
);
}
@@ -0,0 +1,213 @@
/* eslint-disable no-restricted-syntax */
"use client";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronDown, SearchIcon } from "lucide-react";
import React, { useRef, useMemo, useEffect } from "react";
import { useShowMore } from "../../app/hooks/useShowMore";
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
import { Input } from "../ui/input";
interface SelectWithSearchProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
options: {
label: string;
value: string;
}[];
value: string | undefined;
onValueChange: (value: string) => void;
placeholder: string;
searchPlaceholder?: string;
className?: string;
overrideSearchFn?: (
option: { value: string; label: string },
searchTerm: string,
) => 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<
HTMLButtonElement,
SelectWithSearchProps
>(
(
{
options,
onValueChange,
placeholder,
className,
value,
renderOption,
overrideSearchFn,
popoverContentClassName,
searchPlaceholder,
closeOnSelect,
...props
},
ref,
) => {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState("");
const selectedOption = useMemo(
() => options.find((option) => option.value === value),
[options, value],
);
// show 50 initially and then 20 more when reaching the end
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
const optionsToShow = useMemo(() => {
const filteredOptions: {
label: string;
value: string;
}[] = [];
const searchValLowercase = searchValue.toLowerCase();
for (let i = 0; i <= options.length - 1; i++) {
if (filteredOptions.length >= itemsToShow) {
break;
}
const option = options[i];
if (!option) {
continue;
}
if (overrideSearchFn) {
if (overrideSearchFn(option, searchValLowercase)) {
filteredOptions.push(option);
}
} else {
if (option.label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(option);
}
}
}
return filteredOptions;
}, [options, searchValue, itemsToShow, overrideSearchFn]);
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}, [searchValue]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={() => setIsPopoverOpen(true)}
className={cn(
"flex w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
)}
>
<div className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"truncate text-muted-foreground text-sm",
selectedOption && "text-foreground",
)}
>
{selectedOption?.label || placeholder}
</span>
<ChevronDown className="size-4 cursor-pointer text-muted-foreground" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={cn("z-[10001] p-0", popoverContentClassName)}
align={props.align || "center"}
side={props.side}
sideOffset={10}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
style={{
width: "var(--radix-popover-trigger-width)",
maxHeight: "var(--radix-popover-content-available-height)",
}}
ref={popoverElRef}
>
<div>
{/* Search */}
<div className="relative">
<Input
placeholder={searchPlaceholder || "Search"}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="!h-auto rounded-b-none border-0 border-border border-b py-3 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
</div>
<ScrollShadow
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
className="rounded"
>
{/* List */}
<div>
{optionsToShow.length === 0 && (
<div className="flex justify-center py-10">
No results found
</div>
)}
{optionsToShow.map((option, i) => {
const isSelected = value === option.value;
return (
<Button
key={option.value}
role="option"
aria-selected={isSelected}
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={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
>
<div className="flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-4" />}
</div>
<div className="min-w-0 grow">
{renderOption ? renderOption(option) : option.label}
</div>
</Button>
);
})}
</div>
</ScrollShadow>
</div>
</PopoverContent>
</Popover>
);
},
);
SelectWithSearch.displayName = "SelectWithSearch";
@@ -7,17 +7,21 @@ export function RenderCode(props: {
html: string;
className?: string;
scrollableClassName?: string;
scrollableContainerClassName?: string;
}) {
return (
<div
className={cn(
"group relative max-w-full rounded-xl border bg-code",
"group relative max-w-full overflow-hidden rounded-xl border bg-code",
props.className,
)}
>
<ScrollShadow
scrollableClassName={cn("p-4 lg:p-6", props.scrollableClassName)}
className="text-xs md:text-sm [&_*]:leading-relaxed"
className={cn(
"text-xs md:text-sm [&_*]:leading-relaxed",
props.scrollableContainerClassName,
)}
shadowColor="hsl(var(--code))"
>
<div

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