Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2606da6d87 Version Packages (#6606)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 13:02:59 -07:00
kumaryash90 9cf137660a Remove custom config for Ronin (#6612)
Create2 deployer restrictions are lifted by the chain. Enable deployments through default create2 factory.

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the layout and functionality of the `Page` component in `apps/playground-web/src/app/connect/pay/backend/page.tsx`. It improves the presentation of the Universal Bridge REST API information and adds a link to view all endpoints.

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

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

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

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

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

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

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

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the bytecode caching mechanism in the `thirdweb` package by including additional metadata in the cache key, which improves cache differentiation and management.

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

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

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

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

### What changed?

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

### How to test?

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

### Why make this change?

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

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the Content Security Policy in the `next.config.mjs` file to enhance security by modifying the `script-src` directive.

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

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

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

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

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

<!-- start pr-codex -->

---

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

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

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

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

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

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

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

<!-- end pr-codex -->
2025-03-30 22:18:56 +00:00
gregandGitHub a1d785b8ed Fix: Increase commerce price to avoid minimum purchase errors (#6582) 2025-03-29 20:50:25 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ba92cd2cbe Version Packages (#6575)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-29 03:41:26 -07:00
Joaquim VergesandGitHub c3d0ea0059 [PayEmbed] Respect supportedTokens prop (#6580) 2025-03-29 03:35:22 -07:00
MananTank 35c5f57179 [TOOL-3839] Fix double scrollbars in project settings page (#6579) 2025-03-29 07:51:23 +00:00
gregandGitHub 2a1f4be48b Fix: Not Found Status should show Pending (#6578) 2025-03-28 20:11:43 -07:00
gregandGitHub 0121ccd702 fix: reduce cost of playground commerce example (#6577) 2025-03-28 19:19:03 -07:00
Toomas OosaluandGitHub 12bcfc596a insight webhooks docs (#6574) 2025-03-29 11:11:25 +13:00
MananTank a4ff738268 [TOOL-3833] Dashboard: Fix overflow scroll created by sr-only element (#6576) 2025-03-28 19:17:40 +00:00
YashandGitHub b49c191355 Support stylus contracts publish and deploy (#6495) 2025-03-29 07:34:37 +13:00
saminaandGitHub 06f0765665 Update page.mdx (#6572)
Signed-off-by: samina <[email protected]>
2025-03-27 19:54:50 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
12adbd8314 Version Packages (#6570)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-27 17:25:33 -07:00
saminacodes 48e014d426 added FAQ (#6567)
<!--

## 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 new details to the `page.mdx` file, providing guidance on how to enhance the performance of the `Nebula` character and improve its responses.

### Detailed summary
- Added a section on how to make `Nebula` play a certain character better, including:
  - Defining the role clearly.
  - Avoiding personality traits in responses.
  - Setting output expectations.
  - Preloading context for specific domains.
  - Utilizing session handling features.

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

<!-- end pr-codex -->
2025-03-27 23:41:37 +00:00
saminacodes 826748bb5d [Portal] fee-changes (#6573)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the fee structure and related information for the `Buy With Crypto` and swap transactions in the `fee-sharing` documentation.

### Detailed summary
- Removed references to a 1% fee and 70% sharing for `Buy With Crypto`.
- Updated the protocol fee to 0.3% for swap transactions.
- Allowed developers to set their own additional fees.
- Clarified fee structure for buying with crypto and swap transactions.

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

<!-- end pr-codex -->
2025-03-27 22:54:25 +00:00
gregfromstl d4f5a9db12 [Portal] Add cross-chain swapping guide and add bridge submodules (#6571)
<!--

## 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 documentation and functionality surrounding the `thirdweb` Universal Bridge, particularly for cross-chain swapping and related features. It updates guides, introduces new functionalities, and improves the clarity of the code.

### Detailed summary
- Updated `yarn` command in `page.mdx` for installing `thirdweb`.
- Renamed the guide from "Build a Custom Experience" to "Build a Custom Onramp Experience" in `sidebar.tsx`.
- Added a new guide for "Cross-Chain Swapping" in `page.mdx`.
- Enhanced tag filtering in `slugs.ts` to include `@bridge`.
- Updated comments in `Buy.ts` and `Sell.ts` to specify `@bridge Buy` and `@bridge Sell`.
- Improved sidebar link generation in `getSidebarLinkGroups.ts` to include `@bridge` links.
- Added comprehensive steps for cross-chain swapping in the new guide, including installation, obtaining quotes, preparing transactions, and checking swap status.

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

<!-- end pr-codex -->
2025-03-27 19:07:18 +00:00
MananTank 3b827ea32c [NEB-148] Do not add active chain to nebula context by default (#6564) 2025-03-27 18:58:20 +00:00
MananTank 2231db7980 [NEB-125] Nebula: Show popular chains at the top of the list (#6565) 2025-03-27 18:49:02 +00:00
MananTank f9872f442c [TOOL-3761] Dashboard: Use new sidebar layout in project pages (#6551)
<!--

## 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 primarily focuses on enhancing the layout and styling of the application, particularly around sidebar and project-related components. It introduces new sidebar features, refines the UI, and improves mobile responsiveness.

### Detailed summary
- Removed unused files: `tabs.tsx`, `layout.tsx` in `connect`, and `settings/layout.tsx`.
- Simplified layout in `DeployedContractsPage.tsx` by removing unnecessary divs.
- Added `AnnouncementBanner` to multiple layouts for notifications.
- Introduced `useActiveSidebarLink` for better sidebar link management.
- Enhanced `Sidebar` components with new features and improved styling.
- Added `Header` component in `ProjectOverviewPage` for better title display.
- Updated Tailwind CSS configuration to include sidebar styles.
- Improved mobile responsiveness with `useIsMobile` hook.
- Cleaned up `AnnouncementBanner` logic and structure.

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

<!-- end pr-codex -->
2025-03-27 18:38:45 +00:00
Joe-Thirdweb 473a25237e [Dashboard] reactive(1597) chainlist page update (#6566)
Added custom chain page info for Reactive Network

* Banner image
* CTA image
* About section

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces new assets and functionality for a "Reactive Mainnet" section in the dashboard. It adds images and a call-to-action that promotes Web3 automation features.

### Detailed summary
- Added `reactiveBanner.jpg` and `reactiveCTA.jpg` assets.
- Introduced a new section for "Reactive Mainnet" in `chainMetaRecord`:
  - Set `headerImgUrl` to `reactiveBanner.src`.
  - Added an `about` description for Web3 automation.
  - Configured `cta` with `backgroundImageUrl`, `title`, `buttonLink`, and `buttonText`.

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

<!-- end pr-codex -->
2025-03-27 18:23:19 +00:00
Manan TankandGitHub 2d4c5ff07c Fix "Cannot read properties of undefined (reading 'forwardRef')" error on certain bundler setups (#6569) 2025-03-27 10:41:53 -07:00
saminacodes ab738eb7ba new narrative (#6563)
<!--

## 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 `AnnouncementBanner` and `Banner` components to promote the new `Universal Bridge` feature, enhancing the messaging around payment integration, and refining the content in the documentation to reflect these changes.

### Detailed summary
- Updated `AnnouncementBanner` with new `href`, `label`, and `trackingLabel`.
- Changed `Banner` ID, `text`, and `href` to reflect the `Universal Bridge` launch.
- Revised documentation for webhook events to include `Universal Bridge`.
- Enhanced content in the overview page for `Universal Bridge`.
- Updated descriptions and titles for multiple `FeatureCard` components.
- Removed fee-sharing section and added new integration options for `Universal Bridge`.

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

<!-- end pr-codex -->
2025-03-27 14:55:00 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8e6651e591 Version Packages (#6546)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-26 11:37:15 -07:00
gregandGitHub e5026a10ab [SDK] Fix: Account for bigint values in engine typed data (#6562) 2025-03-26 11:10:51 -07:00
MananTank 9c1c5f2fc8 [TOOL-3789] Dashboard: Replace simplehash with insight (#6554) 2025-03-26 14:49:27 +00:00
Phillip HoandGitHub 45cdb86911 chore: hide Engine projects from list (#6560) 2025-03-26 22:05:00 +08:00
Joaquim VergesandGitHub fe3351ce79 [TypeDoc] Remove Gist update from workflow (#6559) 2025-03-26 02:45:20 -07:00
Joaquim VergesandGitHub fc657e0b89 [Portal] Redesign documentation homepage with SDK cards and product icons (#6558) 2025-03-26 02:37:35 -07:00
Phillip HoandGitHub cff2481c06 chore: changset for make req optional when calling usage from cloudflare (#6557) 2025-03-26 12:14:05 +08:00
Phillip HoandGitHub e0e31826a8 chore: make req optional when calling usage from cloudflare (#6556) 2025-03-26 11:57:03 +08:00
Joaquim VergesandGitHub fe8b3ce5a5 [Playground] Rename "Fund Wallet" to "Buy Crypto" across pay components (#6555) 2025-03-25 17:48:21 -07:00
kumaryash90 3c120a7584 [SDK] Functions to manage extensions on a dynamic contract (#6550)
<!--

## 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 `thirdweb` library by adding functionality to manage extensions on dynamic contracts, including the ability to install and uninstall extensions.

### Detailed summary
- Introduced `uninstallExtension` function to remove extensions from dynamic contracts.
- Added `installPublishedExtension` function to install extensions with various options.
- Updated `generateExtensionFunctionsFromAbi` to use a specific string format for `functionSelector`.
- Enhanced tests for `uninstallExtension` and `installPublishedExtension` to ensure proper functionality.
- Updated generated files for `addExtension` and `removeExtension` methods with new parameter types and encoding functions.

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

<!-- end pr-codex -->
2025-03-26 00:24:19 +00:00
MananTank b51157c0ff [TOOL-3735] Fix minor typos (#6531)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on fixing typos and improving text clarity in various components of the application.

### Detailed summary
- In `PayConfig.tsx`, corrected "will are" to "are" in the `bottomText` prop.
- In `page.tsx` (commerce), corrected "paymets" to "payments".
- In `page.tsx` (headless), corrected "Seemlessly" to "Seamlessly" in the `description`.
- In `page.tsx` (embed), corrected "Seemlessly" to "Seamlessly" in the `description`.

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

<!-- end pr-codex -->
2025-03-25 22:48:05 +00:00
MananTank f1df19e612 [TOOL-3689] Dashboard: Keep switch in disabled state if field value is empty in Sponsorship rules form (#6535)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving the `transformedQueryData` computation by filtering out empty values from the `policy` properties. It enhances data handling for `allowedContractAddresses`, `allowedWallets`, `blockedWallets`, and `allowedChainIds`, ensuring cleaner and more accurate data is returned.

### Detailed summary
- Refactored `transformedQueryData` to filter out empty values from `allowedContractAddresses`, `allowedWallets`, `blockedWallets`, and `allowedChainIds`.
- Simplified return conditions for these properties.
- Changed handling of `allowedChainIds` to return an empty array instead of null to prevent API issues.

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

<!-- end pr-codex -->
2025-03-25 22:39:38 +00:00
MananTank 98781531bb [TOOL-3786] Portal: Improve llms.txt, llms-full.txt (#6532) 2025-03-26 03:59:07 +05:30
saminacodes d3a5ea7833 fixed broken links (#6553)
<!--

## 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 documentation and modifying the sidebar structure in the `Nebula` application, including changes to links and adding a new FAQ section.

### Detailed summary
- Added a new FAQ section in `faqs/page.mdx` about using Nebula in a stateless manner.
- Changed the sidebar entry from `MCP Server` to `MCP Clients` in `sidebar.tsx`.
- Updated the link for `Examples` in `sidebar.tsx` to point to a GitHub repository.

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

<!-- end pr-codex -->
2025-03-25 20:43:24 +00:00
MananTank 1a9448e152 Portal: Fix lint error (#6552)
<!--

## 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 restructuring the `sidebar` object in the `apps/portal/src/app/nebula/sidebar.tsx` file, ensuring proper formatting of the sidebar items and improving the overall readability of the code.

### Detailed summary
- Adjusted the formatting of the `sidebar` entries for `MCP Server` and `Tools`.
- Converted closing brackets from separate lines to inline, enhancing code clarity.
- Ensured consistent object structure for items in the `sidebar`.

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

<!-- end pr-codex -->
2025-03-25 19:36:16 +00:00
saminaandGitHub 2fe16119f5 [Portal] AI updated docs (#6536) 2025-03-25 02:33:38 -07:00
Joaquim VergesandGitHub c549d5934e [Bridge] Skip tests without TW_SECRET_KEY environment variable (#6545) 2025-03-25 02:32:53 -07:00
Joaquim VergesandGitHub dd65506dfb [Playground] Refactor Universal Bridge sidebar links and update order (#6543) 2025-03-24 19:30:22 -07:00
MananTank 26cfd1b30e [TOOL-3682] Dashboard: Change deploy contract status for in-app wallet (#6533) 2025-03-25 01:57:05 +00:00
MananTank 495d8edfa2 [TOOL-3757] Dashboard: Prefill last used project in contract deployment page (#6534) 2025-03-25 01:49:04 +00:00
kumaryash90 88c213b6d5 Handle default extensions (#6497)
<!--

## 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 deployment and transaction management of dynamic contracts within the `thirdweb` framework, adding support for extensions and improving the handling of contract metadata.

### Detailed summary
- Improved return formatting in `bootstrap.ts`.
- Updated `platformFeeBps` handling in `custom-contract.tsx`.
- Added tests for dynamic contract transactions in `get-required-transactions.test.ts`.
- Enhanced logic for deploying extensions in `deploy-published.ts`.
- Introduced new functions for managing dynamic contract extensions in `get-required-transactions.ts`.
- Updated `deploy-marketplace.ts` to include dynamic extensions in marketplace contracts.

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

<!-- end pr-codex -->
2025-03-24 17:21:13 +00:00
Joaquim VergesandGitHub d9091bd2e7 [Dashboard] Disable engineCloud service by default (#6530) 2025-03-24 02:31:52 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ab7bf1ad1d Version Packages (#6528)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-24 00:28:06 -07:00
Joaquim VergesandGitHub 21389bbef6 [Service-Utils] feat: Add engine-cloud service definition (#6526) 2025-03-24 00:16:18 -07:00
Joaquim VergesandGitHub 4f51201249 [SDK] Update @mobile-wallet-protocol/client to 1.0.0 (#6527) 2025-03-24 00:15:56 -07:00
pink hoodie.andGitHub a0cee488e9 quick get started docs fixes per Jem's revisions (#6525) 2025-03-23 14:46:23 -07:00
Phillip HoandGitHub 0b8907cb7c chore: Add jittered retry on auth call (#6524) 2025-03-23 22:51:59 +08:00
Reza RahemtolaandGitHub 85a78d4460 [Docs] Fix: typo (#6517)
Signed-off-by: Reza Rahemtola <[email protected]>
2025-03-23 17:42:29 +13:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jake Loo
2a6778eb42 chore(deps): bump the npm_and_yarn group across 5 directories with 1 update (#6522)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jake Loo <[email protected]>
2025-03-23 15:59:58 +13:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
882fd3dcbd Version Packages (#6519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-22 04:59:50 -07:00
Joaquim VergesandGitHub 744491f853 [SDK] fix: Handle wallet chain mismatch on post onramp flow (#6518) 2025-03-22 04:52:59 -07:00
d3f788471b [Portal] Neb Changes (#6451)
Signed-off-by: samina <[email protected]>
Co-authored-by: Manan Tank <[email protected]>
2025-03-22 00:48:04 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
6db81ef68d Version Packages (#6516)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-22 00:39:59 -07:00
Alberto GarcíaandGitHub f8b2bdc64b [Dashboard] Fix: wrong URL (#6510) 2025-03-22 00:18:06 -07:00
Joaquim VergesandGitHub c407bd10c7 [SDK] fix: Fetch fresh quotes before post onramp step in PayEmbed (#6515) 2025-03-22 00:14:45 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e540dc11f5 Version Packages (#6508)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-22 15:36:55 +13:00
Joaquim VergesandGitHub 7476b7927d [SDK] fix: Improve swap transaction details UI (#6509) 2025-03-21 02:45:34 -07:00
Joaquim VergesandGitHub ccb641698e [SDK] fix: Force redirect to phantom app on mobile (#6507) 2025-03-20 18:22:05 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
aa9d858f1c Version Packages (#6504)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-20 17:18:57 -07:00
Joaquim VergesandGitHub d8540216f1 [SDK] fix: Pass chainId to internal 1193 provider when connecting (#6506) 2025-03-20 14:52:26 -07:00
gregfromstl 654f8795b4 [SDK] Feature: Adds Universal Bridge (#6464)
<!--

## 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 `Bridge` module to the `thirdweb` SDK, enabling users to perform token buy and sell operations across chains, along with route discovery and transaction status checks.

### Detailed summary
- Added `Bridge` module for Universal Bridge operations.
- Implemented `Bridge.Buy` and `Bridge.Sell` for token transactions.
- Created `quote` and `prepare` functions for both buy and sell operations.
- Introduced `routes` function to discover available bridge routes.
- Added `status` function to check transaction statuses.
- Defined TypeScript types: `Route`, `Status`, `Quote`, `PreparedQuote`.

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

<!-- end pr-codex -->
2025-03-20 21:27:26 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
279fb918f5 chore(deps): bump lucide-react from 0.479.0 to 0.482.0 (#6487)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-20 02:00:22 -07:00
pink hoodie.andGitHub c5cff26faa Docs Revisions: In-app & ecosystem wallet FAQs page, Pay -> Univ Bridge, Wallets Get Started (#6494)
Signed-off-by: pink hoodie. <[email protected]>
2025-03-20 01:59:57 -07:00
Joaquim VergesandGitHub 78901457cf [SDK] fix: Enforce requireApproval option for wallet connection (#6505) 2025-03-20 01:59:33 -07:00
Joaquim VergesandGitHub 47f8cd6d8d [SDk] fix: Improve multistep swap UI in PayEmbed (#6503) 2025-03-20 00:08:12 -07:00
372 changed files with 15866 additions and 8281 deletions
-20
View File
@@ -28,23 +28,3 @@ jobs:
- name: Run TypeDoc
run: pnpm typedoc
- name: Update Gist
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GIST_TOKEN }}
script: |
const fs = require('fs');
const content = fs.readFileSync('./packages/thirdweb/typedoc/parsed.json', 'utf8');
const gistId = '678fe1f331a01270bb002fee660f285d';
await github.rest.gists.update({
gist_id: gistId,
files: {
'data.json': {
content: content
}
}
});
console.log(`Permalink: https://gist.githubusercontent.com/raw/${gistId}/data.json`);
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Preview } from "@storybook/react";
import "@/styles/globals.css";
import "../src/global.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MoonIcon, SunIcon } from "lucide-react";
import { ThemeProvider, useTheme } from "next-themes";
+1 -2
View File
@@ -49,12 +49,11 @@ module.exports = [
"/templates/:template_slug",
// -- learn --
"/learn",
"/learn/tutorials",
"/learn/tutorials/:tutorial_slug",
"/learn/guides",
"/learn/guides/:guide_slug",
"/learn/courses",
"/learn/glossary",
"/learn/glossary/:glossary_slug",
// -- faucets --
"/faucets",
];
+4 -4
View File
@@ -67,8 +67,8 @@
"input-otp": "^1.4.1",
"ioredis": "^5.6.0",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.482.0",
"next": "15.2.2",
"lucide-react": "0.483.0",
"next": "15.2.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
@@ -106,8 +106,8 @@
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.6",
"@next/bundle-analyzer": "15.2.2",
"@next/eslint-plugin-next": "15.2.2",
"@next/bundle-analyzer": "15.2.3",
"@next/eslint-plugin-next": "15.2.3",
"@playwright/test": "1.51.1",
"@storybook/addon-essentials": "8.6.4",
"@storybook/addon-interactions": "8.6.4",
+13
View File
@@ -361,6 +361,19 @@ async function redirects() {
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
permanent: false,
},
// all /learn/tutorials (and sub-routes) -> /learn/guides
{
source: "/learn/tutorials/:path*",
destination: "/learn/guides/:path*",
permanent: false,
},
{
source: "/learn/tutorials",
destination: "/learn/guides",
permanent: false,
},
...legacyDashboardToTeamRedirects,
];
}
+120 -31
View File
@@ -10,12 +10,9 @@ import {
isMoralisSupported,
transformMoralisResponseToNFT,
} from "lib/wallet/nfts/moralis";
import {
generateSimpleHashUrl,
isSimpleHashSupported,
transformSimpleHashResponseToNFT,
} from "lib/wallet/nfts/simpleHash";
import type { WalletNFT } from "lib/wallet/nfts/types";
import { getVercelEnv } from "../../lib/vercel-utils";
import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../constants/env";
type WalletNFTApiReturn =
| { result: WalletNFT[]; error?: undefined }
@@ -24,40 +21,20 @@ type WalletNFTApiReturn =
export async function getWalletNFTs(params: {
chainId: number;
owner: string;
isInsightSupported: boolean;
}): Promise<WalletNFTApiReturn> {
const { chainId, owner } = params;
const supportedChainSlug = await isSimpleHashSupported(chainId);
if (supportedChainSlug && process.env.SIMPLEHASH_API_KEY) {
const url = generateSimpleHashUrl({ chainSlug: supportedChainSlug, owner });
if (params.isInsightSupported) {
const response = await getWalletNFTsFromInsight({ chainId, owner });
const response = await fetch(url, {
method: "GET",
headers: {
"X-API-KEY": process.env.SIMPLEHASH_API_KEY,
},
next: {
revalidate: 10, // cache for 10 seconds
},
});
if (response.status >= 400) {
if (!response.ok) {
return {
error: response.statusText,
error: response.error,
};
}
try {
const parsedResponse = await response.json();
const result = await transformSimpleHashResponseToNFT(
parsedResponse,
owner,
);
return { result };
} catch {
return { error: "error parsing response" };
}
return { result: response.data };
}
if (isAlchemySupported(chainId)) {
@@ -115,3 +92,115 @@ export async function getWalletNFTs(params: {
return { error: "unsupported chain" };
}
type OwnedNFTInsightResponse = {
name: string;
description: string;
image_url: string;
background_color: string;
external_url: string;
metadata_url: string;
extra_metadata: {
customImage?: string;
customAnimationUrl?: string;
animation_original_url?: string;
image_original_url?: string;
};
collection: {
name: string;
description: string;
extra_metadata: Record<string, string>;
};
contract: {
chain_id: number;
address: string;
type: "erc1155" | "erc721";
name: string;
};
owner_addresses: string[];
token_id: string;
balance: string;
token_type: "erc1155" | "erc721";
};
async function getWalletNFTsFromInsight(params: {
chainId: number;
owner: string;
}): Promise<
| {
data: WalletNFT[];
ok: true;
}
| {
ok: false;
error: string;
}
> {
const { chainId, owner } = params;
const thirdwebDomain =
getVercelEnv() === "production" ? "thirdweb" : "thirdweb-dev";
const url = new URL(`https://insight.${thirdwebDomain}.com/v1/nfts`);
url.searchParams.append("chain", chainId.toString());
url.searchParams.append("limit", "10");
url.searchParams.append("owner_address", owner);
const response = await fetch(url, {
headers: {
"x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID,
},
});
if (!response.ok) {
const errorMessage = await response.text();
return {
ok: false,
error: errorMessage,
};
}
const nftsResponse = (await response.json()) as {
data: OwnedNFTInsightResponse[];
};
const isDev = getVercelEnv() !== "production";
// NOTE: ipfscdn.io/ to thirdwebstorage-dev.com/ replacement is temporary
// This should be fixed in the insight dev endpoint
const walletNFTs = nftsResponse.data.map((nft) => {
const walletNFT: WalletNFT = {
id: nft.token_id,
contractAddress: nft.contract.address,
metadata: {
uri: isDev
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.metadata_url,
name: nft.name,
description: nft.description,
image: isDev
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
: nft.image_url,
animation_url: isDev
? nft.extra_metadata.animation_original_url?.replace(
"ipfscdn.io/",
"thirdwebstorage-dev.com/",
)
: nft.extra_metadata.animation_original_url,
external_url: nft.external_url,
background_color: nft.background_color,
},
owner: params.owner,
tokenURI: nft.metadata_url,
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
supply: nft.balance,
};
return walletNFT;
});
return {
data: walletNFTs,
ok: true,
};
}
@@ -19,30 +19,7 @@ export function MobileSidebar(props: {
triggerClassName?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
const activeLink = useMemo(() => {
function isActive(link: SidebarBaseLink) {
if (link.exactMatch) {
return link.href === pathname;
}
return pathname?.startsWith(link.href);
}
for (const link of props.links) {
if ("group" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
}
}
} else {
if (isActive(link)) {
return link;
}
}
}
}, [props.links, pathname]);
const activeLink = useActiveSidebarLink(props.links);
const defaultTrigger = (
<Button
@@ -75,3 +52,32 @@ export function MobileSidebar(props: {
</Dialog>
);
}
export function useActiveSidebarLink(links: SidebarLink[]) {
const pathname = usePathname();
const activeLink = useMemo(() => {
function isActive(link: SidebarBaseLink) {
if (link.exactMatch) {
return link.href === pathname;
}
return pathname?.startsWith(link.href);
}
for (const link of links) {
if ("group" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
}
}
} else if ("href" in link) {
if (isActive(link)) {
return link;
}
}
}
}, [links, pathname]);
return activeLink;
}
@@ -18,17 +18,39 @@ export function MultiNetworkSelector(props: {
onChange: (chainIds: number[]) => void;
disableChainId?: boolean;
className?: string;
priorityChains?: number[];
}) {
const { allChains, idToChain } = useAllChainsData();
const options = useMemo(() => {
return allChains.map((chain) => {
let sortedChains = allChains;
if (props.priorityChains) {
const priorityChainsSet = new Set();
for (const chainId of props.priorityChains || []) {
priorityChainsSet.add(chainId);
}
const priorityChains = (props.priorityChains || [])
.map((chainId) => {
return idToChain.get(chainId);
})
.filter((v) => !!v);
const otherChains = allChains.filter(
(chain) => !priorityChainsSet.has(chain.chainId),
);
sortedChains = [...priorityChains, ...otherChains];
}
return sortedChains.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [allChains]);
}, [allChains, props.priorityChains, idToChain]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
@@ -2,11 +2,13 @@ import { ExternalLinkIcon } from "lucide-react";
import type React from "react";
import { cn } from "../../lib/utils";
import { NavLink } from "../ui/NavLink";
import { Separator } from "../ui/separator";
export type SidebarBaseLink = {
href: string;
label: React.ReactNode;
exactMatch?: boolean;
icon?: React.FC<{ className?: string }>;
tracking?: {
category: string;
action: string;
@@ -19,6 +21,9 @@ export type SidebarLink =
| {
group: string;
links: SidebarBaseLink[];
}
| {
separator: true;
};
type SidebarContentProps = {
@@ -27,21 +32,18 @@ type SidebarContentProps = {
className?: string;
};
export function Sidebar(props: SidebarContentProps) {
export function CustomSidebar(props: SidebarContentProps) {
return (
<aside
className={cn(
"sticky top-0 hidden w-[230px] flex-shrink-0 self-start lg:block",
props.className,
)}
>
<div className="py-7">
{props.header}
<div className="flex flex-col gap-1">
<RenderSidebarLinks links={props.links} />
<div className={cn("hidden w-[230px] shrink-0 lg:block", props.className)}>
<aside className="sticky top-0 self-start">
<div className="py-7">
{props.header}
<div className="flex flex-col gap-1">
<RenderSidebarLinks links={props.links} />
</div>
</div>
</div>
</aside>
</aside>
</div>
);
}
@@ -61,6 +63,10 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
);
}
if ("separator" in link) {
return <Separator className="my-2" />;
}
const isExternal = link.href.startsWith("http");
return (
<NavLink
@@ -68,9 +74,10 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
key={i}
href={link.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-muted-foreground text-sm hover:bg-accent"
activeClassName="text-foreground"
activeClassName="text-foreground bg-accent"
exactMatch={link.exactMatch}
>
{link.icon && <link.icon className="size-4" />}
{link.label}
{isExternal && <ExternalLinkIcon className="size-3" />}
</NavLink>
@@ -1,6 +1,22 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "../../lib/utils";
import { MobileSidebar } from "./MobileSidebar";
import { Sidebar, type SidebarLink } from "./Sidebar";
import { NavLink } from "../ui/NavLink";
import { Separator } from "../ui/separator";
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
import { CustomSidebar, type SidebarLink } from "./Sidebar";
export function SidebarLayout(props: {
sidebarLinks: SidebarLink[];
@@ -17,7 +33,10 @@ export function SidebarLayout(props: {
props.className,
)}
>
<Sidebar links={sidebarLinks} className={props.desktopSidebarClassName} />
<CustomSidebar
links={sidebarLinks}
className={props.desktopSidebarClassName}
/>
<MobileSidebar
links={sidebarLinks}
triggerClassName={props.mobileSidebarClassName}
@@ -29,3 +48,118 @@ export function SidebarLayout(props: {
</div>
);
}
export function FullWidthSidebarLayout(props: {
contentSidebarLinks: SidebarLink[];
footerSidebarLinks?: SidebarLink[];
children: React.ReactNode;
className?: string;
footer?: React.ReactNode;
}) {
const { contentSidebarLinks, children, footerSidebarLinks } = props;
return (
<div
className={cn(
"relative flex w-full flex-1 overflow-y-hidden",
props.className,
)}
>
{/* left - sidebar */}
<Sidebar collapsible="icon" side="left">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<RenderSidebarGroup
sidebarLinks={contentSidebarLinks}
groupName={undefined}
/>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{footerSidebarLinks && (
<SidebarFooter className="pb-3">
<RenderSidebarGroup
sidebarLinks={footerSidebarLinks}
groupName={undefined}
/>
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
{/* right - content */}
<div className="flex h-full flex-grow flex-col overflow-y-auto">
<MobileSidebarTrigger
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
/>
<main className="container z-0 flex min-w-0 max-w-[1280px] grow flex-col pb-20 max-sm:w-full lg:pt-6">
{children}
</main>
{props.footer}
</div>
</div>
);
}
function RenderSidebarGroup(props: {
sidebarLinks: SidebarLink[];
groupName: string | undefined;
}) {
const { sidebarLinks } = props;
const sidebar = useSidebar();
return (
<SidebarMenu className="gap-1.5">
{sidebarLinks.map((link) => {
if ("href" in link) {
return (
<SidebarMenuItem key={link.href}>
<SidebarMenuButton asChild>
<NavLink
href={link.href}
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
activeClassName="text-foreground bg-accent"
exactMatch={link.exactMatch}
tracking={link.tracking}
onClick={() => {
sidebar.setOpenMobile(false);
}}
>
{link.icon && <link.icon className="size-4" />}
<span>{link.label}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
if ("separator" in link) {
return <SidebarSeparator className="my-1" />;
}
return (
<RenderSidebarGroup
sidebarLinks={link.links}
groupName={link.group}
key={link.group}
/>
);
})}
</SidebarMenu>
);
}
function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
const activeLink = useActiveSidebarLink(props.links);
return (
<div className="mb-4 flex items-center gap-3 border-b px-4 py-4 lg:hidden">
<SidebarTrigger className="size-4" />
<Separator orientation="vertical" className="h-4" />
{activeLink && <span className="text-sm">{activeLink.label}</span>}
</div>
);
}
@@ -15,6 +15,7 @@ export type NavButtonProps = {
action: string;
label: string;
};
onClick?: () => void;
};
export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
@@ -30,7 +31,9 @@ export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
href={props.href}
className={cn(props.className, isActive && props.activeClassName)}
target={props.href.startsWith("http") ? "_blank" : undefined}
prefetch={false}
onClick={() => {
props.onClick?.();
if (props.tracking) {
track({
category: props.tracking.category,
@@ -96,7 +96,10 @@ const BreadcrumbEllipsis = ({
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
className={cn(
"relative flex h-9 w-9 items-center justify-center",
className,
)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
@@ -94,7 +94,10 @@ const PaginationEllipsis = ({
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
className={cn(
"relative flex h-9 w-9 items-center justify-center",
className,
)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
@@ -0,0 +1,773 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import * as React from "react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
// eslint-disable-next-line react-compiler/react-compiler
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer relative hidden flex-shrink-0 text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"absolute inset-y-0 z-10 hidden h-full w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:-inset-2 after:absolute after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:-inset-2 after:absolute after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
@@ -5,6 +5,7 @@ import invariant from "tiny-invariant";
export function useWalletNFTs(params: {
chainId: number;
walletAddress?: string;
isInsightSupported: boolean;
}) {
return useQuery({
queryKey: ["walletNfts", params.chainId, params.walletAddress],
@@ -13,6 +14,7 @@ export function useWalletNFTs(params: {
return getWalletNFTs({
chainId: params.chainId,
owner: params.walletAddress,
isInsightSupported: params.isInsightSupported,
});
},
enabled: !!params.walletAddress,
@@ -28,7 +28,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
<div className="h-6" />
<h2 className="px-4 text-center font-semibold text-lg tracking-tight">
Buy Funds on {sanitizedChainName} using thirdweb Pay
Buy Funds on {sanitizedChainName} using Universal Bridge
</h2>
<div className="h-2" />
@@ -50,8 +50,9 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
<Link
href="https://portal.thirdweb.com/connect/pay/overview"
className="inline-flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground"
target="_blank"
>
Learn more about thirdweb Pay
Learn more about Universal Bridge
<ExternalLinkIcon className="size-3" />
</Link>
</div>
@@ -13,7 +13,6 @@ import { ListerOnly } from "@3rdweb-sdk/react/components/roles/lister-only";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
import { useSimplehashSupport } from "lib/wallet/nfts/simpleHash";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import type { ThirdwebContract } from "thirdweb";
@@ -25,6 +24,7 @@ interface CreateListingButtonProps {
createText?: string;
type?: "direct-listings" | "english-auctions";
twAccount: Account | undefined;
isInsightSupported: boolean;
}
const LISTING_MODES = ["Select NFT", "Manual"] as const;
@@ -34,6 +34,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
type,
contract,
twAccount,
isInsightSupported,
...restButtonProps
}) => {
const address = useActiveAccount()?.address;
@@ -41,13 +42,12 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
const [listingMode, setListingMode] =
useState<(typeof LISTING_MODES)[number]>("Select NFT");
const simplehashQuery = useSimplehashSupport(contract.chain.id);
const isSupportedChain =
contract.chain.id &&
(simplehashQuery.data ||
(isInsightSupported ||
isAlchemySupported(contract.chain.id) ||
isMoralisSupported(contract.chain.id));
return (
<ListerOnly contract={contract}>
<Sheet open={open} onOpenChange={setOpen}>
@@ -83,6 +83,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
actionText={createText}
setOpen={setOpen}
mode={listingMode === "Select NFT" ? "automatic" : "manual"}
isInsightSupported={isInsightSupported}
/>
</div>
</>
@@ -95,6 +96,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
actionText={createText}
setOpen={setOpen}
mode="manual"
isInsightSupported={isInsightSupported}
/>
</div>
)}
@@ -21,7 +21,6 @@ import { useAllChainsData } from "hooks/chains/allChains";
import { useTxNotifications } from "hooks/useTxNotifications";
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
import { useSimplehashSupport } from "lib/wallet/nfts/simpleHash";
import type { WalletNFT } from "lib/wallet/nfts/types";
import { CircleAlertIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
@@ -87,6 +86,7 @@ type CreateListingsFormProps = {
mode: "automatic" | "manual";
type?: "direct-listings" | "english-auctions";
twAccount: Account | undefined;
isInsightSupported: boolean;
};
const auctionTimes = [
@@ -106,16 +106,17 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
setOpen,
twAccount,
mode,
isInsightSupported,
}) => {
const trackEvent = useTrack();
const chainId = contract.chain.id;
const { idToChain } = useAllChainsData();
const network = idToChain.get(chainId);
const [isFormLoading, setIsFormLoading] = useState(false);
const simplehashQuery = useSimplehashSupport(contract.chain.id);
const isSupportedChain =
chainId &&
(!!simplehashQuery.data ||
(isInsightSupported ||
isAlchemySupported(chainId) ||
isMoralisSupported(chainId));
@@ -124,7 +125,9 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({
chainId,
walletAddress: account?.address,
isInsightSupported,
});
const sendAndConfirmTx = useSendAndConfirmTransaction();
const listingNotifications = useTxNotifications(
"NFT listed Successfully",
@@ -29,6 +29,7 @@ export function ContractDirectListingsPageClient(props: {
<ContractDirectListingsPage
contract={props.contract}
twAccount={props.twAccount}
isInsightSupported={metadataQuery.data.isInsightSupported}
/>
);
}
@@ -8,17 +8,19 @@ import { DirectListingsTable } from "./components/table";
interface ContractDirectListingsPageProps {
contract: ThirdwebContract;
twAccount: Account | undefined;
isInsightSupported: boolean;
}
export const ContractDirectListingsPage: React.FC<
ContractDirectListingsPageProps
> = ({ contract, twAccount }) => {
> = ({ contract, twAccount, isInsightSupported }) => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between">
<p className="text-lg">Contract Listings</p>
<div className="flex flex-row gap-4">
<CreateListingButton
isInsightSupported={isInsightSupported}
contract={contract}
type="direct-listings"
createText="Create Direct Listing"
@@ -29,15 +29,18 @@ export default async function Page(props: {
);
}
const { isDirectListingSupported } = await getContractPageMetadata(
info.contract,
);
const { isDirectListingSupported, isInsightSupported } =
await getContractPageMetadata(info.contract);
if (!isDirectListingSupported) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
return (
<ContractDirectListingsPage contract={info.contract} twAccount={account} />
<ContractDirectListingsPage
contract={info.contract}
twAccount={account}
isInsightSupported={isInsightSupported}
/>
);
}
@@ -29,6 +29,7 @@ export function ContractEnglishAuctionsPageClient(props: {
<ContractEnglishAuctionsPage
contract={props.contract}
twAccount={props.twAccount}
isInsightSupported={metadataQuery.data.isInsightSupported}
/>
);
}
@@ -8,11 +8,12 @@ import { EnglishAuctionsTable } from "./components/table";
interface ContractEnglishAuctionsProps {
contract: ThirdwebContract;
twAccount: Account | undefined;
isInsightSupported: boolean;
}
export const ContractEnglishAuctionsPage: React.FC<
ContractEnglishAuctionsProps
> = ({ contract, twAccount }) => {
> = ({ contract, twAccount, isInsightSupported }) => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between">
@@ -23,6 +24,7 @@ export const ContractEnglishAuctionsPage: React.FC<
type="english-auctions"
createText="Create English Auction"
twAccount={twAccount}
isInsightSupported={isInsightSupported}
/>
</div>
</div>
@@ -30,9 +30,8 @@ export default async function Page(props: {
);
}
const { isEnglishAuctionSupported } = await getContractPageMetadata(
info.contract,
);
const { isEnglishAuctionSupported, isInsightSupported } =
await getContractPageMetadata(info.contract);
if (!isEnglishAuctionSupported) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
@@ -42,6 +41,7 @@ export default async function Page(props: {
<ContractEnglishAuctionsPage
contract={info.contract}
twAccount={twAccount}
isInsightSupported={isInsightSupported}
/>
);
}
@@ -21,7 +21,7 @@ import {
} from "./detectedFeatures/permissions";
import { supportedERCs } from "./detectedFeatures/supportedERCs";
import { type EmbedTypeToShow, getEmbedTypeToShow } from "./getEmbedTypeToShow";
import { isAnalyticsSupportedForChain } from "./isAnalyticsSupportedForChain";
import { isInsightSupportedForChain } from "./isAnalyticsSupportedForChain";
export type ContractPageMetadata = {
supportedERCs: {
@@ -36,7 +36,7 @@ export type ContractPageMetadata = {
isPermissionsEnumerableSupported: boolean;
isModularCore: boolean;
embedType: EmbedTypeToShow;
isAnalyticsSupported: boolean;
isInsightSupported: boolean;
isSplitSupported: boolean;
isERC721ClaimConditionsSupported: boolean;
isERC20ClaimConditionsSupported: boolean;
@@ -47,7 +47,7 @@ export type ContractPageMetadata = {
};
export async function getContractPageMetadata(contract: ThirdwebContract) {
return getContractPageMetadataSetup(contract, isAnalyticsSupportedForChain);
return getContractPageMetadataSetup(contract, isInsightSupportedForChain);
}
export async function getContractPageMetadataSetup(
@@ -56,7 +56,7 @@ export async function getContractPageMetadataSetup(
): Promise<ContractPageMetadata> {
const [
functionSelectorsResult,
isAnalyticsSupportedResult,
isInsightSupportedResult,
contractTypeResult,
] = await Promise.allSettled([
resolveFunctionSelectors(contract),
@@ -69,9 +69,9 @@ export async function getContractPageMetadataSetup(
? functionSelectorsResult.value
: [];
const isAnalyticsSupported =
isAnalyticsSupportedResult.status === "fulfilled"
? isAnalyticsSupportedResult.value
const isInsightSupported =
isInsightSupportedResult.status === "fulfilled"
? isInsightSupportedResult.value
: true;
const contractType =
@@ -86,7 +86,7 @@ export async function getContractPageMetadataSetup(
isPermissionsEnumerableSupported(functionSelectors),
isModularCore: isModularCoreContract(functionSelectors),
embedType: getEmbedTypeToShow(functionSelectors),
isAnalyticsSupported: isAnalyticsSupported,
isInsightSupported: isInsightSupported,
isSplitSupported: contractType === "Split",
isVoteContract: contractType === "VoteERC20",
isERC721ClaimConditionsSupported:
@@ -47,7 +47,7 @@ export function getContractPageSidebarLinks(data: {
{
label: "Analytics",
href: `${layoutPrefix}/analytics`,
hide: !data.metadata.isAnalyticsSupported,
hide: !data.metadata.isInsightSupported,
exactMatch: true,
},
{
@@ -4,7 +4,7 @@ import { getVercelEnv } from "lib/vercel-utils";
const thirdwebDomain =
getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb";
export async function isAnalyticsSupportedForChain(
export async function isInsightSupportedForChain(
chainId: number,
): Promise<boolean> {
try {
@@ -32,6 +32,7 @@ export function AccountPageClient(props: {
contract={props.contract}
chainMetadata={props.chainMetadata}
twAccount={props.twAccount}
isInsightSupported={metadataQuery.data.isInsightSupported}
/>
);
}
@@ -12,12 +12,14 @@ interface AccountPageProps {
contract: ThirdwebContract;
chainMetadata: ChainMetadata;
twAccount: Account | undefined;
isInsightSupported: boolean;
}
export const AccountPage: React.FC<AccountPageProps> = ({
contract,
chainMetadata,
twAccount,
isInsightSupported,
}) => {
const symbol = chainMetadata.nativeCurrency.symbol || "Native Token";
@@ -43,7 +45,7 @@ export const AccountPage: React.FC<AccountPageProps> = ({
<div className="flex flex-row items-center justify-between">
<Heading size="title.sm">NFTs owned</Heading>
</div>
<NftsOwned contract={contract} />
<NftsOwned contract={contract} isInsightSupported={isInsightSupported} />
</div>
);
};
@@ -6,12 +6,17 @@ import { NFTCards } from "../../_components/NFTCards";
interface NftsOwnedProps {
contract: ThirdwebContract;
isInsightSupported: boolean;
}
export const NftsOwned: React.FC<NftsOwnedProps> = ({ contract }) => {
export const NftsOwned: React.FC<NftsOwnedProps> = ({
contract,
isInsightSupported,
}) => {
const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({
chainId: contract.chain.id,
walletAddress: contract.address,
isInsightSupported: isInsightSupported,
});
const nfts = walletNFTs?.result || [];
@@ -33,7 +33,8 @@ export default async function Page(props: {
);
}
const { isAccount } = await getContractPageMetadata(contract);
const { isAccount, isInsightSupported } =
await getContractPageMetadata(contract);
if (!isAccount) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
@@ -44,6 +45,7 @@ export default async function Page(props: {
contract={contract}
chainMetadata={chainMetadata}
twAccount={account}
isInsightSupported={isInsightSupported}
/>
);
}
@@ -21,7 +21,7 @@ export function ContractAnalyticsPageClient(props: {
return <ErrorPage />;
}
if (!metadataQuery.data.isAnalyticsSupported) {
if (!metadataQuery.data.isInsightSupported) {
return <RedirectToContractOverview contract={props.contract} />;
}
@@ -34,9 +34,9 @@ export default async function Page(props: {
);
}
const { isAnalyticsSupported } = await getContractPageMetadata(info.contract);
const { isInsightSupported } = await getContractPageMetadata(info.contract);
if (!isAnalyticsSupported) {
if (!isInsightSupported) {
redirect(`/${params.chain_id}/${params.contractAddress}`);
}
@@ -34,7 +34,7 @@ export function ContractOverviewPageClient(props: {
contractPageMetadata.isPermissionsEnumerableSupported
}
chainSlug={chainMetadata.slug}
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
isAnalyticsSupported={contractPageMetadata.isInsightSupported}
functionSelectors={contractPageMetadata.functionSelectors}
// TODO - create a fully client rendered version of publishedBy and ContractCard and plug it here
publishedBy={undefined}
@@ -44,7 +44,7 @@ export default async function Page(props: {
contractPageMetadata.isPermissionsEnumerableSupported
}
chainSlug={chainMetadata.slug}
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
isAnalyticsSupported={contractPageMetadata.isInsightSupported}
functionSelectors={contractPageMetadata.functionSelectors}
publishedBy={
<ErrorBoundary fallback={null}>
Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

@@ -47,6 +47,8 @@ import metalBanner from "./temp-assets/metalBanner.png";
import plumeBanner from "./temp-assets/plumeBanner.png";
import plumeCTA from "./temp-assets/plumeCTA.png";
import reactiveBanner from "./temp-assets/reactiveBanner.jpg";
import reactiveCTA from "./temp-assets/reactiveCTA.jpg";
import rivalzBanner from "./temp-assets/rivalzBanner.png";
import rivalzCTA from "./temp-assets/rivalzCTA.png";
import rootStockBanner from "./temp-assets/rootstock-banner.png";
@@ -575,6 +577,18 @@ const chainMetaRecord = {
buttonText: "Learn More",
},
},
//Reactive Mainnet
1597: {
headerImgUrl: reactiveBanner.src,
about:
"Web3’s first IFTTT infrastructure. Automate multi-chain workflows with event-driven logic – execute actions without compromising decentralization.",
cta: {
backgroundImageUrl: reactiveCTA.src,
title: "Turn Smart Contracts Reactive",
buttonLink: "https://dev.reactive.network",
buttonText: "Learn More",
},
},
//Saakuru Mainnet
7225878: {
headerImgUrl: saakuruBanner.src,
@@ -13,7 +13,7 @@ export function DeployedContractsPageHeader(props: {
const [importModalOpen, setImportModalOpen] = useState(false);
return (
<div className="border-b">
<div>
<ImportModal
isOpen={importModalOpen}
onClose={() => {
@@ -23,27 +23,25 @@ export function DeployedContractsPageHeader(props: {
projectId={props.projectId}
/>
<div className="container">
<div className="flex flex-col gap-3 py-10 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
</div>
<div className="flex gap-3 [&>*]:grow">
<Button
className="gap-2 bg-card"
variant="outline"
onClick={() => setImportModalOpen(true)}
>
<DownloadIcon className="size-4" />
Import contract
</Button>
<Button asChild className="gap-2">
<Link href="/explore">
<PlusIcon className="size-4" />
Deploy contract
</Link>
</Button>
</div>
<div className="flex flex-col gap-3 pb-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
</div>
<div className="flex gap-3 [&>*]:grow">
<Button
className="gap-2 bg-card"
variant="outline"
onClick={() => setImportModalOpen(true)}
>
<DownloadIcon className="size-4" />
Import contract
</Button>
<Button asChild className="gap-2">
<Link href="/explore">
<PlusIcon className="size-4" />
Deploy contract
</Link>
</Button>
</div>
</div>
</div>
@@ -12,13 +12,12 @@ export function DeployedContractsPage(props: {
authToken: string;
}) {
return (
<div className="flex grow flex-col pb-20">
<div className="flex grow flex-col">
<DeployedContractsPageHeader
teamId={props.teamId}
projectId={props.projectId}
/>
<div className="h-10" />
<div className="container flex grow flex-col">
<div className="flex grow flex-col">
<Suspense fallback={<Loading />}>
<DeployedContractsPageAsync {...props} />
</Suspense>
@@ -4,6 +4,7 @@ import { AppFooter } from "@/components/blocks/app-footer";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import type React from "react";
import { TabPathLinks } from "../../@/components/ui/tabs";
import { AnnouncementBanner } from "../../components/notices/AnnouncementBanner";
import { getAuthTokenWalletAddress } from "../api/lib/getAuthToken";
import { TWAutoConnect } from "../components/autoconnect";
import { loginRedirect } from "../login/loginRedirect";
@@ -53,6 +54,7 @@ async function HeaderAndNav(props: {
return (
<div className="bg-card">
<AnnouncementBanner />
<AccountHeader
teamsAndProjects={teamsAndProjects}
account={props.twAccount}
@@ -1,34 +0,0 @@
import { isSimpleHashSupported } from "lib/wallet/nfts/simpleHash";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const runtime = "edge";
export const GET = async (req: NextRequest) => {
const searchParams = req.nextUrl.searchParams;
const chainIdStr = searchParams.get("chainId");
if (!chainIdStr) {
return NextResponse.json(
{
error: "Missing chain ID parameter.",
},
{ status: 400 },
);
}
const chainId = Number.parseInt(chainIdStr);
if (!Number.isInteger(chainId)) {
return NextResponse.json(
{
error: "Invalid chain ID parameter.",
},
{ status: 400 },
);
}
const found = await isSimpleHashSupported(chainId);
return NextResponse.json({ result: found });
};
@@ -1,22 +0,0 @@
// app/providers.tsx
"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
if (typeof window !== "undefined") {
posthog.init(
process.env.NEXT_PUBLIC_POSTHOG_API_KEY ||
"phc_hKK4bo8cHZrKuAVXfXGpfNSLSJuucUnguAgt2j6dgSV",
{
api_host: "https://a.thirdweb.com",
autocapture: true,
debug: false,
capture_pageview: true,
disable_session_recording: true,
},
);
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
return <PHProvider client={posthog}>{children}</PHProvider>;
}
+8 -6
View File
@@ -1,4 +1,4 @@
import "@/styles/globals.css";
import "../global.css";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
@@ -6,11 +6,12 @@ import PlausibleProvider from "next-plausible";
import { Inter } from "next/font/google";
import NextTopLoader from "nextjs-toploader";
import { Suspense } from "react";
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
import { PHProvider } from "../lib/posthog/Posthog";
import { PosthogHeadSetup } from "../lib/posthog/PosthogHeadSetup";
import { PostHogPageView } from "../lib/posthog/PosthogPageView";
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
import { PostHogProvider } from "./components/root-providers";
import { AppRouterProviders } from "./providers";
const fontSans = Inter({
@@ -64,15 +65,16 @@ export default function RootLayout({
customDomain="https://pl.thirdweb.com"
selfHosted
/>
<PosthogHeadSetup />
</head>
<PostHogProvider>
<PHProvider>
<PostHogPageView />
<body
className={cn(
"bg-background font-sans antialiased",
fontSans.variable,
)}
>
<OrganizeContractsToProjectsBanner />
<AppRouterProviders>
{children}
<Suspense fallback={null}>
@@ -93,7 +95,7 @@ export default function RootLayout({
showSpinner={false}
/>
</body>
</PostHogProvider>
</PHProvider>
</html>
);
}
@@ -13,7 +13,7 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { useActiveAccount } from "thirdweb/react";
import { type NebulaContext, promptNebula } from "../api/chat";
import { createSession, updateSession } from "../api/session";
import type { SessionInfo } from "../api/types";
@@ -32,7 +32,6 @@ export function ChatPageContent(props: {
initialPrompt: string | undefined;
}) {
const address = useActiveAccount()?.address;
const activeChain = useActiveWalletChain();
const client = useThirdwebClient(props.authToken);
const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false);
const [messages, setMessages] = useState<Array<ChatMessage>>(() => {
@@ -133,14 +132,9 @@ export function ChatPageContent(props: {
// ignore local storage errors
}
// else - use the active chain
updatedContextFilters.chainIds = activeChain
? [activeChain.id.toString()]
: [];
return updatedContextFilters;
});
}, [address, isNewSession, hasUserUpdatedContextFilters, activeChain]);
}, [address, isNewSession, hasUserUpdatedContextFilters]);
const [sessionId, _setSessionId] = useState<string | undefined>(
props.session?.id,
@@ -199,6 +199,17 @@ export function ContextFiltersForm(props: {
onChange={(values) => {
form.setValue("chainIds", values.join(","));
}}
priorityChains={[
1, // ethereum
56, // bnb smart chain mainnet (bsc)
42161, // arbitrum one mainnet
8453, // base mainnet
43114, // avalanche mainnet
146, // sonic
137, // polygon
80094, // berachain mainnet
10, // optimism
]}
/>
</FormControl>
<FormMessage />
@@ -22,7 +22,7 @@ Send native and ERC-20 currencies
### Deploy
Deploy published contracts
- Deploy a Token ERC20 Contract
- Deploy a Token ERC20 Contract with name "Hello World" and description "My Hello Contract" on Ethereum.
- Deploy a Split contract with two recipients.
- Deploy an ERC1155 Contract named 'Hello World' with description 'Hello badges on Ethereum'
@@ -30,7 +30,7 @@ Deploy published contracts
Retrieve information about smart contracts.
- What ERC standards are implemented by contract address 0x59325733eb952a92e069C87F0A6168b29E80627f on Ethereum?
- What functions can I use to mint more of my contract's NFTs?
- What is the total supply of NFTs for my contract?
- What is the total supply of NFTs on 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e?
### Interact
Query wallet balances, addresses, and token holdings.
@@ -88,10 +88,10 @@ export async function TotalSponsoredChartCardUI({
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
<div className={cn("rounded-lg border p-4 lg:p-6", className)}>
<h3 className="mb-1 font-semibold text-xl tracking-tight">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title || "Total Sponsored"}
</h3>
<p className="text-muted-foreground"> {description}</p>
<p className="text-muted-foreground text-sm"> {description}</p>
<BarChart
isCurrency
chartConfig={chartConfig}
@@ -1,7 +1,9 @@
import { getProjects } from "@/api/projects";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { getTeams } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { TabPathLinks } from "@/components/ui/tabs";
import { redirect } from "next/navigation";
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
import { getValidAccount } from "../../../account/settings/getAccount";
import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
@@ -37,11 +39,9 @@ export default async function TeamLayout(props: {
})),
);
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
?.onWaitlist;
return (
<div className="flex h-full grow flex-col">
<AnnouncementBanner />
<div className="bg-card">
<TeamHeaderLoggedIn
currentTeam={team}
@@ -71,14 +71,6 @@ export default async function TeamLayout(props: {
path: `/team/${params.team_slug}/~/ecosystem`,
name: "Ecosystems",
},
...(isOnNebulaWaitList
? [
{
path: `/team/${params.team_slug}/~/nebula`,
name: "Nebula",
},
]
: []),
{
path: `/team/${params.team_slug}/~/usage`,
name: "Usage",
@@ -92,6 +84,7 @@ export default async function TeamLayout(props: {
</div>
<main className="flex grow flex-col">{props.children}</main>
<AppFooter />
</div>
);
}
@@ -79,7 +79,7 @@ export const CredentialForm = ({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
<TrackedLinkTW
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
href="https://portal.thirdweb.com/engine/features/wallet-credentials"
target="_blank"
label="learn-more"
category="engine"
@@ -65,7 +65,7 @@ export const WalletCredentialsSection: React.FC<WalletCredentialsProps> = ({
<p className="text-muted-foreground text-sm leading-relaxed">
Manage your wallet credentials for different providers.{" "}
<Link
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
href="https://portal.thirdweb.com/engine/features/wallet-credentials"
target="_blank"
>
Learn more about wallet credentials.
@@ -75,6 +75,11 @@ export function TeamProjectsPage(props: {
);
}
// @TODO: HACK hide Engine projects from the list.
_projectsToShow = _projectsToShow.filter(
(project) => !project.name.startsWith("Cloud-hosted Engine ("),
);
return _projectsToShow;
}, [searchTerm, sortBy, projects]);
@@ -5,7 +5,6 @@ import { BillingPortalButton } from "@/components/billing";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { differenceInDays, isAfter } from "date-fns";
import { format } from "date-fns/format";
import { CircleAlertIcon } from "lucide-react";
@@ -37,7 +36,7 @@ export function PlanInfoCard(props: {
<div className="rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
<div>
<div className="flex flex-col items-start gap-1">
<div className="flex flex-col items-start gap-0.5">
<h3 className="font-semibold text-2xl capitalize tracking-tight">
{validPlan} Plan
</h3>
@@ -56,6 +55,7 @@ export function PlanInfoCard(props: {
<div className="flex flex-row items-center gap-2">
{/* go to invoices page */}
<Button asChild variant="outline">
<Link href={`/team/${team.slug}/~/settings/invoices`}>
View Invoices
@@ -73,20 +73,6 @@ export function PlanInfoCard(props: {
>
Manage Billing
</BillingPortalButton>
{isActualFreePlan && (
<Button asChild variant="outline">
<TrackedLinkTW
category="account"
href="/pricing"
label="pricing-plans"
target="_blank"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
View Pricing
</TrackedLinkTW>
</Button>
)}
</div>
</div>
@@ -73,7 +73,7 @@ export function SponsoredTransactionsTableUI(
<div className="overflow-hidden rounded-lg border bg-card">
{/* header */}
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
<h2 className="font-semibold text-lg">Sponsored Transactions</h2>
<h2 className="font-semibold text-xl">Sponsored Transactions</h2>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{/* Filters */}
<div className="flex gap-2">
@@ -23,13 +23,13 @@ export const UsageCard: React.FC<UsageCardProps> = ({
totalUsage,
}) => {
return (
<div className="relative flex min-h-[190px] flex-col rounded-lg border border-border bg-card p-4 lg:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight">{name}</h3>
<p className="text-muted-foreground"> {description}</p>
<div className="relative flex flex-col rounded-lg border border-border bg-card p-4 lg:p-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">{name}</h3>
<p className="text-muted-foreground text-sm"> {description}</p>
<div className="h-6" />
<div className="mt-auto flex flex-col gap-1.5">
<div className="flex flex-col gap-1 text-sm">
{title && <p className="text-foreground">{title}</p>}
{total !== undefined && (
@@ -0,0 +1,109 @@
"use client";
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
import { AppFooter } from "@/components/blocks/app-footer";
import {
BookTextIcon,
BoxIcon,
HomeIcon,
SettingsIcon,
WalletIcon,
} from "lucide-react";
import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
import { NebulaIcon } from "../../../../nebula-app/(app)/icons/NebulaIcon";
export function ProjectSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
showNebula: boolean;
}) {
const { layoutPath, children, showNebula } = props;
const tracking = (label: string) => ({
category: "project-sidebar",
action: "click",
label,
});
return (
<FullWidthSidebarLayout
footer={<AppFooter />}
contentSidebarLinks={[
{
href: layoutPath,
exactMatch: true,
label: "Overview",
icon: HomeIcon,
tracking: tracking("overview"),
},
{
label: "In-App Wallets",
href: `${layoutPath}/connect/in-app-wallets`,
icon: WalletIcon,
tracking: tracking("in-app-wallets"),
},
{
label: "Account Abstraction",
href: `${layoutPath}/connect/account-abstraction`,
icon: SmartAccountIcon,
tracking: tracking("account-abstraction"),
},
{
label: "Universal Bridge",
href: `${layoutPath}/connect/universal-bridge`,
icon: PayIcon,
tracking: tracking("universal-bridge"),
},
{
href: `${layoutPath}/contracts`,
label: "Contracts",
icon: ContractIcon,
tracking: tracking("contracts"),
},
...(showNebula
? [
{
href: `${layoutPath}/nebula`,
label: "Nebula",
icon: NebulaIcon,
tracking: tracking("nebula"),
},
]
: []),
{
href: `${layoutPath}/insight`,
label: "Insight",
icon: InsightIcon,
tracking: tracking("insight"),
},
]}
footerSidebarLinks={[
{
href: `${layoutPath}/settings`,
label: "Project Settings",
icon: SettingsIcon,
tracking: tracking("project-settings"),
},
{
separator: true,
},
{
href: "https://portal.thirdweb.com",
label: "Documentation",
icon: BookTextIcon,
tracking: tracking("documentation"),
},
{
href: "https://playground.thirdweb.com/connect/sign-in/button",
label: "Playground",
icon: BoxIcon,
tracking: tracking("playground"),
},
]}
>
{children}
</FullWidthSidebarLayout>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
export const LAST_USED_PROJECT_ID = "last-used-project-id";
export const LAST_USED_TEAM_ID = "last-used-team-id";
export function SaveLastUsedProject(props: {
projectId: string;
teamId: string;
}) {
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
try {
localStorage.setItem(LAST_USED_PROJECT_ID, props.projectId);
localStorage.setItem(LAST_USED_TEAM_ID, props.teamId);
} catch {
// ignore localStorage errors
}
}, [props.projectId, props.teamId]);
return null;
}
@@ -34,12 +34,12 @@ function FooterCard(props: {
trackingCategory: string;
}) {
return (
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
<h3 className="mb-5 font-semibold text-lg tracking-tight lg:text-xl">
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="mb-4 border-border border-b pt-1 pb-4 font-semibold text-lg leading-none tracking-tight">
{props.title}
</h3>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2.5">
{props.links.map((link) => (
<TrackedLinkTW
key={link.href}
@@ -1,75 +1,54 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import {
TrackedLinkTW,
TrackedUnderlineLink,
} from "@/components/ui/tracked-link";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { CircleAlertIcon } from "lucide-react";
import { useActiveWalletChain } from "thirdweb/react";
import type { UserOpStats } from "types/analytics";
import { AccountAbstractionSummary } from "../../../../../../components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { useLocalStorage } from "hooks/useLocalStorage";
import { ArrowRightIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import { AAFooterSection } from "./AAFooterSection";
import { isOpChainId } from "./isOpChain";
const TRACKING_CATEGORY = "smart-wallet";
export function AccountAbstractionLayout(props: {
projectSlug: string;
projectId: string;
teamSlug: string;
projectKey: string;
children: React.ReactNode;
hasSmartWalletsWithoutBilling: boolean;
userOpStats: UserOpStats;
}) {
const chain = useActiveWalletChain();
const isOpChain = chain?.id ? isOpChainId(chain.id) : false;
const smartWalletsLayoutSlug = `/team/${props.teamSlug}/${props.projectSlug}/connect/account-abstraction`;
return (
<div>
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Account Abstraction
</h1>
<p className="text-muted-foreground text-sm">
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
into your apps.{" "}
Integrate ERC-4337 compliant smart accounts for gasless sponsorships and
more. <br className="lg:hidden" />
<TrackedUnderlineLink
target="_blank"
label="docs-wallets"
category={TRACKING_CATEGORY}
href="https://portal.thirdweb.com/wallets/smart-wallet"
>
View Documentation
Learn more about Account Abstraction
</TrackedUnderlineLink>
</p>
<div className="h-6" />
<div className="flex flex-col gap-6">
{props.hasSmartWalletsWithoutBilling ? (
<SmartWalletsBillingAlert />
) : (
isOpChain && (
<Alert variant="info">
<CircleAlertIcon className="size-4" />
<AlertTitle>
Using the gas credits for OP chain paymaster
</AlertTitle>
<AlertDescription>
Credits will automatically be applied to cover gas fees for any
onchain activity across thirdweb services. <br />
Eligible chains: OP Mainnet, Base, Zora, Frax, Mode.
</AlertDescription>
</Alert>
)
)}
{props.hasSmartWalletsWithoutBilling && <SmartWalletsBillingAlert />}
<GasCreditAlert teamSlug={props.teamSlug} projectId={props.projectId} />
<div>
<AccountAbstractionSummary
aggregateUserOpUsageQuery={props.userOpStats}
/>
<div className="h-12" />
<TabPathLinks
links={[
{
@@ -93,8 +72,64 @@ export function AccountAbstractionLayout(props: {
{props.children}
</div>
</div>
<div className="h-14" />
<AAFooterSection trackingCategory={TRACKING_CATEGORY} />
</div>
);
}
function GasCreditAlert(props: {
teamSlug: string;
projectId: string;
}) {
const [isVisible, setIsVisible] = useLocalStorage(
`gas-credit-${props.projectId}`,
true,
false,
);
if (!isVisible) return null;
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
onClick={() => setIsVisible(false)}
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
aria-label="Close alert"
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
<h2 className="mb-0.5 font-semibold">OP Gas Credit Program</h2>
<p className="text-muted-foreground text-sm">
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<TrackedUnderlineLink
target="_blank"
label="superchain-landing"
category={TRACKING_CATEGORY}
href="https://thirdweb.com/grant/superchain"
>
Learn More
</TrackedUnderlineLink>
</p>
<div className="mt-4 flex items-center gap-4">
<Button asChild variant="outline" size="sm" className="bg-background">
<TrackedLinkTW
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
className="gap-2"
category={TRACKING_CATEGORY}
label="claim-credits"
>
Claim your credits <ArrowRightIcon className="size-4" />
</TrackedLinkTW>
</Button>
</div>
</div>
</div>
);
}
@@ -1,3 +0,0 @@
export function isOpChainId(chainId: number) {
return [10, 8453, 7777777, 252, 34443].includes(chainId);
}
@@ -1,4 +1,3 @@
import { getAggregateUserOpUsage } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import type { Metadata } from "next";
@@ -34,18 +33,13 @@ export default async function Page(props: {
team.billingStatus !== "validPayment" &&
team.billingStatus !== "pastDue";
const userOpStats = await getAggregateUserOpUsage({
teamId: team.id,
projectId: project.id,
});
return (
<AccountAbstractionLayout
userOpStats={userOpStats}
projectSlug={project.slug}
teamSlug={team_slug}
projectKey={project.publishableKey}
hasSmartWalletsWithoutBilling={hasSmartWalletsWithoutBilling}
projectId={project.id}
>
{props.children}
</AccountAbstractionLayout>
@@ -1,5 +1,6 @@
import { getUserOpUsage } from "@/api/analytics";
import { getAggregateUserOpUsage, getUserOpUsage } from "@/api/analytics";
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import {
type Range,
@@ -31,7 +32,14 @@ export default async function Page(props: {
notFound();
}
const project = await getProject(params.team_slug, params.project_slug);
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
@@ -52,7 +60,7 @@ export default async function Page(props: {
type: rangeType,
};
const userOpStats = await getUserOpUsage({
const userOpStatsPromise = getUserOpUsage({
teamId: project.teamId,
projectId: project.id,
from: range.from,
@@ -60,6 +68,16 @@ export default async function Page(props: {
period: interval,
});
const aggregateUserOpStatsPromise = getAggregateUserOpUsage({
teamId: team.id,
projectId: project.id,
});
const [userOpStats, aggregateUserOpStats] = await Promise.all([
userOpStatsPromise,
aggregateUserOpStatsPromise,
]);
return (
<AccountAbstractionAnalytics
userOpStats={userOpStats}
@@ -67,6 +85,7 @@ export default async function Page(props: {
teamId={project.teamId}
projectId={project.id}
teamSlug={params.team_slug}
aggregateUserOpStats={aggregateUserOpStats}
/>
);
}
@@ -1,33 +0,0 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
children: React.ReactNode;
}) {
const { team_slug, project_slug } = await props.params;
const links: SidebarLink[] = [
{
label: "In-App Wallets",
href: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
},
{
label: "Account Abstraction",
href: `/team/${team_slug}/${project_slug}/connect/account-abstraction`,
},
{
label: "Universal Bridge",
href: `/team/${team_slug}/${project_slug}/connect/universal-bridge`,
},
{
label: "Playground",
href: "https://playground.thirdweb.com/connect/sign-in/button",
},
];
return <SidebarLayout sidebarLinks={links}>{props.children}</SidebarLayout>;
}
@@ -10,15 +10,11 @@ import Link from "next/link";
export default async function Page() {
return (
<div className="flex grow flex-col">
<div className="border-border border-b py-10">
<div className="container flex max-w-[1000px] flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-semibold text-2xl tracking-tight sm:text-3xl">
Insight
</h1>
</div>
</div>
<h1 className="mb-6 font-semibold text-2xl tracking-tight sm:text-3xl">
Insight
</h1>
<div className="container max-w-[1000px] py-8 pb-20">
<div className="pb-20">
<BlueprintCard />
</div>
</div>
@@ -1,12 +1,15 @@
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
import { SidebarProvider } from "@/components/ui/sidebar";
import { redirect } from "next/navigation";
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
import { getValidAccount } from "../../../account/settings/getAccount";
import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken";
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
import { ProjectTabs } from "./tabs";
import { ProjectSidebarLayout } from "./components/ProjectSidebarLayout";
import { SaveLastUsedProject } from "./components/SaveLastUsedProject";
export default async function TeamLayout(props: {
export default async function ProjectLayout(props: {
children: React.ReactNode;
breadcrumbNav: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -46,21 +49,32 @@ export default async function TeamLayout(props: {
redirect(`/team/${params.team_slug}`);
}
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
?.onWaitlist;
const layoutPath = `/team/${params.team_slug}/${params.project_slug}`;
return (
<div className="flex grow flex-col">
<div className="bg-card">
<TeamHeaderLoggedIn
currentProject={project}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
account={account}
accountAddress={accountAddress}
/>
<ProjectTabs
layoutPath={`/team/${params.team_slug}/${params.project_slug}`}
/>
<SidebarProvider>
<div className="flex h-dvh min-w-0 grow flex-col">
<div className="sticky top-0 z-10 border-border border-b bg-card">
<AnnouncementBanner />
<TeamHeaderLoggedIn
currentProject={project}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
account={account}
accountAddress={accountAddress}
/>
</div>
<ProjectSidebarLayout
layoutPath={layoutPath}
showNebula={!!isOnNebulaWaitList}
>
{props.children}
</ProjectSidebarLayout>
</div>
<div className="flex grow flex-col">{props.children}</div>
</div>
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
</SidebarProvider>
);
}
@@ -12,6 +12,7 @@ export type NebulaAnalyticsDataItem = {
export const fetchNebulaAnalytics = unstable_cache(
async (params: {
teamId: string;
projectId: string;
authToken: string;
from: string;
to: string;
@@ -20,6 +21,7 @@ export const fetchNebulaAnalytics = unstable_cache(
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
const url = new URL(`${analyticsEndpoint}/v2/nebula/usage`);
url.searchParams.set("teamId", params.teamId);
url.searchParams.set("projectId", params.projectId);
url.searchParams.set("from", params.from);
url.searchParams.set("to", params.to);
url.searchParams.set("period", params.period);
@@ -22,7 +22,7 @@ export function NebulaAnalyticsFilter() {
});
return (
<div className="flex items-center gap-3">
<div className="no-scrollbar flex items-center gap-3 overflow-auto">
<DateRangeSelector
range={range}
popoverAlign="end"
@@ -19,11 +19,12 @@ export function NebulaAnalyticsPage(props: {
};
teamId: string;
authToken: string;
projectId: string;
}) {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<header className="border-b">
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col items-start gap-4 pt-2 pb-6 md:flex-row md:items-center md:justify-between lg:pt-4 lg:pb-10">
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
<div className="flex gap-3">
@@ -44,7 +45,7 @@ export function NebulaAnalyticsPage(props: {
</div>
</header>
<div className="container pt-8 pb-20">
<div className="pt-8">
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
<NebulaAnalyticsFilter />
@@ -56,6 +57,7 @@ export function NebulaAnalyticsPage(props: {
<NebulaAnalyticDashboard
searchParams={props.searchParams}
teamId={props.teamId}
projectId={props.projectId}
authToken={props.authToken}
/>
</ResponsiveSuspense>
@@ -67,6 +69,7 @@ export function NebulaAnalyticsPage(props: {
async function NebulaAnalyticDashboard(props: {
teamId: string;
authToken: string;
projectId: string;
searchParams: {
from: string | undefined | string[];
to: string | undefined | string[];
@@ -80,6 +83,7 @@ async function NebulaAnalyticDashboard(props: {
const res = await fetchNebulaAnalytics({
teamId: props.teamId,
authToken: props.authToken,
projectId: props.projectId,
from: normalizeTimeISOString(range.from),
to: normalizeTimeISOString(range.to),
// internally renamed
@@ -13,8 +13,8 @@ export function NebulaWaitListPageUI(props: {
<div className={cn("flex grow flex-col", props.className)}>
{/* Header */}
{!props.hideHeader && (
<div className="border-b py-10">
<div className="container flex items-center justify-between">
<div className="border-b pt-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
<Button asChild variant="outline">
<Link href="/contact-us" target="_blank">
@@ -25,7 +25,7 @@ export function NebulaWaitListPageUI(props: {
</div>
)}
<div className="container flex grow flex-col overflow-hidden py-32">
<div className="flex grow flex-col overflow-hidden py-32">
<CenteredCard
title="You're on the waitlist"
description="You should receive access to Nebula soon!"
@@ -46,7 +46,7 @@ function UnexpectedErrorPage(props: {
message: string;
}) {
return (
<div className="container flex grow flex-col py-10">
<div className="flex grow flex-col py-10">
<div className="flex min-h-[300px] grow flex-col items-center justify-center rounded-lg border">
<div className="flex flex-col items-center gap-4">
<span className="text-destructive-text">{props.message}</span>
@@ -1,12 +1,15 @@
import { getProject } from "@/api/projects";
import { getTeamBySlug } from "@/api/team";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../login/loginRedirect";
import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-page";
import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { NebulaAnalyticsPage } from "./components/analytics/nebula-analytics-page";
import { NebulaWaitListPage } from "./components/nebula-waitlist-page";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
searchParams: Promise<{
from: string | undefined | string[];
@@ -19,13 +22,22 @@ export default async function Page(props: {
props.searchParams,
]);
const [authToken, team] = await Promise.all([
const [authToken, team, project] = await Promise.all([
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team || !authToken) {
loginRedirect(`/team/${params.team_slug}/~/nebula`);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`);
}
const hasNebulaAccess = team.enabledScopes.includes("nebula");
@@ -36,6 +48,7 @@ export default async function Page(props: {
teamId={team.id}
authToken={authToken}
searchParams={searchParams}
projectId={project.id}
/>
);
}
@@ -29,13 +29,14 @@ import {
getChainMetadata,
} from "thirdweb/chains";
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
import { PieChartCard } from "../../components/Analytics/PieChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
import { RangeSelector } from "components/analytics/range-selector";
interface PageParams {
team_slug: string;
project_slug: string;
@@ -80,8 +81,8 @@ export default async function ProjectOverviewPage(props: PageProps) {
return (
<div className="flex grow flex-col">
<div className="w-full border-border border-b">
<AnalyticsHeader
<div className="w-full">
<Header
title={project.name}
interval={interval}
range={range}
@@ -90,11 +91,11 @@ export default async function ProjectOverviewPage(props: PageProps) {
</div>
{!isActive ? (
<div className="container py-8 pb-20">
<div className="pt-6">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex grow flex-col py-6">
<div className="flex grow flex-col pt-6">
<Suspense fallback={<GenericLoadingPage />}>
<ProjectAnalytics
project={project}
@@ -189,7 +190,7 @@ async function ProjectAnalytics(props: {
link="https://portal.thirdweb.com/connect/quickstart"
/>
)}
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
<div className="grid gap-6 md:grid-cols-2">
{walletConnections.status === "fulfilled" &&
walletConnections.value.length > 0 ? (
<WalletDistributionCard data={walletConnections.value} />
@@ -461,3 +462,23 @@ async function TotalSponsoredCard({
/>
);
}
export function Header(props: {
title: string;
interval: "day" | "week";
range: Range;
showRangeSelector: boolean;
}) {
const { title, interval, range, showRangeSelector } = props;
return (
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
{title}
</h1>
</div>
{showRangeSelector && <RangeSelector interval={interval} range={range} />}
</div>
);
}
@@ -7,14 +7,10 @@ export default async function Layout(props: {
}) {
return (
<div className="flex grow flex-col">
<div className="border-border border-b py-10">
<div className="container max-w-[1000px]">
<h1 className="font-semibold text-3xl tracking-tight">
Project Settings
</h1>
</div>
</div>
<div className="container max-w-[1000px] py-6">{props.children}</div>
<h1 className="font-semibold text-3xl tracking-tight">
Project Settings
</h1>
<div className="py-6">{props.children}</div>
</div>
);
}
@@ -1,41 +0,0 @@
"use client";
import { TabPathLinks } from "@/components/ui/tabs";
export function ProjectTabs(props: {
layoutPath: string;
}) {
const { layoutPath } = props;
return (
<TabPathLinks
tabContainerClassName="px-4 lg:px-6"
links={[
{
path: layoutPath,
exactMatch: true,
name: "Overview",
},
{
// directly link to /in-app-wallets to skip 1 redirect and use `isActive` for checking if the tab is active or not
path: `${layoutPath}/connect/in-app-wallets`,
name: "Connect",
isActive: (path) => path.startsWith(`${layoutPath}/connect`),
},
{
path: `${layoutPath}/contracts`,
name: "Contracts",
},
{
path: `${layoutPath}/insight`,
name: "Insight",
},
{
path: `${layoutPath}/settings`,
name: "Settings",
},
]}
/>
);
}
@@ -1,5 +1,4 @@
import { getTeamBySlug } from "@/api/team";
import { AppFooter } from "@/components/blocks/app-footer";
import { redirect } from "next/navigation";
import { isTeamOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage";
@@ -37,7 +36,6 @@ export default async function RootTeamLayout(props: {
{props.children}
</div>
<AppFooter />
<SaveLastVisitedTeamPage />
</div>
);
@@ -5,18 +5,16 @@ export const Stat: React.FC<{
formatter?: (value: number) => string;
}> = ({ label, value, formatter, icon: Icon }) => {
return (
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 lg:p-6">
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
<div>
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
{value !== undefined && formatter
? formatter(value)
: value?.toLocaleString()}
</dd>
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
{label}
</dt>
<dt className="text-muted-foreground text-sm">{label}</dt>
</div>
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
<Icon className="hidden size-8 text-muted-foreground opacity-50 lg:block" />
</dl>
);
};
@@ -44,10 +44,16 @@ import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
import { upload } from "thirdweb/storage";
import { isZkSyncChain } from "thirdweb/utils";
import { FormHelperText, FormLabel, Text } from "tw-components";
import {
LAST_USED_PROJECT_ID,
LAST_USED_TEAM_ID,
} from "../../../app/team/[team_slug]/[project_slug]/components/SaveLastUsedProject";
import { useAddContractToProject } from "../../../app/team/[team_slug]/[project_slug]/hooks/project-contracts";
import { useCustomFactoryAbi, useFunctionParamsFromABI } from "../hooks";
import {
AddToProjectCardUI,
type MinimalProject,
type MinimalTeam,
type MinimalTeamsAndProjects,
} from "./add-to-project-card";
import { Fieldset } from "./common";
@@ -151,9 +157,42 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
const thirdwebClient = useThirdwebClient(jwt);
const [isImportEnabled, setIsImportEnabled] = useState(true);
const [importSelection, setImportSelection] = useState({
team: teamsAndProjects[0]?.team,
project: teamsAndProjects[0]?.projects[0],
const [importSelection, setImportSelection] = useState<{
team: MinimalTeam | undefined;
project: MinimalProject | undefined;
}>(() => {
const defaultSelection = {
team: teamsAndProjects[0]?.team,
project: teamsAndProjects[0]?.projects[0],
};
try {
const lastUsedTeamSlug = localStorage.getItem(LAST_USED_TEAM_ID);
const lastUsedProjectSlug = localStorage.getItem(LAST_USED_PROJECT_ID);
if (!lastUsedTeamSlug || !lastUsedProjectSlug) {
return defaultSelection;
}
const teamWithProjects = teamsAndProjects.find(
(t) => t.team.id === lastUsedTeamSlug,
);
const project = teamWithProjects?.projects.find(
(p) => p.id === lastUsedProjectSlug,
);
if (teamWithProjects && project) {
return {
team: teamWithProjects.team,
project,
};
}
} catch {
// ignore localStorage errors
}
return defaultSelection;
});
const activeAccount = useActiveAccount();
@@ -473,9 +512,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
name: params.contractMetadata?.name || "",
contractURI: _contractURI,
defaultAdmin: params.deployParams._defaultAdmin as string,
platformFeeBps: hasInbuiltDefaultFeeConfig
? DEFAULT_FEE_BPS_NEW
: DEFAULT_FEE_BPS,
platformFeeBps: DEFAULT_FEE_BPS_NEW,
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
trustedForwarders: params.deployParams._trustedForwarders
? JSON.parse(params.deployParams._trustedForwarders as string)
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { CircleCheck, CircleIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useState } from "react";
import { useActiveWallet } from "thirdweb/react";
export type DeployModalStep = {
type: "deploy" | "setNFTMetadata";
@@ -79,9 +80,9 @@ export function DeployStatusModal(props: {
dialogCloseClassName="hidden"
className="gap-0 p-0 md:max-w-[480px]"
>
<div className="flex flex-col gap-6 p-6">
<div className="flex flex-col gap-5 p-6">
<DialogHeader>
<DialogTitle className="font-semibold text-2xl tracking-tight">
<DialogTitle className="font-semibold text-xl tracking-tight">
Deploy Status
</DialogTitle>
</DialogHeader>
@@ -125,7 +126,10 @@ type DeployModalStepProps = {
function RenderDeployModalStep(props: DeployModalStepProps) {
const { isActive, hasCompleted } = props;
const { title, description } = getStepInfo(props.step);
const wallet = useActiveWallet();
const requiresSignature = wallet?.id !== "inApp";
const { title, description } = getStepInfo(props.step, requiresSignature);
return (
<div className="rounded-lg border border-border bg-card">
<div
@@ -158,13 +162,16 @@ type TitleAndDesc = {
description: React.ReactNode;
};
function getStepInfo(step: DeployModalStep): TitleAndDesc {
function getStepInfo(
step: DeployModalStep,
requiresSignature: boolean,
): TitleAndDesc {
switch (step.type) {
case "deploy": {
return {
title: "Deploying contract",
description:
step.signatureCount > 0
step.signatureCount > 0 && requiresSignature
? `Your wallet will prompt you to sign ${
step.signatureCount === 1 ? "the" : step.signatureCount || 1
} transaction${step.signatureCount > 1 ? "s" : ""}.`
@@ -177,7 +184,7 @@ function getStepInfo(step: DeployModalStep): TitleAndDesc {
title: "Setting NFT metadata",
description: (
<>
{step.signatureCount > 0
{step.signatureCount > 0 && requiresSignature
? "Your wallet will prompt you to sign the transaction. "
: "This may take a few seconds."}
</>
@@ -115,10 +115,10 @@ export function InAppWalletUsersChartCardUI(props: {
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{props.title}
</h3>
<p className="mb-3 text-muted-foreground">{props.description}</p>
<p className="mb-3 text-muted-foreground text-sm">{props.description}</p>
<ExportToCSVButton
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
@@ -27,7 +27,7 @@ export function InAppWalletsSummary(props: {
);
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<div className="grid grid-cols-2 gap-4">
<Stat
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
@@ -1,27 +1,19 @@
"use client";
import { Button } from "@/components/ui/button";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { useLocalStorage } from "hooks/useLocalStorage";
import { CircleAlertIcon, XIcon } from "lucide-react";
import { useSelectedLayoutSegment } from "next/navigation";
import { XIcon } from "lucide-react";
export function AnnouncementBanner(props: {
function AnnouncementBannerUI(props: {
href: string;
label: string;
trackingLabel: string;
}) {
const layoutSegment = useSelectedLayoutSegment();
const [hasDismissedAnnouncement, setHasDismissedAnnouncement] =
useLocalStorage(`dismissed-${props.trackingLabel}`, false, true);
if (
layoutSegment === "/_not-found" ||
hasDismissedAnnouncement ||
layoutSegment === "login" ||
layoutSegment === "nebula-app" ||
layoutSegment === "join" ||
layoutSegment === "get-started"
) {
if (hasDismissedAnnouncement) {
return null;
}
@@ -34,7 +26,6 @@ export function AnnouncementBanner(props: {
target={props.href.startsWith("http") ? "_blank" : undefined}
className="ontainer flex cursor-pointer items-center gap-2 lg:justify-center "
>
<CircleAlertIcon className="hidden size-5 shrink-0 lg:block" />
<span className="inline-block font-medium leading-normal hover:underline">
{props.label}
</span>
@@ -53,12 +44,12 @@ export function AnnouncementBanner(props: {
);
}
export function OrganizeContractsToProjectsBanner() {
export function AnnouncementBanner() {
return (
<AnnouncementBanner
href="https://blog.thirdweb.com/organize-contracts/"
label="Your contracts are now organized into projects. Learn more"
trackingLabel="organize-contracts-to-projects"
<AnnouncementBannerUI
href="https://playground.thirdweb.com/connect/pay"
label="Let users pay with whatever they have without leaving your app"
trackingLabel="ub-launch"
/>
);
}
@@ -125,7 +125,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
<Form {...form}>
<form onSubmit={handleSubmit} autoComplete="off">
<SettingsCard
bottomText="Shared Fees will are sent to recipient address"
bottomText="Shared Fees are sent to recipient address"
errorText={form.getFieldState("payoutAddress").error?.message}
saveButton={{
type: "submit",
@@ -139,9 +139,8 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
Fee Sharing
</h3>
<p className="mt-1.5 mb-4 text-foreground text-sm">
thirdweb collects a 1% fee per end user transaction through{" "}
<strong>Buy With Crypto</strong>. We share 70% of this fee with
you.{" "}
thirdweb collects a 0.3% protocol fee on swap transactions. You
may set your own developer fee in addition to this fee.
<Link
href="https://portal.thirdweb.com/connect/pay/fee-sharing"
target="_blank"
@@ -1,124 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Toaster } from "sonner";
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
import { type ActiveCouponResponse, ApplyCouponCardUI } from "./CouponCard";
const meta = {
title: "Billing/Coupons/ApplyCoupon",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function statusStub(status: number) {
return async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const data: ActiveCouponResponse | null =
status === 200
? {
id: "xyz",
start: 1727992716,
end: 1759528716,
coupon: {
id: "XYZTEST",
name: "TEST COUPON",
duration: "repeating",
duration_in_months: 12,
},
}
: null;
return {
status,
data,
};
};
}
function Story() {
return (
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
<BadgeContainer label="Prefill code - XYZ, Success - 200">
<ApplyCouponCardUI
submit={statusStub(200)}
onCouponApplied={undefined}
prefillPromoCode="XYZ"
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="No Valid Payment setup">
<ApplyCouponCardUI
submit={statusStub(200)}
onCouponApplied={undefined}
isPaymentSetup={false}
/>
</BadgeContainer>
<BadgeContainer label="Success - 200">
<ApplyCouponCardUI
submit={statusStub(200)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Invalid - 400">
<ApplyCouponCardUI
submit={statusStub(400)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Not Authorized - 401">
<ApplyCouponCardUI
submit={statusStub(401)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Already applied - 409">
<ApplyCouponCardUI
submit={statusStub(409)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Rate Limited - 429">
<ApplyCouponCardUI
submit={statusStub(429)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<BadgeContainer label="Other - 500">
<ApplyCouponCardUI
submit={statusStub(500)}
onCouponApplied={undefined}
isPaymentSetup={true}
/>
</BadgeContainer>
<Toaster richColors />
</div>
);
}
@@ -1,360 +0,0 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
import { SettingsCard } from "@/components/blocks/SettingsCard";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format, fromUnixTime } from "date-fns";
import { TagIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useActiveAccount } from "thirdweb/react";
import { z } from "zod";
export type ActiveCouponResponse = {
id: string;
start: number;
end: number | null;
coupon: {
id: string;
name: string | null;
duration: "forever" | "once" | "repeating";
duration_in_months: number | null;
};
};
function ApplyCouponCard(props: {
teamId: string | undefined;
onCouponApplied: (data: ActiveCouponResponse) => void;
isPaymentSetup: boolean;
}) {
const searchParams = useSearchParams();
const couponCode = searchParams?.get("coupon");
return (
<ApplyCouponCardUI
onCouponApplied={props.onCouponApplied}
prefillPromoCode={couponCode || undefined}
scrollIntoView={!!couponCode}
isPaymentSetup={props.isPaymentSetup}
submit={async (promoCode: string) => {
const res = await apiServerProxy<{ data: ActiveCouponResponse }>({
method: "POST",
pathname: "/v1/coupons/redeem",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
promoCode,
teamId: props.teamId,
}),
});
if (res.ok) {
const json = res.data;
return {
status: 200,
data: json.data,
};
}
return {
status: res.status,
data: null,
};
}}
/>
);
}
const couponFormSchema = z.object({
promoCode: z.string().min(1, "Coupon code is required"),
});
export function ApplyCouponCardUI(props: {
submit: (promoCode: string) => Promise<{
status: number;
data: null | ActiveCouponResponse;
}>;
onCouponApplied: ((data: ActiveCouponResponse) => void) | undefined;
prefillPromoCode?: string;
scrollIntoView?: boolean;
isPaymentSetup: boolean;
}) {
const containerRef = useRef<HTMLFormElement | null>(null);
const form = useForm<z.infer<typeof couponFormSchema>>({
resolver: zodResolver(couponFormSchema),
defaultValues: {
promoCode: props.prefillPromoCode || "",
},
});
const scrolled = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (props.scrollIntoView && !scrolled.current) {
const el = containerRef.current;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.querySelector("input")?.focus();
scrolled.current = true;
}
}
}, [props.scrollIntoView]);
const applyCoupon = useMutation({
mutationFn: (promoCode: string) => props.submit(promoCode),
});
async function onSubmit(values: z.infer<typeof couponFormSchema>) {
if (!props.isPaymentSetup) {
return;
}
try {
const res = await applyCoupon.mutateAsync(values.promoCode);
switch (res.status) {
case 200: {
toast.success("Coupon applied successfully");
if (res.data) {
props.onCouponApplied?.(res.data);
}
break;
}
case 400: {
toast.error("Coupon code is invalid");
break;
}
case 401: {
toast.error("You are not authorized to apply coupons", {
description: "Login to dashboard and try again",
});
break;
}
case 409: {
toast.error("Coupon already applied");
break;
}
case 429: {
toast.error("Too many coupons applied in a short period", {
description: "Please try again after some time",
});
break;
}
default: {
toast.error("Failed to apply coupon");
}
}
} catch {
toast.error("Failed to apply coupon");
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} ref={containerRef}>
<SettingsCard
header={{
title: "Apply Coupon",
description:
"Enter your coupon code to apply discounts or free trials on thirdweb products",
}}
bottomText={
props.isPaymentSetup
? ""
: "A valid payment method must be added to apply a coupon."
}
saveButton={{
variant: "outline",
disabled: !props.isPaymentSetup,
isPending: applyCoupon.isPending,
label: "Apply Coupon",
type: "submit",
}}
noPermissionText={undefined}
errorText={
form.getFieldState("promoCode", form.formState).error?.message
}
>
<FormField
control={form.control}
name="promoCode"
render={({ field }) => (
<FormItem>
<FormLabel>Coupon Code</FormLabel>
<FormControl>
<Input
{...field}
className="lg:max-w-[450px]"
disabled={!props.isPaymentSetup}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsCard>
</form>
</Form>
);
}
export function CouponDetailsCardUI(props: {
activeCoupon: ActiveCouponResponse;
deleteCoupon: {
mutateAsync: () => Promise<void>;
isPending: boolean;
};
}) {
const { activeCoupon, deleteCoupon } = props;
return (
<DangerSettingCard
className="border-border"
footerClassName="!bg-transparent !border-border"
title="Coupon Applied"
description="To apply another coupon, you must remove this coupon"
buttonLabel="Remove Coupon"
buttonOnClick={() => {
const promise = deleteCoupon.mutateAsync();
toast.promise(promise, {
success: "Coupon Removed Successfully",
error: "Failed To Remove Coupon",
});
}}
confirmationDialog={{
title: `Delete Coupon "${activeCoupon.coupon.name}" ?`,
description: "Offers added by this coupon will no longer be available.",
}}
isPending={deleteCoupon.isPending}
>
<div className="mt-3 flex max-w-[600px] items-center gap-3 rounded-lg border border-border bg-background p-4">
<TagIcon className="hidden size-6 text-muted-foreground lg:block" />
<div>
<h4 className="font-medium text-foreground">
{activeCoupon.coupon.name || `Coupon #${activeCoupon.coupon.id}`}
</h4>
<p className="text-muted-foreground text-sm">
Valid from {format(fromUnixTime(activeCoupon.start), "MMM d, yyyy")}{" "}
{activeCoupon.end && (
<>to {format(fromUnixTime(activeCoupon.end), "MMMM d, yyyy")}</>
)}
</p>
</div>
</div>
</DangerSettingCard>
);
}
export function CouponSection(props: {
teamId: string | undefined;
isPaymentSetup: boolean;
}) {
const address = useActiveAccount()?.address;
const [optimisticCouponData, setOptimisticCouponData] = useState<
| {
type: "added";
data: ActiveCouponResponse;
}
| {
type: "deleted";
data: null;
}
>();
const activeCoupon = useQuery({
queryKey: ["active-coupon", address, props.teamId],
queryFn: async () => {
const res = await apiServerProxy<{ data: ActiveCouponResponse }>({
method: "GET",
pathname: "/v1/active-coupon",
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
});
if (!res.ok) {
return null;
}
const json = res.data;
return json.data;
},
});
const deleteActiveCoupon = useMutation({
mutationFn: async () => {
const res = await apiServerProxy({
method: "DELETE",
pathname: "/v1/active-coupon",
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
});
if (!res.ok) {
throw new Error("Failed to delete coupon");
}
},
onSuccess: () => {
setOptimisticCouponData({
type: "deleted",
data: null,
});
activeCoupon.refetch().then(() => {
setOptimisticCouponData(undefined);
});
},
});
if (activeCoupon.isPending) {
return <LoadingCouponSection />;
}
const couponData = optimisticCouponData
? optimisticCouponData.data
: activeCoupon.data;
return (
<>
{couponData ? (
<CouponDetailsCardUI
activeCoupon={couponData}
deleteCoupon={{
mutateAsync: deleteActiveCoupon.mutateAsync,
isPending: deleteActiveCoupon.isPending,
}}
/>
) : (
<Suspense fallback={<LoadingCouponSection />}>
<ApplyCouponCard
teamId={props.teamId}
onCouponApplied={(coupon) => {
setOptimisticCouponData({
type: "added",
data: coupon,
});
activeCoupon.refetch().then(() => {
setOptimisticCouponData(undefined);
});
}}
isPaymentSetup={props.isPaymentSetup}
/>
</Suspense>
)}
</>
);
}
function LoadingCouponSection() {
return (
<div className="flex h-[300px] items-center justify-center rounded-lg border border-border bg-card">
<Spinner className="size-6" />
</div>
);
}
@@ -1,60 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Toaster } from "sonner";
import { mobileViewport } from "../../../../stories/utils";
import { CouponDetailsCardUI } from "./CouponCard";
const meta = {
title: "Billing/Coupons/CouponDetails",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {},
};
export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Story() {
const [isPending, setIsPending] = useState(false);
return (
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
<CouponDetailsCardUI
activeCoupon={{
id: "xyz",
start: 1727992716,
end: 1759528716,
coupon: {
id: "XYZFOOBAR",
name: "TEST COUPON",
duration: "repeating",
duration_in_months: 12,
},
}}
deleteCoupon={{
mutateAsync: async () => {
setIsPending(true);
await new Promise((resolve) => setTimeout(resolve, 2000));
setIsPending(false);
},
isPending: isPending,
}}
/>
<Toaster richColors />
</div>
);
}
@@ -0,0 +1,235 @@
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import type { Meta, StoryObj } from "@storybook/react";
import { useMemo, useState } from "react";
import { type CouponData, CouponsUI } from "./CouponsUI";
const meta = {
title: "Billing/Coupons",
component: Story,
} satisfies Meta<typeof CouponsUI>;
export default meta;
type Story = StoryObj<typeof CouponsUI>;
const couponStubs: CouponData[] = [
// Forever
{
id: "di_1QAE7jCQUO4TBFqFQgnS4Qj4",
start: 1729011691,
end: null,
coupon: {
id: "forever_active",
name: "Example 1",
duration: "forever",
duration_in_months: null,
},
},
// Once
{
id: "once",
start: 1733391052,
end: null,
coupon: {
id: "once_active",
name: "Example 2",
duration: "once",
duration_in_months: null,
},
},
// Repeating - 3 months
{
id: "repeating-3",
start: 1733391629,
end: 1743391629, // Will end in the future
coupon: {
id: "repeating_active",
name: "Example 3",
duration: "repeating",
duration_in_months: 3,
},
},
// Repeating - 1 month
{
id: "repeating-1",
start: 1733391713,
end: 1736391713,
coupon: {
id: "repeating_single",
name: "Example 4",
duration: "repeating",
duration_in_months: 1,
},
},
];
export const Variants: Story = {
args: {},
};
function Story() {
const [status, setStatus] = useState<"pending" | "error" | "success">(
"success",
);
const [activeCouponsType, setActiveCouponsType] = useState<
"multiple" | "zero" | "one"
>("multiple");
const activeCoupons = useMemo(() => {
if (activeCouponsType === "multiple") {
return couponStubs;
}
if (activeCouponsType === "zero") {
return [];
}
if (activeCouponsType === "one") {
return couponStubs.slice(0, 1);
}
return [];
}, [activeCouponsType]);
const [hasAccountCoupon, setHasAccountCoupon] = useState(true);
const [isPaymentSetup, setIsPaymentSetup] = useState(true);
const [applyCouponStatus, setApplyCouponStatus] = useState<
200 | 400 | 401 | 409 | 429 | 500
>(200);
return (
<div className="container max-w-[1100px] py-10">
<div className="mb-8 space-y-6">
<div className="flex flex-col gap-6">
<div className="space-y-3">
<h3 className="font-semibold text-lg">Status</h3>
<RadioGroup
value={status}
onValueChange={(value) =>
setStatus(value as "pending" | "error" | "success")
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pending" id="status-pending" />
<Label htmlFor="status-pending">Pending</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="error" id="status-error" />
<Label htmlFor="status-error">Error</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="success" id="status-success" />
<Label htmlFor="status-success">Success</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-3">
<h3 className="font-semibold text-lg">Active Coupons</h3>
<RadioGroup
value={activeCouponsType}
onValueChange={(value) =>
setActiveCouponsType(value as "multiple" | "zero" | "one")
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="multiple" id="coupons-multiple" />
<Label htmlFor="coupons-multiple">Multiple</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="zero" id="coupons-zero" />
<Label htmlFor="coupons-zero">Zero</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="one" id="coupons-one" />
<Label htmlFor="coupons-one">One</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-3">
<h3 className="font-semibold text-lg">Apply Coupon Status</h3>
<RadioGroup
value={applyCouponStatus.toString()}
className="flex gap-4"
onValueChange={(value) =>
setApplyCouponStatus(
Number.parseInt(value) as 200 | 400 | 401 | 409 | 429 | 500,
)
}
>
{[200, 400, 401, 409, 429, 500].map((code) => (
<div key={code} className="flex items-center space-x-2">
<RadioGroupItem
value={code.toString()}
id={`status-${code}`}
/>
<Label htmlFor={`status-${code}`}>{code}</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<div className="space-y-3">
<h3 className="font-semibold text-lg">Other Settings</h3>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Switch
id="has-account-coupon"
checked={hasAccountCoupon}
onCheckedChange={setHasAccountCoupon}
/>
<Label htmlFor="has-account-coupon">Has Account Coupon</Label>
</div>
<div className="flex items-center gap-4">
<Switch
id="payment-setup"
checked={isPaymentSetup}
onCheckedChange={setIsPaymentSetup}
/>
<Label htmlFor="payment-setup">Payment Setup</Label>
</div>
</div>
</div>
</div>
<CouponsUI
status={status}
activeCoupons={activeCoupons}
accountCouponId={hasAccountCoupon ? activeCoupons[0]?.id : undefined}
isPaymentSetup={isPaymentSetup}
applyCoupon={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
status: applyCouponStatus,
data:
applyCouponStatus === 200
? {
id: "xyz",
start: 1727992716,
end: 1759528716,
coupon: {
id: "test",
name: "TEST COUPON",
duration: "repeating",
duration_in_months: 12,
},
}
: null,
};
}}
deleteCoupon={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
</div>
);
}
@@ -0,0 +1,92 @@
import { API_SERVER_URL } from "@/constants/env";
import { Suspense } from "react";
import { getAuthToken } from "../../../../../app/api/lib/getAuthToken";
import { CouponsClient } from "./CouponsClient";
import type { CouponData } from "./CouponsUI";
async function AsyncCoupons(props: {
teamId: string;
isPaymentSetup: boolean;
}) {
const authToken = await getAuthToken();
const allCouponsPromise = fetch(
`${API_SERVER_URL}/v1/active-coupons?teamId=${props.teamId}`,
{
headers: {
authorization: `Bearer ${authToken}`,
},
},
);
const accountCouponPromise = fetch(
`${API_SERVER_URL}/v1/active-coupon?teamId=${props.teamId}`,
{
headers: {
authorization: `Bearer ${authToken}`,
},
},
);
const [allCouponsRes, accountCouponRes] = await Promise.allSettled([
allCouponsPromise,
accountCouponPromise,
]);
if (
allCouponsRes.status === "rejected" ||
accountCouponRes.status === "rejected"
) {
return (
<CouponsClient
status="error"
activeCoupons={[]}
accountCouponId={undefined}
isPaymentSetup={props.isPaymentSetup}
teamId={props.teamId}
/>
);
}
const allCouponsData = (await allCouponsRes.value.json()) as {
data: CouponData[];
};
const accountCouponData = (await accountCouponRes.value.json()) as {
data: CouponData | null;
};
return (
<CouponsClient
status="success"
activeCoupons={allCouponsData.data}
accountCouponId={accountCouponData.data?.id}
isPaymentSetup={props.isPaymentSetup}
teamId={props.teamId}
/>
);
}
export function Coupons(props: {
teamId: string;
isPaymentSetup: boolean;
}) {
return (
<Suspense
fallback={
<CouponsClient
status="pending"
activeCoupons={[]}
accountCouponId={undefined}
isPaymentSetup={true}
teamId={props.teamId}
/>
}
>
<AsyncCoupons
teamId={props.teamId}
isPaymentSetup={props.isPaymentSetup}
/>
</Suspense>
);
}
@@ -0,0 +1,63 @@
"use client";
import { apiServerProxy } from "@/actions/proxies";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { type CouponData, CouponsUI, type CouponsUIProps } from "./CouponsUI";
export function CouponsClient(
props: Omit<
CouponsUIProps,
"applyCoupon" | "onCouponApplied" | "deleteCoupon"
> & {
teamId: string;
},
) {
const router = useDashboardRouter();
return (
<CouponsUI
{...props}
deleteCoupon={async () => {
const res = await apiServerProxy({
method: "DELETE",
pathname: "/v1/active-coupon",
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
});
if (!res.ok) {
throw new Error("Failed to delete coupon");
}
router.refresh();
}}
applyCoupon={async (promoCode: string) => {
const res = await apiServerProxy<{ data: CouponData }>({
method: "POST",
pathname: "/v1/coupons/redeem",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
promoCode,
teamId: props.teamId,
}),
});
if (!res.ok) {
return {
status: res.status,
data: null,
};
}
const json = res.data;
router.refresh();
return {
status: 200,
data: json.data,
};
}}
/>
);
}
@@ -0,0 +1,391 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { format, fromUnixTime } from "date-fns";
import {
AlertCircleIcon,
CalendarIcon,
CalendarX2Icon,
ClockIcon,
InfinityIcon,
PlusIcon,
TicketCheckIcon,
Trash2Icon,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
type Coupon = {
id: string;
name: string;
duration: "forever" | "once" | "repeating";
duration_in_months: number | null;
};
export type CouponData = {
id: string;
start: number;
end: number | null;
coupon: Coupon;
};
type ApplyCouponFn = (promoCode: string) => Promise<{
status: number;
data: null | CouponData;
}>;
export type CouponsUIProps = {
activeCoupons: CouponData[];
status: "pending" | "error" | "success";
accountCouponId: string | undefined;
isPaymentSetup: boolean;
applyCoupon: ApplyCouponFn;
deleteCoupon: () => Promise<void>;
};
export function CouponsUI(props: CouponsUIProps) {
const formatUnixTime = (timestamp: number) => {
const date = fromUnixTime(timestamp);
return format(date, "MMM d, yyyy");
};
const renderDuration = (coupon: Coupon) => {
if (coupon.duration === "forever") {
return (
<div className="flex items-center gap-2">
<InfinityIcon className="size-4 text-muted-foreground" />
<span>Forever</span>
</div>
);
} else if (coupon.duration === "once") {
return (
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span>One-time discount</span>
</div>
);
} else if (coupon.duration === "repeating") {
return (
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span>
Repeats every {coupon.duration_in_months} month
{coupon.duration_in_months !== 1 ? "s" : ""}
</span>
</div>
);
} else {
return null;
}
};
const accountCoupon = props.activeCoupons.find(
(coupon) => coupon.id === props.accountCouponId,
);
return (
<div className="overflow-hidden rounded-lg border">
<div className="flex items-center justify-between border-b bg-card px-6 py-4">
<h3 className="font-semibold text-xl tracking-tight">Coupons</h3>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<PlusIcon className="size-4" />
Apply Coupon
</Button>
</DialogTrigger>
<DialogContent className="overflow-hidden p-0">
<ApplyCouponModalContent
isPaymentSetup={props.isPaymentSetup}
applyCoupon={props.applyCoupon}
accountCoupon={accountCoupon}
/>
</DialogContent>
</Dialog>
</div>
{props.status === "success" && props.activeCoupons.length > 0 ? (
<TableContainer className="rounded-t-none border-none">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Starts On</TableHead>
<TableHead>Ends On</TableHead>
<TableHead>Duration </TableHead>
{accountCoupon && (
<TableHead className="w-24">Actions</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{props.activeCoupons.map((item) => (
<TableRow key={item.id}>
{/* Name */}
<TableCell className="font-medium">
{item.coupon.name}
</TableCell>
{/* Start Date */}
<TableCell>
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
{formatUnixTime(item.start)}
</div>
</TableCell>
{/* End Date */}
<TableCell>
{item.end !== null ? (
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
{formatUnixTime(item.end)}
</div>
) : (
<div className="flex items-center gap-2">
<CalendarX2Icon className="size-4 text-muted-foreground" />
<span className="text-foreground">Never </span>
</div>
)}
</TableCell>
{/* Duration */}
<TableCell>{renderDuration(item.coupon)}</TableCell>
{accountCoupon && (
<TableCell>
<ToolTipLabel
label={
item.id === props.accountCouponId
? "Remove coupon"
: "This coupon cannot be removed"
}
>
<DeleteCouponButton
deleteCoupon={props.deleteCoupon}
disabled={item.id !== props.accountCouponId}
/>
</ToolTipLabel>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<div className="px-6 py-4">
<p className="flex min-h-[150px] items-center justify-center text-muted-foreground">
{props.status === "pending" ? (
<Spinner className="size-8" />
) : props.status === "error" ? (
<span className="text-destructive-text">
Failed to load coupons
</span>
) : (
<span>No coupons</span>
)}
</p>
</div>
)}
</div>
);
}
function DeleteCouponButton(props: {
deleteCoupon: () => Promise<void>;
disabled: boolean;
}) {
const deleteCoupon = useMutation({
mutationFn: props.deleteCoupon,
});
return (
<Button
variant="outline"
size="sm"
disabled={props.disabled}
onClick={() => {
const promise = deleteCoupon.mutateAsync();
toast.promise(promise, {
success: "Coupon removed successfully",
error: "Failed to remove coupon",
});
}}
aria-label="Remove coupon"
>
{deleteCoupon.isPending ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
);
}
const applyCouponFormSchema = z.object({
promoCode: z.string().min(1, "Coupon code is required"),
});
function ApplyCouponModalContent(props: {
isPaymentSetup: boolean;
applyCoupon: ApplyCouponFn;
accountCoupon: CouponData | undefined;
}) {
const applyCoupon = useMutation({
mutationFn: props.applyCoupon,
});
const form = useForm<z.infer<typeof applyCouponFormSchema>>({
resolver: zodResolver(applyCouponFormSchema),
defaultValues: {
promoCode: "",
},
});
async function onSubmit(values: z.infer<typeof applyCouponFormSchema>) {
try {
const res = await applyCoupon.mutateAsync(values.promoCode);
switch (res.status) {
case 200: {
toast.success("Coupon applied successfully");
break;
}
case 400: {
toast.error("Coupon code is invalid");
break;
}
case 401: {
toast.error("You are not authorized to apply coupons", {
description: "Login to dashboard and try again",
});
break;
}
case 409: {
toast.error("Coupon already applied");
break;
}
case 429: {
toast.error("Too many coupons applied in a short period", {
description: "Please try again after some time",
});
break;
}
default: {
toast.error("Failed to apply coupon");
}
}
} catch {
toast.error("Failed to apply coupon");
}
}
const couponEnabled = props.isPaymentSetup && !props.accountCoupon;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
<DialogHeader>
<DialogTitle>Apply Coupon</DialogTitle>
{couponEnabled && (
<DialogDescription>
Enter coupon code to apply discounts or free trials
</DialogDescription>
)}
</DialogHeader>
<div className="h-4" />
{couponEnabled && (
<FormField
control={form.control}
name="promoCode"
render={({ field }) => (
<FormItem>
<FormLabel>Coupon Code</FormLabel>
<FormControl>
<Input
{...field}
className=""
disabled={!props.isPaymentSetup}
/>
</FormControl>
</FormItem>
)}
/>
)}
{!props.isPaymentSetup && (
<Alert variant="destructive">
<AlertCircleIcon className="size-5" />
<AlertTitle>Payment method required</AlertTitle>
<AlertDescription>
A valid payment method must be added to apply a coupon.
</AlertDescription>
</Alert>
)}
{props.isPaymentSetup && props.accountCoupon && (
<Alert variant="destructive">
<AlertCircleIcon className="size-5" />
<AlertTitle>Coupon already applied</AlertTitle>
<AlertDescription>
Remove coupon {`"${props.accountCoupon.coupon.name}"`} to apply
a new coupon
</AlertDescription>
</Alert>
)}
</div>
<div className="mt-4 flex items-center justify-end border-t bg-card p-6">
<Button
type="submit"
disabled={applyCoupon.isPending || !couponEnabled}
className="gap-2"
>
{applyCoupon.isPending ? (
<Spinner className="size-4" />
) : (
<TicketCheckIcon className="size-4" />
)}
Redeem
</Button>
</div>
</form>
</Form>
);
}
@@ -6,9 +6,9 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { AlertCircleIcon } from "lucide-react";
import Link from "next/link";
import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard";
import { CouponSection } from "./CouponCard";
import { CreditsInfoCard } from "./PlanCard";
import { BillingPricing } from "./Pricing";
import { Coupons } from "./SubscriptionCoupons/Coupons";
// TODO - move this in app router folder in other pr
@@ -68,7 +68,7 @@ export const Billing: React.FC<BillingProps> = ({
/>
<CreditsInfoCard twAccount={twAccount} />
<CouponSection teamId={team.id} isPaymentSetup={validPayment} />
<Coupons teamId={team.id} isPaymentSetup={validPayment} />
</div>
);
};
@@ -1,81 +1,22 @@
"use client";
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Flex,
IconButton,
} from "@chakra-ui/react";
import { useLocalStorage } from "hooks/useLocalStorage";
import { XIcon } from "lucide-react";
import { Text, TrackedLink } from "tw-components";
export const SmartWalletsBillingAlert = ({
dismissible = false,
}: {
dismissible?: boolean;
}) => {
const [dismissed, setDismissed] = useLocalStorage(
"dismissed-smart-wallets-billing-alert",
false,
true,
);
if (dismissible && dismissed) {
return true;
}
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
import { CircleAlertIcon } from "lucide-react";
export const SmartWalletsBillingAlert = () => {
return (
<Alert
status="warning"
borderRadius="md"
as={Flex}
alignItems="start"
justifyContent="space-between"
mb={4}
variant="left-accent"
bg="backgroundCardHighlight"
>
<div className="flex flex-row">
<AlertIcon boxSize={4} mt={1} ml={1} />
<Flex flexDir="column" gap={1} pl={1}>
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
<AlertDescription>
<Text as="span" pr={1}>
You&apos;ve enabled Account Abstraction for one of your API keys.
<br />
To enable AA on mainnet chains,
</Text>{" "}
<TrackedLink
href="/team/~/~/settings/billing"
category="api_keys"
label="smart_wallets_missing_billing"
fontWeight="medium"
color="blue.500"
>
<Text as="span" color="blue.500">
subscribe to a billing plan.
</Text>
</TrackedLink>
</AlertDescription>
</Flex>
</div>
{dismissible && (
<IconButton
size="xs"
aria-label="Close notice"
icon={<XIcon className="size-4" />}
colorScheme="blackAlpha"
color="white"
variant="ghost"
opacity={0.6}
_hover={{ opacity: 1 }}
onClick={() => setDismissed(true)}
/>
)}
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
<AlertDescription>
To enable AA on mainnet chains,{" "}
<TrackedUnderlineLink
href="/team/~/~/settings/billing"
category="api_keys"
label="smart_wallets_missing_billing"
>
subscribe to a billing plan.
</TrackedUnderlineLink>
</AlertDescription>
</Alert>
);
};
@@ -41,7 +41,10 @@ import { z } from "zod";
import { projectDomainsSchema, projectNameSchema } from "../validations";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
(srv) =>
srv.name !== "relayer" &&
srv.name !== "chainsaw" &&
srv.name !== "engineCloud", // TODO enable once API server is out
);
export type CreateProjectPrefillOptions = {
@@ -125,4 +125,4 @@ export type ApiKeyPayConfigValidationSchema = z.infer<
>;
// FIXME: Remove
export const HIDDEN_SERVICES = ["relayer", "chainsaw"];
export const HIDDEN_SERVICES = ["relayer", "chainsaw", "engineCloud"];
@@ -6,7 +6,7 @@ export function AccountAbstractionSummary(props: {
aggregateUserOpUsageQuery?: UserOpStats;
}) {
return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<div className="grid grid-cols-2 gap-4">
<Stat
label="Mainnet Sponsored Transactions"
value={props.aggregateUserOpUsageQuery?.successful || 0}
@@ -22,7 +22,7 @@ import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import type { UserOpStats } from "types/analytics";
import { formatTickerNumber } from "../../../lib/format-utils";
@@ -112,11 +112,11 @@ export function SponsoredTransactionsChartCard(props: {
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Sponsored Transactions
</h3>
<p className="mb-3 text-muted-foreground text-sm">
Total number of sponsored transactions.
Total number of sponsored transactions
</p>
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
@@ -139,7 +139,7 @@ export function SponsoredTransactionsChartCard(props: {
</div>
{/* Chart */}
<ChartContainer config={chartConfig} className="h-[400px] w-full">
<ChartContainer config={chartConfig} className="h-[300px] w-full">
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
@@ -164,18 +164,6 @@ export function SponsoredTransactionsChartCard(props: {
axisLine={false}
/>
<YAxis
dataKey={(data) => {
return Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0);
}}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatTickerNumber(value)}
/>
<ChartTooltip
cursor={true}
content={
@@ -208,7 +196,7 @@ export function SponsoredTransactionsChartCard(props: {
export function EmptyAccountAbstractionChartContent() {
return (
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
<span className="mb-6 text-center text-lg">
Send your first sponsored transaction
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
@@ -21,7 +21,7 @@ import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
import { DocLink } from "components/shared/DocLink";
import { format } from "date-fns";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import type { UserOpStats } from "types/analytics";
import { useAllChainsData } from "../../../hooks/chains/allChains";
import { toUSD } from "../../../utils/number";
@@ -111,11 +111,11 @@ export function TotalSponsoredChartCard(props: {
return (
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Gas Sponsored
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The total amount of gas sponsored in USD.
The total amount of gas sponsored in USD
</p>
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
@@ -139,10 +139,7 @@ export function TotalSponsoredChartCard(props: {
</div>
{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
@@ -201,18 +198,6 @@ export function TotalSponsoredChartCard(props: {
axisLine={false}
/>
<YAxis
dataKey={(data) => {
return Object.entries(data)
.filter(([key]) => key !== "time")
.map(([, value]) => value)
.reduce((acc, current) => Number(acc) + Number(current), 0);
}}
tickLine={false}
axisLine={false}
tickFormatter={(value) => toUSD(value)}
/>
<ChartTooltip
cursor={true}
content={
@@ -12,6 +12,7 @@ import type { ThirdwebClient } from "thirdweb";
import type { UserOpStats } from "types/analytics";
import { SponsoredTransactionsTable } from "../../../app/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTable";
import { searchParams } from "../../../app/team/[team_slug]/[project_slug]/connect/account-abstraction/search-params";
import { AccountAbstractionSummary } from "./AccountAbstractionSummary";
import { SponsoredTransactionsChartCard } from "./SponsoredTransactionsChartCard";
import { TotalSponsoredChartCard } from "./TotalSponsoredChartCard";
@@ -21,6 +22,7 @@ export function AccountAbstractionAnalytics(props: {
teamSlug: string;
client: ThirdwebClient;
projectId: string;
aggregateUserOpStats: UserOpStats;
}) {
const [isLoading, startTransition] = useTransition();
@@ -65,6 +67,12 @@ export function AccountAbstractionAnalytics(props: {
return (
<div>
<AccountAbstractionSummary
aggregateUserOpUsageQuery={props.aggregateUserOpStats}
/>
<div className="h-10" />
<div className="flex justify-end gap-3">
<DateRangeSelector
range={range}
@@ -112,24 +112,31 @@ export function AccountAbstractionSettingsPage(
const policy = props.bundlerService;
const transformedQueryData = useMemo(
() => ({
const transformedQueryData = useMemo(() => {
const allowedContractAddresses = policy.allowedContractAddresses?.filter(
(x) => x !== "",
);
const allowedWallets = policy.allowedWallets?.filter((x) => x !== "");
const blockedWallets = policy.blockedWallets?.filter((x) => x !== "");
// there is a bug in API server that makes `allowedChainIds` an array with `0` if we set it to null
const allowedChainIds = policy.allowedChainIds?.filter((x) => x !== 0);
return {
allowedChainIds:
policy.allowedChainIds && policy.allowedChainIds?.length > 0
? policy.allowedChainIds
: null,
allowedChainIds && allowedChainIds?.length > 0 ? allowedChainIds : null,
allowedContractAddresses:
policy.allowedContractAddresses &&
policy.allowedContractAddresses?.length > 0
? joinWithComma(policy.allowedContractAddresses)
allowedContractAddresses && allowedContractAddresses.length > 0
? joinWithComma(allowedContractAddresses)
: null,
allowedWallets:
policy.allowedWallets && policy.allowedWallets?.length > 0
? joinWithComma(policy.allowedWallets)
allowedWallets && allowedWallets?.length > 0
? joinWithComma(allowedWallets)
: null,
blockedWallets:
policy.blockedWallets && policy.blockedWallets?.length > 0
? joinWithComma(policy.blockedWallets)
blockedWallets && blockedWallets?.length > 0
? joinWithComma(blockedWallets)
: null,
bypassWallets:
policy.bypassWallets && policy.bypassWallets?.length > 0
@@ -148,14 +155,13 @@ export function AccountAbstractionSettingsPage(
},
globalLimit: policy.limits?.global ?? null,
allowedOrBlockedWallets:
policy.allowedWallets && policy.allowedWallets?.length > 0
allowedWallets && allowedWallets?.length > 0
? "allowed"
: policy.blockedWallets && policy.blockedWallets?.length > 0
: blockedWallets && blockedWallets?.length > 0
? "blocked"
: null,
}),
[policy],
);
};
}, [policy]);
const form = useForm<z.infer<typeof aaSettingsFormSchema>>({
resolver: zodResolver(aaSettingsFormSchema),
@@ -212,11 +218,12 @@ export function AccountAbstractionSettingsPage(
const parsedValues: Omit<ProjectBundlerService, "name" | "actions"> =
{
allowedContractAddresses:
values.allowedContractAddresses !== null
? toArrFromList(values.allowedContractAddresses)
: null,
allowedChainIds: values.allowedChainIds,
allowedContractAddresses: values.allowedContractAddresses
? toArrFromList(values.allowedContractAddresses)
: null,
// don't set null - `updateProject` API adds chainId 0 to the list if its null and makes it `[0]`
allowedChainIds: values.allowedChainIds || [],
allowedWallets:
values.allowedOrBlockedWallets === "allowed" &&
values.allowedWallets !== null
@@ -1,7 +1,7 @@
"use client";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import posthog from "posthog-js";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import {
useActiveAccount,
@@ -32,48 +32,49 @@ export const PosthogIdentifierClient: React.FC<{
client,
});
const wallet = useActiveWallet();
const posthog = usePostHog();
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (wallet) {
if (wallet && posthog && posthog.__loaded) {
const connector = walletIdToPHName[wallet.id] || wallet.id;
posthog.register({ connector });
posthog.capture("wallet_connected", { connector });
}
}, [wallet]);
}, [wallet, posthog]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (accountAddress) {
if (accountAddress && posthog && posthog.__loaded) {
posthog.identify(accountAddress);
}
}, [accountAddress]);
}, [accountAddress, posthog]);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (accountId) {
if (accountId && posthog && posthog.__loaded) {
posthog.identify(accountId);
}
}, [accountId]);
}, [accountId, posthog]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (chain?.id) {
if (chain?.id && posthog && posthog.__loaded) {
posthog.unregister("network");
posthog.register({ chain_id: chain?.id, ecosystem: "evm" });
}
}, [chain?.id]);
}, [chain?.id, posthog]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (balance?.data?.displayValue) {
if (balance?.data?.displayValue && posthog && posthog.__loaded) {
posthog.register({ balance: balance.data.displayValue });
}
}, [balance]);
}, [balance, posthog]);
return null;
};

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