Compare commits

...
Author SHA1 Message Date
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
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
d14a78c06c Version Packages (#6499)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-19 17:38:22 -07:00
Jonas DanielsandGitHub f81fd46e9c add SMS country selection for in-app wallets (#6502) 2025-03-19 17:30:41 -07:00
Joaquim VergesandGitHub d6ce3aedd8 [SDK] fix: Remove fixed gas limit for Saga Mainnet (#6501) 2025-03-19 14:07:22 -07:00
Joaquim VergesandGitHub 6672549edd [SDK] feat: Add fixed gas limit for Saga Mainnet (#6500) 2025-03-19 13:17:56 -07:00
Joaquim VergesandGitHub 639b542d27 [SDK] docs: Add example for shouldUpdateSessionKey function (#6491) 2025-03-19 12:53:43 -07:00
Joaquim VergesandGitHub b5fb5dd0cc [SDK] feat: Support account overrides in engineAccount() (#6496) 2025-03-19 12:53:27 -07:00
Joaquim VergesandGitHub 8b036dcfa7 [SDK] feat: Add Saga Mainnet to gas-free chains (#6498) 2025-03-19 12:52:35 -07:00
Joaquim VergesandGitHub 56bc4ae335 [Playground] feat: Add PayEmbed component playground (#6493) 2025-03-18 18:43:05 -07:00
0xFirekeeper 71f83a884b .NET Docs - Synchronous EngineWallet.Create (#6469)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on modifying the instantiation of the `engineWallet` variable by removing the `await` keyword from the `EngineWallet.Create` method call, indicating a change in how the method is executed.

### Detailed summary
- Removed `await` from the `EngineWallet.Create` method call for `engineWallet`.
- The method now executes synchronously instead of asynchronously.

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

<!-- end pr-codex -->
2025-03-18 19:29:08 +00:00
saminacodes 2d5ff74335 [Portal] added agglayer and erc-20 guide (#6492)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the documentation for deploying ERC-20 and Agglayer module contracts, providing step-by-step guides and visual aids for users.

### Detailed summary
- Added a new section for "Tutorials" in the `sidebar` with a link to "Deploy ERC-20 Core."
- Introduced a tutorial on deploying an ERC-20 Core contract, including steps and images.
- Created a guide for adding the Agglayer module to modular contracts, detailing prerequisites and steps.
- Included images for each step in both tutorials for better understanding.

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

<!-- end pr-codex -->
2025-03-18 05:17:21 +00:00
Joaquim VergesandGitHub cbc3bf1d36 [Dashboard] feat: Add allowed operations to partner access control (#6478) 2025-03-17 18:23:52 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
20ead112e2 Version Packages (#6460)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-17 16:59:32 -07:00
saminacodes 06d25bb7a9 [Portal] redrafted insight use case page (#6489)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the documentation for various blockchain use cases, enhancing clarity and structure. It introduces a new section summarizing insights and specific blueprints for tracking on-chain activities.

### Detailed summary
- Removed detailed explanations for various use cases in sections for `Gaming`, `DeFi`, `NFTs`, and `Wallets`.
- Added a new section titled `Insight Use Cases`.
- Provided a concise table summarizing use cases and corresponding blueprints for tracking blockchain data.

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

<!-- end pr-codex -->
2025-03-17 23:34:29 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
91f8b2dbf2 Version Packages (#6460)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-17 16:34:12 -07:00
Joaquim VergesandGitHub 9901351fb2 [SDK] fix: Handle serialized bigints for packing userops (#6490) 2025-03-17 16:24:41 -07:00
cb86fa2929 [Portal] Update UB Docs (#6482)
Signed-off-by: samina <[email protected]>
Co-authored-by: gregfromstl <[email protected]>
2025-03-17 12:05:46 -07:00
EimanGitHubJoaquim VergesJonas Danielsgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
53718208cd updated description of using session keys with Engine (#6474)
Signed-off-by: Eiman <[email protected]>
Signed-off-by: Joaquim Verges <[email protected]>
Co-authored-by: Joaquim Verges <[email protected]>
Co-authored-by: Jonas Daniels <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-03-15 15:25:14 -07:00
Jonas DanielsandGitHub ef2613aca5 Pin GitHub Actions to specific commit hashes (#6479) 2025-03-15 15:06:34 -07:00
saminacodes f2e61b83cb [Portal] updated billing docs (#6477)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the billing information section in the documentation, specifically enhancing the instructions for viewing past invoices and modifying the related content.

### Detailed summary
- Updated the navigation steps for viewing past invoices from **Settings > Billing > Manage Billing** to **Settings > Invoices**.
- Changed the instructions for viewing invoices to emphasize breakdown by plan and usage.
- Removed an unused `invoices-settings.png` import.

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

<!-- end pr-codex -->
2025-03-15 00:21:42 +00:00
Jonas DanielsandGitHub ab0d33016d add Stripe integration for team invoices (#6475) 2025-03-14 16:09:35 -07:00
Joe-ThirdwebandGitHub f54a0cb39b [Dashboard] Feature: Adding custom chain info to Hemi Sepolia (#6154)
Signed-off-by: Joe-Thirdweb <[email protected]>
2025-03-14 13:27:16 -07:00
Joaquim VergesandGitHub 90010ee723 [Dashboard] feat: Add access control and server verifier to partner configuration (#6473) 2025-03-13 23:20:00 -07:00
MananTank 269fbe5ecf [TOOL-3691] Dashbaord: Keep project settings submit button always enabled (#6467)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on simplifying the logic for determining if form fields are "dirty" before enabling the save button in the `ProjectGeneralSettingsPage`. It removes checks for dirty states, allowing the save button to be enabled regardless of whether the fields have been modified.

### Detailed summary
- Removed `isNameDirty` check for the `name` field in `ProjectNameSetting`.
- Removed `isDomainsDirty` check for the `domains` field in `AllowedDomainsSetting`.
- Removed `isBundleIdsDirty` check for the `bundleIds` field in `AllowedBundleIDsSetting`.
- Updated save button to always have `disabled: false` for all settings.
- Kept `isPending` functionality unchanged based on `props.isUpdatingProject`.

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

<!-- end pr-codex -->
2025-03-13 22:48:40 +00:00
Jonas DanielsandGitHub 7ed8fc2c43 update dependencies (#6471) 2025-03-13 15:35:06 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9f2f2e794a chore(deps-dev): bump eslint-plugin-storybook from 0.11.3 to 0.11.4 (#6454)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 09:29:21 -07:00
Reza RahemtolaGitHubgraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
eefd2649fd [Docs] Fix: Typo in react/getting-started guide (#6470)
Signed-off-by: Reza Rahemtola <[email protected]>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-03-13 09:24:38 -07:00
MananTank 62de6dd04c [TOOL-3684] Dashboard: Add FTUX in project overview page (#6466)
<!--

## 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 several enhancements and fixes across various components in the dashboard application, including new features, property adjustments, and structural changes for improved functionality and usability.

### Detailed summary
- Added `showRangeSelector` property to `AnalyticsHeader`.
- Updated `RotateSecretKeyButton` to be exported.
- Removed `isEnabled` property and replaced it with `isDisabled` across multiple components.
- Introduced `IntegrateAPIKeyCodeTabs` and related sections in `ProjectFTUX`.
- Enhanced project integration sections with new UI components and improved code examples.
- Refactored `NotificationButtonInner` and other components to remove `isEnabled` checks.

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

<!-- end pr-codex -->
2025-03-13 16:10:03 +00:00
MananTank 6fc6c6e681 Portal: Fix knip lint error - Delete AAChainList (#6468) 2025-03-12 19:26:29 +00:00
Joe-ThirdwebandGitHub 58cd79426d [Portal] Fix: Updating AA supported chains (#6459)
Signed-off-by: Joe-Thirdweb <[email protected]>
2025-03-12 00:09:02 -07:00
kumaryash90 185e8f48df Fix cross chain tab event queries (#6465)
<!--

## 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 modifying the `blockRange` parameter in the `page.tsx` file to adjust the maximum block number limit for a specific condition.

### Detailed summary
- Updated `blockRange` from `latestBlockNumber < 2000000n` to `latestBlockNumber < 100000n`, changing the maximum limit from `2000000n` to `100000n`.

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

<!-- end pr-codex -->
2025-03-12 05:02:08 +00:00
jnsdls d8865b74ef add copy invite link feature to team invites (#6463) 2025-03-12 01:58:07 +00:00
MananTank 1e8d1e4b52 [NEB-113] Save context chains in local storage (#6452)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on enhancing the `ChatPageContent` component by implementing functionality to save and retrieve the last used chain IDs from local storage, ensuring that the user's selection persists across sessions.

### Detailed summary
- Added `saveLastUsedChainIds` function to store chain IDs in local storage.
- Implemented `getLastUsedChainIds` function to retrieve chain IDs from local storage.
- Updated `updatedContextFilters` to use last used chain IDs if available.
- Ensured wallet address is set only from a connected wallet.

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

<!-- end pr-codex -->
2025-03-11 17:36:20 +00:00
MananTank 91194ce63a [TOOL-3681] Portal: Fix code identation in reference pages (#6461)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on improving code readability and fixing a typo in the console log message, as well as adjusting the CSS class for better text formatting in a component.

### Detailed summary
- In `packages/thirdweb/src/react/core/utils/wallet.ts`, the console log message was changed from `"Walelt name"` to `"wallet name"` for clarity.
- In `apps/portal/src/components/Document/Code.tsx`, the CSS class was modified from `rounded-lg border bg-card font-mono text-sm leading-relaxed` to `whitespace-pre rounded-lg border bg-card font-mono text-sm leading-relaxed` to enhance text formatting.

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

<!-- end pr-codex -->
2025-03-11 17:27:01 +00:00
MananTank a1141f316f [TOOL-3673] Dashboard: change Turnstile widget appearance on missing turnstile token (#6453) 2025-03-11 12:53:44 +00:00
kumaryash90 1273cc7b83 Compute addresses with ref contracts (#5699)
TOOL-2796 (XPROT-934)

## Problem solved

This was an issue when installing modules published with ref contracts. Module installation goes through infra deployment functions which use computed addresses.

SDK should process ref contracts when computing published contract addresses.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the handling of published contract references in the `thirdweb` library, particularly in the deployment process. It introduces a new function to compute addresses for these references and updates existing functions and tests accordingly.

### Detailed summary
- Added `processRefDeployments` function to process contract references in constructor params.
- Introduced `computeRefDeployments` function for computing addresses of published contract references.
- Updated the handling of `salt` values to default to an empty string instead of `"thirdweb"`.
- Modified `computeDeploymentInfoFromMetadata` to use processed constructor parameters.
- Adjusted tests to reflect changes in contract deployment parameters and added new tests for address computation of deployed contracts with references.

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

<!-- end pr-codex -->
2025-03-11 00:51:50 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
691110286d Version Packages (#6448)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-10 00:27:12 -07:00
Joaquim VergesandGitHub 4d197a822d [SDK] test: Add factory address to smart wallet test (#6450) 2025-03-10 00:20:16 -07:00
Joaquim VergesandGitHub f655657fce [SDK] fix: EIP1193 provider value filtering in transactions (#6449) 2025-03-10 00:01:00 -07:00
Joaquim VergesandGitHub 109f250068 [SDK] fix: Handle authorizationList tx prop in ethers5 adapter (#6447) 2025-03-09 23:06:21 -07:00
saminaandGitHub f7cf09b3cb [Portal] Account Docs (#6442) 2025-03-09 22:39:20 -07:00
gregfromstl dd06be8def [Dashboard] Feature: update dashboard routes page to handle more routes (#6446)
<!--

## 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 renaming and updating the `RoutesPage` functionality, enhancing token properties, and adjusting UI elements in the routing components for better clarity and usability.

### Detailed summary
- Renamed `ChainListPage` to `RoutesPage`.
- Updated token properties to include `name` and `symbol` for both origin and destination tokens.
- Increased `filters.limit` from `10,000` to `50,000`.
- Modified UI classes for improved styling and consistency.

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

<!-- end pr-codex -->
2025-03-10 04:57:15 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
bf8da3588f Version Packages (#6445)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-03-09 21:45:12 -07:00
gregandGitHub f6cbdb7e30 BREAKING: Removes 3D model support on MediaRenderer (#6443) 2025-03-09 20:46:12 -07:00
320 changed files with 17755 additions and 9621 deletions
+4 -4
View File
@@ -6,15 +6,15 @@ runs:
steps:
# we use bun for some test suites
- name: Setup bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: 1.0.35
# pnpm for our dependencies
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
version: 9
version: 9.11.0
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
check-latest: true
+10 -10
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Optimize CI
id: check_skip
uses: withgraphite/graphite-ci-action@main
uses: withgraphite/graphite-ci-action@9cb601a55e114099561b6d755505de377d45db40 # v0.0.9 ("main")
with:
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
@@ -38,7 +38,7 @@ jobs:
name: Build Packages
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
@@ -54,13 +54,13 @@ jobs:
runs-on: ubuntu-latest-8
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
- name: Setup Biome
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@c016c38f26f2c4a6eb3662679143614a254263fd # v2.3.0
with:
version: latest
@@ -74,13 +74,13 @@ jobs:
runs-on: ubuntu-latest-8
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
- name: Set up foundry
uses: foundry-rs/foundry-toolchain@v1
uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # v1.3.1
with:
cache: false
version: nightly-c4a984fbf2c48b793c8cd53af84f56009dd1070c
@@ -88,7 +88,7 @@ jobs:
- run: pnpm test
- name: Code Coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
with:
directory: packages/
flags: packages
@@ -106,7 +106,7 @@ jobs:
bundler: [vite, webpack, esbuild]
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
@@ -172,13 +172,13 @@ jobs:
runs-on: ubuntu-latest-8
steps:
- name: Check out the code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup & Install
uses: ./.github/composite-actions/install
- name: Report bundle size
uses: andresz1/size-limit-action@master
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
+1 -1
View File
@@ -16,4 +16,4 @@ jobs:
github.event.pull_request.author_association == 'COLLABORATOR' ||
github.event.pull_request.author_association == 'CONTRIBUTOR'
steps:
- uses: toshimaru/[email protected]
- uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
+4 -4
View File
@@ -42,11 +42,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,4 +72,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+1 -1
View File
@@ -17,6 +17,6 @@ jobs:
pull-requests: write
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout branch
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
# Do not use the GITHUB_TOKEN by default
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout branch
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
# Do not use the GITHUB_TOKEN by default
@@ -36,7 +36,7 @@ jobs:
- name: Create release Pull Request or publish to NPM
id: changesets
uses: changesets/action@v1
uses: changesets/action@06245a4e0a36c064a573d4150030f5ec548e4fcc #v1.4.0
with:
publish: pnpm release
version: pnpm version-packages
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v.9.1.0
with:
stale-issue-message: 'This issue has been inactive for 7 days. It is now marked as stale and will be closed in 2 days if no further activity occurs.'
stale-pr-message: 'This PR has been inactive for 7 days. It is now marked as stale and will be closed in 2 days if no further activity occurs.'
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install
uses: ./.github/composite-actions/install
@@ -30,7 +30,7 @@ jobs:
run: pnpm typedoc
- name: Update Gist
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GIST_TOKEN }}
script: |
+3
View File
@@ -102,3 +102,6 @@ ANALYTICS_SERVICE_URL=""
# Required for Nebula Chat
NEXT_PUBLIC_NEBULA_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
+3
View File
@@ -27,5 +27,8 @@ const config: StorybookConfig = {
},
},
staticDirs: ["../public"],
features: {
experimentalRSC: true,
},
};
export default config;
+28 -27
View File
@@ -45,9 +45,9 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@sentry/nextjs": "9.3.0",
"@sentry/nextjs": "9.5.0",
"@shazow/whatsabi": "0.20.0",
"@tanstack/react-query": "5.67.1",
"@tanstack/react-query": "5.67.3",
"@tanstack/react-table": "^8.21.2",
"@thirdweb-dev/service-utils": "workspace:*",
"@vercel/functions": "2.0.0",
@@ -61,18 +61,18 @@
"date-fns": "4.1.0",
"fetch-event-stream": "0.1.5",
"flat": "^6.0.1",
"framer-motion": "12.4.10",
"framer-motion": "12.5.0",
"fuse.js": "7.1.0",
"idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.5.0",
"ioredis": "^5.6.0",
"ipaddr.js": "^2.2.0",
"lucide-react": "0.477.0",
"next": "15.2.0",
"lucide-react": "0.483.0",
"next": "15.2.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.4",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"nuqs": "^2.4.0",
"nuqs": "^2.4.1",
"p-limit": "^6.2.0",
"papaparse": "^5.5.2",
"pluralize": "^8.0.0",
@@ -94,7 +94,8 @@
"shiki": "1.27.0",
"sonner": "2.0.1",
"spdx-correct": "^3.2.0",
"swagger-ui-react": "^5.20.0",
"stripe": "17.7.0",
"swagger-ui-react": "^5.20.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
@@ -104,21 +105,21 @@
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@chromatic-com/storybook": "3.2.5",
"@next/bundle-analyzer": "15.2.0",
"@next/eslint-plugin-next": "15.2.0",
"@playwright/test": "1.50.1",
"@storybook/addon-essentials": "8.6.3",
"@storybook/addon-interactions": "8.6.3",
"@storybook/addon-links": "8.6.3",
"@storybook/addon-onboarding": "8.6.3",
"@storybook/addon-viewport": "8.6.3",
"@storybook/blocks": "8.6.3",
"@storybook/nextjs": "8.6.3",
"@storybook/react": "8.6.3",
"@storybook/test": "8.6.3",
"@chromatic-com/storybook": "3.2.6",
"@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",
"@storybook/addon-links": "8.6.4",
"@storybook/addon-onboarding": "8.6.4",
"@storybook/addon-viewport": "8.6.4",
"@storybook/blocks": "8.6.4",
"@storybook/nextjs": "8.6.4",
"@storybook/react": "8.6.4",
"@storybook/test": "8.6.4",
"@types/color": "4.2.0",
"@types/node": "22.13.9",
"@types/node": "22.13.10",
"@types/papaparse": "^5.3.15",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.5",
@@ -129,16 +130,16 @@
"@types/swagger-ui-react": "^4.19.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"autoprefixer": "^10.4.19",
"autoprefixer": "^10.4.21",
"checkly": "5.0.1",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"eslint-plugin-storybook": "0.11.3",
"knip": "5.45.0",
"eslint-plugin-storybook": "0.11.6",
"knip": "5.46.0",
"next-sitemap": "^4.2.3",
"postcss": "8.5.3",
"storybook": "8.6.3",
"storybook": "8.6.4",
"tailwindcss": "3.4.17",
"typescript": "5.8.2"
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use server";
import "server-only";
import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
@@ -0,0 +1,60 @@
import "server-only";
import Stripe from "stripe";
import type { Team } from "../api/team";
let existingStripe: Stripe | undefined;
function getStripe() {
if (!existingStripe) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (!STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
existingStripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2025-02-24.acacia",
});
}
return existingStripe;
}
export async function getTeamInvoices(
team: Team,
options?: { cursor?: string },
) {
try {
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Get the list of invoices for the customer
const invoices = await getStripe().invoices.list({
customer: customerId,
limit: 10,
starting_after: options?.cursor,
});
return invoices;
} catch (error) {
console.error("Error fetching billing history:", error);
// If the error is that the customer doesn't exist, return an empty array
// instead of throwing an error
if (
error instanceof Stripe.errors.StripeError &&
error.message.includes("No such customer")
) {
return {
data: [],
has_more: false,
};
}
throw new Error("Failed to fetch billing history");
}
}
+53
View File
@@ -0,0 +1,53 @@
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "../constants/env";
export type SMSCountryTiers = {
tier1: string[];
tier2: string[];
tier3: string[];
tier4: string[];
tier5: string[];
};
export async function getSMSCountryTiers() {
if (!THIRDWEB_API_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(`${API_SERVER_URL}/v1/sms/list-country-tiers`, {
headers: {
"Content-Type": "application/json",
"x-service-api-key": THIRDWEB_API_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
});
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
};
}
}
+2 -1
View File
@@ -3,7 +3,8 @@ import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
import type { TeamResponse } from "@thirdweb-dev/service-utils";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
export type Team = TeamResponse;
export type Team = TeamResponse & { stripeCustomerId: string | null };
export async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
@@ -96,7 +96,6 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
onClick: () => setEnvironment(env.environment),
isActive: activeEnvironment === env.environment,
name: env.title,
isEnabled: true,
}))}
tabClassName="text-sm gap-2 !text-sm"
tabIconClassName="size-4"
@@ -91,13 +91,11 @@ export function DatePickerWithRange(props: {
name: "From",
onClick: () => setScreen("from"),
isActive: screen === "from",
isEnabled: true,
},
{
name: "To",
onClick: () => setScreen("to"),
isActive: screen === "to",
isEnabled: true,
},
]}
/>
@@ -1,5 +1,4 @@
import * as parserBabel from "prettier/plugins/babel";
import * as estree from "prettier/plugins/estree";
import { format } from "prettier/standalone";
import { type BundledLanguage, codeToHtml } from "shiki";
@@ -21,10 +20,11 @@ export async function getCodeHtml(
ignoreFormattingErrors?: boolean;
},
) {
const estreePlugin = await import("prettier/plugins/estree");
const formattedCode = isPrettierSupportedLang(lang)
? await format(code, {
parser: "babel-ts",
plugins: [parserBabel, estree],
plugins: [parserBabel, estreePlugin.default],
printWidth: 60,
}).catch((e) => {
if (!options?.ignoreFormattingErrors) {
+3 -3
View File
@@ -79,7 +79,7 @@ export function TabButtons(props: {
name: React.ReactNode;
onClick: () => void;
isActive: boolean;
isEnabled?: boolean;
isDisabled?: boolean;
icon?: React.FC<{ className?: string }>;
}[];
tabClassName?: string;
@@ -119,11 +119,11 @@ export function TabButtons(props: {
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2 font-medium text-sm hover:bg-accent lg:px-3 lg:text-base",
!tab.isActive &&
"text-muted-foreground hover:text-foreground",
!tab.isEnabled && "cursor-not-allowed opacity-50",
tab.isDisabled && "cursor-not-allowed opacity-50",
props.tabClassName,
tab.isActive && props.activeTabClassName,
)}
onClick={tab.isEnabled ? tab.onClick : undefined}
onClick={!tab.isDisabled ? tab.onClick : undefined}
>
{tab.icon && (
<tab.icon className={cn("size-6", props.tabIconClassName)} />
@@ -1,59 +1,42 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { NATIVE_TOKEN_ADDRESS, defineChain, getContract } from "thirdweb";
import { defineChain } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { name } from "thirdweb/extensions/common";
type RouteListCardProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri: string | null;
originTokenIconUri?: string | null;
originTokenSymbol: string;
originTokenName: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri: string | null;
destinationTokenIconUri?: string | null;
destinationTokenSymbol: string;
destinationTokenName: string;
};
export async function RouteListCard({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenName,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenName,
}: RouteListCardProps) {
const [
originChain,
originTokenName,
destinationChain,
destinationTokenName,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: name({
contract: getContract({
address: originTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(originChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: name({
contract: getContract({
address: destinationTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(destinationChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
originTokenIconUri
? resolveSchemeWithErrorHandler({
uri: originTokenIconUri,
@@ -78,17 +61,17 @@ export async function RouteListCard({
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
className="size-8 rounded-full bg-white"
className="size-8 rounded-full border border-muted-foreground"
/>
) : (
<div className="size-8 rounded-full bg-white/10" />
<div className="size-8 rounded-full bg-muted-foreground" />
)}
{resolvedDestinationTokenIconUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
className="-translate-x-4 size-8 rounded-full bg-white ring-2 ring-card"
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
/>
) : (
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
@@ -2,59 +2,41 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { TableCell, TableRow } from "@/components/ui/table";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { NATIVE_TOKEN_ADDRESS, getContract } from "thirdweb";
import { defineChain, getChainMetadata } from "thirdweb/chains";
import { symbol } from "thirdweb/extensions/common";
type RouteListRowProps = {
originChainId: number;
originTokenAddress: string;
originTokenIconUri: string | null;
originTokenIconUri?: string | null;
originTokenSymbol?: string;
originTokenName?: string;
destinationChainId: number;
destinationTokenAddress: string;
destinationTokenIconUri: string | null;
destinationTokenIconUri?: string | null;
destinationTokenSymbol?: string;
destinationTokenName?: string;
};
export async function RouteListRow({
originChainId,
originTokenAddress,
originTokenIconUri,
originTokenSymbol,
destinationChainId,
destinationTokenAddress,
destinationTokenIconUri,
destinationTokenSymbol,
}: RouteListRowProps) {
const [
originChain,
originTokenSymbol,
destinationChain,
destinationTokenSymbol,
resolvedOriginTokenIconUri,
resolvedDestinationTokenIconUri,
] = await Promise.all([
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(originChainId)),
originTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: symbol({
contract: getContract({
address: originTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(originChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(destinationChainId)),
destinationTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS
? "ETH"
: symbol({
contract: getContract({
address: destinationTokenAddress,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(destinationChainId),
client: getThirdwebClient(),
}),
}).catch(() => undefined),
originTokenIconUri
? resolveSchemeWithErrorHandler({
uri: originTokenIconUri,
@@ -80,7 +62,7 @@ export async function RouteListRow({
<img
src={resolvedOriginTokenIconUri}
alt={originTokenAddress}
className="size-6 rounded-full bg-muted-foreground"
className="size-6 rounded-full border border-muted-foreground"
/>
) : (
<div className="size-6 rounded-full bg-muted-foreground" />
@@ -115,7 +97,7 @@ export async function RouteListRow({
<img
src={resolvedDestinationTokenIconUri}
alt={destinationTokenAddress}
className="size-6 rounded-full bg-muted-foreground"
className="size-6 rounded-full border border-muted-foreground"
/>
) : (
<div className="size-6 rounded-full bg-muted-foreground" />
@@ -50,7 +50,7 @@ async function getRoutesToRender(params: SearchParams) {
}
}
// Temporary, will update this after the /routes endpoint
filters.limit = 10_000;
filters.limit = 50_000;
const routes = await getRoutes(filters);
@@ -107,9 +107,13 @@ export async function RoutesData(props: {
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenSymbol={route.originToken.symbol}
originTokenName={route.originToken.name}
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenSymbol={route.destinationToken.symbol}
destinationTokenName={route.destinationToken.name}
/>
))}
</TableBody>
@@ -126,9 +130,13 @@ export async function RoutesData(props: {
originChainId={route.originToken.chainId}
originTokenAddress={route.originToken.address}
originTokenIconUri={route.originToken.iconUri}
originTokenSymbol={route.originToken.symbol}
originTokenName={route.originToken.name}
destinationChainId={route.destinationToken.chainId}
destinationTokenAddress={route.destinationToken.address}
destinationTokenIconUri={route.destinationToken.iconUri}
destinationTokenSymbol={route.destinationToken.symbol}
destinationTokenName={route.destinationToken.name}
/>
</li>
))}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -22,7 +22,7 @@ export const metadata: Metadata = {
},
};
export default async function ChainListPage(props: {
export default async function RoutesPage(props: {
searchParams: Promise<SearchParams>;
}) {
const authToken = await getAuthToken();
@@ -4,11 +4,15 @@ export type Route = {
originToken: {
address: Address;
chainId: number;
iconUri: string;
iconUri?: string;
name: string;
symbol: string;
};
destinationToken: {
address: Address;
chainId: number;
iconUri: string;
iconUri?: string;
name: string;
symbol: string;
};
};
@@ -71,7 +71,6 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
name: mode,
isActive: mode === listingMode,
onClick: () => setListingMode(mode),
isEnabled: true,
}))}
tabClassName="text-sm gap-2 !text-sm"
tabContainerClassName="gap-0.5"
@@ -120,7 +120,7 @@ export default async function Page(props: {
const events = await getContractEvents({
contract: twCloneFactoryContract,
events: [ProxyDeployedEvent],
blockRange: latestBlockNumber < 2000000n ? latestBlockNumber : 2000000n,
blockRange: latestBlockNumber < 100000n ? latestBlockNumber : 100000n,
});
const event = events.find(
(e) =>
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

@@ -10,6 +10,8 @@ import ancient8Banner from "./temp-assets/ancient8-banner.png";
import assetChainBanner from "./temp-assets/assetChainBanner.png";
import assetChainCTABG from "./temp-assets/assetChainCTABanner.png";
import hemiSepoliaBanner from "./temp-assets/HemiBanner.png";
import hemiSepoliaCTA from "./temp-assets/HemiCTA.png";
import appChainBanner from "./temp-assets/appChainBanner.jpg";
// TEMPORARY
import appchainCTA from "./temp-assets/appchainCTA.png";
@@ -452,6 +454,18 @@ const chainMetaRecord = {
buttonText: "Learn more",
},
},
//HemiSepolia
743111: {
headerImgUrl: hemiSepoliaBanner.src,
about:
"The Hemi Network is a modular Layer-2 protocol for superior scaling, security, and interoperability, powered by Bitcoin and Ethereum.",
cta: {
backgroundImageUrl: hemiSepoliaCTA.src,
title: "One Network, Powered by Bitcoin and Ethereum",
buttonLink: "https://hemi.xyz/",
buttonText: "Learn More",
},
},
//Hashfire
4227: {
headerImgUrl: hashfireBanner.src,
@@ -100,7 +100,6 @@ const DeployOptions = (props: {
key,
name: value.title,
isActive: activeTab === key,
isEnabled: true,
onClick: () => setActiveTab(key as TabId),
}))}
tabClassName="font-medium"
@@ -22,6 +22,11 @@ export async function getRawAccount() {
},
});
if (!res.ok) {
console.error("Error fetching account", res.status, res.statusText);
return undefined;
}
const json = await res.json();
if (json.error) {
+11 -2
View File
@@ -19,6 +19,7 @@ import {
} from "thirdweb/react";
import { createWallet, inAppWallet } from "thirdweb/wallets";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { isVercel } from "../../lib/vercel-utils";
import { ThirdwebMiniLogo } from "../components/ThirdwebMiniLogo";
import { getSDKTheme } from "../components/sdk-component-theme";
import { doLogin, doLogout, getLoginPayload, isLoggedIn } from "./auth-actions";
@@ -246,14 +247,17 @@ function CustomConnectEmbed(props: {
}) {
const { theme } = useTheme();
const client = useThirdwebClient();
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileToken, setTurnstileToken] = useState<string | undefined>(
undefined,
);
const [alwaysShowTurnstile, setAlwaysShowTurnstile] = useState(false);
return (
<div className="flex flex-col items-center gap-4">
<Turnstile
options={{
// only show if interaction is required
appearance: "interaction-only",
appearance: alwaysShowTurnstile ? "always" : "interaction-only",
// match the theme of the rest of the app
theme: theme === "light" ? "light" : "dark",
}}
@@ -265,6 +269,11 @@ function CustomConnectEmbed(props: {
auth={{
getLoginPayload,
doLogin: async (params) => {
if (isVercel() && !turnstileToken) {
setAlwaysShowTurnstile(true);
throw new Error("Please complete the captcha.");
}
try {
const result = await doLogin(params, turnstileToken);
if (result.error) {
+1 -1
View File
@@ -40,7 +40,7 @@ export async function getLoginPayload(
export async function doLogin(
payload: VerifyLoginPayloadParams,
turnstileToken: string,
turnstileToken: string | undefined,
) {
if (!THIRDWEB_API_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
@@ -89,13 +89,11 @@ export function LoginOrSignup(props: {
name: "Create account",
onClick: () => setTab("signup"),
isActive: tab === "signup",
isEnabled: true,
},
{
name: "I already have an account",
onClick: () => setTab("login"),
isActive: tab === "login",
isEnabled: true,
},
]}
/>
@@ -227,13 +227,11 @@ function InviteModalContent(props: {
name: "Starter",
onClick: () => setPlanToShow("starter"),
isActive: planToShow === "starter",
isEnabled: true,
},
{
name: "Growth",
onClick: () => setPlanToShow("growth"),
isActive: planToShow === "growth",
isEnabled: true,
},
]}
/>
@@ -1,13 +1,13 @@
"use client";
import { getBillingCheckoutUrl } from "@/actions/billing";
import { apiServerProxy } from "@/actions/proxies";
import { sendTeamInvites } from "@/actions/sendTeamInvite";
import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import { apiServerProxy } from "../../../../@/actions/proxies";
import { useTrack } from "../../../../hooks/analytics/useTrack";
import { updateTeam } from "../../../team/[team_slug]/(team)/~/settings/general/updateTeam";
import { InviteTeamMembersUI } from "./InviteTeamMembers";
@@ -96,6 +96,7 @@ export function ChatPageContent(props: {
const setContextFilters = useCallback((v: NebulaContext | undefined) => {
_setContextFilters(v);
setHasUserUpdatedContextFilters(true);
saveLastUsedChainIds(v?.chainIds || undefined);
}, []);
const isNewSession = !props.session;
@@ -118,7 +119,21 @@ export function ChatPageContent(props: {
walletAddress: null,
};
// Only set wallet address from connected wallet
updatedContextFilters.walletAddress = address || null;
// if we have last used chains in storage, continue using them
try {
const lastUsedChainIds = getLastUsedChainIds();
if (lastUsedChainIds) {
updatedContextFilters.chainIds = lastUsedChainIds;
return updatedContextFilters;
}
} catch {
// ignore local storage errors
}
// else - use the active chain
updatedContextFilters.chainIds = activeChain
? [activeChain.id.toString()]
: [];
@@ -493,3 +508,31 @@ function WalletDisconnectedDialog(props: {
</Dialog>
);
}
const NEBULA_LAST_USED_CHAIN_IDS_KEY = "nebula-last-used-chain-ids";
function saveLastUsedChainIds(chainIds: string[] | undefined) {
try {
if (chainIds && chainIds.length > 0) {
localStorage.setItem(
NEBULA_LAST_USED_CHAIN_IDS_KEY,
JSON.stringify(chainIds),
);
} else {
localStorage.removeItem(NEBULA_LAST_USED_CHAIN_IDS_KEY);
}
} catch {
// ignore local storage errors
}
}
function getLastUsedChainIds(): string[] | null {
try {
const lastUsedChainIdsStr = localStorage.getItem(
NEBULA_LAST_USED_CHAIN_IDS_KEY,
);
return lastUsedChainIdsStr ? JSON.parse(lastUsedChainIdsStr) : null;
} catch {
return null;
}
}
@@ -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.
@@ -1,10 +1,11 @@
"use client";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
import { BillingPortalButton } from "@/components/billing";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
@@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
@@ -44,22 +44,21 @@ function BillingAlertBanner(props: {
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
<p className="mt-1 mb-4 text-sm">{props.description}</p>
<BillingPortalButton
buttonProps={{
size: "sm",
className: cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
),
}}
teamSlug={props.teamSlug}
getBillingPortalUrl={props.getBillingPortalUrl}
<Button
asChild
className={cn(
"gap-2",
props.variant === "warning" &&
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
props.variant === "error" &&
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
)}
>
{props.ctaLabel}
</BillingPortalButton>
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
{props.ctaLabel}
</Link>
</Button>
</div>
);
}
@@ -73,7 +72,6 @@ export function PastDueBannerUI(props: {
ctaLabel="View Invoices"
variant="warning"
title="Unpaid Invoices"
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
You have unpaid invoices. Service may be suspended if not paid
@@ -94,7 +92,6 @@ export function ServiceCutOffBannerUI(props: {
ctaLabel="Pay Now"
variant="error"
title="Service Suspended"
getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
Your service has been suspended due to unpaid invoices. Pay now to
@@ -63,7 +63,12 @@ export default async function TeamOverviewPage(props: {
return (
<div className="flex grow flex-col">
<div className="border-b">
<AnalyticsHeader title="Analytics" interval={interval} range={range} />
<AnalyticsHeader
title="Analytics"
interval={interval}
range={range}
showRangeSelector={true}
/>
</div>
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16">
<Suspense fallback={<GenericLoadingPage />}>
@@ -0,0 +1,50 @@
import {} from "@/components/ui/breadcrumb";
import { getAuthToken } from "../../../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";
import { AddPartnerForm } from "../components/client/add-partner-form.client";
import { fetchEcosystem } from "../hooks/fetchEcosystem";
export default async function AddPartnerPage({
params,
}: {
params: Promise<{ slug: string; team_slug: string }>;
}) {
const { slug, team_slug } = await params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}`);
}
const teamSlug = team_slug;
const ecosystemSlug = slug;
try {
const ecosystem = await fetchEcosystem({
teamIdOrSlug: teamSlug,
slug: ecosystemSlug,
authToken,
});
return (
<div className="flex flex-col">
<div className="container">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Add New Partner
</h1>
<AddPartnerForm ecosystem={ecosystem} authToken={authToken} />
</div>
</div>
);
} catch (error) {
console.error("Error fetching ecosystem:", error);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">Error</h1>
<p>Could not load ecosystem. Please try again.</p>
</div>
</div>
);
}
}
@@ -1,43 +1,23 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import type { Ecosystem } from "../../../../../types";
import { AddPartnerForm } from "./add-partner-form.client";
export function AddPartnerDialogButton(props: {
teamSlug: string;
ecosystem: Ecosystem;
authToken: string;
}) {
const [open, setOpen] = useState(false);
const addPartnerUrl = `/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/add-partner`;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle className="font-semibold text-2xl tracking-tight">
Add Partner
</DialogTitle>
</DialogHeader>
<AddPartnerForm
ecosystem={props.ecosystem}
onPartnerAdded={() => setOpen(false)}
authToken={props.authToken}
/>
</DialogContent>
</Dialog>
<Link href={addPartnerUrl} passHref>
<Button className="gap-2 max-sm:w-full" variant="outline" size="sm">
<PlusIcon className="size-4" />
Add Partner
</Button>
</Link>
);
}
@@ -21,7 +21,11 @@ export function EcosystemPermissionsPage({
/>
<AuthOptionsSection ecosystem={ecosystem} authToken={authToken} />
{ecosystem?.permission === "PARTNER_WHITELIST" && (
<EcosystemPartnersSection ecosystem={ecosystem} authToken={authToken} />
<EcosystemPartnersSection
teamSlug={params.team_slug}
ecosystem={ecosystem}
authToken={authToken}
/>
)}
</div>
);
@@ -1,32 +1,22 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
"use client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { z } from "zod";
import type { Ecosystem } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import type { Ecosystem, Partner } from "../../../../../types";
import { useAddPartner } from "../../hooks/use-add-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
export function AddPartnerForm({
ecosystem,
onPartnerAdded,
authToken,
}: { authToken: string; ecosystem: Ecosystem; onPartnerAdded: () => void }) {
const form = useForm<z.input<typeof partnerFormSchema>>({
resolver: zodResolver(partnerFormSchema),
});
}: {
ecosystem: Ecosystem;
authToken: string;
}) {
const router = useDashboardRouter();
const params = useParams();
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const { mutateAsync: addPartner, isPending } = useAddPartner(
{
@@ -34,7 +24,13 @@ export function AddPartnerForm({
},
{
onSuccess: () => {
onPartnerAdded();
toast.success("Partner added successfully", {
description: "The partner has been added to your ecosystem.",
});
// Redirect to the redirect page that will take us back to the configuration page
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
router.push(redirectPath);
},
onError: (error) => {
const message =
@@ -46,95 +42,28 @@ export function AddPartnerForm({
},
);
const handleSubmit = (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
addPartner({
ecosystem,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
accessControl: finalAccessControl,
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
addPartner({
ecosystem,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
});
})}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> App Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "lg:-translate-y-4 hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription>
Space or comma-separated list of regex domains (e.g.
*.example.com)
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem>
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription>
Space or comma-separated list of bundle IDs
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button disabled={isPending} type="submit" className="w-full gap-2">
{isPending && <Spinner className="size-4" />}
Add
</Button>
</form>
</Form>
<PartnerForm
onSubmit={handleSubmit}
isSubmitting={isPending}
submitLabel="Add"
/>
);
}
@@ -0,0 +1,677 @@
"use client";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { type Control, useFieldArray } from "react-hook-form";
import { SingleNetworkSelector } from "../../../../../../../../../../../@/components/blocks/NetworkSelectors";
import type { PartnerFormValues } from "./partner-form.client";
type AllowedOperationsSectionProps = {
control: Control<PartnerFormValues>;
enabled: boolean;
onToggle: (checked: boolean) => void;
};
export function AllowedOperationsSection({
control,
enabled,
onToggle,
}: AllowedOperationsSectionProps) {
// Setup field array for allowed operations
const allowedOperationsFields = useFieldArray({
control,
name: "accessControl.allowedOperations",
});
return (
<div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-center justify-between gap-6 ">
<div>
<Label htmlFor="allowed-operations-switch" className="text-base">
Allowed Operations
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Configure which signing operations are allowed for this partner
</p>
</div>
<Switch
id="allowed-operations-switch"
checked={enabled}
onCheckedChange={onToggle}
/>
</div>
{enabled && (
<div className="rounded-lg">
<div className="flex flex-col gap-6">
{allowedOperationsFields.fields.map((field, index) => (
<div
key={field.id}
className="rounded-md border border-border p-4"
>
<div className="mb-4 flex items-center justify-between">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.signMethod`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Allowed Signature Method</FormLabel>
<FormDescription className="text-xs">
Select the types of signatures that are allowed for
this partner
</FormDescription>
<Select
onValueChange={(value) => {
// Update the signature method
field.onChange(value);
// Reset the operation data based on the selected method
if (value === "eth_signTransaction") {
// Remove any existing fields for other methods
allowedOperationsFields.update(index, {
signMethod: value,
allowedTransactions: [],
});
} else if (value === "eth_signTypedData_v4") {
allowedOperationsFields.update(index, {
signMethod: value,
allowedTypedData: [],
});
} else if (value === "personal_sign") {
allowedOperationsFields.update(index, {
signMethod: value,
allowedPersonalSigns: [],
});
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a signature method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="eth_signTransaction">
eth_signTransaction
</SelectItem>
<SelectItem value="eth_signTypedData_v4">
eth_signTypedData_v4
</SelectItem>
<SelectItem value="personal_sign">
personal_sign
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
aria-label="Remove operation"
onClick={() => {
allowedOperationsFields.remove(index);
}}
className="!w-auto ml-4 h-10 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
{/* Render different fields based on the signature method */}
{allowedOperationsFields.fields[index]?.signMethod ===
"eth_signTransaction" && (
<TransactionRestrictions control={control} index={index} />
)}
{allowedOperationsFields.fields[index]?.signMethod ===
"eth_signTypedData_v4" && (
<TypedDataRestrictions control={control} index={index} />
)}
{allowedOperationsFields.fields[index]?.signMethod ===
"personal_sign" && (
<PersonalSignRestrictions control={control} index={index} />
)}
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
allowedOperationsFields.append({
signMethod: "eth_signTransaction",
allowedTransactions: [],
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add operation
</Button>
</div>
</div>
)}
</div>
);
}
// Component for eth_signTransaction restrictions
function TransactionRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const transactionsArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedTransactions`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Transactions</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific transactions patterns that are allowed for this
partner. Optional, default is all transactions.
</FormDescription>
<FormDescription className="mb-4 text-warning-text text-xs">
Attention - 'eth_signTransaction' signatures are only for EOA
transactions. To specify allowed Smart Account transactions, you need to
select the 'eth_personalSign' signature method, with type 'userOp' (user
operations).
</FormDescription>
<div className="flex flex-col gap-4">
{transactionsArray.fields.map((field, txIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.contractAddress`}
render={({ field }) => (
<FormItem>
<FormLabel>Contract Address (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.selector`}
render={({ field }) => (
<FormItem>
<FormLabel>Function Selector (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTransactions.${txIndex}.maxValue`}
render={({ field }) => (
<FormItem>
<FormLabel>Max Value in Wei (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove transaction"
onClick={() => {
transactionsArray.remove(txIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
transactionsArray.append({
chainId: 1,
contractAddress: "",
selector: "",
maxValue: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add transaction
</Button>
</div>
</div>
);
}
// Component for eth_signTypedData_v4 restrictions
function TypedDataRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const typedDataArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedTypedData`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Typed Data</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific typed data patterns that are allowed for this
partner. Optional, default is any typed data.
</FormDescription>
<div className="flex flex-col gap-4">
{typedDataArray.fields.map((field, dataIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.domain`}
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="Domain name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.verifyingContract`}
render={({ field }) => (
<FormItem>
<FormLabel>Verifying Contract (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain (optional)</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedTypedData.${dataIndex}.primaryType`}
render={({ field }) => (
<FormItem>
<FormLabel>Primary Type (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="Type" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove typed data"
onClick={() => {
typedDataArray.remove(dataIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
typedDataArray.append({
domain: "",
verifyingContract: undefined,
chainId: undefined,
primaryType: undefined,
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add typed data
</Button>
</div>
</div>
);
}
// Component for personal_sign restrictions
function PersonalSignRestrictions({
control,
index,
}: {
control: Control<PartnerFormValues>;
index: number;
}) {
const personalSignArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${index}.allowedPersonalSigns`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">Allowed Personal Sign Data</Label>
<FormDescription className="mb-4 text-xs">
Specify the types of personal signatures that are allowed for this
partner. Optional, default is all personal signatures.
</FormDescription>
<div className="flex flex-col gap-4">
{personalSignArray.fields.map((field, signIndex) => (
<div key={field.id} className="rounded-md border border-border p-3">
<div className="mb-4 flex items-center justify-between">
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.messageType`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Message Type</FormLabel>
<Select
onValueChange={(value) => {
// Update the message type
field.onChange(value);
// Reset the message data based on the selected type
if (value === "userOp") {
personalSignArray.update(signIndex, {
messageType: value,
allowedTransactions: [],
});
} else if (value === "other") {
personalSignArray.update(signIndex, {
messageType: value,
message: "",
});
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select message type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="userOp">userOp</SelectItem>
<SelectItem value="other">other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
aria-label="Remove personal sign"
onClick={() => {
personalSignArray.remove(signIndex);
}}
className="!w-auto ml-4 h-10 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
{personalSignArray.fields[signIndex]?.messageType === "userOp" ? (
<UserOpTransactions
control={control}
opIndex={index}
signIndex={signIndex}
/>
) : (
<FormField
control={control}
name={`accessControl.allowedOperations.${index}.allowedPersonalSigns.${signIndex}.message`}
render={({ field }) => (
<FormItem>
<FormLabel>Message (optional)</FormLabel>
<FormControl>
<Input placeholder="Message pattern" {...field} />
</FormControl>
<FormDescription className="text-xs">
Specify a message pattern that is allowed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
personalSignArray.append({
messageType: "other",
message: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add personal sign
</Button>
</div>
</div>
);
}
// Component for userOp transactions within personal_sign
function UserOpTransactions({
control,
opIndex,
signIndex,
}: {
control: Control<PartnerFormValues>;
opIndex: number;
signIndex: number;
}) {
const userOpTransactionsArray = useFieldArray({
control,
name: `accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions`,
});
return (
<div className="mt-4">
<Label className="mb-3 inline-block">
Allowed User Operations (smart account transactions)
</Label>
<FormDescription className="mb-4 text-xs">
Specify the specific User Operations that are allowed for this partner.
Optional, default is all User Operations.
</FormDescription>
<div className="flex flex-col gap-4">
{userOpTransactionsArray.fields.map((field, txIndex) => (
<div
key={field.id}
className="flex flex-row gap-4 rounded-md border border-border p-4"
>
<div className="flex flex-1 flex-col gap-2">
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.chainId`}
render={({ field }) => (
<FormItem>
<FormLabel>Chain</FormLabel>
<FormControl>
<SingleNetworkSelector
chainId={field.value}
onChange={(chainId) => field.onChange(chainId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.contractAddress`}
render={({ field }) => (
<FormItem>
<FormLabel>Contract Address (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.selector`}
render={({ field }) => (
<FormItem>
<FormLabel>Function Selector (optional)</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col">
<FormField
control={control}
name={`accessControl.allowedOperations.${opIndex}.allowedPersonalSigns.${signIndex}.allowedTransactions.${txIndex}.maxValue`}
render={({ field }) => (
<FormItem>
<FormLabel>Max Value in Wei (optional)</FormLabel>
<div className="flex">
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button
variant="outline"
aria-label="Remove transaction"
onClick={() => {
userOpTransactionsArray.remove(txIndex);
}}
className="!w-auto ml-2 px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
))}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
userOpTransactionsArray.append({
chainId: 1,
contractAddress: "",
selector: "",
maxValue: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add transaction
</Button>
</div>
</div>
);
}
@@ -0,0 +1,374 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import type { z } from "zod";
import type { Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { AllowedOperationsSection } from "./allowed-operations-section";
export type PartnerFormValues = z.infer<typeof partnerFormSchema>;
type PartnerFormProps = {
partner?: Partner; // Optional for add form
onSubmit: (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => void;
isSubmitting: boolean;
submitLabel: string;
};
export function PartnerForm({
partner,
onSubmit,
isSubmitting,
submitLabel,
}: PartnerFormProps) {
// Check if partner has accessControl and serverVerifier
const hasAccessControl = partner ? !!partner.accessControl : false;
const hasServerVerifier =
hasAccessControl && !!partner?.accessControl?.serverVerifier;
const hasAllowedOperations =
hasAccessControl && !!partner?.accessControl?.allowedOperations?.length;
const form = useForm<PartnerFormValues>({
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: partner?.name || "",
domains: partner?.allowlistedDomains.join(",") || "",
bundleIds: partner?.allowlistedBundleIds.join(",") || "",
// Set the actual accessControl data if it exists
accessControl: partner?.accessControl,
// Set the UI control properties based on existing data
accessControlEnabled: hasAccessControl,
serverVerifierEnabled: hasServerVerifier,
allowedOperationsEnabled: hasAllowedOperations,
},
mode: "onChange", // Validate on change for better user experience
});
// Watch the boolean flags for UI state
const accessControlEnabled = form.watch("accessControlEnabled");
const serverVerifierEnabled = form.watch("serverVerifierEnabled");
const allowedOperationsEnabled = form.watch("allowedOperationsEnabled");
// Setup field array for headers
const customHeaderFields = useFieldArray({
control: form.control,
name: "accessControl.serverVerifier.headers",
});
const handleSubmit = form.handleSubmit(
(values) => {
// Construct the final accessControl object based on the enabled flags
let finalAccessControl: Partner["accessControl"] | null = null;
if (values.accessControlEnabled) {
finalAccessControl = {} as Partner["accessControl"];
if (finalAccessControl && values.serverVerifierEnabled) {
finalAccessControl.serverVerifier = {
url: values.accessControl?.serverVerifier?.url || "",
headers: values.accessControl?.serverVerifier?.headers || [],
};
}
if (finalAccessControl && values.allowedOperationsEnabled) {
finalAccessControl.allowedOperations =
values.accessControl?.allowedOperations || [];
}
// if no values have been set, remove the accessControl object
if (
finalAccessControl &&
Object.keys(finalAccessControl).length === 0
) {
finalAccessControl = null;
}
}
onSubmit(values, finalAccessControl);
},
(errors) => {
// Log validation errors for debugging
console.error("Form validation errors:", errors);
},
);
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex grow flex-col gap-5">
<FormField
control={form.control}
name="name"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4 lg:col-span-4">
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.domains?.message &&
"block translate-y-0 text-destructive opacity-100",
)}
>
{form.formState.errors.domains?.message ??
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue=""
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.bundleIds?.message &&
"block text-destructive",
)}
>
{form.formState.errors.bundleIds?.message ??
"Space or comma-separated list of bundle IDs"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Access Control Section */}
<div className="mb-4 flex items-center justify-between gap-6">
<div>
<Label htmlFor="access-control-switch" className="text-base">
Access Control
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Enable access control for this partner
</p>
</div>
<Switch
id="access-control-switch"
checked={accessControlEnabled}
onCheckedChange={(checked) => {
form.setValue("accessControlEnabled", checked);
// If disabling access control, also disable server verifier and allowed operations
if (!checked) {
form.setValue("serverVerifierEnabled", false);
form.setValue("allowedOperationsEnabled", false);
}
}}
/>
</div>
{accessControlEnabled && (
<>
<div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-center justify-between gap-6">
<div>
<Label
htmlFor="server-verifier-switch"
className="text-base"
>
Server Verifier
</Label>
<p className="mt-0.5 text-muted-foreground text-xs">
Configure a server verifier for access control
</p>
</div>
<Switch
id="server-verifier-switch"
checked={serverVerifierEnabled}
onCheckedChange={(checked) => {
form.setValue("serverVerifierEnabled", checked);
// Initialize serverVerifier fields if enabling
if (
checked &&
!form.getValues("accessControl.serverVerifier")
) {
form.setValue("accessControl.serverVerifier", {
url: "",
headers: [],
});
}
}}
/>
</div>
{serverVerifierEnabled && (
<div className="mt-4 grid grid-cols-1 gap-6">
<FormField
control={form.control}
name="accessControl.serverVerifier.url"
render={({ field }) => (
<FormItem>
<FormLabel>Server Verifier URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-verifier"
/>
</FormControl>
<FormDescription
className={cn(
"text-xs",
form.formState.errors.accessControl
?.serverVerifier?.url && "text-destructive",
)}
>
{form.formState.errors.accessControl?.serverVerifier
?.url?.message ||
"Enter the URL of your server where verification requests will be sent"}
</FormDescription>
</FormItem>
)}
/>
<div>
<Label className="mb-3 inline-block">
Custom Headers
</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, headerIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...form.register(
`accessControl.serverVerifier.headers.${headerIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...form.register(
`accessControl.serverVerifier.headers.${headerIdx}.value`,
)}
/>
<Button
variant="outline"
aria-label="Remove header"
onClick={() => {
customHeaderFields.remove(headerIdx);
}}
className="!w-auto px-3"
type="button"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
<Button
variant="outline"
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
type="button"
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
<p className="mt-3 text-muted-foreground text-xs">
Set custom headers to be sent along with verification
requests
</p>
</div>
</div>
)}
</div>
{/* Allowed Operations Section */}
<AllowedOperationsSection
control={form.control}
enabled={allowedOperationsEnabled}
onToggle={(checked) => {
form.setValue("allowedOperationsEnabled", checked);
// Initialize allowedOperations array if enabling
if (
checked &&
!form.getValues("accessControl.allowedOperations")
) {
form.setValue("accessControl.allowedOperations", []);
}
}}
/>
</>
)}
</div>
<Button
disabled={isSubmitting}
type="submit"
className="mt-4 w-full gap-2"
>
{isSubmitting && <Spinner className="size-4" />}
{submitLabel}
</Button>
</form>
</Form>
);
}
@@ -1,44 +1,24 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import type { z } from "zod";
import type { Ecosystem, Partner } from "../../../../../types";
import { partnerFormSchema } from "../../constants";
import { useUpdatePartner } from "../../hooks/use-update-partner";
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
export function UpdatePartnerForm({
ecosystem,
partner,
onSuccess,
authToken,
}: {
ecosystem: Ecosystem;
partner: Partner;
onSuccess: () => void;
authToken: string;
}) {
const form = useForm<z.input<typeof partnerFormSchema>>({
resolver: zodResolver(partnerFormSchema),
defaultValues: {
name: partner.name,
domains: partner.allowlistedDomains.join(","),
bundleIds: partner.allowlistedBundleIds.join(","),
},
});
const router = useDashboardRouter();
const params = useParams();
const teamSlug = params.team_slug as string;
const ecosystemSlug = params.slug as string;
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
{
@@ -46,123 +26,48 @@ export function UpdatePartnerForm({
},
{
onSuccess: () => {
form.reset();
onSuccess();
toast.success("Partner updated successfully", {
description: "The partner details have been updated.",
});
// Redirect to the redirect page that will take us back to the configuration page
const redirectPath = `/team/${teamSlug}/~/ecosystem/${ecosystemSlug}`;
router.push(redirectPath);
},
onError: (error) => {
const message =
error instanceof Error
? error.message
: "Failed to add ecosystem partner";
: "Failed to update ecosystem partner";
toast.error(message);
},
},
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => {
updatePartner({
ecosystem,
partnerId: partner.id,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
});
})}
className="flex flex-col gap-4"
>
<div className="flex grow flex-col gap-5">
<FormField
control={form.control}
name="name"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Name </FormLabel>
<FormControl>
<Input
placeholder="App name"
{...field}
value={field.value}
/>
</FormControl>
<FormDescription
className={cn(
"text-xs transition-all",
form.formState.errors.name?.message
? "block translate-y-0 text-destructive opacity-100"
: "hidden opacity-0",
)}
>
{form.formState.errors.name?.message}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="domains"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4 lg:col-span-4">
<FormLabel> Domains </FormLabel>
<FormControl>
<Input placeholder="Domains" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.domains?.message &&
"block translate-y-0 text-destructive opacity-100", // If there are errors show them rather than the tip
)}
>
{form.formState.errors.domains?.message ??
"Space or comma-separated list of regex domains (e.g. *.example.com)"}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="bundleIds"
defaultValue="" // Note: you *must* provide a default value here or the field won't reset
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel> Bundle ID </FormLabel>
<FormControl>
<Input placeholder="Bundle ID" className="peer" {...field} />
</FormControl>
<FormDescription
className={cn(
"block text-xs",
form.formState.errors.bundleIds?.message &&
"block text-destructive",
)}
>
{form.formState.errors.bundleIds?.message ??
"Space or comma-separated list of bundle IDs"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
const handleSubmit = (
values: PartnerFormValues,
finalAccessControl: Partner["accessControl"] | null,
) => {
updatePartner({
ecosystem,
partnerId: partner.id,
name: values.name,
allowlistedDomains: values.domains
.split(/,| /)
.filter((d) => d.length > 0),
allowlistedBundleIds: values.bundleIds
.split(/,| /)
.filter((d) => d.length > 0),
accessControl: finalAccessControl,
});
};
<Button
disabled={isPending}
type="submit"
className="mt-4 w-full gap-2"
>
{isPending && <Spinner className="size-4" />}
Update
</Button>
</form>
</Form>
return (
<PartnerForm
partner={partner}
onSubmit={handleSubmit}
isSubmitting={isPending}
submitLabel="Update"
/>
);
}
@@ -1,42 +0,0 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState } from "react";
import type { Ecosystem, Partner } from "../../../../../types";
import { UpdatePartnerForm } from "./update-partner-form.client";
export function UpdatePartnerModal({
children,
ecosystem,
partner,
authToken,
}: {
children: React.ReactNode;
ecosystem: Ecosystem;
partner: Partner;
authToken: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Update {partner.name}</DialogTitle>
</DialogHeader>
<UpdatePartnerForm
ecosystem={ecosystem}
partner={partner}
onSuccess={() => setOpen(false)}
authToken={authToken}
/>
</DialogContent>
</Dialog>
);
}
@@ -3,9 +3,10 @@ import { AddPartnerDialogButton } from "../client/AddPartnerDialogButton";
import { PartnersTable } from "./partners-table";
export function EcosystemPartnersSection({
teamSlug,
ecosystem,
authToken,
}: { ecosystem: Ecosystem; authToken: string }) {
}: { teamSlug: string; ecosystem: Ecosystem; authToken: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-6 lg:px-6">
<div className="flex flex-col items-start justify-between max-sm:mb-5 lg:flex-row">
@@ -21,10 +22,18 @@ export function EcosystemPartnersSection({
own app.
</p>
</div>
<AddPartnerDialogButton ecosystem={ecosystem} authToken={authToken} />
<AddPartnerDialogButton
teamSlug={teamSlug}
ecosystem={ecosystem}
authToken={authToken}
/>
</div>
<PartnersTable ecosystem={ecosystem} authToken={authToken} />
<PartnersTable
ecosystem={ecosystem}
authToken={authToken}
teamSlug={teamSlug}
/>
</div>
);
}
@@ -12,20 +12,28 @@ import {
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Link } from "../../../../../../../../../../../tw-components/link";
import type { Ecosystem, Partner } from "../../../../../types";
import { usePartners } from "../../../hooks/use-partners";
import { useDeletePartner } from "../../hooks/use-delete-partner";
import { UpdatePartnerModal } from "../client/update-partner-modal.client";
export function PartnersTable({
ecosystem,
authToken,
}: { ecosystem: Ecosystem; authToken: string }) {
const { partners, isPending } = usePartners({ ecosystem, authToken });
teamSlug,
}: {
ecosystem: Ecosystem;
authToken: string;
teamSlug: string;
}) {
const { partners, isPending } = usePartners({
ecosystem,
authToken,
});
if (isPending) {
return (
@@ -59,6 +67,7 @@ export function PartnersTable({
partner={partner}
ecosystem={ecosystem}
authToken={authToken}
teamSlug={teamSlug}
/>
))}
</TableBody>
@@ -70,8 +79,10 @@ export function PartnersTable({
function PartnerRow(props: {
partner: Partner;
ecosystem: Ecosystem;
teamSlug: string;
authToken: string;
}) {
const router = useDashboardRouter();
const { mutateAsync: deletePartner, isPending: isDeleting } =
useDeletePartner(
{
@@ -117,22 +128,21 @@ function PartnerRow(props: {
<td className="table-cell py-3 align-middle">
<div className="flex justify-end gap-3 pr-3">
<UpdatePartnerModal
partner={props.partner}
ecosystem={props.ecosystem}
authToken={props.authToken}
<Button
type="button"
variant="outline"
size="icon"
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
disabled={isDeleting}
onClick={() => {
router.push(
`/team/${props.teamSlug}/~/ecosystem/${props.ecosystem.slug}/configuration/partners/${props.partner.id}/edit`,
);
}}
>
<Button
type="button"
variant="outline"
size="icon"
className="text-accent-foreground/50 hover:bg-accent hover:text-accent-foreground"
disabled={isDeleting}
>
<Pencil className="size-4" />
<span className="sr-only">Edit</span>
</Button>
</UpdatePartnerModal>
<Pencil className="size-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
title={`Are you sure you want to delete the partner ${props.partner.name}?`}
description={
@@ -1,33 +1,248 @@
import { isAddress, isHex } from "thirdweb/utils";
import z from "zod";
const isDomainRegex =
/^(?:\*|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*(?:\*(?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*)?)|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|localhost(?::\d{1,5})?)$/;
export const partnerFormSchema = z.object({
name: z
// Define schemas for the different operation types
const allowedTransactionSchema = z.object({
chainId: z.number().refine(
(data) => {
if (data !== undefined) {
return data > 0;
}
return true;
},
{
message: "Invalid chain ID",
},
),
contractAddress: z
.string()
.trim()
.min(3, {
message: "Name must be at least 3 characters",
})
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
domains: z
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isAddress(data);
}
return true;
},
{
message: "Invalid contract address",
},
),
selector: z
.string()
.trim()
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
})
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
bundleIds: z
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isHex(data) && data.length === 10; // 0x + 4 bytes for the selector (8 chars)
}
return true;
},
{
message: "Invalid function selector",
},
),
maxValue: z
.string()
.trim()
.transform((s) =>
s
.split(/,| /)
.filter((d) => d.length > 0)
.join(","),
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return BigInt(data) >= 0;
}
return true;
},
{
message: "Invalid max value",
},
),
});
const allowedTypedDataSchema = z
.object({
domain: z.string().refine((data) => data.length > 0, {
message: "Domain is required",
}),
verifyingContract: z
.string()
.optional()
.transform((data) => (data?.length === 0 ? undefined : data))
.refine(
(data) => {
if (data) {
return isAddress(data);
}
return true;
},
{
message: "Invalid verifying contract address",
},
),
chainId: z
.number()
.optional()
.refine(
(data) => {
if (data !== undefined) {
return data > 0;
}
return true;
},
{
message: "Invalid chain ID",
},
),
primaryType: z.string().optional(),
})
.transform((data) => {
return {
...data,
verifyingContract:
data.verifyingContract && data.verifyingContract.length > 0
? data.verifyingContract
: undefined,
primaryType:
data.primaryType && data.primaryType.length > 0
? data.primaryType
: undefined,
};
});
const personalSignRestrictionSchema = z.discriminatedUnion("messageType", [
z.object({
messageType: z.literal("userOp"),
allowedTransactions: z
.array(allowedTransactionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
messageType: z.literal("other"),
message: z.string().optional(),
}),
]);
const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
z.object({
signMethod: z.literal("eth_signTransaction"),
allowedTransactions: z
.array(allowedTransactionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
signMethod: z.literal("eth_signTypedData_v4"),
allowedTypedData: z
.array(allowedTypedDataSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
z.object({
signMethod: z.literal("personal_sign"),
allowedPersonalSigns: z
.array(personalSignRestrictionSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
}),
]);
export const partnerFormSchema = z
.object({
name: z
.string()
.trim()
.min(3, {
message: "Name must be at least 3 characters",
})
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
message: "Name can only contain letters, numbers and spaces",
}),
domains: z
.string()
.trim()
.transform((s) => s.split(/,| /).filter((d) => d.length > 0))
.refine((domains) => domains.every((d) => isDomainRegex.test(d)), {
message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state
})
.transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types
bundleIds: z
.string()
.trim()
.transform((s) =>
s
.split(/,| /)
.filter((d) => d.length > 0)
.join(","),
),
accessControlEnabled: z.boolean().default(false),
serverVerifierEnabled: z.boolean().default(false),
allowedOperationsEnabled: z.boolean().default(false),
accessControl: z
.object({
serverVerifier: z
.object({
url: z.string().optional(),
headers: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
})
.optional(),
allowedOperations: z
.array(allowedOperationsSchema)
.optional()
.transform((data) => (data?.length === 0 ? undefined : data)),
})
.optional(),
})
.refine(
(data) => {
// If serverVerifier is enabled, validate the URL
if (data.accessControlEnabled && data.serverVerifierEnabled) {
return (
data.accessControl?.serverVerifier?.url &&
data.accessControl.serverVerifier.url.trim() !== ""
);
}
// Otherwise, no validation needed
return true;
},
{
message:
"Server Verifier URL is required when Server Verifier is enabled",
path: ["accessControl", "serverVerifier", "url"],
},
)
.refine(
(data) => {
// If serverVerifier is enabled, validate the URL format
if (
data.accessControlEnabled &&
data.serverVerifierEnabled &&
data.accessControl?.serverVerifier?.url
) {
try {
new URL(data.accessControl.serverVerifier.url);
return true;
} catch {
return false;
}
}
// Otherwise, no validation needed
return true;
},
{
message: "Please enter a valid URL",
path: ["accessControl", "serverVerifier", "url"],
},
);
@@ -0,0 +1,34 @@
import { API_SERVER_URL } from "@/constants/env";
import type { Ecosystem } from "../../../../types";
/**
* Fetches ecosystem data from the server
*/
export async function fetchEcosystem(args: {
teamIdOrSlug: string;
slug: string;
authToken: string;
}): Promise<Ecosystem> {
const { teamIdOrSlug, slug, authToken } = args;
const res = await fetch(
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
revalidate: 0,
},
},
);
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystem",
);
}
return (await res.json()).result as Ecosystem;
}
@@ -0,0 +1,32 @@
import type { Ecosystem, Partner } from "../../../../types";
export async function fetchPartnerDetails(args: {
authToken: string;
ecosystem: Ecosystem;
partnerId: string;
}): Promise<Partner> {
const { authToken, ecosystem, partnerId } = args;
try {
const response = await fetch(
`${ecosystem.url}/${ecosystem.id}/partner/${partnerId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch partner details: ${response.status} - ${response.statusText}`,
);
}
return await response.json();
} catch (error) {
console.error("Error fetching partner:", error);
throw error;
}
}
@@ -0,0 +1,34 @@
import type { Ecosystem, Partner } from "../../../../types";
/**
* Fetches partners for an ecosystem
*/
export async function fetchPartners({
ecosystem,
authToken,
}: {
ecosystem: Ecosystem;
authToken: string;
}): Promise<Partner[]> {
const res = await fetch(`${ecosystem.url}/${ecosystem.id}/partners`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
next: {
revalidate: 0,
},
});
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch partners",
);
}
const partners = (await res.json()) as Partner[];
return partners.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
@@ -10,6 +10,7 @@ type AddPartnerParams = {
name: string;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
accessControl?: Partner["accessControl"] | null;
};
export function useAddPartner(
@@ -41,6 +42,7 @@ export function useAddPartner(
name: params.name,
allowlistedDomains: params.allowlistedDomains,
allowlistedBundleIds: params.allowlistedBundleIds,
accessControl: params.accessControl ?? undefined,
// TODO - remove the requirement for permissions in API endpoint
permissions: ["FULL_CONTROL_V1"],
}),
@@ -11,6 +11,12 @@ type UpdatePartnerParams = {
name: string;
allowlistedDomains: string[];
allowlistedBundleIds: string[];
accessControl?: {
serverVerifier?: {
url: string;
headers?: { key: string; value: string }[];
} | null;
} | null;
};
export function useUpdatePartner(
@@ -42,6 +48,7 @@ export function useUpdatePartner(
name: params.name,
allowlistedDomains: params.allowlistedDomains,
allowlistedBundleIds: params.allowlistedBundleIds,
accessControl: params.accessControl,
}),
},
);
@@ -0,0 +1,96 @@
import {} from "@/components/ui/breadcrumb";
import { getAuthToken } from "../../../../../../../../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";
import { UpdatePartnerForm } from "../../../components/client/update-partner-form.client";
import { fetchEcosystem } from "../../../hooks/fetchEcosystem";
import { fetchPartnerDetails } from "../../../hooks/fetchPartnerDetails";
export default async function EditPartnerPage({
params,
}: {
params: Promise<{ slug: string; team_slug: string; partner_id: string }>;
}) {
const { slug, team_slug, partner_id } = await params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${team_slug}/~/ecosystem/${slug}/configuration`);
}
const teamSlug = team_slug;
const ecosystemSlug = slug;
const partnerId = partner_id;
try {
const ecosystem = await fetchEcosystem({
teamIdOrSlug: teamSlug,
slug: ecosystemSlug,
authToken,
});
try {
// TODO re-enable this once IAW service is re deployed
const partner = await fetchPartnerDetails({
ecosystem,
partnerId,
authToken,
});
// const partners = await fetchPartners({
// ecosystem,
// authToken,
// });
// const partner = partners.find((p) => p.id === partnerId);
if (!partner) {
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Error
</h1>
<p>Could not load partner details. Please try again.</p>
</div>
</div>
);
}
return (
<div className="flex flex-col">
<div className="container">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Edit Partner: {partner.name}
</h1>
<UpdatePartnerForm
ecosystem={ecosystem}
partner={partner}
authToken={authToken}
/>
</div>
</div>
);
} catch (partnerError) {
console.error("Error fetching partner:", partnerError);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">
Error
</h1>
<p>Could not load partner details. Please try again.</p>
</div>
</div>
);
}
} catch (ecosystemError) {
console.error("Error fetching ecosystem:", ecosystemError);
return (
<div className="container py-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 font-semibold text-2xl tracking-tight">Error</h1>
<p>Could not load ecosystem. Please try again.</p>
</div>
</div>
);
}
}
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type { Ecosystem, Partner } from "../../../types";
import { fetchPartners } from "../configuration/hooks/fetchPartners";
export function usePartners({
ecosystem,
@@ -8,21 +9,7 @@ export function usePartners({
const partnersQuery = useQuery({
queryKey: ["ecosystem", ecosystem.id, "partners"],
queryFn: async () => {
const res = await fetch(`${ecosystem.url}/${ecosystem.id}/partners`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
if (!res.ok) {
const data = await res.json();
console.error(data);
throw new Error(
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystems",
);
}
return (await res.json()) as Partner[];
return fetchPartners({ ecosystem, authToken });
},
retry: false,
});
@@ -55,4 +55,57 @@ export type Partner = {
permissions: [PartnerPermission];
createdAt: string;
updatedAt: string;
accessControl?: {
serverVerifier?: {
url: string;
headers?: { key: string; value: string }[];
};
allowedOperations?: AllowedOperations[];
};
};
type AllowedArgument = {
offset: number;
type: "address" | "uint256" | "bytes32" | "bool" | "string";
comparisonOperator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
value: string;
};
type AllowedTransaction = {
chainId: number;
contractAddress?: string;
selector?: string;
arguments?: AllowedArgument[];
maxValue?: string;
};
type AllowedTypedData = {
domain: string;
verifyingContract?: string;
chainId?: number;
primaryType?: string;
};
type PersonalSignRestriction =
| {
messageType: "userOp";
allowedTransactions?: AllowedTransaction[];
}
| {
messageType: "other";
message?: string;
};
type AllowedOperations =
| {
signMethod: "eth_signTransaction";
allowedTransactions?: AllowedTransaction[];
}
| {
signMethod: "eth_signTypedData_v4";
allowedTypedData?: AllowedTypedData[];
}
| {
signMethod: "personal_sign";
allowedPersonalSigns?: PersonalSignRestriction[];
};
@@ -86,7 +86,6 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
key,
name,
isActive: activeTab === key,
isEnabled: true,
onClick: () => setActiveTab(key),
icon:
(key === "aws-kms" && !isAwsKmsConfigured) ||
@@ -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.
@@ -55,7 +55,7 @@ export function SettingsLayout(props: {
/>
<div
className={cn(
"flex grow flex-col",
"flex max-w-full grow flex-col",
// if showing full nav on mobile - hide the page content
showFullNavOnMobile && "max-sm:hidden",
)}
@@ -12,13 +12,18 @@ export function getTeamSettingsLinks(teamSlug: string) {
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
name: "Billing",
href: `${prefix}/billing`,
group: "team",
},
{
name: "Billing",
href: `${prefix}/billing`,
name: "Invoices",
href: `${prefix}/invoices`,
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
group: "team",
},
{
@@ -9,6 +9,7 @@ import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { differenceInDays, isAfter } from "date-fns";
import { format } from "date-fns/format";
import { CircleAlertIcon } from "lucide-react";
import Link from "next/link";
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
export function PlanInfoCard(props: {
@@ -53,7 +54,14 @@ export function PlanInfoCard(props: {
)}
</div>
<div className="flex flex-row gap-2">
<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
</Link>
</Button>
{/* manage team billing */}
<BillingPortalButton
teamSlug={team.slug}
@@ -0,0 +1,200 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ChevronLeftIcon,
ChevronRight,
CreditCard,
Download,
Receipt,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { useTransition } from "react";
import type Stripe from "stripe";
import { searchParams } from "../search-params";
export function BillingHistory(props: {
invoices: Stripe.Invoice[];
hasMore: boolean;
}) {
console.log(props.invoices.map((i) => i.id));
const [isLoading, startTransition] = useTransition();
const [cursor, setCursor] = useQueryState(
"cursor",
searchParams.cursor.withOptions({
startTransition,
history: "push",
shallow: false,
}),
);
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const getStatusBadge = (invoice: Stripe.Invoice) => {
switch (invoice.status) {
case "paid":
return (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
Paid
</Badge>
);
case "open":
// we treate "uncollectible" as unpaid
case "uncollectible": {
// if the invoice due date is in the past, we want to display it as past due
if (invoice.due_date && invoice.due_date < Date.now()) {
return (
<Badge
variant="outline"
className="border-red-200 bg-red-50 text-red-800"
>
Past Due
</Badge>
);
}
return <Badge variant="outline">Open</Badge>;
}
case "void":
return <Badge variant="secondary">Void</Badge>;
default:
return <Badge variant="outline">Unknown</Badge>;
}
};
if (props.invoices.length === 0) {
return (
<div className="py-6 text-center">
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 font-medium text-lg">No billing history</h3>
<p className="mt-1 text-muted-foreground text-sm">
Your invoice history will appear here once you have made payments.
</p>
</div>
);
}
return (
<div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">#{invoice.number}</TableCell>
<TableCell>{formatDate(invoice.created)}</TableCell>
<TableCell>
{invoice.status === "paid"
? formatCurrency(invoice.amount_paid, invoice.currency)
: formatCurrency(invoice.amount_due, invoice.currency)}
</TableCell>
<TableCell>{getStatusBadge(invoice)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{invoice.status === "open" &&
invoice.hosted_invoice_url && (
<Button variant="default" size="sm" asChild>
<a
href={invoice.hosted_invoice_url}
target="_blank"
rel="noopener noreferrer"
>
<CreditCard className="mr-2 h-4 w-4" />
Pay Now
</a>
</Button>
)}
{invoice.invoice_pdf && (
<Button variant="ghost" size="sm" asChild>
<a
href={invoice.invoice_pdf}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
PDF
</a>
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
<hr className="my-4" />
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => {
// use browser history to go back
// this is KINDA hacky but it works (as long as the user doesn't send the URL to someone else...)
window.history.back();
}}
disabled={isLoading || !cursor}
>
{isLoading ? (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<ChevronLeftIcon className="mr-2 h-4 w-4" />
)}
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const lastInvoice = props.invoices[props.invoices.length - 1];
if (lastInvoice && props.hasMore) {
setCursor(lastInvoice.id);
}
}}
disabled={!props.hasMore || isLoading}
>
Next
{isLoading && props.hasMore ? (
<div className="ml-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<ChevronRight className="ml-2 h-4 w-4" />
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { getTeamInvoices } from "@/actions/stripe-actions";
import { getTeamBySlug } from "@/api/team";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getValidAccount } from "../../../../../../account/settings/getAccount";
import { BillingHistory } from "./components/billing-history";
import { searchParamLoader } from "./search-params";
export default async function Page(props: {
params: Promise<{
team_slug: string;
}>;
searchParams: Promise<SearchParams>;
}) {
const [params, searchParams] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
]);
const pagePath = `/team/${params.team_slug}/settings/invoices`;
const [, team] = await Promise.all([
// only called to verify login status etc
getValidAccount(pagePath),
getTeamBySlug(params.team_slug),
]);
if (!team) {
redirect("/team");
}
const invoices = await getTeamInvoices(team, {
cursor: searchParams.cursor ?? undefined,
});
return (
<Card>
<CardHeader>
<CardTitle>Invoice History</CardTitle>
<CardDescription className="mt-2">
View your past invoices and payment history
</CardDescription>
</CardHeader>
<CardContent>
<BillingHistory invoices={invoices.data} hasMore={invoices.has_more} />
</CardContent>
</Card>
);
}
@@ -0,0 +1,7 @@
import { createLoader, parseAsString } from "nuqs/server";
export const searchParams = {
cursor: parseAsString,
};
export const searchParamLoader = createLoader(searchParams);
@@ -18,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { BASE_URL } from "@/constants/env";
import { useMutation } from "@tanstack/react-query";
import { formatDate } from "date-fns";
import { EllipsisIcon, MailIcon } from "lucide-react";
@@ -117,6 +118,7 @@ export function ManageInvitesSection(props: {
className="border-border border-b last:border-b-0"
>
<InviteRow
teamSlug={props.team.slug}
invite={invite}
client={props.client}
userHasEditPermission={props.userHasEditPermission}
@@ -143,6 +145,7 @@ export function ManageInvitesSection(props: {
}
function InviteRow(props: {
teamSlug: string;
invite: TeamInvite;
userHasEditPermission: boolean;
client: ThirdwebClient;
@@ -198,6 +201,7 @@ function InviteRow(props: {
{/* Options */}
{props.userHasEditPermission && (
<ManageInviteButton
teamSlug={props.teamSlug}
invite={props.invite}
userHasEditPermission={props.userHasEditPermission}
deleteInvite={props.deleteInvite}
@@ -210,6 +214,7 @@ function InviteRow(props: {
}
function ManageInviteButton(props: {
teamSlug: string;
invite: TeamInvite;
userHasEditPermission: boolean;
deleteInvite: (inviteId: string) => Promise<void>;
@@ -235,6 +240,18 @@ function ManageInviteButton(props: {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{props.invite.status === "pending" && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${BASE_URL}/join/team/${props.teamSlug}/${props.invite.id}`,
);
toast.success("Invite link copied to clipboard");
}}
>
Copy Invite Link
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
@@ -62,13 +62,11 @@ export function TeamMembersSettingsPage(props: {
isActive: manageTab === "members",
name: "Team Members",
onClick: () => setManageTab("members"),
isEnabled: true,
},
{
isActive: manageTab === "invites",
name: "Pending Invites",
onClick: () => setManageTab("invites"),
isEnabled: true,
},
]}
/>
@@ -0,0 +1,36 @@
"use client";
import { TabButtons } from "@/components/ui/tabs";
import { useState } from "react";
type TabKey = "ts" | "react" | "react-native" | "dotnet" | "unity" | "unreal";
const tabNames: Record<TabKey, string> = {
ts: "TypeScript",
react: "React",
"react-native": "React Native",
dotnet: ".NET",
unity: "Unity",
unreal: "Unreal Engine",
};
export function IntegrateAPIKeyCodeTabs(props: {
tabs: Record<TabKey, React.ReactNode>;
}) {
const [tab, setTab] = useState<TabKey>("ts");
return (
<div>
<TabButtons
tabClassName="!text-sm"
tabs={Object.entries(tabNames).map(([key, name]) => ({
name,
onClick: () => setTab(key as TabKey),
isActive: tab === key,
}))}
/>
<div className="h-3" />
{props.tabs[tab]}
</div>
);
}
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react";
import { projectStub } from "../../../../../../stories/stubs";
import { ProjectFTUX } from "./ProjectFTUX";
const meta = {
title: "Project/ProjectFTUX",
component: ProjectFTUX,
decorators: [
(Story) => (
<div className="container py-8 pb-20">
<Story />
</div>
),
],
} satisfies Meta<typeof ProjectFTUX>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
project: {
...projectStub("foo", "bar"),
secretKeys: [
{
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
hash: "1234567890",
masked: "1234567890",
},
],
},
teamSlug: "bar",
},
};
@@ -0,0 +1,517 @@
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CodeServer } from "@/components/ui/code/code.server";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import {
ChevronRightIcon,
CircleAlertIcon,
ExternalLinkIcon,
} from "lucide-react";
import { ContractIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
import { InsightIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
import { DotNetIcon } from "../../../../../../components/icons/brand-icons/DotNetIcon";
import { GithubIcon } from "../../../../../../components/icons/brand-icons/GithubIcon";
import { ReactIcon } from "../../../../../../components/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "../../../../../../components/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "../../../../../../components/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "../../../../../../components/icons/brand-icons/UnrealIcon";
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
import { SecretKeySection } from "./SecretKeySection";
export function ProjectFTUX(props: { project: Project; teamSlug: string }) {
return (
<div className="flex flex-col gap-10">
<IntegrateAPIKeySection
project={props.project}
teamSlug={props.teamSlug}
/>
<ProductsSection
teamSlug={props.teamSlug}
projectSlug={props.project.slug}
/>
<SDKSection />
<StarterKitsSection />
</div>
);
}
// Integrate API key section ------------------------------------------------------------
function IntegrateAPIKeySection({
project,
teamSlug,
}: {
project: Project;
teamSlug: string;
}) {
const secretKeyMasked = project.secretKeys[0]?.masked;
const clientId = project.publishableKey;
return (
<section>
<h2 className="mb-3 font-semibold text-xl tracking-tight">
Integrate API key
</h2>
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-6 ">
<div>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application.
</p>
<CopyTextButton
textToCopy={clientId}
className="!h-auto w-full max-w-[350px] justify-between truncate bg-background px-3 py-3 font-mono"
textToShow={clientId}
copyIconPosition="right"
tooltip="Copy Client ID"
/>
</div>
{secretKeyMasked && (
<SecretKeySection
secretKeyMasked={secretKeyMasked}
projectId={project.id}
/>
)}
</div>
<div className="h-5" />
<IntegrationCodeExamples project={project} teamSlug={teamSlug} />
</div>
</section>
);
}
function IntegrationCodeExamples(props: {
project: Project;
teamSlug: string;
}) {
return (
<IntegrateAPIKeyCodeTabs
tabs={{
ts: (
<CodeServer
code={typescriptCodeExample(props.project)}
lang="ts"
className="bg-background"
/>
),
react: (
<CodeServer
code={reactCodeExample(props.project)}
lang="ts"
className="bg-background"
/>
),
"react-native": (
<CodeServer
code={reactCodeExample(props.project)}
lang="ts"
className="bg-background"
/>
),
unity: (
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>
Configure Client ID in Thirdweb Manager prefab
</AlertTitle>
<AlertDescription className="leading-loose">
Configure "Client ID" and "Bundle ID" in{" "}
<UnderlineLink
href="https://portal.thirdweb.com/unity/v5/thirdwebmanager"
target="_blank"
>
Thirdweb Manager prefab
</UnderlineLink>
<span className="block text-sm">
Make sure to configure your app's bundle ID in "Allowed Bundle
IDs" in{" "}
<UnderlineLink
target="_blank"
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
Project settings
</UnderlineLink>
</span>
</AlertDescription>
</Alert>
),
dotnet: (
<div className="flex flex-col gap-3">
<CodeServer
code={dotNotCodeExample(props.project)}
lang="csharp"
className="bg-background"
/>
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>
Configure your app's bundle ID in "Allowed Bundle IDs" in
Project
</AlertTitle>
<AlertDescription className="leading-loose">
Go to{" "}
<UnderlineLink
target="_blank"
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
Project settings
</UnderlineLink>{" "}
and add your app's bundle ID to the "Allowed Bundle IDs" list.
</AlertDescription>
</Alert>
</div>
),
unreal: (
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>
Configure Client ID in Thirdweb Unreal Plugin{" "}
</AlertTitle>
<AlertDescription className="leading-loose">
Configure "Client ID" and "Bundle ID" in{" "}
<UnderlineLink
href="https://portal.thirdweb.com/unreal-engine/getting-started"
target="_blank"
>
thirdweb plugin settings
</UnderlineLink>
<span className="block text-sm">
Make sure to configure your app's bundle ID in "Allowed Bundle
IDs" in{" "}
<UnderlineLink
target="_blank"
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
Project settings
</UnderlineLink>
</span>
</AlertDescription>
</Alert>
),
}}
/>
);
}
const typescriptCodeExample = (project: Project) => `\
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({
// use clientId for client side usage
clientId: "${project.publishableKey}",
// use secretKey for server side usage
secretKey: "${project.secretKeys[0]?.masked}", // replace this with full secret key
});`;
const reactCodeExample = (project: Project) => `\
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({
clientId: "${project.publishableKey}",
});`;
const dotNotCodeExample = (project: Project) => `\
using Thirdweb;
// For web applications
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}");
// For native applications - Replace "yourBundleId" with your app's bundle ID
var client = ThirdwebClient.Create(clientId: "${project.publishableKey}", bundleId: "yourBundleId");
// For backend applications (Note: below shown secret key is not the full secret key)
var client = ThirdwebClient.Create(secretKey: "${project.secretKeys[0]?.masked}");`;
// products section ------------------------------------------------------------
function ProductsSection(props: {
teamSlug: string;
projectSlug: string;
}) {
const products: Array<{
title: string;
description: string;
href: string;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
}> = [
{
title: "Engine",
description:
"Scale your application with a backend server to read, write, and deploy contracts at production-grade.",
href: `/team/${props.teamSlug}/~/engine`,
icon: EngineIcon,
trackingLabel: "engine",
},
{
title: "Contracts",
description:
"Deploy your own contracts or leverage existing solutions for onchain implementation",
href: `/team/${props.teamSlug}/${props.projectSlug}/contracts`,
icon: ContractIcon,
trackingLabel: "contracts",
},
{
title: "Insight",
description:
"Add indexing capabilities to retrieve real-time onchain data",
href: `/team/${props.teamSlug}/${props.projectSlug}/insight`,
icon: InsightIcon,
trackingLabel: "insight",
},
{
title: "Nebula",
description:
"Integrate a blockchain AI model to improve your users insight into your application and the blockchain",
href: `/team/${props.teamSlug}/~/nebula`,
icon: NebulaIcon,
trackingLabel: "nebula",
},
];
return (
<div className="">
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Complete your full-stack application
</h2>
<p className="text-muted-foreground">
Tools to build frontend, backend, and onchain with built-in
infrastructure and analytics.
</p>
<div className="h-4" />
{/* Feature Cards */}
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<ProductCard
key={product.title}
title={product.title}
description={product.description}
href={product.href}
icon={product.icon}
trackingLabel={product.trackingLabel}
/>
))}
</section>
</div>
);
}
function ProductCard(props: {
title: string;
description: string;
href: string;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
}) {
return (
<div className="relative flex flex-col rounded-lg border bg-card p-4 hover:border-active-border">
<div className="mb-3 flex size-9 items-center justify-center rounded-full border p-1">
<props.icon className="size-5 text-muted-foreground" />
</div>
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
<TrackedLinkTW
href={props.href}
target="_blank"
className="before:absolute before:inset-0"
category="project-ftux"
label={props.trackingLabel}
>
{props.title}
</TrackedLinkTW>
</h3>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
);
}
// sdk section ------------------------------------------------------------
type SDKCardProps = {
name: string;
href: string;
icon: React.FC<{ className?: string }>;
trackingLabel: string;
};
const sdks: SDKCardProps[] = [
{
name: "TypeScript",
href: "https://portal.thirdweb.com/sdk/typescript",
icon: TypeScriptIcon,
trackingLabel: "typescript",
},
{
name: "React",
href: "https://portal.thirdweb.com/react/v5",
icon: ReactIcon,
trackingLabel: "react",
},
{
name: "React Native",
href: "https://portal.thirdweb.com/react-native/v5",
icon: ReactIcon,
trackingLabel: "react_native",
},
{
name: "Unity",
href: "https://portal.thirdweb.com/unity/v5",
icon: UnityIcon,
trackingLabel: "unity",
},
{
name: "Unreal Engine",
href: "https://portal.thirdweb.com/unreal-engine",
icon: UnrealIcon,
trackingLabel: "unreal",
},
{
name: ".NET",
href: "https://portal.thirdweb.com/dotnet",
icon: DotNetIcon,
trackingLabel: "dotnet",
},
];
function SDKSection() {
return (
<section>
<h2 className="mb-3 font-semibold text-xl tracking-tight">Client SDKs</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{sdks.map((sdk) => (
<SDKCard
key={sdk.name}
name={sdk.name}
href={sdk.href}
icon={sdk.icon}
trackingLabel={sdk.trackingLabel}
/>
))}
</div>
</section>
);
}
function SDKCard(props: SDKCardProps) {
return (
<div className="relative flex items-center gap-3 rounded-lg border bg-card p-4 hover:border-active-border">
<div className="flex size-9 items-center justify-center rounded-full border">
<props.icon className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<p className="font-medium text-base">
<TrackedLinkTW
href={props.href}
className="before:absolute before:inset-0"
target="_blank"
category="project-ftux"
label={props.trackingLabel}
>
{props.name}
</TrackedLinkTW>
</p>
<p className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
View Docs
<ExternalLinkIcon className="size-3" />
</p>
</div>
</div>
);
}
// starter kits section ------------------------------------------------------------
type StartedKitCardProps = {
name: string;
href: string;
trackingLabel: string;
};
const startedKits: StartedKitCardProps[] = [
{
name: "Next Starter",
href: "https://github.com/thirdweb-example/next-starter",
trackingLabel: "next_starter",
},
{
name: "Vite Starter",
href: "https://github.com/thirdweb-example/vite-starter",
trackingLabel: "vite_starter",
},
{
name: "Expo Starter",
href: "https://github.com/thirdweb-example/expo-starter",
trackingLabel: "expo_starter",
},
{
name: "Node Starter",
href: "https://github.com/thirdweb-example/node-starter",
trackingLabel: "node_starter",
},
];
function StarterKitsSection() {
return (
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="font-semibold text-xl tracking-tight">Starter Kits</h2>
<TrackedLinkTW
href="https://github.com/thirdweb-example"
target="_blank"
category="project-ftux"
label="view_all_templates"
className="inline-flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
>
View all <ChevronRightIcon className="size-3" />
</TrackedLinkTW>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{startedKits.map((kit) => (
<StarterKitCard
key={kit.name}
name={kit.name}
href={kit.href}
trackingLabel={kit.trackingLabel}
/>
))}
</div>
</section>
);
}
function StarterKitCard(props: StartedKitCardProps) {
return (
<div className="relative flex items-center gap-3 rounded-lg border bg-card p-4 hover:border-active-border">
<div className="flex size-9 items-center justify-center rounded-full border">
<GithubIcon className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<TrackedLinkTW
href={props.href}
target="_blank"
category="project-ftux"
label={props.trackingLabel}
className="before:absolute before:inset-0"
>
{props.name}
</TrackedLinkTW>
<p className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
View Repo
<ExternalLinkIcon className="size-3" />
</p>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import { rotateSecretKeyClient } from "@3rdweb-sdk/react/hooks/useApi";
import { useState } from "react";
import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage";
export function SecretKeySection(props: {
secretKeyMasked: string;
projectId: string;
}) {
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
return (
<div>
<h3>Secret Key</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies and authenticates your application from a backend. <br />{" "}
This is not the full secret key, Refer to your saved secret key at the
time of creation for the full secret key.
</p>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm lg:w-[350px]">
{secretKeyMasked}
</div>
<RotateSecretKeyButton
rotateSecretKey={async () => {
return rotateSecretKeyClient(props.projectId);
}}
onSuccess={(data) => {
setSecretKeyMasked(data.data.secretMasked);
}}
/>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { getProject } from "@/api/projects";
import { getSMSCountryTiers } from "@/api/sms";
import { getTeamBySlug } from "@/api/team";
import { redirect } from "next/navigation";
import { InAppWalletSettingsPage } from "../../../../../../../components/embedded-wallets/Configure";
@@ -9,9 +10,10 @@ export default async function Page(props: {
}) {
const { team_slug, project_slug } = await props.params;
const [team, project] = await Promise.all([
const [team, project, smsCountryTiers] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
getSMSCountryTiers(),
]);
if (!team) {
@@ -29,6 +31,7 @@ export default async function Page(props: {
trackingCategory="in-app-wallet-project-settings"
teamSlug={team_slug}
validTeamPlan={getValidTeamPlan(team)}
smsCountryTiers={smsCountryTiers}
/>
);
}
@@ -43,25 +43,21 @@ function Story() {
name: "60 Days",
onClick: () => setTab("60-day"),
isActive: tab === "60-day",
isEnabled: true,
},
{
name: "30 Days",
onClick: () => setTab("30-day"),
isActive: tab === "30-day",
isEnabled: true,
},
{
name: "7 Days",
onClick: () => setTab("7-day"),
isActive: tab === "7-day",
isEnabled: true,
},
{
name: "Pending",
onClick: () => setTab("pending"),
isActive: tab === "pending",
isEnabled: true,
},
]}
/>
@@ -31,8 +31,8 @@ import {
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
import { EmptyState } from "../../components/Analytics/EmptyState";
import { PieChartCard } from "../../components/Analytics/PieChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
@@ -85,11 +85,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
title={project.name}
interval={interval}
range={range}
showRangeSelector={isActive}
/>
</div>
{!isActive ? (
<div className="container p-6">
<EmptyState />
<div className="container py-8 pb-20">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex grow flex-col py-6">
@@ -406,7 +406,6 @@ function ProjectNameSetting(props: {
handleSubmit: () => void;
}) {
const { form, handleSubmit } = props;
const isNameDirty = form.getFieldState("name").isDirty;
return (
<SettingsCard
@@ -419,8 +418,8 @@ function ProjectNameSetting(props: {
errorText={form.getFieldState("name").error?.message}
saveButton={{
onClick: handleSubmit,
disabled: !isNameDirty,
isPending: props.isUpdatingProject && isNameDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
bottomText="Please use 64 characters at maximum"
>
@@ -501,7 +500,6 @@ function AllowedDomainsSetting(props: {
handleSubmit: () => void;
}) {
const { form, handleSubmit } = props;
const isDomainsDirty = form.getFieldState("domains").isDirty;
const helperText = (
<ul className="flex list-disc flex-col gap-1.5 py-1 pl-3 text-muted-foreground text-sm [&>li]:pl-1">
@@ -544,8 +542,8 @@ function AllowedDomainsSetting(props: {
errorText={form.getFieldState("domains", form.formState).error?.message}
saveButton={{
onClick: handleSubmit,
disabled: !isDomainsDirty,
isPending: props.isUpdatingProject && isDomainsDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
bottomText="This is only applicable for web applications"
>
@@ -606,13 +604,12 @@ function AllowedBundleIDsSetting(props: {
handleSubmit: () => void;
}) {
const { form, handleSubmit } = props;
const isBundleIdsDirty = form.getFieldState("bundleIds").isDirty;
return (
<SettingsCard
saveButton={{
onClick: handleSubmit,
disabled: !isBundleIdsDirty,
isPending: props.isUpdatingProject && isBundleIdsDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
noPermissionText={undefined}
header={{
@@ -715,7 +712,7 @@ function EnabledServicesSetting(props: {
errorText={undefined}
saveButton={{
onClick: handleSubmit,
disabled: !form.formState.isDirty,
disabled: false,
isPending: props.isUpdatingProject,
}}
bottomText=""
@@ -984,7 +981,7 @@ function DeleteProject(props: {
);
}
function RotateSecretKeyButton(props: {
export function RotateSecretKeyButton(props: {
rotateSecretKey: RotateSecretKey;
onSuccess: (data: RotateSecretKeyAPIReturnType) => void;
}) {
@@ -1219,8 +1216,6 @@ function TransferProject(props: {
selectedTeamId === props.currentTeamId ||
!props.isOwnerAccount;
console.log({ selectedTeam: selectedTeamWithRole, isDisabled });
const handleTransfer = () => {
if (!hasOwnerRole) {
return;
@@ -39,6 +39,7 @@ function Component() {
title="Project Overview"
interval="day"
range={getLastNDaysRange("last-120")}
showRangeSelector={true}
/>
</BadgeContainer>
</div>
@@ -5,8 +5,9 @@ export function AnalyticsHeader(props: {
title: string;
interval: "day" | "week";
range: Range;
showRangeSelector: boolean;
}) {
const { title, interval, range } = props;
const { title, interval, range, showRangeSelector } = props;
return (
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
@@ -15,7 +16,7 @@ export function AnalyticsHeader(props: {
{title}
</h1>
</div>
<RangeSelector interval={interval} range={range} />
{showRangeSelector && <RangeSelector interval={interval} range={range} />}
</div>
);
}
@@ -153,7 +153,6 @@ export function NotificationButtonInner(props: {
/>
),
onClick: () => setTab("inbox"),
isEnabled: true,
},
{
isActive: tab === "changelogs",
@@ -164,7 +163,6 @@ export function NotificationButtonInner(props: {
/>
),
onClick: () => setTab("changelogs"),
isEnabled: true,
},
]}
/>
@@ -63,6 +63,7 @@ const GAS_FREE_CHAINS = [
247253, // Saakuru Testnet
19011, // Homeverse Mainnet
40875, // Homeverse Testnet
5464, // Saga Mainnet
];
function useIsNetworkMismatch(txChainId: number) {
@@ -64,6 +64,14 @@ function Variants(props: {
isUpdating={false}
trackingCategory="foo"
updateApiKey={() => {}}
smsCountryTiers={{
// scaffold some countries to play around with the UI
tier1: ["US", "CA"],
tier2: ["GB", "AU", "NZ"],
tier3: ["FR", "DE", "ES", "IT"],
tier4: ["JP", "KR", "MX", "RU"],
tier5: ["BR", "AR", "CO", "CL", "PE", "VE", "SA"],
}}
/>
</div>
</div>
@@ -1,6 +1,7 @@
"use client";
import type { Project } from "@/api/projects";
import type { SMSCountryTiers } from "@/api/sms";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
@@ -36,6 +37,7 @@ import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { toArrFromList } from "utils/string";
import type { Team } from "../../../@/api/team";
import CountrySelector from "./sms-country-select/country-selector";
type InAppWalletSettingsPageProps = {
trackingCategory: string;
@@ -43,6 +45,7 @@ type InAppWalletSettingsPageProps = {
teamId: string;
teamSlug: string;
validTeamPlan: Team["billingPlan"];
smsCountryTiers: SMSCountryTiers;
};
const TRACKING_CATEGORY = "embedded-wallet";
@@ -108,6 +111,7 @@ export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) {
canEditAdvancedFeatures={props.validTeamPlan !== "free"}
updateApiKey={handleUpdateProject}
isUpdating={updateProject.isPending}
smsCountryTiers={props.smsCountryTiers}
/>
);
}
@@ -120,6 +124,7 @@ const InAppWalletSettingsPageUI: React.FC<
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
smsCountryTiers: SMSCountryTiers;
}
> = (props) => {
const embeddedWalletService = props.project.services.find(
@@ -185,6 +190,11 @@ export const InAppWalletSettingsUI: React.FC<
}
: undefined),
redirectUrls: (config.redirectUrls || []).join("\n"),
smsEnabledCountryISOs: config.smsEnabledCountryISOs
? config.smsEnabledCountryISOs
: canEditAdvancedFeatures
? ["US", "CA"]
: [],
},
});
@@ -228,6 +238,7 @@ export const InAppWalletSettingsUI: React.FC<
applicationImageUrl: branding?.applicationImageUrl,
applicationName: branding?.applicationName || props.project.name,
redirectUrls: toArrFromList(redirectUrls || "", true),
smsEnabledCountryISOs: values.smsEnabledCountryISOs,
};
});
@@ -256,6 +267,8 @@ export const InAppWalletSettingsUI: React.FC<
canEditAdvancedFeatures={canEditAdvancedFeatures}
/>
<NativeAppsFieldset form={form} />
{/* Authentication */}
<Fieldset legend="Authentication">
<JSONWebTokenFields
@@ -269,9 +282,15 @@ export const InAppWalletSettingsUI: React.FC<
form={form}
canEditAdvancedFeatures={canEditAdvancedFeatures}
/>
</Fieldset>
<NativeAppsFieldset form={form} />
<div className="h-5" />
<SMSCountryFields
form={form}
canEditAdvancedFeatures={canEditAdvancedFeatures}
smsCountryTiers={props.smsCountryTiers}
/>
</Fieldset>
<div className="flex justify-end">
<Button type="submit" variant="primary" className="gap-2">
@@ -364,6 +383,61 @@ function BrandingFieldset(props: {
);
}
function SMSCountryFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
canEditAdvancedFeatures: boolean;
smsCountryTiers: SMSCountryTiers;
}) {
return (
<div>
<SwitchContainer
switchId="sms-switch"
title="SMS"
description="Optionally allow users in selected countries to login via SMS OTP."
>
<GatedSwitch
id="sms-switch"
trackingLabel="sms"
checked={
!!props.form.watch("smsEnabledCountryISOs").length &&
props.canEditAdvancedFeatures
}
upgradeRequired={!props.canEditAdvancedFeatures}
onCheckedChange={(checked) =>
props.form.setValue(
"smsEnabledCountryISOs",
checked
? // by default, enable US and CA only
["US", "CA"]
: [],
)
}
/>
</SwitchContainer>
<AdvancedConfigurationContainer
className="grid grid-cols-1"
show={
props.canEditAdvancedFeatures &&
!!props.form.watch("smsEnabledCountryISOs").length
}
>
<FormField
control={props.form.control}
name="smsEnabledCountryISOs"
render={({ field }) => (
<CountrySelector
countryTiers={props.smsCountryTiers}
selected={field.value}
onChange={field.onChange}
/>
)}
/>
</AdvancedConfigurationContainer>
</div>
);
}
function JSONWebTokenFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
canEditAdvancedFeatures: boolean;
@@ -0,0 +1,179 @@
import type { SMSCountryTiers } from "@/api/sms";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { CheckIcon, MinusIcon } from "lucide-react";
import {
countryNames,
countryPrefixes,
getCountryFlag,
tierPricing,
} from "./utils";
interface CountrySelectorProps {
countryTiers: SMSCountryTiers;
selected: string[];
onChange: (selectedCountries: string[]) => void;
}
export default function CountrySelector({
countryTiers,
selected,
onChange,
}: CountrySelectorProps) {
// Helper function to check if a country is selected
const isCountrySelected = (country: string) => selected.includes(country);
// Check if all countries in a tier are selected
const isTierSelected = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
return tierCountries.every((country) => isCountrySelected(country));
};
// Check if some countries in a tier are selected
const isTierIndeterminate = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
const selectedInTier = tierCountries.filter((country) =>
isCountrySelected(country),
);
return (
selectedInTier.length > 0 && selectedInTier.length < tierCountries.length
);
};
// Toggle a tier selection
const toggleTier = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
let newSelected: string[];
if (isTierSelected(tier)) {
// Deselect all countries in this tier
newSelected = selected.filter(
(country) => !tierCountries.includes(country),
);
} else {
// Select all countries in this tier
const currentSelected = new Set(selected);
for (const country of tierCountries) {
currentSelected.add(country);
}
newSelected = Array.from(currentSelected);
}
// Call onChange with the updated selection
onChange(newSelected);
};
// Toggle a single country selection
const toggleCountry = (country: string) => {
let newSelected: string[];
if (isCountrySelected(country)) {
// Remove country from selection
newSelected = selected.filter((c) => c !== country);
} else {
// Add country to selection
newSelected = [...selected, country];
}
// Call onChange with the updated selection
onChange(newSelected);
};
// Get selected countries count for a tier
const getSelectedCountInTier = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
return tierCountries.filter((country) => isCountrySelected(country)).length;
};
return (
<div className="space-y-3">
{Object.entries(countryTiers).map(([tier, countries], index) => (
<div key={tier} className="overflow-hidden rounded-md border">
<div className="flex items-center bg-muted/30 p-2">
<div className="relative mr-2 flex items-center">
<Checkbox
id={`tier-${index + 1}`}
checked={isTierSelected(tier)}
onCheckedChange={() => toggleTier(tier)}
className="h-4 w-4"
/>
{isTierIndeterminate(tier) && (
<MinusIcon className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-1/2 h-2.5 w-2.5 transform text-primary" />
)}
</div>
<label
htmlFor={`tier-${index + 1}`}
className="flex-1 cursor-pointer font-medium text-sm"
>
Tier {index + 1}
{isTierIndeterminate(tier) && (
<span className="ml-1 font-normal text-muted-foreground text-xs">
({getSelectedCountInTier(tier)}/{countries.length})
</span>
)}
</label>
<span
className={cn(
"font-medium text-xs",
tier === "tier1"
? "text-green-600 dark:text-green-500"
: "text-muted-foreground",
)}
>
{tierPricing[tier as keyof typeof tierPricing]}
</span>
</div>
<div
className={cn(
"grid grid-cols-2 gap-1.5 p-2",
countries.length === 2
? "md:grid-cols-2"
: countries.length === 3
? "md:grid-cols-3"
: "md:grid-cols-4",
)}
>
{countries.map((country) => (
<div
key={country}
className={cn(
"relative flex cursor-pointer items-center justify-between rounded-md border p-1.5 transition-colors ",
isCountrySelected(country)
? "border-primary bg-primary/10"
: "border-transparent hover:bg-muted/50",
)}
onClick={() => toggleCountry(country)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleCountry(country);
}
}}
role="button"
tabIndex={0}
title={countryNames[country] || country}
>
<span className="flex items-center space-x-2">
<span className="text-lg" aria-hidden="true">
{getCountryFlag(country)}
</span>
<span className="font-medium text-xs">
{countryNames[country] || country}
{countryPrefixes[country]
? ` (${countryPrefixes[country]})`
: ""}
</span>
</span>
<div className="flex h-3 w-3 items-center justify-center">
{isCountrySelected(country) && (
<CheckIcon className="h-3 w-3 text-primary" />
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,465 @@
// Pricing for each tier
export const tierPricing = {
tier1: "Included",
tier2: "$0.05 per SMS",
tier3: "$0.10 per SMS",
tier4: "$0.20 per SMS",
tier5: "$0.40 per SMS",
} as const;
// Country names mapped to ISO codes
export const countryNames: Record<string, string> = {
// Tier 1
CA: "Canada",
US: "United States",
// Tier 2
BH: "Bahrain",
CN: "China",
CR: "Costa Rica",
GB: "United Kingdom",
GL: "Greenland",
KR: "Korea Republic of",
LI: "Liechtenstein",
NF: "Norfolk Island",
PL: "Poland",
PR: "Puerto Rico",
PT: "Portugal",
SG: "Singapore",
TH: "Thailand",
TR: "Turkey",
VI: "Virgin Islands, U.S.",
// Tier 3
AR: "Argentina",
AT: "Austria",
AU: "Australia",
BA: "Bosnia and Herzegovina",
BN: "Brunei",
BR: "Brazil",
BS: "Bahamas",
BW: "Botswana",
CH: "Switzerland",
CL: "Chile",
CO: "Colombia",
CU: "Cuba",
CY: "Cyprus",
CZ: "Czech Republic",
DE: "Germany",
DK: "Denmark",
EE: "Estonia",
ES: "Spain",
FI: "Finland",
FM: "Micronesia",
FO: "Faroe Islands",
FR: "France",
GR: "Greece",
GU: "Guam",
HK: "Hong Kong",
HU: "Hungary",
IE: "Ireland",
IN: "India",
IS: "Iceland",
IT: "Italy",
JP: "Japan",
LT: "Lithuania",
LU: "Luxembourg",
LV: "Latvia",
MD: "Moldova",
MK: "Macedonia",
MO: "Macao",
MT: "Malta",
NA: "Namibia",
NO: "Norway",
PW: "Palau",
RO: "Romania",
SB: "Solomon Islands",
SE: "Sweden",
SK: "Slovakia",
TW: "Taiwan",
UY: "Uruguay",
ZA: "South Africa",
// Tier 4
AD: "Andorra",
AE: "United Arab Emirates",
AG: "Antigua and Barbuda",
AI: "Anguilla",
AL: "Albania",
AM: "Armenia",
AO: "Angola",
AS: "American Samoa",
AW: "Aruba",
BB: "Barbados",
BE: "Belgium",
BG: "Bulgaria",
CK: "Cook Islands",
CM: "Cameroon",
DJ: "Djibouti",
DO: "Dominican Republic",
ER: "Eritrea",
FJ: "Fiji",
FK: "Falkland Islands",
GE: "Georgia",
GF: "French Guiana",
GI: "Gibraltar",
GM: "Gambia",
GN: "Guinea",
GP: "Guadeloupe",
GQ: "Equatorial Guinea",
HR: "Croatia",
KI: "Kiribati",
KN: "St Kitts and Nevis",
LR: "Liberia",
MC: "Monaco",
ME: "Montenegro",
MH: "Marshall Islands",
MQ: "Martinique",
MS: "Montserrat",
MU: "Mauritius",
MX: "Mexico",
NC: "New Caledonia",
NI: "Nicaragua",
NL: "Netherlands",
NU: "Niue",
NZ: "New Zealand",
OM: "Oman",
PA: "Panama",
PF: "French Polynesia",
PM: "St Pierre and Miquelon",
PY: "Paraguay",
RE: "Reunion/Mayotte",
SA: "Saudi Arabia",
SI: "Slovenia",
SL: "Sierra Leone",
SM: "San Marino",
SR: "Suriname",
ST: "Sao Tome and Principe",
SV: "El Salvador",
TC: "Turks and Caicos Islands",
TO: "Tonga",
TV: "Tuvalu",
UA: "Ukraine",
VE: "Venezuela",
VN: "Vietnam",
WF: "Wallis and Futuna",
// Tier 5
AF: "Afghanistan",
AZ: "Azerbaijan",
BD: "Bangladesh",
BF: "Burkina Faso",
BI: "Burundi",
BJ: "Benin",
BM: "Bermuda",
BO: "Bolivia",
BY: "Belarus",
BZ: "Belize",
CD: "DR Congo",
CG: "Congo",
CI: "Ivory Coast",
CV: "Cape Verde",
DM: "Dominica",
DZ: "Algeria",
EC: "Ecuador",
EG: "Egypt",
ET: "Ethiopia",
GA: "Gabon",
GD: "Grenada",
GH: "Ghana",
GT: "Guatemala",
GW: "Guinea-Bissau",
GY: "Guyana",
HN: "Honduras",
HT: "Haiti",
IL: "Israel",
IQ: "Iraq",
JM: "Jamaica",
JO: "Jordan",
KE: "Kenya",
KG: "Kyrgyzstan",
KH: "Cambodia",
KW: "Kuwait",
KY: "Cayman Islands",
KZ: "Kazakhstan",
LA: "Laos PDR",
LB: "Lebanon",
LC: "St Lucia",
LK: "Sri Lanka",
LS: "Lesotho",
LY: "Libya",
MA: "Morocco",
MG: "Madagascar",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
MR: "Mauritania",
MV: "Maldives",
MW: "Malawi",
MY: "Malaysia",
MZ: "Mozambique",
NE: "Niger",
NG: "Nigeria",
NP: "Nepal",
PE: "Peru",
PH: "Philippines",
PK: "Pakistan",
PS: "Palestinian Territory",
QA: "Qatar",
RS: "Serbia",
RW: "Rwanda",
SC: "Seychelles",
SD: "Sudan",
SN: "Senegal",
SO: "Somalia",
SS: "South Sudan",
SY: "Syria",
SZ: "Swaziland",
TD: "Chad",
TG: "Togo",
TL: "East Timor",
TM: "Turkmenistan",
TN: "Tunisia",
TT: "Trinidad and Tobago",
UG: "Uganda",
VC: "St Vincent Grenadines",
VG: "Virgin Islands, British",
VU: "Vanuatu",
WS: "Samoa",
YE: "Yemen",
ZM: "Zambia",
ZW: "Zimbabwe",
} as const;
export const countryPrefixes: Record<string, string> = {
// Tier 1
CA: "+1",
US: "+1",
// Tier 2
BH: "+973",
CN: "+86",
CR: "+506",
GB: "+44",
GL: "+299",
KR: "+82",
LI: "+423",
NF: "+672",
PL: "+48",
PR: "+1",
PT: "+351",
SG: "+65",
TH: "+66",
TR: "+90",
VI: "+1",
// Tier 3
AR: "+54",
AT: "+43",
AU: "+61",
BA: "+387",
BN: "+673",
BR: "+55",
BS: "+1",
BW: "+267",
CH: "+41",
CL: "+56",
CO: "+57",
CU: "+53",
CY: "+357",
CZ: "+420",
DE: "+49",
DK: "+45",
EE: "+372",
ES: "+34",
FI: "+358",
FM: "+691",
FO: "+298",
FR: "+33",
GR: "+30",
GU: "+1",
HK: "+852",
HU: "+36",
IE: "+353",
IN: "+91",
IS: "+354",
IT: "+39",
JP: "+81",
LT: "+370",
LU: "+352",
LV: "+371",
MD: "+373",
MK: "+389",
MO: "+853",
MT: "+356",
NA: "+264",
NO: "+47",
PW: "+680",
RO: "+40",
SB: "+677",
SE: "+46",
SK: "+421",
TW: "+886",
UY: "+598",
ZA: "+27",
// Tier 4
AD: "+376",
AE: "+971",
AG: "+1",
AI: "+1",
AL: "+355",
AM: "+374",
AO: "+244",
AS: "+1",
AW: "+297",
BB: "+1",
BE: "+32",
BG: "+359",
CK: "+682",
CM: "+237",
DJ: "+253",
DO: "+1",
ER: "+291",
FJ: "+679",
FK: "+500",
GE: "+995",
GF: "+594",
GI: "+350",
GM: "+220",
GN: "+224",
GP: "+590",
GQ: "+240",
HR: "+385",
KI: "+686",
KN: "+1",
LR: "+231",
MC: "+377",
ME: "+382",
MH: "+692",
MQ: "+596",
MS: "+1",
MU: "+230",
MX: "+52",
NC: "+687",
NI: "+505",
NL: "+31",
NU: "+683",
NZ: "+64",
OM: "+968",
PA: "+507",
PF: "+689",
PM: "+508",
PY: "+595",
RE: "+262",
SA: "+966",
SI: "+386",
SL: "+232",
SM: "+378",
SR: "+597",
ST: "+239",
SV: "+503",
TC: "+1",
TO: "+676",
TV: "+688",
UA: "+380",
VE: "+58",
VN: "+84",
WF: "+681",
// Tier 5
AF: "+93",
AZ: "+994",
BD: "+880",
BF: "+226",
BI: "+257",
BJ: "+229",
BM: "+1",
BO: "+591",
BY: "+375",
BZ: "+501",
CD: "+243",
CG: "+242",
CI: "+225",
CV: "+238",
DM: "+1",
DZ: "+213",
EC: "+593",
EG: "+20",
ET: "+251",
GA: "+241",
GD: "+1",
GH: "+233",
GT: "+502",
GW: "+245",
GY: "+592",
HN: "+504",
HT: "+509",
IL: "+972",
IQ: "+964",
JM: "+1",
JO: "+962",
KE: "+254",
KG: "+996",
KH: "+855",
KW: "+965",
KY: "+1",
KZ: "+7",
LA: "+856",
LB: "+961",
LC: "+1",
LK: "+94",
LS: "+266",
LY: "+218",
MA: "+212",
MG: "+261",
ML: "+223",
MM: "+95",
MN: "+976",
MR: "+222",
MV: "+960",
MW: "+265",
MY: "+60",
MZ: "+258",
NE: "+227",
NG: "+234",
NP: "+977",
PE: "+51",
PH: "+63",
PK: "+92",
PS: "+970",
QA: "+974",
RS: "+381",
RW: "+250",
SC: "+248",
SD: "+249",
SN: "+221",
SO: "+252",
SS: "+211",
SY: "+963",
SZ: "+268",
TD: "+235",
TG: "+228",
TL: "+670",
TM: "+993",
TN: "+216",
TT: "+1",
UG: "+256",
VC: "+1",
VG: "+1",
VU: "+678",
WS: "+685",
YE: "+967",
ZM: "+260",
ZW: "+263",
} as const;
// Helper function to convert country code to flag emoji
export const getCountryFlag = (countryCode: string) => {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
@@ -109,6 +109,7 @@ export const apiKeyEmbeddedWalletsValidationSchema = z.object({
}),
]),
redirectUrls: redirectUriSchema,
smsEnabledCountryISOs: z.array(z.string()),
});
export const apiKeyPayConfigValidationSchema = z.object({
+1
View File
@@ -55,6 +55,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
"relayer",
"chainsaw",
],
stripeCustomerId: "cus_1234567890",
};
return team;
+7 -7
View File
@@ -23,10 +23,10 @@
"@radix-ui/react-tooltip": "1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "6.0.8",
"lucide-react": "0.477.0",
"next": "15.2.0",
"next-themes": "^0.4.4",
"jose": "6.0.10",
"lucide-react": "0.483.0",
"next": "15.2.3",
"next-themes": "^0.4.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"server-only": "^0.0.1",
@@ -37,11 +37,11 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.0",
"@types/node": "22.13.9",
"@types/node": "22.13.10",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"eslint": "9.21.0",
"eslint-config-next": "15.2.0",
"eslint": "9.22.0",
"eslint-config-next": "15.2.3",
"postcss": "8.5.3",
"tailwindcss": "3.4.17",
"typescript": "5.8.2"
+8 -8
View File
@@ -31,17 +31,17 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "1.1.8",
"@tanstack/react-query": "5.67.1",
"@tanstack/react-query": "5.67.3",
"@thirdweb-dev/engine": "^0.0.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "6.0.8",
"lucide-react": "0.477.0",
"next": "15.2.0",
"next-themes": "^0.4.4",
"jose": "6.0.10",
"lucide-react": "0.483.0",
"next": "15.2.3",
"next-themes": "^0.4.6",
"nextjs-toploader": "^1.6.12",
"openapi-types": "^12.1.3",
"prettier": "3.3.3",
"prettier": "3.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "7.54.2",
@@ -55,11 +55,11 @@
"zod": "3.24.2"
},
"devDependencies": {
"@types/node": "22.13.9",
"@types/node": "22.13.10",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"eslint": "8.57.0",
"eslint-config-next": "15.2.0",
"eslint-config-next": "15.2.3",
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"postcss": "8.5.3",
"tailwindcss": "3.4.17",
@@ -0,0 +1,180 @@
import { Suspense, lazy } from "react";
import { CodeLoading } from "../../../../components/code/code.client";
import type { PayEmbedPlaygroundOptions } from "./types";
const CodeClient = lazy(
() => import("../../../../components/code/code.client"),
);
export function CodeGen(props: {
options: PayEmbedPlaygroundOptions;
}) {
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient
code={getCode(props.options)}
lang="tsx"
loader={<CodeLoading />}
// Need to add max-h in both places - TODO figure out a better way
className="xl:h-[calc(100vh-100px)]"
scrollableClassName="xl:h-[calc(100vh-100px)]"
/>
</Suspense>
</div>
);
}
function getCode(options: PayEmbedPlaygroundOptions) {
const walletCodes: string[] = [];
const imports = {
react: ["PayEmbed"] as string[],
thirdweb: [] as string[],
wallets: [] as string[],
chains: [] as string[],
};
// Check if we have a custom chain (not base chain which has id 8453)
const isCustomChain =
options.payOptions.buyTokenChain &&
options.payOptions.buyTokenChain.id !== 8453;
if (isCustomChain) {
// Add defineChain to imports if using a custom chain
imports.thirdweb.push("defineChain");
} else {
// Otherwise use the base chain
imports.chains.push("base");
}
// Generate chain reference code
let chainCode: string;
if (isCustomChain && options.payOptions.buyTokenChain?.id) {
chainCode = `defineChain(${options.payOptions.buyTokenChain.id})`;
} else {
chainCode = "base";
}
for (const wallet of options.connectOptions.walletIds) {
walletCodes.push(`createWallet("${wallet}")`);
}
if (options.connectOptions.walletIds.length > 0) {
imports.wallets.push("createWallet");
}
let themeProp: string | undefined;
if (
options.theme.type === "dark" &&
Object.keys(options.theme.darkColorOverrides || {}).length > 0
) {
themeProp = `darkTheme({
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
})`;
imports.react.push("darkTheme");
}
if (options.theme.type === "light") {
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
themeProp = `lightTheme({
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
})`;
imports.react.push("lightTheme");
} else {
themeProp = quotes("light");
}
}
if (options.connectOptions.enableAccountAbstraction) {
imports.chains.push("sepolia");
}
// Generate payOptions based on the mode
let payOptionsCode = "{";
if (options.payOptions.title || options.payOptions.image) {
payOptionsCode += `
metadata: {
${options.payOptions.title ? `name: ${quotes(options.payOptions.title)},` : ""}
${options.payOptions.image ? `image: ${quotes(options.payOptions.image)},` : ""}
},`;
}
// Add mode-specific options
if (options.payOptions.mode) {
payOptionsCode += `
mode: "${options.payOptions.mode}",`;
// Add buyWithCrypto and buyWithFiat if they're set to false
if (options.payOptions.buyWithCrypto === false) {
payOptionsCode += `
buyWithCrypto: false,`;
}
if (options.payOptions.buyWithFiat === false) {
payOptionsCode += `
buyWithFiat: false,`;
}
if (options.payOptions.mode === "fund_wallet" || !options.payOptions.mode) {
payOptionsCode += `
prefillBuy: {
chain: ${chainCode},
amount: ${options.payOptions.buyTokenAmount ? quotes(options.payOptions.buyTokenAmount) : '"0.01"'},
${options.payOptions.buyTokenInfo ? `token: ${JSON.stringify(options.payOptions.buyTokenInfo)},` : ""}
},`;
} else if (options.payOptions.mode === "direct_payment") {
payOptionsCode += `
paymentInfo: {
chain: ${chainCode},
sellerAddress: ${options.payOptions.sellerAddress ? quotes(options.payOptions.sellerAddress) : '"0x0000000000000000000000000000000000000000"'},
amount: ${options.payOptions.buyTokenAmount ? quotes(options.payOptions.buyTokenAmount) : '"0.01"'},
${options.payOptions.buyTokenInfo ? `token: ${JSON.stringify(options.payOptions.buyTokenInfo)},` : ""}
},`;
} else if (options.payOptions.mode === "transaction") {
payOptionsCode += `
transaction: claimTo({
contract: myNftContract,
quantity: 1n,
tokenId: 0n,
to: "0x...",
}),`;
}
}
payOptionsCode += `
}`;
const accountAbstractionCode = options.connectOptions.enableAccountAbstraction
? `\n accountAbstraction: {
chain: ${isCustomChain ? `defineChain(${options.payOptions.buyTokenChain?.id})` : "base"},
sponsorGas: true,
}`
: "";
const connectOptionsCode = `${accountAbstractionCode ? `{${accountAbstractionCode}\n }` : ""}`;
return `\
import { createThirdwebClient } from "thirdweb";
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
${imports.thirdweb.length > 0 ? `import { ${imports.thirdweb.join(", ")} } from "thirdweb";` : ""}
${imports.wallets.length > 0 ? `import { ${imports.wallets.join(", ")} } from "thirdweb/wallets";` : ""}
${imports.chains.length > 0 ? `import { ${imports.chains.join(", ")} } from "thirdweb/chains";` : ""}
const client = createThirdwebClient({
clientId: "....",
});
function Example() {
return (
<PayEmbed
client={client}
payOptions={${payOptionsCode}}${connectOptionsCode ? `\n connectOptions={${connectOptionsCode}}` : ""}${themeProp ? `\n theme={${themeProp}}` : ""}
/>
);
}`;
}
function quotes(value: string) {
return `"${value}"`;
}
@@ -0,0 +1,43 @@
import type { Chain } from "thirdweb";
import type { LocaleId, ThemeOverrides, TokenInfo } from "thirdweb/react";
import type { WalletId } from "thirdweb/wallets";
export type PayEmbedPlaygroundOptions = {
theme: {
type: "dark" | "light";
darkColorOverrides: ThemeOverrides["colors"];
lightColorOverrides: ThemeOverrides["colors"];
};
payOptions: {
mode?: "fund_wallet" | "direct_payment" | "transaction";
title: string | undefined;
image: string | undefined;
// fund_wallet mode options
buyTokenAddress: string | undefined;
buyTokenAmount: string | undefined;
buyTokenChain: Chain | undefined;
buyTokenInfo?: TokenInfo;
buyWithCrypto?: boolean;
buyWithFiat?: boolean;
// direct_payment mode options
sellerAddress?: string;
// transaction mode options
transactionData?: string; // Simplified for demo; could be more complex in real implementation
};
connectOptions: {
walletIds: WalletId[];
modalTitle: string | undefined;
modalTitleIcon: string | undefined;
localeId: LocaleId;
enableAuth: boolean;
enableAccountAbstraction: boolean;
termsOfServiceLink: string | undefined;
privacyPolicyLink: string | undefined;
buttonLabel: string | undefined;
ShowThirdwebBranding: boolean;
requireApproval: boolean;
};
};
@@ -0,0 +1,556 @@
"use client";
import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
CreditCardIcon,
ExternalLinkIcon,
FuelIcon,
PaletteIcon,
Settings2Icon,
} from "lucide-react";
import Link from "next/link";
import type React from "react";
import { useEffect, useState } from "react";
import { defineChain } from "thirdweb/chains";
import { Switch } from "../../../../components/ui/switch";
import { CollapsibleSection } from "../../sign-in/components/CollapsibleSection";
import { ColorFormGroup } from "../../sign-in/components/ColorFormGroup";
import type { PayEmbedPlaygroundOptions } from "../components/types";
export function LeftSection(props: {
options: PayEmbedPlaygroundOptions;
setOptions: React.Dispatch<React.SetStateAction<PayEmbedPlaygroundOptions>>;
}) {
const { options, setOptions } = props;
const { theme, payOptions } = options;
const setThemeType = (themeType: "dark" | "light") => {
setOptions((v) => ({
...v,
theme: {
...v.theme,
type: themeType,
},
}));
};
// Local token state that persists between modes
const [tokenName, setTokenName] = useState<string>(
payOptions.buyTokenInfo?.name || "",
);
const [tokenSymbol, setTokenSymbol] = useState<string>(
payOptions.buyTokenInfo?.symbol || "",
);
const [tokenAddress, setTokenAddress] = useState<string>(
payOptions.buyTokenInfo?.address || "",
);
const [tokenIcon, setTokenIcon] = useState<string>(
payOptions.buyTokenInfo?.icon || "",
);
// Determine if highlighting is needed - if any field is filled but not all required fields
const needsHighlighting =
(tokenName || tokenSymbol || tokenAddress) &&
(!tokenName || !tokenSymbol || !tokenAddress);
// Update the global state when all required fields are filled
useEffect(() => {
if (tokenName && tokenSymbol && tokenAddress) {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyTokenInfo: {
name: tokenName,
symbol: tokenSymbol,
address: tokenAddress,
...(tokenIcon ? { icon: tokenIcon } : {}),
},
},
}));
} else if (!tokenName && !tokenSymbol && !tokenAddress) {
// If all fields are empty, set buyTokenInfo to undefined
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyTokenInfo: undefined,
},
}));
}
}, [tokenName, tokenSymbol, tokenAddress, tokenIcon, setOptions]);
return (
<div className="flex flex-col gap-4">
{/* <CollapsibleSection
title="Wallets"
icon={WalletCardsIcon}
defaultOpen
triggerContainerClassName="pt-0"
>
<div className="h-4" />
<WalletsSelection {...props} />
</CollapsibleSection> */}
<CollapsibleSection
title="Payment Options"
icon={CreditCardIcon}
defaultOpen
>
<div className="flex flex-col gap-6 pt-5">
<section className="flex flex-col gap-3">
<Label htmlFor="pay-mode">Mode</Label>
<CustomRadioGroup
id="pay-mode"
options={[
{ value: "fund_wallet", label: "Fund Wallet" },
{ value: "direct_payment", label: "Direct Payment" },
{ value: "transaction", label: "Transaction" },
]}
onValueChange={(value) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
mode: value as
| "fund_wallet"
| "direct_payment"
| "transaction",
},
}));
}}
value={payOptions.mode || "fund_wallet"}
/>
</section>
{/* Conditional form fields based on selected mode */}
<div className="mt-4">
{/* Fund Wallet Mode Options */}
{(!payOptions.mode || payOptions.mode === "fund_wallet") && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="buy-token-amount">Amount</Label>
<Input
id="buy-token-amount"
placeholder="0.01"
value={payOptions.buyTokenAmount || ""}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyTokenAmount: e.target.value,
},
}))
}
/>
</div>
{/* Chain selection */}
<div className="flex flex-col gap-2">
<Label htmlFor="fund-wallet-chain-id">Chain ID</Label>
<Input
id="fund-wallet-chain-id"
type="number"
placeholder="1 (Ethereum)"
value={payOptions.buyTokenChain?.id || ""}
onChange={(e) => {
const chainId = Number.parseInt(e.target.value);
if (!Number.isNaN(chainId)) {
const chain = defineChain(chainId);
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyTokenChain: chain,
},
}));
}
}}
/>
</div>
</div>
{/* Token selection for fund_wallet mode */}
<div className="flex flex-col gap-2">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-name">Token Name</Label>
<Input
id="token-name"
placeholder="Name"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
className={
needsHighlighting && !tokenName
? "border-red-500"
: ""
}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-symbol">Token Symbol</Label>
<Input
id="token-symbol"
placeholder="Symbol"
value={tokenSymbol}
onChange={(e) => setTokenSymbol(e.target.value)}
className={
needsHighlighting && !tokenSymbol
? "border-red-500"
: ""
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-address">Token Address</Label>
<Input
id="token-address"
placeholder="0x..."
value={tokenAddress}
onChange={(e) => setTokenAddress(e.target.value)}
className={
needsHighlighting && !tokenAddress
? "border-red-500"
: ""
}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-icon">
Token Icon URL (optional)
</Label>
<Input
id="token-icon"
placeholder="https://..."
value={tokenIcon}
onChange={(e) => setTokenIcon(e.target.value)}
/>
</div>
</div>
{needsHighlighting && (
<div className="mt-1 text-red-500 text-xs">
All three token fields (Name, Symbol, and Address) are
required
</div>
)}
</div>
</div>
</div>
)}
{/* Direct Payment Mode Options */}
{payOptions.mode === "direct_payment" && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label htmlFor="seller-address">Seller Address</Label>
<Input
id="seller-address"
placeholder="0x0000..."
value={payOptions.sellerAddress || ""}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
sellerAddress: e.target.value,
},
}))
}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="payment-amount">Price</Label>
<Input
id="payment-amount"
placeholder="0.01"
value={payOptions.buyTokenAmount || ""}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyTokenAmount: e.target.value,
},
}))
}
/>
</div>
{/* Chain selection */}
<div className="flex flex-col gap-2">
<Label htmlFor="direct-payment-chain-id">Chain ID</Label>
<Input
id="direct-payment-chain-id"
type="number"
placeholder="1 (Ethereum)"
value={payOptions.buyTokenChain?.id || ""}
onChange={(e) => {
const chainId = Number.parseInt(e.target.value);
if (!Number.isNaN(chainId)) {
const chain = defineChain(chainId);
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyTokenChain: chain,
},
}));
}
}}
/>
</div>
</div>
{/* Token selection for direct_payment mode - shares state with fund_wallet mode */}
<div className="flex flex-col gap-2">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-name">Token Name</Label>
<Input
id="token-name"
placeholder="Name"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
className={
needsHighlighting && !tokenName
? "border-red-500"
: ""
}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-symbol">Token Symbol</Label>
<Input
id="token-symbol"
placeholder="Symbol"
value={tokenSymbol}
onChange={(e) => setTokenSymbol(e.target.value)}
className={
needsHighlighting && !tokenSymbol
? "border-red-500"
: ""
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-4">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-address">Token Address</Label>
<Input
id="token-address"
placeholder="0x..."
value={tokenAddress}
onChange={(e) => setTokenAddress(e.target.value)}
className={
needsHighlighting && !tokenAddress
? "border-red-500"
: ""
}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="token-icon">
Token Icon URL (optional)
</Label>
<Input
id="token-icon"
placeholder="https://..."
value={tokenIcon}
onChange={(e) => setTokenIcon(e.target.value)}
/>
</div>
</div>
{needsHighlighting && (
<div className="mt-1 text-red-500 text-xs">
All three token fields (Name, Symbol, and Address) are
required
</div>
)}
</div>
</div>
</div>
)}
{/* Transaction Mode Options */}
{payOptions.mode === "transaction" && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label>Transaction</Label>
<p className="text-muted-foreground text-sm">
This demo uses a ERC1155 NFT claim transaction. Check the
code section for the transaction details. You can provide
any contract call or transfer here, the price will be
automatically inferred from the transaction itself.
</p>
</div>
</div>
)}
</div>
<div className="mt-4 flex flex-col gap-4">
<Label>Payment Methods</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-1 items-center space-x-2">
<Checkbox
id="buy-with-crypto"
checked={payOptions.buyWithCrypto}
onCheckedChange={(checked) =>
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyWithCrypto: checked === true,
},
}))
}
/>
<Label htmlFor="buy-with-crypto" className="text-xs">
Buy with Crypto
</Label>
</div>
<div className="flex flex-1 items-center space-x-2">
<Checkbox
id="buy-with-fiat"
checked={payOptions.buyWithFiat}
onCheckedChange={(checked) =>
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
buyWithFiat: checked === true,
},
}))
}
/>
<Label htmlFor="buy-with-fiat" className="text-xs">
Buy with Fiat
</Label>
</div>
</div>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Metadata Options" icon={Settings2Icon}>
<div className="flex flex-col gap-6 pt-5">
{/* Locale TODO (pay) */}
{/* <LocaleFormControl
connectOptions={options}
setConnectOptions={setConnectOptions}
/> */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-4">
{/* Modal title */}
<div className="flex flex-col gap-2">
<Label htmlFor="modal-title">Title</Label>
<Input
id="modal-title"
placeholder="Product name"
value={options.payOptions.title}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...payOptions,
title: e.target.value,
},
}))
}
/>
</div>
{/* Modal Title Icon */}
<div className="flex flex-col gap-2">
<Label htmlFor="modal-title">Image</Label>
<Input
id="modal-title"
placeholder="https://..."
value={options.payOptions.image}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...payOptions,
image: e.target.value,
},
}))
}
/>
</div>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Appearance" icon={PaletteIcon}>
{/* Theme */}
<section className="flex flex-col gap-3 pt-6">
<Label htmlFor="theme"> Theme </Label>
<CustomRadioGroup
id="theme"
options={[
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
]}
onValueChange={setThemeType}
value={theme.type}
/>
</section>
<div className="h-6" />
{/* Colors */}
<ColorFormGroup
theme={options.theme}
onChange={(newTheme) => {
setOptions((v) => ({
...v,
theme: newTheme,
}));
}}
/>
</CollapsibleSection>
<CollapsibleSection title="Sponsor gas fees" icon={FuelIcon}>
<div className="mt-4 flex items-start gap-6">
<div className="flex flex-col gap-2">
<p className="">
Abstract away gas fees for users of your app by enabling ERC-4337
Account Abstraction
</p>
<Link
href="https://portal.thirdweb.com/connect/account-abstraction/overview"
target="_blank"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
Learn more about Account Abstraction
<ExternalLinkIcon className="size-4" />
</Link>
</div>
<Switch
checked={options.connectOptions.enableAccountAbstraction}
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
connectOptions: {
...v.connectOptions,
enableAccountAbstraction: checked,
},
}));
}}
/>
</div>
</CollapsibleSection>
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { ZERO_ADDRESS, getContract } from "thirdweb";
import { base } from "thirdweb/chains";
import { claimTo } from "thirdweb/extensions/erc1155";
import {
PayEmbed,
darkTheme,
lightTheme,
useActiveAccount,
} from "thirdweb/react";
import { type WalletId, createWallet } from "thirdweb/wallets";
import { Button } from "../../../../components/ui/button";
import { THIRDWEB_CLIENT } from "../../../../lib/client";
import { cn } from "../../../../lib/utils";
import { CodeGen } from "../components/CodeGen";
import type { PayEmbedPlaygroundOptions } from "../components/types";
const nftContract = getContract({
address: "0xf0d0CBf84005Dd4eC81364D1f5D7d896Bd53D1B8",
chain: base,
client: THIRDWEB_CLIENT,
});
type Tab = "ui" | "code";
export function RightSection(props: {
options: PayEmbedPlaygroundOptions;
tab?: string;
}) {
const pathname = usePathname();
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
return "ui";
});
function setPreviewTab(tab: "ui" | "code") {
_setPreviewTab(tab);
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
}
const account = useActiveAccount();
const themeObj =
props.options.theme.type === "dark"
? darkTheme({
colors: props.options.theme.darkColorOverrides,
})
: lightTheme({
colors: props.options.theme.lightColorOverrides,
});
const embed = (
<PayEmbed
client={THIRDWEB_CLIENT}
theme={themeObj}
// locale={connectOptions.localeId}
connectOptions={{
accountAbstraction: props.options.connectOptions
.enableAccountAbstraction
? {
chain: props.options.payOptions.buyTokenChain || base,
sponsorGas: true,
}
: undefined,
}}
payOptions={{
metadata: {
name:
props.options.payOptions.title ||
(props.options.payOptions.mode === "transaction"
? "Transaction"
: props.options.payOptions.mode === "direct_payment"
? "Purchase"
: "Fund Wallet"),
image:
props.options.payOptions.image ||
`https://placehold.co/600x400/${
props.options.theme.type === "dark"
? "1d1d23/7c7a85"
: "f2eff3/6f6d78"
}?text=Your%20Product%20Here&font=roboto`,
},
// Mode-specific options
// biome-ignore lint/suspicious/noExplicitAny: union type
mode: (props.options.payOptions.mode as any) || "fund_wallet",
// Only include buyWithCrypto and buyWithFiat when they're false
...(props.options.payOptions.buyWithCrypto === false
? { buyWithCrypto: false }
: {}),
...(props.options.payOptions.buyWithFiat === false
? { buyWithFiat: false }
: {}),
...(props.options.payOptions.mode === "fund_wallet" ||
!props.options.payOptions.mode
? {
// Fund wallet mode options
prefillBuy: {
chain: props.options.payOptions.buyTokenChain || base,
amount: props.options.payOptions.buyTokenAmount || "0.01",
...(props.options.payOptions.buyTokenInfo
? {
token: props.options.payOptions.buyTokenInfo,
}
: {}),
},
}
: {}),
...(props.options.payOptions.mode === "direct_payment"
? {
// Direct payment mode options
paymentInfo: {
chain: props.options.payOptions.buyTokenChain || base,
sellerAddress:
props.options.payOptions.sellerAddress ||
"0x0000000000000000000000000000000000000000",
amount: props.options.payOptions.buyTokenAmount || "0.01",
...(props.options.payOptions.buyTokenInfo
? {
token: props.options.payOptions.buyTokenInfo,
}
: {}),
},
}
: {}),
...(props.options.payOptions.mode === "transaction"
? {
// Transaction mode options (simplified for demo)
transaction: claimTo({
contract: nftContract,
quantity: 1n,
tokenId: 2n,
to: account?.address || ZERO_ADDRESS,
}),
}
: {}),
}}
/>
);
return (
<div className="flex shrink-0 flex-col gap-4 xl:sticky xl:top-0 xl:max-h-[100vh] xl:w-[764px]">
<TabButtons
tabs={[
{
name: "UI",
isActive: previewTab === "ui",
onClick: () => setPreviewTab("ui"),
},
{
name: "Code",
isActive: previewTab === "code",
onClick: () => setPreviewTab("code"),
},
]}
/>
<div
className={cn(
"relative flex min-h-[300px] grow justify-center rounded-lg",
previewTab !== "code" && "items-center",
)}
>
<BackgroundPattern />
{previewTab === "ui" && embed}
{previewTab === "code" && <CodeGen options={props.options} />}
</div>
</div>
);
}
/**
* @internal
*/
export function getWallets(walletIds: WalletId[]) {
const wallets = [
...walletIds.map((id) => {
if (id === "xyz.abs") {
return abstractWallet();
}
return createWallet(id);
}),
];
return wallets;
}
function BackgroundPattern() {
const color = "hsl(var(--foreground)/15%)";
return (
<div
className="absolute inset-0 z-[-1]"
style={{
backgroundImage: `radial-gradient(${color} 1px, transparent 1px)`,
backgroundSize: "24px 24px",
maskImage:
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 60%)",
}}
/>
);
}
function TabButtons(props: {
tabs: Array<{
name: string;
isActive: boolean;
onClick: () => void;
}>;
}) {
return (
<div>
<div className="flex justify-start gap-1 rounded-lg border bg-muted p-2 shadow-md md:inline-flex">
{props.tabs.map((tab) => (
<Button
key={tab.name}
onClick={tab.onClick}
variant="ghost"
className={cn(
"gap-2 px-4 text-base",
tab.isActive
? "bg-accent text-foreground"
: "bg-transparent text-muted-foreground",
)}
>
{tab.name}
</Button>
))}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { use, useState } from "react";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { base } from "thirdweb/chains";
import type { PayEmbedPlaygroundOptions } from "../components/types";
import { LeftSection } from "./LeftSection";
import { RightSection } from "./RightSection";
// NOTE: Only set the values that are actually the default values used by Connect component
const defaultConnectOptions: PayEmbedPlaygroundOptions = {
theme: {
type: "dark",
darkColorOverrides: {},
lightColorOverrides: {},
},
payOptions: {
mode: "fund_wallet",
title: "",
image: "",
buyTokenAddress: NATIVE_TOKEN_ADDRESS,
buyTokenAmount: "0.01",
buyTokenChain: base,
sellerAddress: "",
transactionData: "",
buyTokenInfo: undefined,
buyWithCrypto: true,
buyWithFiat: true,
},
connectOptions: {
walletIds: [
"io.metamask",
"com.coinbase.wallet",
"me.rainbow",
"io.rabby",
"io.zerion.wallet",
],
modalTitle: undefined,
modalTitleIcon: undefined,
localeId: "en_US",
enableAuth: false,
termsOfServiceLink: undefined,
privacyPolicyLink: undefined,
enableAccountAbstraction: false,
buttonLabel: undefined,
ShowThirdwebBranding: true,
requireApproval: false,
},
};
export default function PayEmbedPlayground(props: {
searchParams: Promise<{ tab: string }>;
}) {
const searchParams = use(props.searchParams);
const [options, setOptions] = useState<PayEmbedPlaygroundOptions>(
defaultConnectOptions,
);
return (
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
<LeftSection options={options} setOptions={setOptions} />
</div>
<RightSection tab={searchParams.tab} options={options} />
</div>
);
}
@@ -0,0 +1,80 @@
import { APIHeader } from "@/components/blocks/APIHeader";
import { CodeExample } from "@/components/code/code-example";
import { StyledPayEmbedPreview } from "@/components/pay/embed";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase,
title: "Fund wallets | thirdweb Universal Bridge",
description:
"The easiest way for users to fund their wallets. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.",
};
export default function Page() {
return (
<ThirdwebProvider>
<main className="container px-0 pb-20">
<APIHeader
title="The easiest way for users to fund their wallets"
description={
<>
Onramp users with credit card &amp; cross-chain crypto payments —
and generate revenue for each user transaction.
</>
}
docsLink="https://portal.thirdweb.com/connect/pay/get-started"
heroLink="/pay.png"
/>
<section className="space-y-8">
<StyledPayEmbed />
</section>
</main>
</ThirdwebProvider>
);
}
function StyledPayEmbed() {
return (
<>
<div className="space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
Fund Wallet
</h2>
<p className="max-w-[600px]">
Inline component that allows users to buy any currency.
<br />
Customize theme, currency, amounts, payment methods and more.
</p>
</div>
<CodeExample
preview={<StyledPayEmbedPreview />}
code={`
import { PayEmbed } from "thirdweb/react";
function App() {
return (
<PayEmbed
client={client}
payOptions={{
mode: "fund_wallet",
metadata: {
name: "Get funds",
},
prefillBuy: {
chain: base,
amount: "0.01",
},
// ... theme, currency, amounts, payment methods, etc.
}}
/>
);
};`}
lang="tsx"
/>
</>
);
}
@@ -1,23 +1,25 @@
import { APIHeader } from "@/components/blocks/APIHeader";
import { CodeExample } from "@/components/code/code-example";
import { StyledPayEmbedPreview } from "@/components/pay/embed";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
import { APIHeader } from "../../../components/blocks/APIHeader";
import PayEmbedPlayground from "./embed/page";
export const metadata: Metadata = {
metadataBase,
title: "Integrate Fiat & Cross-Chain Crypto Payments | thirdweb Pay",
title:
"Integrate Fiat & Cross-Chain Crypto Payments | thirdweb Universal Bridge",
description:
"The easiest way for users to transact in your app. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.",
"The easiest way for users to transact in your app. Onramp users, pay with any token and generate revenue for each user transaction. Integrate for free.",
};
export default function Page() {
export default function Page(props: {
searchParams: Promise<{ tab: string }>;
}) {
return (
<ThirdwebProvider>
<main className="container px-0 pb-20">
<div className="">
<APIHeader
title="The easiest way for users to fund their wallets"
title="Universal Bridge UI component"
description={
<>
Onramp users with credit card &amp; cross-chain crypto payments —
@@ -28,53 +30,8 @@ export default function Page() {
heroLink="/pay.png"
/>
<section className="space-y-8">
<StyledPayEmbed />
</section>
</main>
<PayEmbedPlayground searchParams={props.searchParams} />
</div>
</ThirdwebProvider>
);
}
function StyledPayEmbed() {
return (
<>
<div className="space-y-2">
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
Fund Wallet
</h2>
<p className="max-w-[600px]">
Inline component that allows users to buy any currency.
<br />
Customize theme, currency, amounts, payment methods and more.
</p>
</div>
<CodeExample
preview={<StyledPayEmbedPreview />}
code={`
import { PayEmbed } from "thirdweb/react";
function App() {
return (
<PayEmbed
client={client}
payOptions={{
mode: "fund_wallet",
metadata: {
name: "Get funds",
},
prefillBuy: {
chain: base,
amount: "0.01",
},
// ... theme, currency, amounts, payment methods, etc.
}}
/>
);
};`}
lang="tsx"
/>
</>
);
}
@@ -201,8 +201,13 @@ export function LeftSection(props: {
{/* Colors */}
<ColorFormGroup
connectOptions={connectOptions}
setConnectOptions={setConnectOptions}
theme={connectOptions.theme}
onChange={(newTheme) => {
setConnectOptions((v) => ({
...v,
theme: newTheme,
}));
}}
/>
</CollapsibleSection>

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