Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1732e63bb8 Version Packages (#8075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 02:02:04 -07:00
Joaquim VergesandGitHub 7b8ceebb63 [SDK] Show policy error for 7702 transactions (#8077) 2025-09-19 01:53:09 -07:00
MananTank 1d90437d34 Portal: Add SwapWidget in bridge/swap page (#8072)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the documentation for the `SwapWidget` component, simplifying the example code, and enhancing the user experience by adding new features and usage guidelines.

### Detailed summary
- Added `DocImage` component to display `swap-dark.png`.
- Simplified the swap interface example to use the `SwapWidget`.
- Introduced a live playground section for the `SwapWidget`.
- Added configuration options for default token selection in the widget.
- Removed the complex swap interface code for clarity.
- Included tips for gas optimization and route caching.

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

<!-- end pr-codex -->
2025-09-19 00:32:32 +00:00
Joaquim VergesandGitHub 59616d0082 [Portal] Add documentation for third-party library adapters (#8074) 2025-09-18 17:31:46 -07:00
MananTank 32980f854b [MNY-190] Playground: Add SwapWidget (#8069)
<!--

## 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 significant updates to the `SwapWidget` component, including a new `persistTokenSelections` prop for managing token selection persistence. It also enhances the UI components and integrates a new bridge feature.

### Detailed summary
- Added `persistTokenSelections` prop to `SwapWidget`.
- Updated `SwapWidgetPlayground` for better token management.
- Introduced `BridgeNetworkSelector` and `CurrencySelector` components.
- Added `bridgeFeatureCards` to the feature metadata.
- Enhanced `LeftSection` for improved token and currency selection.
- Updated the sidebar to include the bridge section.
- Refactored network selection logic to use the bridge API.
- Improved code formatting and organization in multiple files.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- New Features
  - SwapWidget gains a persistTokenSelections prop to opt out of saving token choices.
  - Added a Swap Widget Playground with live preview, generated code view, theme/currency/prefill/branding controls, and UI/Code tabs.
  - Playground is integrated into site navigation and feature cards under a new Bridge section.

- Chores
  - Playground now uses bridge-supported chains and improved TypeScript formatting for generated snippets.
  - Added a reusable currency selector for widget configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 00:17:37 +00:00
MananTank f3b3b89fca Dashboard: Add tracking for SwapWidget impression, chain pageview (#8071)
<!--

## 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 analytics tracking for chain page views and enhances the reporting for swap widgets in the dashboard application.

### Detailed summary
- Added `ChainPageView` component to track impressions of chain pages.
- Integrated `reportChainPageview` function in the analytics module.
- Enhanced `BuyAndSwapEmbed` to report when the swap widget is shown.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- Chores
  - Added one-time telemetry when the Swap widget is displayed to improve usage insights.
  - Tracks the page context (asset, bridge, or chain) when the widget first appears.
  - Added one-time telemetry to record chain pageviews (captures the chain identifier) when visiting chain pages.
  - Integrated with existing UI; no impact on behavior, user action, or performance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-18 21:03:00 +00:00
Jonas DanielsandGitHub 44826925d0 Add Stripe checkout for Growth plan subscription (#8068) 2025-09-17 22:41:55 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
00dcc346bf Version Packages (#8063)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-18 10:58:25 +12:00
Manan TankandGitHub e0738d68d2 SDK, Dashboard: Fix SwapWidget setting same token for buy and sell in chain page (#8067) 2025-09-17 15:47:19 -07:00
Joaquim VergesandGitHub e160deb8b7 [SDK] Fix wallet reconnection for previously connected wallets (#8066) 2025-09-17 15:18:13 -07:00
Manan TankandGitHub a6f846fc1e [BLD-286] SDK: add stable classNames to elements in Connect UI (#8065) 2025-09-17 13:34:27 -07:00
Manan TankandGitHub 0e3f3460ad Add SwapWidget in SDK, add in dashboard, BuyWidget UI improvements (#8044) 2025-09-17 12:57:58 -07:00
MananTank 552f7025c1 Dashboard: Fix Import Engine form submit not working (#8064)
<!--

## 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 dialog for importing engine instances in the `ImportEngineButton` component, enhancing user interaction by managing the dialog state and updating UI elements.

### Detailed summary
- Added `useState` to manage the dialog's open state.
- Changed navigation from `router.push` to `router.refresh` after import.
- Wrapped the dialog in a conditional open state.
- Updated the layout and styling of the dialog content and links.
- Modified form field styles for better UI consistency.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* New Features
  * Import dialog now closes automatically after a successful import and refreshes the current page.
  * Added an in-dialog warning beneath the URL field for clearer guidance.
  * Updated “Get started” link destination.

* Style
  * Redesigned import dialog with improved layout, spacing, rounded content, and a bottom action bar; inputs use card styling.
  * Refined labels, placeholders, and header padding for clearer readability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-17 15:58:39 +00:00
MananTank 15f083912c SDK: Fix onSuccess prop not called properly on CheckoutWidget and BuyWidget (#8054)
<!--

## 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 addresses an issue with the `onSuccess` callback not being triggered properly in the `CheckoutWidget` and `BuyWidget` components, improving the handling of buy completion and post-transaction actions.

### Detailed summary
- Renamed `handleBuyComplete` to `handleDoneOrContinueClick`.
- Adjusted the invocation of `onComplete` to only occur when `uiOptions.mode` is not "transaction".
- Updated the dependency array for `handleExecutionComplete` to include `onComplete` and `uiOptions.mode`.
- Changed the `onDone` prop in `SuccessScreen` to use `handleDoneOrContinueClick`.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed an issue where the success callback in checkout and buy widgets did not fire reliably.
  * Improved non-transaction flows so completion triggers after execution finishes (instead of on Done), ensuring proper state reset and more consistent behavior.

* **Chores**
  * Added a patch-level changeset entry documenting the fix.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-17 10:47:49 +00:00
GiselleNessi 04862de9e4 feat: add feedback collection UI to navigation (#7895)
<!--

## 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 feature for collecting user feedback within the application. It implements feedback submission forms in both desktop and mobile components, capturing user feedback and sending it to PostHog for analysis.

### Detailed summary
- Added `reportProductFeedback` function in `report.ts` to handle feedback submissions.
- Implemented feedback submission in the `SecondaryNavLinks` component.
- Added feedback dropdown with a form in `SecondaryNavLinks`.
- Integrated feedback functionality in the `MobileBurgerMenuButton` component.
- Included success notifications using `toast` for feedback submissions in both components.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Replaced external Feedback link with an in-app feedback panel in the header and mobile menu.
  * Panel includes a textarea (max 1000 chars), Cancel and Submit actions; Submit is disabled when empty, shows a success message, clears input and closes the panel.
  * Adds an in-panel “Contact support” link for technical issues and preserves existing header controls.

* **Style**
  * Expandable/collapsible feedback UI with visual indicators, improved positioning and mobile menu scrolling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-16 16:37:22 +00:00
ea46572947 [Dashboard] Display eip7702 support status (#8060)
Co-authored-by: Cursor Agent <[email protected]>
2025-09-15 17:39:14 -07:00
Joaquim VergesandGitHub f276dcfd76 [SDK] Filter out AGW from searchable wallets (#8062) 2025-09-15 17:38:51 -07:00
Joaquim VergesandGitHub 6495b94910 [SDK] Simplify wallet connection code and add Abstract wallet support (#8061) 2025-09-15 16:29:01 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
210f631cba Version Packages (#8053)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-15 11:01:51 -07:00
gregandGitHub 557f0e29e8 Fix: Add brief pause after onramp (#8052) 2025-09-15 10:06:47 -07:00
Joaquim VergesandGitHub f9bbf8101c [Nebula] Add deprecation banner with migration information (#8051) 2025-09-14 17:10:33 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
59d226512c Version Packages (#8049)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-14 04:21:13 -07:00
Joaquim VergesandGitHub 778bf2cc4b [SDK] Add wallet validation before executing transactions (#8050) 2025-09-14 04:06:05 -07:00
Joaquim VergesandGitHub 97e5080fdf [SDK] Fix fiat payments with no wallet connected (#8048) 2025-09-14 03:54:57 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9f9fadf242 Version Packages (#8047)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-13 03:08:48 -07:00
Joaquim VergesandGitHub 224c9d034f [SDK] Upgrade WalletConnect and improve chain switching reliability (#8046) 2025-09-13 02:54:32 -07:00
Jonas DanielsandGitHub 8b8dfd9bf9 Handle ENS resolution errors gracefully (#8045) 2025-09-12 16:52:09 -07:00
Jonas DanielsandGitHub 70e52188a1 Add tier6 for SMS country pricing (#8043) 2025-09-12 16:51:43 -07:00
MananTank c82b606cd2 [MNY-166] Dashboard: Add Dex Screener charts in public ERC20 token page (#8035)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on removing the `token-price-data` API and the `PriceChart` component, while introducing a new `DexScreener` feature that displays token price information based on the chain ID and contract address.

### Detailed summary
- Deleted `token-price-data.ts` and `PriceChart.tsx`.
- Added `mapChainIdToDexScreenerChainSlug` to map chain IDs to DexScreener chain slugs.
- Introduced `DexScreener` and `DexScreenerIframe` components for displaying token price data.
- Updated `erc20.tsx` to integrate `DexScreener` and remove token price fetching logic.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added embedded DexScreener chart on ERC20 public pages for supported chains with automatic light/dark theming and responsive SSR fallback.
  * Introduced chain-to-DexScreener slug mapping for type-safe embeds.

* **Refactor**
  * Removed token price data fetching and the TokenStats/price chart UI from ERC20 public pages.

* **Style**
  * Widened layout to max-w-7xl, simplified Buy section spacing, and moved DexScreener before analytics; analytics and recent transfers now sit in a unified bottom container.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-12 19:25:59 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1c6b12449b Version Packages (#8040)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 04:43:43 -07:00
Joaquim VergesandGitHub ea8061048a [SDK] Fix signAuthorization for 1193 providers (#8042) 2025-09-12 04:35:10 -07:00
Joaquim VergesandGitHub 3ad9f7deba [SDK] Allow passing overrides to common extension functions (#8041) 2025-09-11 18:58:34 -07:00
Joaquim VergesandGitHub 0b454050b1 [SDK] Accept pre-signed authorizations for 7702 account transactions (#8038) 2025-09-11 17:07:44 -07:00
Joaquim VergesandGitHub dc1f7ab2ee [SDK] Support full MetaMask disconnection on wallet disconnect (#8039) 2025-09-11 16:47:40 -07:00
GiselleNessi bd17c08891 Fix/feedback api endpoints (#8024)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing input validation and error handling in the `submitSupportFeedback` and `checkFeedbackStatus` functions, as well as improving the `SupportCaseDetails` component for better user experience during feedback submission.

### Detailed summary
- Improved input validation in `submitSupportFeedback` for `ticketId` and `rating`.
- Enhanced error handling with more specific error messages.
- Added comprehensive validation for API response structure.
- Updated `SupportCaseDetails` to handle status check errors non-blockingly.
- Adjusted feedback submission logic to improve user experience and error reporting.
- Refactored button and feedback display logic for clarity and usability.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Interactive 5‑star rating, accessible controls, auto‑resizing feedback textarea, and integrated Send Feedback button with spinner.
  * “Checking feedback status…” loader and clear “Thank you” state after successful submission.

* **Improvements**
  * Non‑blocking status check with a user‑facing warning if verification fails.
  * Faster, clearer error messages and timeouts for network/server issues; form validation prevents invalid submissions.
  * Feedback resets and success toasts on submission.

* **Bug Fixes**
  * Feedback UI no longer fails when status verification errors occur; malformed API responses handled gracefully.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-11 09:03:02 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
79e3694e37 Version Packages (#8037)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-10 13:41:10 -07:00
Joaquim VergesandGitHub ef15ba7c43 [SDK] Pass through authorizationList for injected providers (#8036) 2025-09-10 13:30:46 -07:00
MananTank a724bc39a2 [BLD-247] Add propagation time in domain restrictions in project settings page (#8025)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates the `bottomText` prop of the `SettingsCard` component in the `ProjectGeneralSettingsPage.tsx` file to provide more detailed information regarding domain restrictions for web applications.

### Detailed summary
- Changed `bottomText` from a string to a fragment containing:
  - A message about applicability for web applications.
  - An additional note that changes to domain restrictions may take up to 5 minutes to take effect.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Documentation**
  * Clarified Allowed Domains setting copy to state it's only applicable for web applications.
  * Added guidance that domain restriction changes may take up to 5 minutes to take effect.
  * Purely a UI copy update; no functional or behavioral changes.
  * Improves in-app guidance and reduces configuration confusion.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 23:23:31 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2eaff98d8a Version Packages (#8030)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 16:22:34 -07:00
Joaquim VergesandGitHub 1759112d72 [SDK] Add signAuthorization support to 1193 provider (#8029) 2025-09-09 16:14:27 -07:00
MananTank 85d6074e92 [BLD-277] Fix Server Wallets balance fetching for custom chains (#8026)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the code to utilize a new hook, `useGetV5DashboardChain`, which replaces the previous `defineChain` function in multiple files. This change enhances the code's readability and maintainability by standardizing the method of retrieving chain information.

### Detailed summary
- Replaced `defineChain` with `useGetV5DashboardChain` in `useAbiProcessing.ts`.
- Updated the `chain` variable initialization in `ServerWalletTableRow` to use `useV5DashboardChain`.
- Modified the `chain` parameter in `WalletBalanceCell` to utilize `useV5DashboardChain` instead of `defineChain`.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Refactor**
  * Unified chain handling across wallet tables and webhook ABI processing using a new dashboard adapter, improving consistency and reliability.
  * Streamlined balance and smart account address resolution to reduce redundant computations and potential mismatches.
  * Internal imports and hooks updated without changing public interfaces or user workflows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 19:13:00 +00:00
Joaquim VergesandGitHub 2f6f3a0947 [Dashboard] Update analytics API integration and improve number formatting (#8023) 2025-09-09 04:16:47 -07:00
209 changed files with 7814 additions and 2566 deletions
+2
View File
@@ -64,3 +64,5 @@ ANALYTICS_SERVICE_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
GROWTH_PLAN_SKU=""
PAYMENT_METHOD_CONFIGURATION=""
+2
View File
@@ -21,6 +21,8 @@
"@radix-ui/react-tooltip": "1.2.7",
"@sentry/nextjs": "9.34.0",
"@shazow/whatsabi": "0.22.2",
"@stripe/react-stripe-js": "4.0.2",
"@stripe/stripe-js": "7.9.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/api": "workspace:*",
+62 -1
View File
@@ -1,8 +1,13 @@
import "server-only";
import { headers } from "next/headers";
import Stripe from "stripe";
import type { Team } from "@/api/team/get-team";
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
import {
GROWTH_PLAN_SKU,
PAYMENT_METHOD_CONFIGURATION,
STRIPE_SECRET_KEY,
} from "@/constants/server-envs";
let existingStripe: Stripe | undefined;
@@ -72,3 +77,59 @@ export async function getStripeBalance(customerId: string) {
// Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0)
return customer.balance === 0 ? 0 : customer.balance / -100;
}
export async function fetchClientSecret(team: Team) {
"use server";
const origin = (await headers()).get("origin");
const stripe = getStripe();
const customerId = team.stripeCustomerId;
if (!customerId) {
throw new Error("No customer ID found");
}
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
line_items: [
{
// Provide the exact Price ID (for example, price_1234) of
// the product you want to sell
price: GROWTH_PLAN_SKU,
quantity: 1,
},
],
mode: "subscription",
return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
customer: customerId,
customer_update: {
address: "auto",
},
payment_method_collection: "always",
payment_method_configuration: PAYMENT_METHOD_CONFIGURATION,
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
});
if (!session.client_secret) {
throw new Error("No client secret found");
}
return session.client_secret;
}
export async function getStripeSessionById(sessionId: string) {
const session = await getStripe().checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent"],
});
return session;
}
+129 -27
View File
@@ -81,33 +81,6 @@ export function reportOnboardingStarted() {
posthog.capture("onboarding started");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that select a paid plan during onboarding
* - To know **which** plan was selected
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelected(properties: {
plan: Team["billingPlan"];
}) {
posthog.capture("onboarding plan selected", properties);
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that skip the plan-selection step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportOnboardingPlanSelectionSkipped() {
posthog.capture("onboarding plan selection skipped");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that invite members during onboarding
@@ -161,6 +134,18 @@ export function reportOnboardingMembersUpsellPlanSelected(properties: {
posthog.capture("onboarding members upsell plan selected", properties);
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that completed the team member step during onboarding
*
* ### Who is responsible for this event?
* @jnsdls
*
*/
export function reportTeamMemberStepCompleted() {
posthog.capture("onboarding members completed");
}
/**
* ### Why do we need to report this event?
* - To track the number of teams that completed onboarding
@@ -250,6 +235,68 @@ export function reportAssetBuySuccessful(properties: {
});
}
type TokenSwapParams = {
buyTokenChainId: number;
buyTokenAddress: string;
sellTokenChainId: number;
sellTokenAddress: string;
pageType: "asset" | "bridge" | "chain";
};
/**
* ### Why do we need to report this event?
* - To track number of successful token swaps from the token page
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
posthog.capture("token swap successful", properties);
}
/**
* ### Why do we need to report this event?
* - To track impressions of the swap widget
* - To create a funnel "swap widget shown" -> "swap widget successful" to understand the conversion rate
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportSwapWidgetShown(properties: {
pageType: "asset" | "bridge" | "chain";
}) {
posthog.capture("swap widget shown", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed token swaps from the token page
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapFailed(
properties: TokenSwapParams & {
errorMessage: string;
},
) {
posthog.capture("token swap failed", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled token swaps from the token page
* - To track which tokens are being swapped the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenSwapCancelled(properties: TokenSwapParams) {
posthog.capture("token swap cancelled", properties);
}
/**
* ### Why do we need to report this event?
* - To track number of failed asset purchases from the token page
@@ -272,6 +319,26 @@ export function reportAssetBuyFailed(properties: {
});
}
/**
* ### Why do we need to report this event?
* - To track number of cancelled asset purchases from the token page
* - To track the errors that users encounter when trying to purchase an asset
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportAssetBuyCancelled(properties: {
chainId: number;
contractType: AssetContractType;
assetType: "nft" | "coin";
}) {
posthog.capture("asset buy cancelled", {
assetType: properties.assetType,
chainId: properties.chainId,
contractType: properties.contractType,
});
}
// Assets Landing Page ----------------------------
/**
@@ -477,6 +544,17 @@ export function reportAssetPageview(properties: {
posthog.capture("asset pageview", properties);
}
/**
* ### Why do we need to report this event?
* - To understand which chains are being viewed the most
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportChainPageview(properties: { chainId: number }) {
posthog.capture("chain pageview", properties);
}
/**
* ### Why do we need to report this event?
* - To track the usage of fund wallet modal
@@ -547,3 +625,27 @@ export function reportChainInfraRpcOmissionAgreed(properties: {
}) {
posthog.capture("chain infra checkout rpc omission agreed", properties);
}
// ----------------------------
// FEEDBACK
// ----------------------------
/**
* ### Why do we need to report this event?
* - To track user feedback and sentiment about the product
* - To identify common issues or feature requests
* - To measure user satisfaction and engagement
* - To prioritize product improvements based on user input
*
* ### Who is responsible for this event?
* @gisellechacon
*/
export function reportProductFeedback(properties: {
feedback: string;
source: "desktop" | "mobile";
}) {
posthog.capture("product feedback submitted", {
feedback: properties.feedback,
source: properties.source,
});
}
@@ -0,0 +1,173 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import type { Chain, ThirdwebClient } from "thirdweb";
import { BuyWidget, SwapWidget } from "thirdweb/react";
import {
reportAssetBuyCancelled,
reportAssetBuyFailed,
reportAssetBuySuccessful,
reportSwapWidgetShown,
reportTokenSwapCancelled,
reportTokenSwapFailed,
reportTokenSwapSuccessful,
} from "@/analytics/report";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyAndSwapEmbed(props: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string | undefined;
buyAmount: string | undefined;
pageType: "asset" | "bridge" | "chain";
}) {
const { theme } = useTheme();
const [tab, setTab] = useState<"buy" | "swap">("swap");
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
const isMounted = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (isMounted.current) {
return;
}
isMounted.current = true;
reportSwapWidgetShown({
pageType: props.pageType,
});
}, [props.pageType]);
return (
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col">
<div className="flex gap-2.5 p-4 border-b border-dashed">
<TabButton
label="Swap"
onClick={() => setTab("swap")}
isActive={tab === "swap"}
/>
<TabButton
label="Buy"
onClick={() => setTab("buy")}
isActive={tab === "buy"}
/>
</div>
{tab === "buy" && (
<BuyWidget
amount={props.buyAmount || "1"}
chain={props.chain}
className="!rounded-2xl !w-full !border-none"
title=""
client={props.client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
const errorMessage = parseError(e);
if (props.pageType === "asset") {
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
error: errorMessage,
});
}
}}
onCancel={() => {
if (props.pageType === "asset") {
reportAssetBuyCancelled({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}
}}
onSuccess={() => {
if (props.pageType === "asset") {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}
}}
theme={themeObj}
tokenAddress={props.tokenAddress as `0x${string}`}
paymentMethods={["card"]}
/>
)}
{tab === "swap" && (
<SwapWidget
client={props.client}
theme={themeObj}
className="!rounded-2xl !border-none !w-full"
prefill={{
sellToken: {
chainId: props.chain.id,
tokenAddress: props.tokenAddress,
},
// only set `buyToken` as "Native token" if `sellToken` is not a "native token" already
buyToken: props.tokenAddress
? {
chainId: props.chain.id,
}
: undefined,
}}
onError={(error, quote) => {
const errorMessage = parseError(error);
reportTokenSwapFailed({
errorMessage: errorMessage,
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
sellTokenChainId: quote.intent.originChainId,
sellTokenAddress: quote.intent.originTokenAddress,
pageType: props.pageType,
});
}}
onSuccess={(quote) => {
reportTokenSwapSuccessful({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
sellTokenChainId: quote.intent.originChainId,
sellTokenAddress: quote.intent.originTokenAddress,
pageType: props.pageType,
});
}}
onCancel={(quote) => {
reportTokenSwapCancelled({
buyTokenChainId: quote.intent.destinationChainId,
buyTokenAddress: quote.intent.destinationTokenAddress,
sellTokenChainId: quote.intent.originChainId,
sellTokenAddress: quote.intent.originTokenAddress,
pageType: props.pageType,
});
}}
/>
)}
</div>
);
}
function TabButton(props: {
label: string;
onClick: () => void;
isActive: boolean;
}) {
return (
<Button
onClick={props.onClick}
className={cn(
"rounded-full text-muted-foreground px-5 text-base bg-accent",
props.isActive && "text-foreground border-foreground",
)}
variant="outline"
>
{props.label}
</Button>
);
}
@@ -0,0 +1,23 @@
import { GridPattern } from "@/components/ui/background-patterns";
export function GridPatternEmbedContainer(props: {
children: React.ReactNode;
}) {
return (
<div className=" sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70 hidden lg:block"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
/>
<div className="sm:w-[420px] z-10">{props.children}</div>
</div>
);
}
@@ -91,3 +91,24 @@ if (REDIS_URL) {
REDIS_URL,
);
}
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
if (GROWTH_PLAN_SKU) {
experimental_taintUniqueValue(
"Do not pass GROWTH_PLAN_SKU to the client",
process,
GROWTH_PLAN_SKU,
);
}
export const PAYMENT_METHOD_CONFIGURATION =
process.env.PAYMENT_METHOD_CONFIGURATION || "";
if (PAYMENT_METHOD_CONFIGURATION) {
experimental_taintUniqueValue(
"Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
process,
PAYMENT_METHOD_CONFIGURATION,
);
}
+6 -3
View File
@@ -17,18 +17,21 @@ export async function resolveEns(
ensName: await resolveName({
address: ensNameOrAddress,
client,
}),
}).catch(() => null),
};
}
if (!isValidENSName(ensNameOrAddress)) {
throw new Error("Invalid ENS name");
return {
address: null,
ensName: null,
};
}
const address = await resolveAddress({
client,
name: ensNameOrAddress,
});
}).catch(() => null);
if (!address) {
return {
+1 -1
View File
@@ -184,7 +184,7 @@ async function fetchContractName(chainId: number, contractAddress: string) {
return name;
}
const formatNumber = (num: number) => {
export const formatNumber = (num: number) => {
if (num >= 1000000) {
return `${(num / 1000000).toLocaleString(undefined, {
maximumFractionDigits: 1,
@@ -1,24 +1,24 @@
"use client";
import { useTheme } from "next-themes";
import { defineChain, type ThirdwebClient } from "thirdweb";
import type { ThirdwebClient } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { BuyWidget } from "thirdweb/react";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
export function BuyFundsSection(props: {
chain: ChainMetadata;
client: ThirdwebClient;
}) {
const { theme } = useTheme();
return (
<section className="flex flex-col gap-4 items-center justify-center">
<BuyWidget
amount="0"
// eslint-disable-next-line no-restricted-syntax
chain={defineChain(props.chain.chainId)}
<GridPatternEmbedContainer>
<BuyAndSwapEmbed
client={props.client}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
// eslint-disable-next-line no-restricted-syntax
chain={defineDashboardChain(props.chain.chainId, props.chain)}
buyAmount={undefined}
tokenAddress={undefined}
pageType="chain"
/>
</section>
</GridPatternEmbedContainer>
);
}
@@ -0,0 +1,13 @@
"use client";
import { reportChainPageview } from "@/analytics/report";
import { useEffectOnce } from "@/hooks/useEffectOnce";
export function ChainPageView(props: { chainId: number }) {
useEffectOnce(() => {
reportChainPageview({
chainId: props.chainId,
});
});
return null;
}
@@ -43,9 +43,7 @@ function useChainStatswithRPC(_rpcUrl: string) {
const latency = (performance.now() - startTimeStamp).toFixed(0);
const blockNumber = Number.parseInt(json.result.number, 16);
const blockGasLimit = Number.parseInt(json.result.gasLimit, 16);
return {
blockGasLimit,
blockNumber,
latency,
};
@@ -61,8 +59,70 @@ function useChainStatswithRPC(_rpcUrl: string) {
});
}
function useEIP7702Support(_rpcUrl: string) {
let rpcUrl = _rpcUrl.replace(
// eslint-disable-next-line no-template-curly-in-string
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is expected in this case
"${THIRDWEB_API_KEY}",
NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
);
// based on the environment hit dev or production
if (hostnameEndsWith(rpcUrl, "rpc.thirdweb.com")) {
if (!isProd) {
rpcUrl = rpcUrl.replace("rpc.thirdweb.com", "rpc.thirdweb-dev.com");
}
}
return useQuery({
enabled: !!rpcUrl,
queryFn: async () => {
try {
const res = await fetch(rpcUrl, {
body: JSON.stringify({
id: 1,
jsonrpc: "2.0",
method: "eth_estimateGas",
params: [
{
from: "0xdeadbeef00000000000000000000000000000000",
to: "0xdeadbeef00000000000000000000000000000000",
data: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
value: "0x0",
},
"latest",
{
"0xdeadbeef00000000000000000000000000000000": {
code: "0xef01000000000000000000000000000000000000000001",
},
},
],
}),
method: "POST",
});
const json = await res.json();
// If the response has a valid result object, EIP-7702 is enabled
return {
isSupported: !!json.result,
};
} catch {
// If the request fails or errors, EIP-7702 is not supported
return {
isSupported: false,
};
}
},
queryKey: ["eip-7702-support", { rpcUrl }],
refetchInterval: false, // Don't refetch this as it's unlikely to change
refetchOnWindowFocus: false,
});
}
export function ChainLiveStats(props: { rpc: string }) {
const stats = useChainStatswithRPC(props.rpc);
const eip7702Support = useEIP7702Support(props.rpc);
return (
<>
@@ -107,8 +167,8 @@ export function ChainLiveStats(props: { rpc: string }) {
)}
</PrimaryInfoItem>
{/* Block Height */}
<PrimaryInfoItem title="Block Height" titleIcon={<PulseDot />}>
{/* Latest Block */}
<PrimaryInfoItem title="Latest Block" titleIcon={<PulseDot />}>
{stats.isError ? (
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
) : stats.data ? (
@@ -120,16 +180,37 @@ export function ChainLiveStats(props: { rpc: string }) {
)}
</PrimaryInfoItem>
{/* Block Gas Limit */}
<PrimaryInfoItem title="Block Gas Limit" titleIcon={<PulseDot />}>
{stats.isError ? (
<p className="fade-in-0 animate-in text-destructive-text">N/A</p>
) : stats.data ? (
<p className="fade-in-0 animate-in">
{stats.data.blockGasLimit ?? "N/A"}
</p>
{/* EIP-7702 */}
<PrimaryInfoItem
title="EIP-7702"
titleIcon={
eip7702Support.data ? (
eip7702Support.data.isSupported ? (
<ToolTipLabel label="Enabled">
<CircleCheckIcon className="size-4 text-success-text" />
</ToolTipLabel>
) : (
<ToolTipLabel label="Disabled">
<XIcon className="size-4 text-destructive-text" />
</ToolTipLabel>
)
) : eip7702Support.isError ? (
<ToolTipLabel label="Disabled">
<XIcon className="size-4 text-destructive-text" />
</ToolTipLabel>
) : null
}
>
{eip7702Support.data ? (
eip7702Support.data.isSupported ? (
"Available"
) : (
"Not Available"
)
) : eip7702Support.isError ? (
"Not Available"
) : (
<div className="flex h-[28px] w-[140px] py-1">
<div className="flex h-[28px] w-[80px] py-1">
<Skeleton className="h-full w-full" />
</div>
)}
@@ -24,6 +24,7 @@ import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
import { StarButton } from "../../components/client/star-button";
import { getChain, getChainMetadata } from "../../utils";
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
import { ChainPageView } from "./components/client/chain-pageview";
import { ChainHeader } from "./components/server/chain-header";
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
@@ -71,6 +72,7 @@ export default async function ChainPageLayout(props: {
return (
<div className="flex grow flex-col">
<ChainPageView chainId={chain.chainId} />
<div className="border-border border-b bg-card">
<TeamHeader />
</div>
@@ -10,6 +10,7 @@ import {
} from "@/components/analytics/date-range-selector";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { formatNumber } from "@/lib/search";
import {
type AnalyticsQueryParams,
type TotalQueryResult,
@@ -376,7 +377,7 @@ function AnalyticsStatUI(props: { label: string; data: number | undefined }) {
<SkeletonContainer
loadedData={props.data}
render={(v) => {
return <dd className="font-normal text-xl">{v.toLocaleString()}</dd>;
return <dd className="font-normal text-xl">{formatNumber(v)}</dd>;
}}
skeletonData={10000}
/>
@@ -61,7 +61,7 @@ export async function getContractEventAnalytics(params: {
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed to fetch analytics data: ${errorText}`);
throw new Error(`Failed to fetch events analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
@@ -26,12 +26,18 @@ export async function getContractFunctionBreakdown(params: {
startDate?: Date;
endDate?: Date;
}): Promise<FunctionBreakdownEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"group_by=day",
"group_by=function_selector",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(*) as count",
`limit=${daysDifference * 10}`, // at most 10 functions per day
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -68,17 +74,16 @@ export async function getContractFunctionBreakdown(params: {
if (
typeof value === "object" &&
value !== null &&
"time" in value &&
"day" in value &&
"count" in value &&
"function_selector" in value &&
typeof value.function_selector === "string" &&
typeof value.time === "string" &&
typeof value.count === "number"
typeof value.day === "string"
) {
collectedAggregations.push({
count: value.count,
count: Number(value.count),
function_selector: value.function_selector,
time: value.time,
time: value.day,
});
}
}
@@ -30,11 +30,17 @@ export async function getContractTransactionAnalytics(params: {
startDate?: Date;
endDate?: Date;
}): Promise<TransactionAnalyticsEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(block_timestamp) as count",
"group_by=day",
`limit=${daysDifference}`,
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -55,7 +61,8 @@ export async function getContractTransactionAnalytics(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch transaction analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
@@ -67,14 +74,13 @@ export async function getContractTransactionAnalytics(params: {
if (
typeof tx === "object" &&
tx !== null &&
"time" in tx &&
"day" in tx &&
"count" in tx &&
typeof tx.time === "string" &&
typeof tx.count === "number"
typeof tx.day === "string"
) {
returnValue.push({
count: tx.count,
time: new Date(tx.time),
count: Number(tx.count),
time: new Date(tx.day),
});
}
}
@@ -30,11 +30,18 @@ export async function getContractUniqueWalletAnalytics(params: {
startDate?: Date;
endDate?: Date;
}): Promise<TransactionAnalyticsEntry[]> {
const daysDifference =
params.startDate && params.endDate
? Math.ceil(
(params.endDate.getTime() - params.startDate.getTime()) /
(1000 * 60 * 60 * 24),
)
: 30;
const queryParams = [
`chain=${params.chainId}`,
"group_by=time",
"aggregate=toStartOfDay(toDate(block_timestamp)) as time",
"aggregate=count(distinct from_address) as count",
"group_by=day",
"aggregate=count(distinct from_address)",
`limit=${daysDifference}`,
params.startDate
? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}`
: "",
@@ -55,10 +62,12 @@ export async function getContractUniqueWalletAnalytics(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch wallet analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
console.log("wallet analytics json", json);
const aggregations = Object.values(json.aggregations[0]);
const returnValue: TransactionAnalyticsEntry[] = [];
@@ -67,14 +76,13 @@ export async function getContractUniqueWalletAnalytics(params: {
if (
typeof tx === "object" &&
tx !== null &&
"time" in tx &&
"day" in tx &&
"count" in tx &&
typeof tx.time === "string" &&
typeof tx.count === "number"
typeof tx.day === "string"
) {
returnValue.push({
count: tx.count,
time: new Date(tx.time),
count: Number(tx.count),
time: new Date(tx.day),
});
}
}
@@ -33,7 +33,8 @@ export async function getTotalContractEvents(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(`Failed to fetch events analytics data: ${errorText}`);
}
const json = (await res.json()) as InsightResponse;
@@ -6,7 +6,7 @@ type InsightResponse = {
aggregations: [
{
0: {
total: number;
count: number;
};
},
];
@@ -19,10 +19,9 @@ export async function getTotalContractTransactions(params: {
contractAddress: string;
chainId: number;
}): Promise<{ count: number }> {
const queryParams = [
`chain=${params.chainId}`,
"aggregate=count(block_number) as total",
].join("&");
const queryParams = [`chain=${params.chainId}`, "aggregate=count()"].join(
"&",
);
const res = await fetch(
`https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`,
@@ -34,12 +33,15 @@ export async function getTotalContractTransactions(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(
`Failed to fetch transactions analytics data: ${errorText}`,
);
}
const json = (await res.json()) as InsightResponse;
return {
count: json.aggregations[0][0].total,
count: json.aggregations[0][0].count,
};
}
@@ -6,7 +6,7 @@ type InsightResponse = {
aggregations: [
{
0: {
total: number;
count: number;
};
},
];
@@ -21,7 +21,7 @@ export async function getTotalContractUniqueWallets(params: {
}): Promise<{ count: number }> {
const queryParams = [
`chain=${params.chainId}`,
"aggregate=count(distinct from_address) as total",
"aggregate=count(distinct from_address)",
].join("&");
const res = await fetch(
@@ -34,12 +34,15 @@ export async function getTotalContractUniqueWallets(params: {
);
if (!res.ok) {
throw new Error("Failed to fetch analytics data");
const errorText = await res.text();
throw new Error(
`Failed to fetch unique wallets analytics data: ${errorText}`,
);
}
const json = (await res.json()) as InsightResponse;
return {
count: json.aggregations[0][0].total,
count: json.aggregations[0][0].count,
};
}
@@ -1,51 +0,0 @@
import "server-only";
import { isProd } from "@/constants/env-utils";
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
export type TokenPriceData = {
price_usd: number;
price_usd_cents: number;
percent_change_24h: number;
market_cap_usd: number;
volume_24h_usd: number;
volume_change_24h: number;
holders: number;
historical_prices: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
};
export async function getTokenPriceData(params: {
chainId: number;
contractAddress: string;
}) {
try {
const url = new URL(
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
);
url.searchParams.set("include_historical_prices", "true");
url.searchParams.set("chain_id", params.chainId.toString());
url.searchParams.set("address", params.contractAddress);
url.searchParams.set("include_holders", "true");
const res = await fetch(url, {
headers: {
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
},
});
if (!res.ok) {
console.error("Failed to fetch token price data", await res.text());
return undefined;
}
const json = await res.json();
const priceData = json.data[0] as TokenPriceData | undefined;
return priceData;
} catch {
return undefined;
}
}
@@ -1,48 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import type { Chain, ThirdwebClient } from "thirdweb";
import { BuyWidget } from "thirdweb/react";
import {
reportAssetBuyFailed,
reportAssetBuySuccessful,
} from "@/analytics/report";
import { parseError } from "@/utils/errorParser";
import { getSDKTheme } from "@/utils/sdk-component-theme";
export function BuyTokenEmbed(props: {
client: ThirdwebClient;
chain: Chain;
tokenAddress: string;
}) {
const { theme } = useTheme();
return (
<BuyWidget
amount="1"
chain={props.chain}
className="!rounded-xl !w-full"
client={props.client}
connectOptions={{
autoConnect: false,
}}
onError={(e) => {
const errorMessage = parseError(e);
reportAssetBuyFailed({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
error: errorMessage,
});
}}
onSuccess={() => {
reportAssetBuySuccessful({
assetType: "coin",
chainId: props.chain.id,
contractType: "DropERC20",
});
}}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
tokenAddress={props.tokenAddress as `0x${string}`}
/>
);
}
@@ -1,264 +0,0 @@
"use client";
import { differenceInCalendarDays, format } from "date-fns";
import { ArrowDownIcon, ArrowUpIcon, InfoIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { TokenPriceData } from "../_apis/token-price-data";
function PriceChartUI(props: {
showTimeOfDay: boolean;
data: Array<{
date: string;
price_usd: number;
price_usd_cents: number;
}>;
}) {
const data = props.data.map((item) => ({
price: item.price_usd,
time: new Date(item.date).getTime(),
}));
return (
<ThirdwebAreaChart
cardContentClassName="p-0"
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="border-none bg-transparent p-0"
config={{
price: {
color: "hsl(var(--chart-1))",
label: "Price",
},
}}
data={data}
hideLabel={false}
isPending={false}
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
toolTipValueFormatter={(value) => {
return tokenPriceUSDFormatter.format(value as number);
}}
/>
);
}
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 8,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
style: "currency",
});
const marketCapFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
minimumFractionDigits: 0,
notation: "compact",
roundingMode: "halfEven",
style: "currency",
});
const holdersFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
notation: "compact",
});
const percentChangeFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
return (_v: string, item: unknown) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(
new Date(time),
includeTimeOfDay ? "MMM d, yyyy hh:mm a" : "MMM d, yyyy",
);
}
return undefined;
};
}
export function TokenStats(params: {
chainId: number;
contractAddress: string;
tokenPriceData: TokenPriceData;
}) {
const [interval, setInterval] = useState<Interval>("max");
const filteredHistoricalPrices = useMemo(() => {
const currentDate = new Date();
return params.tokenPriceData.historical_prices.filter((item) => {
const date = new Date(item.date);
const maxDiff =
interval === "24h"
? 1
: interval === "7d"
? 7
: interval === "30d"
? 30
: interval === "1y"
? 365
: Number.MAX_SAFE_INTEGER;
return differenceInCalendarDays(currentDate, date) <= maxDiff;
});
}, [params.tokenPriceData, interval]);
const priceUsd = params.tokenPriceData.price_usd;
const percentChange24h = params.tokenPriceData.percent_change_24h;
const marketCap = params.tokenPriceData.market_cap_usd;
const holders = params.tokenPriceData.holders;
const formattedAbsChange = percentChangeFormatter.format(
Math.abs(percentChange24h),
);
const isAlmostZeroChange =
formattedAbsChange === percentChangeFormatter.format(0);
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
return (
<div className="bg-card rounded-lg border">
{/* price and change */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
<div>
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
<div className="flex items-center gap-3">
<p
className={cn(
"font-bold text-3xl lg:text-4xl tracking-tight",
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
)}
>
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
</p>
<Badge
className="gap-2 lg:text-sm text-xs px-1.5"
variant={
isAlmostZeroChange
? "default"
: percentChange24h > 0
? "success"
: "destructive"
}
>
<div className="flex items-center gap-0.5">
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
<ArrowUpIcon className="size-3" />
) : (
<ArrowDownIcon className="size-3" />
)}
<span>
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
</span>
</div>
<span>(1d)</span>
</Badge>
</div>
</div>
<IntervalSelector interval={interval} setInterval={setInterval} />
</div>
<div className="px-0">
<PriceChartUI
data={filteredHistoricalPrices || []}
showTimeOfDay={interval === "24h"}
/>
</div>
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
<TokenStat
label="Market Cap"
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
value={
typeof marketCap === "number"
? marketCapFormatter.format(marketCap)
: marketCap
}
/>
<TokenStat
label="Number of Holders"
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
value={
// show 0 value as N/A
holders === 0
? "N/A"
: typeof holders === "number"
? holdersFormatter.format(holders)
: holders
}
/>
</div>
</div>
);
}
function TokenStat(props: {
value: string | number;
label: string;
tooltip: string;
}) {
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<p className="text-muted-foreground text-sm">{props.label}</p>
<ToolTipLabel label={props.tooltip}>
<InfoIcon className="size-3.5 text-muted-foreground" />
</ToolTipLabel>
</div>
<div className="flex">
<p className="font-semibold text-foreground text-xl tracking-tight">
{props.value}
</p>
</div>
</div>
);
}
type Interval = "24h" | "7d" | "30d" | "1y" | "max";
function IntervalSelector(props: {
interval: Interval;
setInterval: (timeframe: Interval) => void;
}) {
const intervals: Record<Interval, string> = {
"1y": "1Y",
"7d": "1W",
"24h": "1D",
"30d": "1M",
max: "MAX",
};
return (
<div className="flex gap-2">
{Object.entries(intervals).map(([key, value]) => (
<Button
className={cn(
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
props.interval === key && "border-active-border bg-accent",
)}
key={key}
onClick={() => props.setInterval(key as Interval)}
size="sm"
tabIndex={0}
variant="outline"
>
{value}
</Button>
))}
</div>
);
}
@@ -0,0 +1,77 @@
export const mapChainIdToDexScreenerChainSlug = {
8453: "base",
1: "ethereum",
56: "bsc",
369: "pulsechain",
137: "polygon",
2741: "abstract",
43114: "avalanche",
999: "hyperliquid",
480: "worldchain",
42161: "arbitrum",
388: "cronos",
59144: "linea",
1514: "story",
397: "near",
295: "hedera",
146: "sonic",
10: "optimism",
80094: "berachain",
57073: "ink",
130: "unichain",
5000: "mantle",
324: "zksync",
466: "apechain",
1116: "core",
250: "fantom",
1868: "soneium",
81457: "blast",
2000: "dogechain",
14: "flare",
2040: "vana",
4337: "beam",
109: "shibarium",
747: "flowevm",
1088: "metis",
1030: "conflux",
43113: "avalanchedfk",
534352: "scroll",
747474: "katana",
42220: "celo",
1284: "moonbeam",
4200: "merlinchain",
2222: "kava",
39797: "energi",
34443: "mode",
252: "fraxtal",
48900: "zircuit",
20: "elastos",
100: "gnosischain",
204: "opbnb",
169: "manta",
1313161554: "aurora",
3073: "movement",
4689: "iotex",
23294: "oasissapphire",
6001: "bouncebit",
42170: "arbitrumnova",
1101: "polygonzkevm",
40: "telos",
592: "astar",
42262: "oasisemerald",
1285: "moonriver",
245022934: "neonevm",
7777777: "zora",
122: "fuse",
321: "kcc",
1234: "stepnetwork",
106: "velas",
167000: "taiko",
288: "boba",
42766: "zkfair",
32520: "bitgert",
82: "meter",
} as const;
export type DexScreenerChainSlug =
(typeof mapChainIdToDexScreenerChainSlug)[keyof typeof mapChainIdToDexScreenerChainSlug];
@@ -0,0 +1,58 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { ClientOnly } from "@/components/blocks/client-only";
import type { DexScreenerChainSlug } from "./dex-screener-chains";
function DexScreenerIframe(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
const { theme } = useTheme();
const iframeUrl = useMemo(() => {
const resolvedTheme = theme === "light" ? "light" : "dark";
const url = new URL("https://dexscreener.com");
url.pathname = `${props.chain}/${props.contractAddress}`;
url.searchParams.set("embed", "1");
url.searchParams.set("loadChartSettings", "0");
url.searchParams.set("chartTheme", resolvedTheme);
url.searchParams.set("theme", resolvedTheme);
url.searchParams.set("trades", "1");
url.searchParams.set("chartStyle", "1");
url.searchParams.set("chartLeftToolbar", "0");
url.searchParams.set("chartType", "usd");
url.searchParams.set("interval", "15");
url.searchParams.set("chartDefaultOnMobile", "1");
return url.toString();
}, [theme, props.chain, props.contractAddress]);
return (
<iframe
className="w-full h-[500px] lg:h-[1000px] rounded-lg overflow-hidden border bg-card"
src={iframeUrl}
title="DexScreener"
></iframe>
);
}
export function DexScreener(props: {
chain: DexScreenerChainSlug;
contractAddress: string;
}) {
return (
<ClientOnly
ssr={
<div className="w-full h-[500px] lg:h-[1000px] rounded-lg border bg-card items-center justify-center flex">
<Spinner className="size-10" />
</div>
}
>
<DexScreenerIframe
chain={props.chain}
contractAddress={props.contractAddress}
/>
</ClientOnly>
);
}
@@ -3,18 +3,18 @@ import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { getContractMetadata } from "thirdweb/extensions/common";
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
import { GridPattern } from "@/components/ui/background-patterns";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container";
import { HAS_USED_DASHBOARD } from "@/constants/cookie";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { AssetPageView } from "../_components/asset-page-view";
import { getContractCreator } from "../_components/getContractCreator";
import { PageHeader } from "../_components/PageHeader";
import { getTokenPriceData } from "./_apis/token-price-data";
import { ContractHeaderUI } from "./_components/ContractHeader";
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
import { TokenStats } from "./_components/PriceChart";
import { DexScreener } from "./_components/dex-screener";
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
import { RecentTransfers } from "./_components/RecentTransfers";
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
@@ -30,7 +30,6 @@ export async function ERC20PublicPage(props: {
tokenDecimals,
tokenInfoFromUB,
functionSelectors,
tokenPriceData,
] = await Promise.all([
getContractMetadata({
contract: props.serverContract,
@@ -45,10 +44,6 @@ export async function ERC20PublicPage(props: {
tokenAddress: props.serverContract.address,
}),
resolveFunctionSelectors(props.serverContract),
getTokenPriceData({
chainId: props.serverContract.chain.id,
contractAddress: props.serverContract.address,
}),
]);
if (!contractMetadata.image && tokenInfoFromUB) {
@@ -83,10 +78,10 @@ export async function ERC20PublicPage(props: {
return (
<div className="flex grow flex-col">
<AssetPageView assetType="coin" chainId={props.chainMetadata.chainId} />
<PageHeader containerClassName="max-w-5xl" />
<PageHeader containerClassName="max-w-7xl" />
<div className="border-b">
<div className="container max-w-5xl">
<div className="container max-w-7xl">
<ContractHeaderUI
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
@@ -105,57 +100,45 @@ export async function ERC20PublicPage(props: {
</div>
</div>
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
<div className="flex grow flex-col gap-8">
{showBuyEmbed && (
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
<GridPattern
width={30}
height={30}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70"
style={{
maskImage:
"linear-gradient(to bottom right,white,transparent,transparent)",
}}
/>
<div className="sm:w-[420px] z-10">
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</div>
</div>
)}
<div className="h-8" />
{tokenPriceData ? (
<>
<TokenStats
chainId={props.chainMetadata.chainId}
contractAddress={props.clientContract.address}
tokenPriceData={tokenPriceData}
/>
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
</>
) : (
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
)}
</GridPatternEmbedContainer>
</div>
)}
{props.chainMetadata.chainId in mapChainIdToDexScreenerChainSlug && (
<div className="container max-w-7xl pb-10">
<DexScreener
chain={
mapChainIdToDexScreenerChainSlug[
props.chainMetadata
.chainId as keyof typeof mapChainIdToDexScreenerChainSlug
]
}
contractAddress={props.clientContract.address}
/>
</div>
)}
<div className="container flex max-w-7xl grow flex-col pb-10">
<div className="flex grow flex-col gap-8">
<ContractAnalyticsOverview
chainId={props.chainMetadata.chainId}
chainSlug={props.chainMetadata.slug}
contractAddress={props.clientContract.address}
/>
<RecentTransfers
chainMetadata={props.chainMetadata}
@@ -188,10 +171,12 @@ function BuyEmbed(props: {
}) {
if (!props.claimConditionMeta) {
return (
<BuyTokenEmbed
<BuyAndSwapEmbed
chain={props.clientContract.chain}
client={props.clientContract.client}
tokenAddress={props.clientContract.address}
buyAmount={undefined}
pageType="asset"
/>
);
}
@@ -1,7 +1,13 @@
"use client";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { reportProductFeedback } from "@/analytics/report";
import { NotificationsButton } from "@/components/notifications/notification-button";
import { NavLink } from "@/components/ui/NavLink";
import type { Account } from "@/hooks/useApi";
import { AccountButton } from "./account-button.client";
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
@@ -31,6 +37,32 @@ export function SecondaryNav(props: {
}
export function SecondaryNavLinks() {
const [showFeedbackDropdown, setShowFeedbackDropdown] = useState(false);
const [modalFeedback, setModalFeedback] = useState("");
const handleModalSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
// Report feedback to PostHog
reportProductFeedback({
feedback: modalFeedback,
source: "desktop",
});
// Show success notification
toast.success("Feedback submitted successfully!", {
description: "Thank you for your feedback. We'll review it shortly.",
});
setModalFeedback("");
setShowFeedbackDropdown(false);
};
const handleModalCancel = () => {
setModalFeedback("");
setShowFeedbackDropdown(false);
};
return (
<div className="flex items-center gap-6">
<ResourcesDropdownButton />
@@ -44,14 +76,77 @@ export function SecondaryNavLinks() {
Docs
</Link>
<Link
className="text-muted-foreground text-sm hover:text-foreground"
href="https://feedback.thirdweb.com"
rel="noopener noreferrer"
target="_blank"
>
Feedback
</Link>
<div className="relative">
<button
type="button"
onClick={() => setShowFeedbackDropdown(!showFeedbackDropdown)}
className="text-muted-foreground text-sm hover:text-foreground border border-border px-3 py-1.5 rounded-full hover:bg-muted transition-colors"
>
Feedback
</button>
{showFeedbackDropdown && (
<div
id="feedback-dropdown"
role="dialog"
aria-labelledby="feedback-heading"
className="absolute top-full right-0 mt-2 bg-background border border-border rounded-2xl p-3 w-96 z-50"
>
<h2
id="feedback-heading"
className="text-foreground text-base font-sans mb-2"
>
Share your feedback with us:
</h2>
<form onSubmit={handleModalSubmit} className="contents">
<label htmlFor="feedback-text" className="sr-only">
Feedback
</label>
<textarea
id="feedback-text"
value={modalFeedback}
onChange={(e) => setModalFeedback(e.target.value)}
maxLength={1000}
aria-describedby="feedback-help"
className="w-full bg-background text-foreground rounded-lg p-4 min-h-[120px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans mb-4 text-sm"
placeholder="Tell us what you think..."
/>
<div className="flex items-start justify-between gap-4">
<div className="text-muted-foreground text-xs font-sans">
<div>Have a technical issue?</div>
<div>
<NavLink
href="/team/~/support"
className="underline hover:text-foreground transition-colors"
>
Contact support
</NavLink>
.
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
type="button"
onClick={handleModalCancel}
className="bg-transparent text-foreground px-4 py-1.5 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!modalFeedback.trim()}
aria-disabled={!modalFeedback.trim()}
className="bg-primary text-primary-foreground px-4 py-1.5 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Submit
</button>
</div>
</div>
</form>
</div>
)}
</div>
</div>
);
}
@@ -1,6 +1,8 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
LogOutIcon,
MenuIcon,
MoonIcon,
@@ -12,8 +14,11 @@ import {
import Link from "next/link";
import { useTheme } from "next-themes";
import { useLayoutEffect, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { reportProductFeedback } from "@/analytics/report";
import { Button } from "@/components/ui/button";
import { NavLink } from "@/components/ui/NavLink";
import { Separator } from "@/components/ui/separator";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { useEns } from "@/hooks/contract-hooks";
@@ -36,6 +41,8 @@ export function MobileBurgerMenuButton(
},
) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showFeedbackSection, setShowFeedbackSection] = useState(false);
const [modalFeedback, setModalFeedback] = useState("");
const { setTheme, theme } = useTheme();
const ensQuery = useEns({
addressOrEnsName:
@@ -44,6 +51,29 @@ export function MobileBurgerMenuButton(
});
// const [isCMDSearchModalOpen, setIsCMDSearchModalOpen] = useState(false);
const handleModalSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
// Report feedback to PostHog
reportProductFeedback({
feedback: modalFeedback,
source: "mobile",
});
// Show success notification
toast.success("Feedback submitted successfully!", {
description: "Thank you for your feedback. We'll review it shortly.",
});
setModalFeedback("");
setShowFeedbackSection(false);
};
const handleModalCancel = () => {
setModalFeedback("");
setShowFeedbackSection(false);
};
useLayoutEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = "hidden";
@@ -71,7 +101,7 @@ export function MobileBurgerMenuButton(
</Button>
{isMenuOpen && (
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200">
<div className="fade-in-0 fixed inset-0 z-50 flex animate-in flex-col bg-background p-6 duration-200 overflow-y-auto">
<Button
className="!h-auto absolute top-4 right-4 p-1"
onClick={() => setIsMenuOpen(false)}
@@ -191,14 +221,78 @@ export function MobileBurgerMenuButton(
Docs
</Link>
<Link
className="text-muted-foreground hover:text-foreground"
href="https://feedback.thirdweb.com"
rel="noopener noreferrer"
target="_blank"
>
Feedback
</Link>
<div className="flex flex-col gap-3">
<button
type="button"
className="flex items-center justify-between text-muted-foreground hover:text-foreground"
onClick={() => setShowFeedbackSection(!showFeedbackSection)}
>
<span>Feedback</span>
{showFeedbackSection ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</button>
{showFeedbackSection && (
<div className="pl-0 pr-4 space-y-4 mb-6">
<h3
id="mobile-feedback-heading"
className="text-sm font-medium text-foreground mb-2"
>
Share your feedback with us:
</h3>
<form onSubmit={handleModalSubmit} className="contents">
<label htmlFor="mobile-feedback-text" className="sr-only">
Feedback
</label>
<textarea
id="mobile-feedback-text"
value={modalFeedback}
onChange={(e) => setModalFeedback(e.target.value)}
maxLength={1000}
aria-describedby="mobile-feedback-help"
className="w-full bg-background text-foreground rounded-lg p-3 min-h-[100px] resize-none border border-border focus:border-border focus:outline-none placeholder-muted-foreground font-sans text-sm"
placeholder="Tell us what you think..."
/>
<div className="flex flex-col gap-3">
<p
id="mobile-feedback-help"
className="text-muted-foreground text-xs"
>
Have a technical issue?{" "}
<NavLink
href="/team/~/support"
className="underline hover:text-foreground transition-colors"
>
Contact support
</NavLink>
.
</p>
<div className="flex gap-3">
<button
type="button"
onClick={handleModalCancel}
className="flex-1 bg-transparent text-foreground px-3 py-2 rounded-full font-sans text-sm border border-border hover:bg-muted transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!modalFeedback.trim()}
aria-disabled={!modalFeedback.trim()}
className="flex-1 bg-primary text-primary-foreground px-3 py-2 rounded-full font-sans text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Submit
</button>
</div>
</div>
</form>
</div>
)}
</div>
</div>
<div className="h-6" />
@@ -0,0 +1,452 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { type ProjectService, SERVICES } from "@thirdweb-dev/service-utils";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { reportOnboardingCompleted } from "@/analytics/report";
import type { Project } from "@/api/project/projects";
import type { CreateProjectPrefillOptions } from "@/components/project/create-project-modal";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Card } from "@/components/ui/card";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { createProjectClient } from "@/hooks/useApi";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
import { toArrFromList } from "@/utils/string";
import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
const ALL_PROJECT_SERVICES = SERVICES.filter(
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
);
export function CreateProjectFormOnboarding(props: {
prefill?: CreateProjectPrefillOptions;
enableNebulaServiceByDefault: boolean;
teamSlug: string;
teamId: string;
}) {
const [screen, setScreen] = useState<
{ id: "create" } | { id: "api-details"; project: Project; secret: string }
>({ id: "create" });
return (
<Card>
<DynamicHeight>
{screen.id === "create" && (
<CreateProjectForm
createProject={async (params) => {
const res = await createProjectClient(props.teamId, params);
await createVaultAccountAndAccessToken({
project: res.project,
projectSecretKey: res.secret,
}).catch((error) => {
console.error(
"Failed to create vault account and access token",
error,
);
throw error;
});
return {
project: res.project,
secret: res.secret,
};
}}
enableNebulaServiceByDefault={props.enableNebulaServiceByDefault}
onProjectCreated={(params) => {
setScreen({
id: "api-details",
project: params.project,
secret: params.secret,
});
}}
prefill={props.prefill}
/>
)}
{screen.id === "api-details" && (
<CreatedProjectDetails
onComplete={() => {
setScreen({ id: "create" });
}}
project={screen.project}
secret={screen.secret}
teamSlug={props.teamSlug}
/>
)}
</DynamicHeight>
</Card>
);
}
const createProjectFormSchema = z.object({
domains: projectDomainsSchema,
name: projectNameSchema,
});
type CreateProjectFormSchema = z.infer<typeof createProjectFormSchema>;
function CreateProjectForm(props: {
createProject: (param: Partial<Project>) => Promise<{
project: Project;
secret: string;
}>;
prefill?: CreateProjectPrefillOptions;
enableNebulaServiceByDefault: boolean;
onProjectCreated: (params: { project: Project; secret: string }) => void;
}) {
const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">();
const createProject = useMutation({
mutationFn: props.createProject,
});
const form = useForm<CreateProjectFormSchema>({
defaultValues: {
domains: props.prefill?.domains || "",
name: props.prefill?.name || "",
},
resolver: zodResolver(createProjectFormSchema),
});
function handleAPICreation(values: { name: string; domains: string }) {
const servicesToEnableByDefault = props.enableNebulaServiceByDefault
? ALL_PROJECT_SERVICES
: ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula");
const formattedValues: Partial<Project> = {
domains: toArrFromList(values.domains),
name: values.name,
// enable all services
services: servicesToEnableByDefault.map((srv) => {
if (srv.name === "storage") {
return {
actions: srv.actions.map((sa) => sa.name),
name: srv.name,
} satisfies ProjectService;
}
if (srv.name === "pay") {
return {
actions: [],
name: "pay",
payoutAddress: null,
} satisfies ProjectService;
}
return {
actions: [],
name: srv.name,
} satisfies ProjectService;
}),
};
createProject.mutate(formattedValues, {
onError: () => {
toast.error("Failed to create a project");
},
onSuccess: (data) => {
props.onProjectCreated(data);
toast.success("Project created successfully");
},
});
}
const handleSubmit = form.handleSubmit((values) => {
if (!values.domains) {
setShowAlert("no-domain");
} else if (values.domains === "*") {
setShowAlert("any-domain");
} else {
handleAPICreation({
domains: values.domains,
name: values.name,
});
}
});
if (showAlert) {
return (
<DomainsAlert
isCreating={createProject.isPending}
onGoBack={() => setShowAlert(undefined)}
onProceed={() => {
handleAPICreation({
domains: form.getValues("domains"),
name: form.getValues("name"),
});
}}
type={showAlert}
/>
);
}
return (
<Form {...form}>
<form autoComplete="off" onSubmit={handleSubmit}>
<div className="p-6">
<header className="mb-4">
<h2 className="text-2xl">Create Project</h2>
</header>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input
className="bg-card"
placeholder="My Project"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="relative">
<CheckboxWithLabel className="absolute top-0 right-0 text-foreground">
<Checkbox
checked={form.watch("domains") === "*"}
onCheckedChange={(checked) => {
form.setValue("domains", checked ? "*" : "", {
shouldDirty: true,
});
}}
/>
Allow all domains
</CheckboxWithLabel>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>Allowed Domains</FormLabel>
<FormControl>
<Textarea
className="bg-card"
placeholder="thirdweb.com, *.example.com, localhost:3000"
{...field}
/>
</FormControl>
<FormMessage />
<div className="pt-1">
<FormDescription className="mb-2 text-sm leading-relaxed">
Prevent third-parties from using your Client ID by
restricting access to allowed domains. Highly
recommended for frontend applications.
</FormDescription>
<ul className="flex list-disc flex-col gap-2 pl-3 text-muted-foreground text-sm [&>li]:pl-1 ">
<li>
Authorize all domains with{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
*
</span>
<br />
<span>
Example:{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
*.thirdweb.com
</span>{" "}
accepts all{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
.thirdweb.com
</span>{" "}
sites
</span>
</li>
<li>
Authorize local URLs with{" "}
<span className="inline-block rounded bg-muted px-2 font-mono text-xs">
{"localhost:<port>"}
</span>
</li>
<li>Separate domains by commas or new lines</li>
</ul>
</div>
</FormItem>
)}
/>
</div>
</div>
</div>
<footer className="flex gap-4 border-border border-t p-6 md:gap-2">
<Button
className="min-w-28 gap-2 ml-auto"
disabled={createProject.isPending}
type="submit"
>
{createProject.isPending && <Spinner className="size-4" />}
Create Projecct
</Button>
</footer>
</form>
</Form>
);
}
function DomainsAlert(props: {
type: "no-domain" | "any-domain";
isCreating: boolean;
onProceed: () => void;
onGoBack: () => void;
}) {
return (
<div>
<div className="mb-4 p-6">
<header>
<h2 className="text-2xl">
{props.type === "no-domain" && "No Domains Configured"}
{props.type === "any-domain" && "Unrestricted Web Access"}
</h2>
<p>
{props.type === "no-domain" &&
"This will deny requests from all origins, rendering the key unusable in frontend applications. Proceed only if you intend to use this key in server or native apps environments."}
{props.type === "any-domain" &&
"Requests from all origins will be authorized. Your key can be used by any website without restriction"}
</p>
</header>
</div>
<footer className="!justify-between flex gap-4 border-border border-t bg-card p-6">
<Button
className="min-w-28 gap-2"
onClick={props.onGoBack}
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Update Domains
</Button>
<Button
className="min-w-28 gap-2"
disabled={props.isCreating}
onClick={props.onProceed}
type="button"
variant="destructive"
>
{props.isCreating && <Spinner className="size-4" />}
Proceed
</Button>
</footer>
</div>
);
}
function CreatedProjectDetails(props: {
project: Project;
secret: string;
onComplete: () => void;
teamSlug: string | undefined;
}) {
const [secretStored, setSecretStored] = useState(false);
const router = useDashboardRouter();
const clientId = props.project.publishableKey;
return (
<div>
<div className="p-6">
<header>
<h2 className="text-2xl">{props.project.name}</h2>
</header>
<div className="h-3" />
<section>
<h3>Client ID</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies your application.
</p>
<CopyTextButton
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
copyIconPosition="right"
textToCopy={clientId}
textToShow={clientId}
tooltip="Copy Client ID"
/>
</section>
<div className="h-6" />
<section>
<h3>Secret Key</h3>
<p className="mb-2 text-muted-foreground text-sm">
Identifies and authenticates your application from a backend.
</p>
<CopyTextButton
className="!h-auto w-full justify-between truncate bg-card px-3 py-3 font-mono"
copyIconPosition="right"
textToCopy={props.secret || ""}
textToShow={props.secret || ""}
tooltip="Copy Secret Key"
/>
</section>
<div className="h-3" />
<Alert variant="destructive">
<AlertTitle>Do not share or expose your secret key</AlertTitle>
<AlertDescription>
<div className="mb-5">
Secret keys cannot be recovered. If you lose your secret key, you
will need to rotate the secret key or create a new Project.
</div>
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={secretStored}
onCheckedChange={(v) => {
setSecretStored(!!v);
}}
/>
I confirm that I've securely stored my secret key
</CheckboxWithLabel>
</AlertDescription>
</Alert>
</div>
<footer className="flex border-border border-t bg-card p-6">
{props.teamSlug && (
<Button
className="min-w-28 gap-2 ml-auto"
disabled={!secretStored}
onClick={() => {
reportOnboardingCompleted();
router.push(`/team/${props.teamSlug}/${props.project.slug}`);
}}
>
View Project
<ArrowRightIcon className="size-4" />
</Button>
)}
</footer>
</div>
);
}
@@ -0,0 +1,29 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team/get-team";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { CreateProjectFormOnboarding } from "./_components/create-project-form";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
}) {
const params = await props.params;
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
]);
if (!team || !authToken) {
notFound();
}
return (
<TeamOnboardingLayout currentStep={4}>
<CreateProjectFormOnboarding
enableNebulaServiceByDefault={true}
teamSlug={team.slug}
teamId={team.id}
/>
</TeamOnboardingLayout>
);
}
@@ -0,0 +1,31 @@
"use client";
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import type { Team } from "@/api/team/get-team";
const stripePromise = loadStripe(
// biome-ignore lint/style/noNonNullAssertion: TODO: fix this later
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
export function GrowthPlanCheckout(props: {
team: Team;
fetchClientSecret: (team: Team) => Promise<string>;
}) {
return (
<div id="checkout">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{
fetchClientSecret: () => props.fetchClientSecret(props.team),
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
@@ -1,13 +1,23 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import {
fetchClientSecret,
getStripeSessionById,
} from "@/actions/stripe-actions";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team/get-team";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { pollWithTimeout } from "@/utils/pollWithTimeout";
import { TeamOnboardingLayout } from "../../../../../login/onboarding/onboarding-layout";
import { PlanSelector } from "./_components/plan-selector";
import { GrowthPlanCheckout } from "./_components/stripe-checkout";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
searchParams: Promise<{ session_id?: string }>;
}) {
const params = await props.params;
const [params, searchParams] = await Promise.all([
props.params,
props.searchParams,
]);
const [team, authToken] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
@@ -17,18 +27,47 @@ export default async function Page(props: {
notFound();
}
async function getTeam() {
"use server";
const resolvedTeam = await getTeamBySlug(params.team_slug);
if (!resolvedTeam) {
return team as Team;
// IF we have a session id, check if the session was completed successfully
let error: string | null = null;
if (searchParams.session_id) {
const session = await getStripeSessionById(searchParams.session_id);
switch (session.status) {
case "complete": {
// poll the team until it shows as "growth" plan (to handle race condition with stripe webhook)
await pollWithTimeout({
shouldStop: async () => {
const refreshedTeam = await getTeamBySlug(team.slug);
return refreshedTeam?.billingPlan === "growth";
},
timeoutMs: 5000,
});
redirect(`/get-started/team/${team.slug}/add-members`);
break;
}
case "open": {
error =
"Something went wrong when trying to process your payment method. Please try again.";
break;
}
default: {
error = "Please try again later.";
break;
}
}
return resolvedTeam;
}
return (
<TeamOnboardingLayout currentStep={2}>
<PlanSelector getTeam={getTeam} team={team} />
<div className="rounded-lg border bg-card overflow-hidden py-4 md:py-8">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to proces subscription</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<GrowthPlanCheckout team={team} fetchClientSecret={fetchClientSecret} />
</div>
</TeamOnboardingLayout>
);
}
@@ -1,6 +1,7 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { useEffect, useMemo } from "react";
@@ -24,6 +25,9 @@ export function AppRouterProviders(props: {
children: React.ReactNode;
autoConnect: boolean;
}) {
// get the current pathname
const pathname = usePathname();
return (
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
@@ -36,6 +40,9 @@ export function AppRouterProviders(props: {
defaultTheme="dark"
disableTransitionOnChange
enableSystem={false}
forcedTheme={
pathname.startsWith("/get-started") ? "dark" : undefined
}
>
<Toaster richColors />
<SanctionedAddressesChecker>
@@ -34,7 +34,7 @@ interface SupportCaseDetailsProps {
team: Team;
}
export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
const [replyMessage, setReplyMessage] = useState("");
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [localMessages, setLocalMessages] = useState(ticket.messages || []);
@@ -42,6 +42,15 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
// rating/feedback
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState("");
// non-blocking warning when status check fails
const [statusCheckFailed, setStatusCheckFailed] = useState(false);
// Helper function to handle status check errors consistently
const handleStatusCheckError = (_error: unknown) => {
// Set degraded state for warning banner
setStatusCheckFailed(true);
return;
};
// Check if feedback has already been submitted for this ticket
const feedbackStatusQuery = useQuery({
@@ -49,18 +58,25 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
queryFn: async () => {
const result = await checkFeedbackStatus(ticket.id);
if ("error" in result) {
throw new Error(result.error);
handleStatusCheckError(result.error);
return false; // Non-blocking: allow feedback submission despite status check failure
}
// Clear degraded state on success
if (statusCheckFailed) setStatusCheckFailed(false);
return result.hasFeedback;
},
enabled: ticket.status === "closed",
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 1, // Reduce retries since we want non-blocking behavior
});
const feedbackSubmitted = feedbackStatusQuery.data ?? false;
const isLoading = feedbackStatusQuery.isLoading;
const hasError = feedbackStatusQuery.isError;
// query never throws; use local degraded flag for the inline warning
const hasError = statusCheckFailed;
const handleStarClick = (starIndex: number) => {
setRating(starIndex + 1);
@@ -70,29 +86,36 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
const submitFeedbackMutation = useMutation({
mutationFn: async () => {
if (rating === 0) {
throw new Error("Please select a rating");
const error = "Please select a rating";
throw new Error(error);
}
const result = await submitSupportFeedback({
rating,
feedback,
ticketId: ticket.id,
});
if ("error" in result) {
// Add more specific error information
throw new Error(result.error);
}
return result;
},
onSuccess: () => {
toast.success("Thank you for your feedback!");
setRating(0);
setFeedback("");
// mark as submitted immediately
queryClient.setQueryData(["feedbackStatus", ticket.id], true);
},
onError: (err) => {
console.error("Failed to submit feedback:", err);
const msg = err instanceof Error ? err.message : String(err ?? "");
let message = "Failed to submit feedback. Please try again.";
if (/network|fetch/i.test(msg)) {
message = "Network error. Please check your connection and try again.";
} else if (
@@ -102,6 +125,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
} else if (/API Server error/i.test(msg)) {
message = "Server error. Please try again later.";
}
toast.error(message);
},
});
@@ -157,8 +181,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
}
setReplyMessage("");
} catch (error) {
console.error("Failed to send reply:", error);
} catch {
toast.error("Failed to send Message. Please try again.");
// Remove the optimistic message on error
@@ -233,73 +256,77 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
</div>
)}
{ticket.status === "closed" && !isLoading && !feedbackSubmitted && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
This ticket is closed. Give us a quick rating to let us know how
we did!
</p>
{hasError && (
<p className="text-destructive text-xs mt-2">
Couldn't verify prior feedback right now — you can still submit
a rating.
{ticket.status === "closed" &&
!isLoading &&
(!feedbackSubmitted || hasError) && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
This ticket is closed. Give us a quick rating to let us know how
we did!
</p>
)}
{hasError && (
<div className="mt-2">
<p className="text-destructive text-xs">
Couldn't verify prior feedback right now — you can still
submit a rating.
</p>
</div>
)}
<div className="flex gap-2 mb-6 mt-4">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={`star-${starValue}`}
<div className="flex gap-2 mb-6 mt-4">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={`star-${starValue}`}
type="button"
onClick={() => handleStarClick(starValue - 1)}
className="transition-colors"
aria-label={`Rate ${starValue} out of 5 stars`}
>
<StarIcon
size={32}
className={cn(
"transition-colors",
starValue <= rating
? "text-pink-500 fill-current stroke-current"
: "text-muted-foreground fill-current stroke-current",
"hover:text-pink-500",
)}
strokeWidth={starValue <= rating ? 2 : 1}
/>
</button>
))}
</div>
<div className="relative">
<AutoResizeTextarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Optional: Tell us how we can improve."
maxLength={1000}
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
/>
<Button
type="button"
onClick={() => handleStarClick(starValue - 1)}
className="transition-colors"
aria-label={`Rate ${starValue} out of 5 stars`}
onClick={handleSendFeedback}
disabled={submitFeedbackMutation.isPending || rating === 0}
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
variant="secondary"
size="sm"
>
<StarIcon
size={32}
className={cn(
"transition-colors",
starValue <= rating
? "text-pink-500 fill-current stroke-current"
: "text-muted-foreground fill-current stroke-current",
"hover:text-pink-500",
)}
strokeWidth={starValue <= rating ? 2 : 1}
/>
</button>
))}
{submitFeedbackMutation.isPending ? (
<>
<Spinner className="size-4 mr-2" />
Sending...
</>
) : (
"Send Feedback"
)}
</Button>
</div>
</div>
)}
<div className="relative">
<AutoResizeTextarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Optional: Tell us how we can improve."
maxLength={1000}
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
/>
<Button
type="button"
onClick={handleSendFeedback}
disabled={submitFeedbackMutation.isPending || rating === 0}
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
variant="secondary"
size="sm"
>
{submitFeedbackMutation.isPending ? (
<>
<Spinner className="size-4 mr-2" />
Sending...
</>
) : (
"Send Feedback"
)}
</Button>
</div>
</div>
)}
{ticket.status === "closed" && feedbackSubmitted && (
{ticket.status === "closed" && feedbackSubmitted && !hasError && (
<div className="border-t p-6">
<p className="text-muted-foreground text-sm">
Thank you for your feedback! We appreciate your input and will use
@@ -455,3 +482,5 @@ function TicketMessage(props: { message: SupportMessage }) {
</div>
);
}
export { SupportCaseDetails };
@@ -13,7 +13,16 @@ export async function submitSupportFeedback({
ticketId: string;
}): Promise<FeedbackSubmitResult> {
try {
// Fail fast on missing configuration
// Basic input validation first - fail fast on invalid input
if (!ticketId?.trim()) {
return { error: "ticketId is required." };
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { error: "Rating must be an integer between 1 and 5." };
}
// Configuration validation after input validation
const siwaUrl =
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
@@ -27,15 +36,6 @@ export async function submitSupportFeedback({
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
}
// Basic input validation/normalization
if (!ticketId?.trim()) {
return { error: "ticketId is required." };
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { error: "Rating must be an integer between 1 and 5." };
}
const normalizedFeedback = (feedback ?? "")
.toString()
.trim()
@@ -49,6 +49,7 @@ export async function submitSupportFeedback({
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 10_000);
const response = await fetch(`${siwaUrl}/v1/csat/saveCSATFeedback`, {
method: "POST",
cache: "no-store",
@@ -62,13 +63,33 @@ export async function submitSupportFeedback({
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
const error = `API Server error: ${response.status} - ${errorText}`;
return { error };
}
return { success: true };
try {
const data = await response.json();
// Validate response structure
if (typeof data !== "object" || data === null) {
return { error: "Invalid response format from API" };
}
if (!data.success) {
return { error: "API returned unsuccessful response" };
}
return { success: true };
} catch (_jsonError) {
return { error: "Invalid JSON response from API" };
}
} catch (error) {
console.error("Feedback submission error:", error);
return { error: "Internal server error" };
if (error instanceof Error) {
if (error.name === "AbortError") return { error: "Request timeout" };
if (error instanceof TypeError) return { error: "Network error" };
return { error: error.message };
}
return { error: "Unknown error occurred" };
}
}
@@ -76,12 +97,12 @@ export async function checkFeedbackStatus(
ticketId: string,
): Promise<FeedbackStatusResult> {
try {
// Basic input validation
// Basic input validation first - fail fast on invalid input
if (!ticketId?.trim()) {
return { error: "ticketId is required." };
}
// Fail fast on missing configuration
// Configuration validation after input validation
const siwaUrl =
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
@@ -95,29 +116,71 @@ export async function checkFeedbackStatus(
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
}
const fullUrl = `${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(ticketId)}`;
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 10_000);
const response = await fetch(fullUrl, {
method: "GET",
cache: "no-store",
headers: {
"Content-Type": "application/json",
"x-service-api-key": apiKey,
const response = await fetch(
`${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(
ticketId.trim(),
)}`,
{
method: "GET",
cache: "no-store",
headers: {
"Content-Type": "application/json",
"x-service-api-key": apiKey,
},
signal: ac.signal,
},
signal: ac.signal,
}).finally(() => clearTimeout(t));
).finally(() => clearTimeout(t));
if (!response.ok) {
const errorText = await response.text();
return { error: `API Server error: ${response.status} - ${errorText}` };
const error = `API Server error: ${response.status} - ${errorText}`;
return { error };
}
let data: {
has_feedback: boolean;
feedback_data: {
id: string;
rating: number | null;
feedback: string | null;
ticket_id: string | null;
created_at: string;
} | null;
};
try {
data = await response.json();
} catch (_jsonError) {
return { error: "Invalid JSON response from API" };
}
// Comprehensive validation of the API response structure
if (
typeof data.has_feedback !== "boolean" ||
(data.feedback_data != null &&
(typeof data.feedback_data !== "object" ||
typeof data.feedback_data.id !== "string" ||
(data.feedback_data.rating != null &&
typeof data.feedback_data.rating !== "number") ||
(data.feedback_data.feedback != null &&
typeof data.feedback_data.feedback !== "string") ||
(data.feedback_data.ticket_id != null &&
typeof data.feedback_data.ticket_id !== "string") ||
typeof data.feedback_data.created_at !== "string"))
) {
return { error: "Invalid response format from API" };
}
const data = await response.json();
return { hasFeedback: data.has_feedback };
} catch (error) {
console.error("Feedback status check error:", error);
return { error: "Internal server error" };
if (error instanceof Error) {
if (error.name === "AbortError") return { error: "Request timeout" };
if (error instanceof TypeError) return { error: "Network error" };
return { error: error.message };
}
return { error: "Unknown error occurred" };
}
}
@@ -190,6 +190,13 @@ export function TransactionsSection(props: { client: ThirdwebClient }) {
}
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(
`Failed to fetch transactions: ${response.status} - ${await response.text()}`,
);
}
const json = (await response.json()) as {
data?: WalletTransaction[];
};
@@ -8,6 +8,7 @@ import {
ExternalLinkIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -17,7 +18,6 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -70,6 +70,7 @@ export function ImportEngineButton(props: {
teamSlug: string;
projectSlug: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const router = useDashboardRouter();
const form = useForm<ImportEngineParams>({
resolver: zodResolver(formSchema),
@@ -82,7 +83,7 @@ export function ImportEngineButton(props: {
const importMutation = useMutation({
mutationFn: async (importParams: ImportEngineParams) => {
await importEngine({ ...importParams, teamIdOrSlug: props.teamSlug });
router.push(`/team/${props.teamSlug}/${props.projectSlug}/engine`);
router.refresh();
},
});
@@ -90,6 +91,7 @@ export function ImportEngineButton(props: {
try {
await importMutation.mutateAsync(data);
toast.success("Engine imported successfully");
setIsOpen(false);
} catch (e) {
const message = e instanceof Error ? e.message : undefined;
toast.error(
@@ -102,42 +104,40 @@ export function ImportEngineButton(props: {
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Dialog>
<DialogTrigger asChild>
<Button
className="gap-2 rounded-full bg-card"
size="sm"
variant="outline"
>
<ArrowDownToLineIcon className="size-3.5" />
Import Engine
</Button>
</DialogTrigger>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
className="gap-2 rounded-full bg-card"
size="sm"
variant="outline"
>
<ArrowDownToLineIcon className="size-3.5" />
Import Engine
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogContent className="p-0 rounded-lg overflow-hidden">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Import Engine Instance</DialogTitle>
<DialogDescription>
Import an Engine instance hosted on your infrastructure.
</DialogDescription>
</DialogHeader>
<div>
<div>
<Link
className="flex items-center justify-between gap-2 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent"
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
rel="noopener noreferrer"
target="_blank"
>
Get help setting up Engine for free
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</div>
<div className="px-4 lg:px-6 pb-6">
<Link
className="mb-4 flex items-center justify-between gap-2 rounded-full border border-border bg-card p-3 text-sm hover:bg-accent"
href="https://portal.thirdweb.com/engine/v2/get-started"
rel="noopener noreferrer"
target="_blank"
>
Get help setting up Engine for free
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
<div className="mt-6 flex flex-col gap-4">
<div className="space-y-5">
<FormField
control={form.control}
name="name"
@@ -146,6 +146,7 @@ export function ImportEngineButton(props: {
<FormLabel>Name</FormLabel>
<FormControl>
<Input
className="bg-card"
autoFocus
placeholder="Enter a descriptive label"
{...field}
@@ -164,6 +165,7 @@ export function ImportEngineButton(props: {
<FormLabel>URL</FormLabel>
<FormControl>
<Input
className="bg-card"
placeholder="Enter your Engine URL"
type="url"
{...field}
@@ -182,8 +184,8 @@ export function ImportEngineButton(props: {
</div>
</div>
<DialogFooter>
<Button className="min-w-28 gap-1.5 rounded-full" type="submit">
<div className="border-t bg-card p-6 flex justify-end">
<Button className="gap-2 rounded-full" type="submit">
{importMutation.isPending ? (
<Spinner className="size-4" />
) : (
@@ -191,10 +193,10 @@ export function ImportEngineButton(props: {
)}
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
</Form>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -539,7 +539,12 @@ function AllowedDomainsSetting(props: {
return (
<SettingsCard
bottomText="This is only applicable for web applications"
bottomText={
<>
This is only applicable for web applications. Changes to domain
restrictions may take up to 5 minutes to take effect
</>
}
errorText={form.getFieldState("domains", form.formState).error?.message}
header={{
description:
@@ -8,6 +8,7 @@ export type SMSCountryTiers = {
tier3: string[];
tier4: string[];
tier5: string[];
tier6: string[];
};
export async function getSMSCountryTiers() {
@@ -40,6 +41,7 @@ export async function getSMSCountryTiers() {
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
@@ -53,6 +55,7 @@ export async function getSMSCountryTiers() {
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
}
@@ -66,6 +66,7 @@ function Variants(props: { currentPlan: Team["billingPlan"] }) {
tier3: ["FR", "DE", "ES", "IT"],
tier4: ["JP", "KR", "MX", "RU"],
tier5: ["BR", "AR", "CO", "CL", "PE", "VE", "SA"],
tier6: ["RU", "PG", "UZ", "TZ", "KM", "BT"],
}}
teamId="bar"
teamPlan={props.currentPlan}
@@ -5,6 +5,7 @@ export const tierPricing = {
tier3: "$0.10 per SMS",
tier4: "$0.20 per SMS",
tier5: "$0.40 per SMS",
tier6: "$1.00 per SMS",
} as const;
// Country names mapped to ISO codes
@@ -21,6 +22,14 @@ export const countryNames: Record<string, string> = {
AM: "Armenia",
AO: "Angola",
// Tier 6
RU: "Russia/Kazakhstan",
PG: "Papua New Guinea",
UZ: "Uzbekistan",
TZ: "Tanzania",
KM: "Comoros",
BT: "Bhutan",
// Tier 3
AR: "Argentina",
AS: "American Samoa",
@@ -244,6 +253,14 @@ export const countryPrefixes: Record<string, string> = {
AM: "+374",
AO: "+244",
// Tier 6
RU: "+7",
PG: "+675",
UZ: "+998",
TZ: "+255",
KM: "+269",
BT: "+975",
// Tier 3
AR: "+54",
AS: "+1",
@@ -9,8 +9,8 @@ import {
XIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { defineChain, type ThirdwebClient } from "thirdweb";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
@@ -48,6 +48,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { cn } from "@/lib/utils";
import CreateServerWallet from "../components/create-server-wallet.client";
@@ -277,10 +278,7 @@ function ServerWalletTableRow(props: {
const { wallet, project, teamSlug, client, chainId, showSmartAccount } =
props;
const chain = useMemo(() => {
// eslint-disable-next-line no-restricted-syntax
return defineChain(chainId);
}, [chainId]);
const chain = useV5DashboardChain(chainId);
const smartAccountAddressQuery = useQuery({
queryFn: async () => {
@@ -458,10 +456,10 @@ function WalletBalanceCell(props: {
chainId: number;
client: ThirdwebClient;
}) {
const chain = useV5DashboardChain(props.chainId);
const balance = useWalletBalance({
address: props.address,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(props.chainId),
chain: chain,
client: props.client,
});
@@ -3,8 +3,8 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { defineChain } from "thirdweb/chains";
import { getContract, resolveAbiFromContractApi } from "thirdweb/contract";
import { useGetV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { parseAddresses } from "../utils/webhookPayloadUtils";
import type {
AbiData,
@@ -29,6 +29,7 @@ export function useAbiMultiFetch({
) => EventSignature[] | FunctionSignature[];
type: "event" | "transaction";
}) {
const getChain = useGetV5DashboardChain();
const pairs = useMemo(() => {
const result: { chainId: string; address: string }[] = [];
const seen = new Set<string>();
@@ -54,8 +55,7 @@ export function useAbiMultiFetch({
return Promise.all(
pairs.map(async ({ chainId, address }) => {
try {
// eslint-disable-next-line no-restricted-syntax
const chainObj = defineChain(Number(chainId));
const chainObj = getChain(Number(chainId));
const contract = getContract({
address,
chain: chainObj,
@@ -1,10 +1,8 @@
"use client";
import { useTheme } from "next-themes";
import type { Address } from "thirdweb";
import { BuyWidget, type TokenInfo } from "thirdweb/react";
import type { TokenInfo } from "thirdweb/react";
import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed";
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { bridgeAppThirdwebClient } from "../../constants";
export function UniversalBridgeEmbed({
@@ -16,16 +14,17 @@ export function UniversalBridgeEmbed({
token: TokenInfo | undefined;
amount?: string;
}) {
const { theme } = useTheme();
const chain = useV5DashboardChain(chainId || 1);
return (
<BuyWidget
client={bridgeAppThirdwebClient}
amount={amount || "0"}
chain={chain}
tokenAddress={token?.address as Address | undefined}
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
/>
<div className="lg:w-[400px]">
<BuyAndSwapEmbed
client={bridgeAppThirdwebClient}
chain={chain}
buyAmount={amount}
tokenAddress={token?.address}
pageType="bridge"
/>
</div>
);
}
+7 -7
View File
@@ -49,7 +49,7 @@ export default async function BridgePage({
}
return (
<div className="relative mx-auto flex h-screen w-full flex-col items-center justify-center overflow-hidden border py-10">
<div className="relative mx-auto flex h-dvh w-full flex-col items-center justify-center overflow-hidden border py-10">
<main className="container z-10 flex justify-center">
<UniversalBridgeEmbed
amount={amount as string}
@@ -69,7 +69,7 @@ export default async function BridgePage({
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0 opacity-20"
src="/assets/login/background.svg"
/>
@@ -79,17 +79,17 @@ export default async function BridgePage({
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with thirdweb Payments
<h3 className="font-semibold text-base tracking-tight">
Get Started with thirdweb Bridge
</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and
chain.
thirdweb Bridge allows developers to easily bridge and swap
tokens between chains and wallets.
</p>
</div>
<a
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
href="https://portal.thirdweb.com/payments"
href="https://portal.thirdweb.com/bridge"
rel="noopener noreferrer"
target="_blank"
>
@@ -3,6 +3,7 @@ import {
BoxIcon,
LogOutIcon,
MailIcon,
PenIcon,
RocketIcon,
UserIcon,
UsersIcon,
@@ -73,33 +74,39 @@ export function AccountOnboardingLayout(props: {
const teamOnboardingSteps: OnboardingStep[] = [
{
description: "Provide team details",
icon: BoxIcon,
icon: PenIcon,
number: 1,
title: "Team Details",
},
{
description: "Choose a plan that fits your needs",
description: "Activate your 14 day free trial",
icon: RocketIcon,
number: 2,
title: "Plan Selection",
title: "Payment Details",
},
{
description: "Invite members to your team",
description: "Invite your team mates",
icon: UsersIcon,
number: 3,
title: "Team Members",
},
{
description: "Create your first project",
icon: BoxIcon,
number: 4,
title: "Create Project",
},
];
export function TeamOnboardingLayout(props: {
children: React.ReactNode;
currentStep: 1 | 2 | 3;
currentStep: 1 | 2 | 3 | 4;
}) {
return (
<OnboardingLayout
currentStep={props.currentStep}
steps={teamOnboardingSteps}
title="Setup your team"
title="Get started with thirdweb"
>
{props.children}
</OnboardingLayout>
@@ -5,8 +5,8 @@ import { upload } from "thirdweb/storage";
import { apiServerProxy } from "@/actions/proxies";
import { sendTeamInvites } from "@/actions/team/sendTeamInvite";
import {
reportOnboardingCompleted,
reportOnboardingStarted,
reportTeamMemberStepCompleted,
} from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import { useEffectOnce } from "@/hooks/useEffectOnce";
@@ -123,8 +123,8 @@ export function InviteTeamMembers(props: {
}}
onComplete={() => {
// at this point the team onboarding is complete
reportOnboardingCompleted();
router.replace(`/team/${props.team.slug}`);
reportTeamMemberStepCompleted();
router.push(`/get-started/team/${props.team.slug}/create-project`);
}}
team={props.team}
/>
@@ -81,6 +81,38 @@ export function NebulaLoggedOutStatePage(props: {
</div>
</header>
{/* Deprecation banner */}
<div className="bg-destructive p-6">
<div className="container text-md text-destructive-foreground">
<h4 className="font-bold text-lg">
Nebula is now{" "}
<Link
className="underline text-destructive-foreground hover:text-destructive-foreground/80"
href="https://thirdweb.com/ai"
target="_blank"
>
thirdweb AI.
</Link>{" "}
This site will be deprecated on September 30, 2025.
</h4>
<p>
To continue using thirdweb AI, please sign in to the{" "}
<Link
className="underline text-destructive-foreground hover:text-destructive-foreground/80"
href="https://thirdweb.com/login"
target="_blank"
>
thirdweb dashboard
</Link>{" "}
and create a new project.
</p>
<p>
If you have any funds left in your Nebula account, please withdraw
them before September 30, 2025.
</p>
</div>
</div>
{showPage === "connect" && (
<NebulaLoginPage redirectPath={`/?${redirectPathParams}`} />
)}
@@ -0,0 +1,105 @@
import { lazy, Suspense } from "react";
import { LoadingDots } from "@/components/ui/LoadingDots";
import type { SwapWidgetPlaygroundOptions } from "./types";
const CodeClient = lazy(() =>
import("../../../../components/code/code.client").then((m) => ({
default: m.CodeClient,
})),
);
function CodeLoading() {
return (
<div className="flex min-h-[300px] grow items-center justify-center bg-card border rounded-lg">
<LoadingDots />
</div>
);
}
export function CodeGen(props: { options: SwapWidgetPlaygroundOptions }) {
return (
<div className="flex w-full grow flex-col">
<Suspense fallback={<CodeLoading />}>
<CodeClient className="grow" code={getCode(props.options)} lang="ts" />
</Suspense>
</div>
);
}
function getCode(options: SwapWidgetPlaygroundOptions) {
const imports = {
react: ["SwapWidget"] as string[],
};
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");
}
}
const props: Record<string, string | undefined | boolean> = {
theme: themeProp,
prefill:
options.prefill?.buyToken || options.prefill?.sellToken
? JSON.stringify(options.prefill, null, 2)
: undefined,
currency:
options.currency !== "USD" && options.currency
? quotes(options.currency)
: undefined,
showThirdwebBranding:
options.showThirdwebBranding === false ? false : undefined,
client: "client",
};
return `\
import { createThirdwebClient } from "thirdweb";
import { ${imports.react.join(", ")} } from "thirdweb/react";
const client = createThirdwebClient({
clientId: "....",
});
function Example() {
return (
<SwapWidget
${stringifyProps(props)}
/>
);
}`;
}
function quotes(value: string) {
return `"${value}"`;
}
function stringifyProps(props: Record<string, string | undefined | boolean>) {
const _props: Record<string, string | undefined | boolean> = {};
for (const key in props) {
if (props[key] !== undefined && props[key] !== "") {
_props[key] = props[key];
}
}
return Object.entries(_props)
.map(([key, value]) => `${key}={${value}}`)
.join("\n\t ");
}
@@ -0,0 +1,226 @@
"use client";
import { CoinsIcon, PaletteIcon } from "lucide-react";
import type React from "react";
import { useId } from "react";
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { CollapsibleSection } from "@/app/wallets/sign-in/components/CollapsibleSection";
import { ColorFormGroup } from "@/app/wallets/sign-in/components/ColorFormGroup";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
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 { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { CurrencySelector } from "../../../../components/blocks/CurrencySelector";
import type { SwapWidgetPlaygroundOptions } from "./types";
export function LeftSection(props: {
options: SwapWidgetPlaygroundOptions;
setOptions: React.Dispatch<React.SetStateAction<SwapWidgetPlaygroundOptions>>;
}) {
const { options, setOptions } = props;
const setThemeType = (themeType: "dark" | "light") => {
setOptions((v) => ({
...v,
theme: {
...v.theme,
type: themeType,
},
}));
};
const themeId = useId();
return (
<div className="flex flex-col gap-4">
<CollapsibleSection defaultOpen icon={CoinsIcon} title="Token Selection">
<div className="flex flex-col gap-6 pt-5">
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<CurrencySelector
value={options.currency}
onChange={(currency) => {
setOptions((v) => ({ ...v, currency }));
}}
/>
</section>
<div className="border-t border-dashed" />
<TokenFieldset
title="Sell Token"
type="sellToken"
options={options}
setOptions={setOptions}
/>
<div className="border-t border-dashed" />
<TokenFieldset
title="Buy Token"
type="buyToken"
options={options}
setOptions={setOptions}
/>
</div>
</CollapsibleSection>
<CollapsibleSection icon={PaletteIcon} title="Appearance">
{/* Theme */}
<section className="flex flex-col gap-3 pt-6">
<Label htmlFor="theme"> Theme </Label>
<CustomRadioGroup
id={themeId}
onValueChange={setThemeType}
options={[
{ label: "Dark", value: "dark" },
{ label: "Light", value: "light" },
]}
value={options.theme.type}
/>
</section>
<div className="h-6" />
{/* Colors */}
<ColorFormGroup
onChange={(newTheme) => {
setOptions((v) => ({
...v,
theme: newTheme,
}));
}}
theme={options.theme}
/>
<div className="my-4 flex items-center gap-2">
<Checkbox
checked={options.showThirdwebBranding}
id={"branding"}
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
showThirdwebBranding: checked === true,
}));
}}
/>
<Label htmlFor={"branding"}>Show Branding</Label>
</div>
</CollapsibleSection>
</div>
);
}
function TokenFieldset(props: {
type: "buyToken" | "sellToken";
title: string;
options: SwapWidgetPlaygroundOptions;
setOptions: React.Dispatch<React.SetStateAction<SwapWidgetPlaygroundOptions>>;
}) {
const { options, setOptions } = props;
const chainId = options.prefill?.[props.type]?.chainId;
const tokenAddress = options.prefill?.[props.type]?.tokenAddress;
return (
<div>
<h3 className="mb-1 font-medium">{props.title}</h3>
<p className="text-sm text-muted-foreground mb-3">
Sets the default token and amount to{" "}
{props.type === "buyToken" ? "buy" : "sell"} in the widget, <br />
User can change this default selection in the widget
</p>
<div className="space-y-4">
{/* Chain selection */}
<div className="space-y-2">
<Label>Chain</Label>
<BridgeNetworkSelector
chainId={chainId}
onChange={(chainId) => {
setOptions((v) => ({
...v,
prefill: {
...v.prefill,
[props.type]: {
...v.prefill?.[props.type],
chainId,
tokenAddress: undefined, // clear token selection
},
},
}));
}}
placeholder="Select a chain"
className="bg-card"
/>
</div>
{/* Token selection - only show if chain is selected */}
<div className="space-y-2">
<Label>Token</Label>
<TokenSelector
chainId={chainId}
client={THIRDWEB_CLIENT}
disableAddress
enabled={true}
onChange={(token) => {
setOptions((v) => ({
...v,
prefill: {
...v.prefill,
[props.type]: {
chainId: token.chainId,
tokenAddress: token.address,
},
},
}));
}}
placeholder="Select a token"
selectedToken={
tokenAddress && chainId
? {
address: tokenAddress,
chainId: chainId,
}
: chainId
? {
address: getAddress(NATIVE_TOKEN_ADDRESS),
chainId: chainId,
}
: undefined
}
className="bg-card"
/>
</div>
{chainId && (
<div className="space-y-2">
<Label> Token Amount</Label>
<Input
className="bg-card"
value={options.prefill?.[props.type]?.amount || ""}
onChange={(e) => {
setOptions((v) => {
return {
...v,
prefill: {
...v.prefill,
[props.type]: {
...v.prefill?.[props.type],
amount: e.target.value,
chainId: chainId,
},
},
};
});
}}
placeholder="0.01"
/>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { darkTheme, lightTheme, SwapWidget } from "thirdweb/react";
import { Button } from "@/components/ui/button";
import { THIRDWEB_CLIENT } from "@/lib/client";
import { cn } from "@/lib/utils";
import { CodeGen } from "./code";
import type { SwapWidgetPlaygroundOptions } from "./types";
type Tab = "ui" | "code";
export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
return "ui";
});
function setPreviewTab(tab: "ui" | "code") {
_setPreviewTab(tab);
}
const themeObj =
props.options.theme.type === "dark"
? darkTheme({
colors: props.options.theme.darkColorOverrides,
})
: lightTheme({
colors: props.options.theme.lightColorOverrides,
});
return (
<div className="flex shrink-0 flex-col gap-4 xl:sticky xl:top-0 xl:max-h-[100vh] xl:w-[764px]">
<TabButtons
tabs={[
{
isActive: previewTab === "ui",
name: "UI",
onClick: () => setPreviewTab("ui"),
},
{
isActive: previewTab === "code",
name: "Code",
onClick: () => setPreviewTab("code"),
},
]}
/>
<div
className={cn(
"relative flex min-h-[300px] grow justify-center rounded-lg",
previewTab !== "code" && "items-center",
)}
>
<BackgroundPattern />
{previewTab === "ui" && (
<SwapWidget
client={THIRDWEB_CLIENT}
theme={themeObj}
prefill={props.options.prefill}
currency={props.options.currency}
showThirdwebBranding={props.options.showThirdwebBranding}
key={JSON.stringify(props.options)}
persistTokenSelections={false}
/>
)}
{previewTab === "code" && <CodeGen options={props.options} />}
</div>
</div>
);
}
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-card p-2 shadow-md md:inline-flex">
{props.tabs.map((tab) => (
<Button
className={cn(
"gap-2 px-4 text-base",
tab.isActive
? "bg-accent text-foreground"
: "bg-transparent text-muted-foreground",
)}
key={tab.name}
onClick={tab.onClick}
variant="ghost"
>
{tab.name}
</Button>
))}
</div>
</div>
);
}
@@ -0,0 +1,45 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { LeftSection } from "./left-section";
import { RightSection } from "./right-section";
import type { SwapWidgetPlaygroundOptions } from "./types";
const defaultOptions: SwapWidgetPlaygroundOptions = {
prefill: undefined,
currency: "USD",
showThirdwebBranding: true,
theme: {
darkColorOverrides: {},
lightColorOverrides: {},
type: "dark",
},
};
export function SwapWidgetPlayground() {
const { theme } = useTheme();
const [options, setOptions] =
useState<SwapWidgetPlaygroundOptions>(defaultOptions);
// change theme on global theme change
useEffect(() => {
setOptions((prev) => ({
...prev,
theme: {
...prev.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
}, [theme]);
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 options={options} />
</div>
);
}
@@ -0,0 +1,12 @@
import type { SwapWidgetProps, ThemeOverrides } from "thirdweb/react";
export type SwapWidgetPlaygroundOptions = {
theme: {
type: "dark" | "light";
darkColorOverrides: ThemeOverrides["colors"];
lightColorOverrides: ThemeOverrides["colors"];
};
currency?: SwapWidgetProps["currency"];
prefill?: SwapWidgetProps["prefill"];
showThirdwebBranding?: SwapWidgetProps["showThirdwebBranding"];
};
@@ -0,0 +1,35 @@
import { BringToFrontIcon } from "lucide-react";
import { PageLayout } from "@/components/blocks/APIHeader";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { SwapWidgetPlayground } from "./components/swap-widget-playground";
const title = "Swap Widget Component";
const description =
"Embeddable component for users to swap any cryptocurrency with cross-chain support";
const ogDescription =
"Configure a component to swap cryptocurrency with specified amounts, customization, and more. This interactive playground shows how to customize the component.";
export const metadata = createMetadata({
description: ogDescription,
title,
image: {
icon: "payments",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={BringToFrontIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/references/typescript/v5/SwapWidget?utm_source=playground"
>
<SwapWidgetPlayground />
</PageLayout>
</ThirdwebProvider>
);
}
@@ -3,6 +3,7 @@ import {
BlocksIcon,
BotIcon,
BoxIcon,
BringToFrontIcon,
CircleUserIcon,
CreditCardIcon,
DollarSignIcon,
@@ -247,3 +248,12 @@ export const aiFeatureCards: FeatureCardMetadata[] = [
description: "Use the thirdweb blockchain models with the Vercel AI SDK",
},
];
export const bridgeFeatureCards: FeatureCardMetadata[] = [
{
icon: BringToFrontIcon,
title: "Swap Widget",
link: "/bridge/swap-widget",
description: "A widget for swapping tokens with cross-chain support",
},
];
+20 -1
View File
@@ -1,6 +1,11 @@
"use client";
import { ArrowLeftRightIcon, BotIcon, Code2Icon } from "lucide-react";
import {
ArrowLeftRightIcon,
BotIcon,
BringToFrontIcon,
Code2Icon,
} from "lucide-react";
import type { ShadcnSidebarLink } from "@/components/blocks/full-width-sidebar-layout";
import { ContractIcon } from "../icons/ContractIcon";
import { PayIcon } from "../icons/PayIcon";
@@ -204,6 +209,19 @@ const payments: ShadcnSidebarLink = {
],
};
const bridge: ShadcnSidebarLink = {
subMenu: {
label: "Bridge",
icon: BringToFrontIcon,
},
links: [
{
href: "/bridge/swap-widget",
label: "Swap Widget",
},
],
};
const transactions: ShadcnSidebarLink = {
subMenu: {
label: "Transactions",
@@ -240,6 +258,7 @@ export const sidebarLinks: ShadcnSidebarLink[] = [
transactions,
contracts,
payments,
bridge,
tokens,
accountAbstractions,
{
+2
View File
@@ -3,6 +3,7 @@ import { ThirdwebIcon } from "../icons/ThirdwebMiniLogo";
import {
accountAbstractionsFeatureCards,
aiFeatureCards,
bridgeFeatureCards,
contractsFeatureCards,
type FeatureCardMetadata,
paymentsFeatureCards,
@@ -47,6 +48,7 @@ export default function Page() {
title="Contracts"
/>
<FeatureSection featureCards={paymentsFeatureCards} title="Payments" />
<FeatureSection featureCards={bridgeFeatureCards} title="Bridge" />
<FeatureSection
featureCards={accountAbstractionsFeatureCards}
title="Account Abstraction"
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { LoadingDots } from "../../../components/ui/LoadingDots";
import { LoadingDots } from "@/components/ui/LoadingDots";
import type { BridgeComponentsPlaygroundOptions } from "./types";
const CodeClient = lazy(() =>
@@ -12,21 +12,15 @@ import type React from "react";
import { useId, useState } from "react";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb/chains";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import type { TokenMetadata } from "@/lib/types";
import { CurrencySelector } from "../../../components/blocks/CurrencySelector";
import { CollapsibleSection } from "../../wallets/sign-in/components/CollapsibleSection";
import { ColorFormGroup } from "../../wallets/sign-in/components/ColorFormGroup";
import type { BridgeComponentsPlaygroundOptions } from "./types";
@@ -36,7 +30,7 @@ export function LeftSection(props: {
setOptions: React.Dispatch<
React.SetStateAction<BridgeComponentsPlaygroundOptions>
>;
lockedWidget?: "buy" | "checkout" | "transaction";
lockedWidget: "buy" | "checkout" | "transaction";
}) {
const { options, setOptions } = props;
const { theme, payOptions } = options;
@@ -68,7 +62,7 @@ export function LeftSection(props: {
return undefined;
});
const payModeId = useId();
const _payModeId = useId();
const buyTokenAmountId = useId();
const sellerAddressId = useId();
const paymentAmountId = useId();
@@ -119,76 +113,20 @@ export function LeftSection(props: {
title="Payment Options"
>
<div className="flex flex-col gap-6 pt-5">
{props.lockedWidget === undefined && (
<section className="flex flex-col gap-3">
<Label htmlFor={payModeId}>Widget</Label>
<CustomRadioGroup
id={payModeId}
onValueChange={(value) => {
setOptions(
(v) =>
({
...v,
payOptions: {
...v.payOptions,
widget: value as "buy" | "checkout" | "transaction",
},
}) satisfies BridgeComponentsPlaygroundOptions,
);
}}
options={[
{ label: "Buy", value: "buy" },
{ label: "Checkout", value: "checkout" },
{ label: "Transaction", value: "transaction" },
]}
value={payOptions.widget || "buy"}
/>
</section>
)}
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<Select
value={payOptions.currency || "USD"}
onValueChange={(value) => {
<CurrencySelector
value={payOptions.currency}
onChange={(currency) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
currency: value,
currency: currency,
},
}));
}}
>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
/>
</section>
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
@@ -197,9 +135,8 @@ export function LeftSection(props: {
{/* Chain selection */}
<div className="flex flex-col gap-2">
<Label>Chain</Label>
<SingleNetworkSelector
<BridgeNetworkSelector
chainId={selectedChain}
disableTestnets={true}
onChange={handleChainChange}
placeholder="Select a chain"
className="bg-card"
@@ -211,7 +148,6 @@ export function LeftSection(props: {
<div className="flex flex-col gap-2">
<Label>Token</Label>
<TokenSelector
addNativeTokenIfMissing={true}
chainId={selectedChain}
client={THIRDWEB_CLIENT}
enabled={true}
@@ -28,7 +28,7 @@ const CURRENCIES = [
"ISK",
] as const;
type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
type SupportedFiatCurrency = (typeof CURRENCIES)[number];
export type BridgeComponentsPlaygroundOptions = {
theme: {
@@ -59,13 +59,7 @@ export function ConnectButtonPage(props: {
}) {
const { theme } = useTheme();
const [connectOptions, setConnectOptions] =
useState<ConnectPlaygroundOptions>(() => ({
...defaultConnectOptions,
theme: {
...defaultConnectOptions.theme,
type: theme === "dark" ? "dark" : "light",
},
}));
useState<ConnectPlaygroundOptions>(defaultConnectOptions);
// change theme on global theme change
useEffect(() => {
@@ -28,7 +28,7 @@ import {
TableRow,
} from "../ui/table";
const url = `http://${process.env.NEXT_PUBLIC_API_URL}`;
const url = `https://${process.env.NEXT_PUBLIC_API_URL}`;
const chain = baseSepolia;
const editionDropAddress = "0x638263e3eAa3917a53630e61B1fBa685308024fa";
@@ -0,0 +1,53 @@
"use client";
import type { SwapWidgetProps } from "thirdweb/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function CurrencySelector(props: {
value: SwapWidgetProps["currency"];
onChange: (value: SwapWidgetProps["currency"]) => void;
}) {
return (
<Select
value={props.value}
onValueChange={(value) => {
props.onChange(value as SwapWidgetProps["currency"]);
}}
>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
);
}
@@ -2,10 +2,8 @@
import { useCallback, useMemo } from "react";
import { ChainIcon } from "@/components/blocks/ChainIcon";
import { MultiSelect } from "@/components/blocks/multi-select";
import { Badge } from "@/components/ui/badge";
import { SelectWithSearch } from "@/components/ui/select-with-search";
import { useAllChainsData } from "@/hooks/chains";
import { useBridgeSupportedChains } from "@/hooks/chains";
function cleanChainName(chainName: string) {
return chainName.replace("Mainnet", "");
@@ -13,164 +11,31 @@ function cleanChainName(chainName: string) {
type Option = { label: string; value: string };
export function MultiNetworkSelector(props: {
selectedChainIds: number[];
onChange: (chainIds: number[]) => void;
disableChainId?: boolean;
className?: string;
priorityChains?: number[];
popoverContentClassName?: string;
chainIds?: number[];
selectedBadgeClassName?: string;
}) {
const { allChains, idToChain } = useAllChainsData().data;
const chainsToShow = useMemo(() => {
if (!props.chainIds) {
return allChains;
}
const chainIdSet = new Set(props.chainIds);
return allChains.filter((chain) => chainIdSet.has(chain.chainId));
}, [allChains, props.chainIds]);
const options = useMemo(() => {
let sortedChains = chainsToShow;
if (props.priorityChains) {
const priorityChainsSet = new Set();
for (const chainId of props.priorityChains || []) {
priorityChainsSet.add(chainId);
}
const priorityChains = (props.priorityChains || [])
.map((chainId) => {
return idToChain.get(chainId);
})
.filter((v) => !!v);
const otherChains = chainsToShow.filter(
(chain) => !priorityChainsSet.has(chain.chainId),
);
sortedChains = [...priorityChains, ...otherChains];
}
return sortedChains.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [chainsToShow, props.priorityChains, idToChain]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return false;
}
if (Number.isInteger(Number.parseInt(searchValue))) {
return String(chain.chainId).startsWith(searchValue);
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[idToChain],
);
const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(Number(option.value));
if (!chain) {
return option.label;
}
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge className="gap-2" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
);
return (
<MultiSelect
className={props.className}
disabled={allChains.length === 0}
onSelectedValuesChange={(chainIds) => {
props.onChange(chainIds.map(Number));
}}
options={options}
overrideSearchFn={searchFn}
placeholder={
allChains.length === 0 ? "Loading Chains..." : "Select Chains"
}
popoverContentClassName={props.popoverContentClassName}
renderOption={renderOption}
searchPlaceholder="Search by Name or Chain Id"
selectedBadgeClassName={props.selectedBadgeClassName}
selectedValues={props.selectedChainIds.map(String)}
/>
);
}
export function SingleNetworkSelector(props: {
export function BridgeNetworkSelector(props: {
chainId: number | undefined;
onChange: (chainId: number) => void;
className?: string;
popoverContentClassName?: string;
// if specified - only these chains will be shown
chainIds?: number[];
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
disableTestnets?: boolean;
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData().data;
const chainsToShow = useMemo(() => {
let chains = allChains;
if (props.disableTestnets) {
chains = chains.filter((chain) => !chain.testnet);
}
if (props.chainIds) {
const chainIdSet = new Set(props.chainIds);
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
}
return chains;
}, [allChains, props.chainIds, props.disableTestnets]);
const chainsQuery = useBridgeSupportedChains();
const options = useMemo(() => {
return chainsToShow.map((chain) => {
return (chainsQuery.data || [])?.map((chain) => {
return {
label: cleanChainName(chain.name),
value: String(chain.chainId),
};
});
}, [chainsToShow]);
}, [chainsQuery.data]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
const chain = idToChain.get(Number(option.value));
const chain = chainsQuery.data?.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return false;
}
@@ -180,12 +45,14 @@ export function SingleNetworkSelector(props: {
}
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
},
[idToChain],
[chainsQuery.data],
);
const renderOption = useCallback(
(option: Option) => {
const chain = idToChain.get(Number(option.value));
const chain = chainsQuery.data?.find(
(chain) => chain.chainId === Number(option.value),
);
if (!chain) {
return option.label;
}
@@ -193,27 +60,16 @@ export function SingleNetworkSelector(props: {
return (
<div className="flex justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<ChainIcon
className="size-5"
ipfsSrc={chain.icon?.url}
loading="lazy"
/>
<ChainIcon className="size-5" ipfsSrc={chain.icon} loading="lazy" />
{cleanChainName(chain.name)}
</span>
{!props.disableChainId && (
<Badge className="gap-2 max-sm:hidden" variant="outline">
<span className="text-muted-foreground">Chain ID</span>
{chain.chainId}
</Badge>
)}
</div>
);
},
[idToChain, props.disableChainId],
[chainsQuery.data],
);
const isLoadingChains = allChains.length === 0;
const isLoadingChains = chainsQuery.isPending;
return (
<SelectWithSearch
@@ -1,332 +0,0 @@
/* eslint-disable no-restricted-syntax */
/** biome-ignore-all lint/a11y/useSemanticElements: TODO */
"use client";
import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useShowMore } from "../../lib/useShowMore";
import { Input } from "../ui/input";
import { ScrollShadow } from "../ui/ScrollShadow";
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
options: {
label: string;
value: string;
}[];
selectedValues: string[];
onSelectedValuesChange: (value: string[]) => void;
placeholder: string;
searchPlaceholder?: string;
popoverContentClassName?: string;
selectedBadgeClassName?: string;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
className?: string;
overrideSearchFn?: (
option: { value: string; label: string },
searchTerm: string,
) => boolean;
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
}
export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
(
{
options,
onSelectedValuesChange,
placeholder,
maxCount = Number.POSITIVE_INFINITY,
className,
selectedValues,
overrideSearchFn,
renderOption,
popoverContentClassName,
selectedBadgeClassName,
searchPlaceholder,
...props
},
ref,
) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
onSelectedValuesChange(newSelectedValues);
}
},
[selectedValues, onSelectedValuesChange],
);
const toggleOption = useCallback(
(option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
onSelectedValuesChange(newSelectedValues);
},
[selectedValues, onSelectedValuesChange],
);
const handleClear = useCallback(() => {
onSelectedValuesChange([]);
}, [onSelectedValuesChange]);
const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};
const clearExtraOptions = useCallback(() => {
const newSelectedValues = selectedValues.slice(0, maxCount);
onSelectedValuesChange(newSelectedValues);
}, [selectedValues, onSelectedValuesChange, maxCount]);
// show 50 initially and then 20 more when reaching the end
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
const optionsToShow = useMemo(() => {
const filteredOptions: {
label: string;
value: string;
}[] = [];
const searchValLowercase = searchValue.toLowerCase();
for (let i = 0; i <= options.length - 1; i++) {
if (filteredOptions.length >= itemsToShow) {
break;
}
const option = options[i];
if (!option) {
continue;
}
if (overrideSearchFn) {
if (overrideSearchFn(option, searchValLowercase)) {
filteredOptions.push(option);
}
} else {
if (option.label.toLowerCase().includes(searchValLowercase)) {
filteredOptions.push(option);
}
}
}
return filteredOptions;
}, [options, searchValue, itemsToShow, overrideSearchFn]);
// scroll to top when options change
const popoverElRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: ok
useEffect(() => {
const scrollContainer =
popoverElRef.current?.querySelector("[data-scrollable]");
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
});
}
}, [searchValue]);
return (
<Popover modal onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
className={cn(
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
className,
)}
onClick={handleTogglePopover}
>
{selectedValues.length > 0 ? (
<div className="flex w-full justify-between">
{/* badges */}
<div className="flex flex-wrap items-center gap-1.5">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
if (!option) {
return null;
}
return (
<ClosableBadge
className={selectedBadgeClassName}
key={value}
label={option.label}
onClose={() => toggleOption(value)}
/>
);
})}
{/* +X more */}
{selectedValues.length > maxCount && (
<ClosableBadge
label={`+ ${selectedValues.length - maxCount} more`}
onClose={clearExtraOptions}
/>
)}
</div>
<div className="flex items-center justify-between gap-2">
{/* Clear All */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: TODO */}
{/* biome-ignore lint/a11y/useFocusableInteractive: TODO */}
<div
className="rounded p-1 hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
role="button"
>
<XIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
<Separator
className="flex h-full min-h-6"
orientation="vertical"
/>
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex w-full items-center justify-between">
<span className="text-muted-foreground text-sm">
{placeholder}
</span>
<ChevronDownIcon className="h-4 cursor-pointer text-muted-foreground" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
className={cn("p-0", popoverContentClassName)}
onEscapeKeyDown={() => setIsPopoverOpen(false)}
ref={popoverElRef}
sideOffset={10}
style={{
maxHeight: "var(--radix-popover-content-available-height)",
width: "var(--radix-popover-trigger-width)",
}}
>
<div>
{/* Search */}
<div className="relative">
<Input
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={searchPlaceholder || "Search"}
value={searchValue}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-4 size-4 text-muted-foreground" />
</div>
<ScrollShadow
className="rounded"
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
>
{/* List */}
<div>
{optionsToShow.length === 0 && (
<div className="flex justify-center py-10">
No results found
</div>
)}
{optionsToShow.map((option, i) => {
const isSelected = selectedValues.includes(option.value);
return (
<Button
aria-selected={isSelected}
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
key={option.value}
onClick={() => toggleOption(option.value)}
ref={
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
role="option"
variant="ghost"
>
<div
className={cn(
"flex size-4 items-center justify-center rounded-sm border border-foreground",
isSelected
? "bg-inverted text-inverted-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className="size-4" />
</div>
<div className="min-w-0 grow">
{renderOption ? renderOption(option) : option.label}
</div>
</Button>
);
})}
</div>
</ScrollShadow>
</div>
</PopoverContent>
</Popover>
);
},
);
function ClosableBadge(props: {
label: string;
onClose: () => void;
className?: string;
}) {
return (
<span
className={cn(
"flex items-center gap-3 rounded-xl border border-border bg-muted px-2 py-1 text-foreground text-xs",
props.className,
)}
>
{props.label}
<XIcon
className="h-3 w-3 cursor-pointer text-muted-foreground"
onClick={(e) => {
e.stopPropagation();
props.onClose();
}}
/>
</span>
);
}
MultiSelect.displayName = "MultiSelect";
@@ -2,22 +2,15 @@
import { CoinsIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import {
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import type { ThirdwebClient } from "thirdweb";
import { shortenAddress } from "thirdweb/utils";
import { Badge } from "@/components/ui/badge";
import { Img } from "@/components/ui/Img";
import { SelectWithSearch } from "@/components/ui/select-with-search";
import { useAllChainsData } from "@/hooks/chains";
import { useTokensData } from "@/hooks/useTokensData";
import type { TokenMetadata } from "@/lib/types";
import { cn, fallbackChainIcon, replaceIpfsUrl } from "@/lib/utils";
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
export function TokenSelector(props: {
selectedToken: { chainId: number; address: string } | undefined;
onChange: (token: TokenMetadata) => void;
@@ -28,49 +21,13 @@ export function TokenSelector(props: {
client: ThirdwebClient;
disabled?: boolean;
enabled?: boolean;
addNativeTokenIfMissing: boolean;
}) {
const tokensQuery = useTokensData({
chainId: props.chainId,
enabled: props.enabled,
});
const { idToChain } = useAllChainsData().data;
const tokens = useMemo(() => {
if (!tokensQuery.data) {
return [];
}
if (props.addNativeTokenIfMissing) {
const hasNativeToken = tokensQuery.data.some(
(token) => token.address === checksummedNativeTokenAddress,
);
if (!hasNativeToken && props.chainId) {
return [
{
address: checksummedNativeTokenAddress,
chainId: props.chainId,
decimals: 18,
name:
idToChain.get(props.chainId)?.nativeCurrency.name ??
"Native Token",
symbol:
idToChain.get(props.chainId)?.nativeCurrency.symbol ?? "ETH",
} satisfies TokenMetadata,
...tokensQuery.data,
];
}
}
return tokensQuery.data;
}, [
tokensQuery.data,
props.chainId,
props.addNativeTokenIfMissing,
idToChain,
]);
const tokens = tokensQuery.data || [];
const addressChainToToken = useMemo(() => {
const value = new Map<string, TokenMetadata>();
for (const token of tokens) {
+27 -23
View File
@@ -1,39 +1,43 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ChainMetadata } from "thirdweb/chains";
import { THIRDWEB_CLIENT } from "../lib/client";
import { isProd } from "../lib/env";
async function fetchChainsFromApi() {
const res = await fetch("https://api.thirdweb.com/v1/chains");
type BridgeChain = {
chainId: number;
name: string;
icon: string;
nativeCurrency: {
name: string;
symbol: string;
decimals: number;
};
};
async function fetchBridgeSupportedChainsFromApi() {
const res = await fetch(
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/chains`,
{
headers: {
"x-client-id": THIRDWEB_CLIENT.clientId,
},
},
);
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
return json.data as ChainMetadata[];
return json.data as BridgeChain[];
}
export function useAllChainsData() {
const query = useQuery({
export function useBridgeSupportedChains() {
return useQuery({
queryFn: async () => {
const idToChain = new Map<number, ChainMetadata>();
const chains = await fetchChainsFromApi();
for (const c of chains) {
idToChain.set(c.chainId, c);
}
return {
allChains: chains,
idToChain,
};
return fetchBridgeSupportedChainsFromApi();
},
queryKey: ["all-chains"],
queryKey: ["bridge-supported-chains"],
});
return {
data: query.data || { allChains: [], idToChain: new Map() },
isPending: query.isLoading,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
// export const isProd =
// (process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
// "production";
export const isProd =
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
"production";
function getVercelEnv(): "production" | "preview" | "development" {
const onVercel = process.env.VERCEL || process.env.NEXT_PUBLIC_VERCEL_ENV;
@@ -1,37 +0,0 @@
"use client";
import { useCallback, useState } from "react";
/**
*
* @internal
*/
export function useShowMore<T extends HTMLElement>(
initialItemsToShow: number,
itemsToAdd: number,
) {
// start with showing first `initialItemsToShow` items, when the last item is in view, show `itemsToAdd` more
const [itemsToShow, setItemsToShow] = useState(initialItemsToShow);
const lastItemRef = useCallback(
(node: T) => {
if (!node) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setItemsToShow((prev) => prev + itemsToAdd); // show 10 more items
}
},
{ threshold: 1 },
);
observer.observe(node);
// when the node is removed from the DOM, observer will be disconnected automatically by the browser
},
[itemsToAdd],
);
return { itemsToShow, lastItemRef };
}
+82 -299
View File
@@ -6,11 +6,13 @@ import {
TabsTrigger,
TabsContent,
ArticleIconCard,
DocImage
} from "@doc";
import {
ReactIcon,
TypeScriptIcon,
} from "@/icons";
import SwapWidgetImage from "./swap-dark.png";
export const metadata = createMetadata({
image: {
@@ -23,7 +25,6 @@ export const metadata = createMetadata({
# Swap Tokens
Build a custom token swap interface that allows users to exchange tokens across chains using thirdweb Payments.
<Tabs defaultValue="react">
<TabsList>
@@ -39,309 +40,97 @@ Build a custom token swap interface that allows users to exchange tokens across
<TabsContent value="react">
### Simple Swap Component
You can use the [`SwapWidget`](/references/typescript/v5/SwapWidget) component to easily integrate a token swap interface into your app
Create a basic swap interface with token selection and amount input:
```tsx
import React, { useState, useEffect } from "react";
import { Bridge } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { createThirdwebClient } from "thirdweb";
import { ethereum, polygon } from "thirdweb/chains";
import { SwapWidget } from "thirdweb/react";
const client = createThirdwebClient({
clientId: "YOUR_CLIENT_ID",
});
function SwapInterface() {
const account = useActiveAccount();
const [fromToken, setFromToken] = useState("ETH");
const [toToken, setToToken] = useState("MATIC");
const [amount, setAmount] = useState("");
const [quote, setQuote] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const getQuote = async () => {
if (!amount || !account) return;
setIsLoading(true);
try {
const preparedQuote = await Bridge.Buy.prepare({
originChainId: ethereum.id,
originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", // ETH
destinationChainId: polygon.id,
destinationTokenAddress: "0x0000000000000000000000000000000000001010", // MATIC
amount: BigInt(amount * 1e18), // Convert to wei
sender: account.address,
receiver: account.address,
client,
});
setQuote(preparedQuote);
} catch (error) {
console.error("Failed to get quote:", error);
}
setIsLoading(false);
};
useEffect(() => {
if (amount && account) {
const timer = setTimeout(getQuote, 500);
return () => clearTimeout(timer);
}
}, [amount, account]);
const executeSwap = async () => {
if (!quote || !account) return;
try {
for (const step of quote.steps) {
for (const transaction of step.transactions) {
const result = await sendAndConfirmTransaction({
transaction,
account,
});
// Wait for cross-chain completion if needed
if (["buy", "sell", "transfer"].includes(transaction.action)) {
let swapStatus;
do {
swapStatus = await Bridge.status({
transactionHash: result.transactionHash,
chainId: transaction.chainId,
client,
});
if (swapStatus.status === "PENDING") {
await new Promise(resolve => setTimeout(resolve, 3000));
}
} while (swapStatus.status === "PENDING");
}
}
}
alert("Swap completed successfully!");
setQuote(null);
setAmount("");
} catch (error) {
console.error("Swap failed:", error);
alert("Swap failed. Please try again.");
}
};
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6 text-center">Token Swap</h2>
{/* From Token */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">From</label>
<div className="flex gap-2">
<input
type="number"
placeholder="0.0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<select
value={fromToken}
onChange={(e) => setFromToken(e.target.value)}
className="p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="ETH">ETH</option>
<option value="USDC">USDC</option>
<option value="WETH">WETH</option>
</select>
</div>
<p className="text-sm text-gray-500 mt-1">Ethereum</p>
</div>
{/* Swap Arrow */}
<div className="flex justify-center mb-4">
<button className="p-2 rounded-full bg-gray-100 hover:bg-gray-200">
↓
</button>
</div>
{/* To Token */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">To</label>
<div className="flex gap-2">
<input
type="text"
placeholder="0.0"
value={quote ? (Number(quote.destinationAmount) / 1e18).toFixed(6) : ""}
readOnly
className="flex-1 p-3 border rounded-lg bg-gray-50"
/>
<select
value={toToken}
onChange={(e) => setToToken(e.target.value)}
className="p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="MATIC">MATIC</option>
<option value="USDC">USDC</option>
<option value="WETH">WETH</option>
</select>
</div>
<p className="text-sm text-gray-500 mt-1">Polygon</p>
</div>
{/* Quote Info */}
{quote && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm">
<span className="font-medium">Rate:</span> 1 {fromToken} = {(Number(quote.destinationAmount) / Number(quote.originAmount)).toFixed(6)} {toToken}
</p>
<p className="text-sm">
<span className="font-medium">Fee:</span> {((Number(quote.originAmount) - Number(amount) * 1e18) / 1e18).toFixed(6)} {fromToken}
</p>
<p className="text-sm">
<span className="font-medium">Estimated time:</span> {Math.round(quote.estimatedExecutionTimeMs / 1000)}s
</p>
</div>
)}
{/* Swap Button */}
<button
onClick={executeSwap}
disabled={!quote || isLoading || !account}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{!account ? "Connect Wallet" : isLoading ? "Getting Quote..." : "Swap"}
</button>
{!account && (
<p className="text-center text-sm text-gray-500 mt-3">
Connect your wallet to start swapping
</p>
)}
</div>
);
function Example() {
return <SwapWidget client={client} />;
}
```
### Advanced Swap with Route Selection
<DocImage src={SwapWidgetImage} />
Build a more sophisticated swap interface with multiple route options:
## Live Playground
<ArticleIconCard
title="Swap Widget Playground"
description="Try out the Swap Widget in our live playground"
icon={ReactIcon}
href="https://playground.thirdweb.com/bridge/swap-widget"
/>
### Configuring default token selection
By default, no tokens are selected in the widget UI. You can configure the default token selection for the swap widget by passing in the `prefill` prop.
it only sets the default token selection, User can change these selections in the widget UI
#### Set an ERC20 token to Buy by default
```tsx
import React, { useState, useEffect } from "react";
import { Bridge } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
function AdvancedSwapInterface() {
const account = useActiveAccount();
const [routes, setRoutes] = useState([]);
const [selectedRoute, setSelectedRoute] = useState(null);
const [quotes, setQuotes] = useState([]);
// Get available routes
const getRoutes = async () => {
try {
const availableRoutes = await Bridge.routes({
originChainId: 1, // Ethereum
originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
client,
});
setRoutes(availableRoutes.slice(0, 5)); // Show top 5 routes
} catch (error) {
console.error("Failed to get routes:", error);
}
};
// Get quotes for multiple routes
const getQuotes = async (amount) => {
if (!amount || !account) return;
const quotePromises = routes.map(async (route) => {
try {
const quote = await Bridge.Buy.prepare({
originChainId: route.originToken.chainId,
originTokenAddress: route.originToken.address,
destinationChainId: route.destinationToken.chainId,
destinationTokenAddress: route.destinationToken.address,
amount: BigInt(amount * 1e18),
sender: account.address,
receiver: account.address,
client,
});
return { route, quote };
} catch (error) {
return { route, error };
}
});
const results = await Promise.all(quotePromises);
setQuotes(results.filter(result => result.quote));
};
useEffect(() => {
getRoutes();
}, []);
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Advanced Token Swap</h2>
{/* Amount Input */}
<div className="mb-6">
<input
type="number"
placeholder="Enter amount to swap"
onChange={(e) => getQuotes(e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Route Options */}
<div className="space-y-3">
<h3 className="text-lg font-medium">Available Routes</h3>
{quotes.map(({ route, quote }, index) => (
<div
key={index}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => setSelectedRoute({ route, quote })}
>
<div className="flex justify-between items-center">
<div>
<p className="font-medium">
{route.originToken.symbol} → {route.destinationToken.symbol}
</p>
<p className="text-sm text-gray-500">
{route.originToken.name} to {route.destinationToken.name}
</p>
</div>
<div className="text-right">
<p className="font-medium">
{(Number(quote.destinationAmount) / Math.pow(10, route.destinationToken.decimals)).toFixed(6)}
</p>
<p className="text-sm text-gray-500">
~{Math.round(quote.estimatedExecutionTimeMs / 1000)}s
</p>
</div>
</div>
</div>
))}
</div>
{selectedRoute && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium mb-2">Selected Route</h4>
<p>{selectedRoute.route.originToken.symbol} → {selectedRoute.route.destinationToken.symbol}</p>
<button
onClick={() => executeSwap(selectedRoute.quote)}
className="mt-3 w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Execute Swap
</button>
</div>
)}
</div>
);
}
<SwapWidget client={client} prefill={{
buyToken: {
// Base USDC
chainId: 8453,
tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
},
}} />
```
#### Set a native token to Sell by default
To configure a native token to sell by default, you can omit the `tokenAddress` property.
```tsx
<SwapWidget client={client} prefill={{
// Base native token (ETH)
sellToken: {
chainId: 8453,
},
}} />
```
#### Set amount and token to Buy by default
```tsx
<SwapWidget client={client} prefill={{
buyToken: {
chainId: 8453,
amount: "0.1",
tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
},
}} />
```
#### Set both buy and sell tokens by default
```tsx
<SwapWidget client={client} prefill={{
// Base USDC
buyToken: {
chainId: 8453,
tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
},
// Polygon native token (MATIC)
sellToken: {
chainId: 137,
},
}} />
```
</TabsContent>
<TabsContent value="typescript">
@@ -607,17 +396,13 @@ export class PriceComparator {
}
```
<Callout variant="info">
**Gas Optimization Tip:** For frequently traded pairs, consider caching route data to reduce API calls and improve UI responsiveness.
</Callout>
</TabsContent>
</Tabs>
## View it in action:
<ArticleIconCard
title="Token Swap Playground"
description="Try building and testing swap interfaces with live data"
icon={ReactIcon}
href="https://playground.thirdweb.com/connect/pay"
/>
## Key Features
@@ -627,9 +412,7 @@ export class PriceComparator {
- **Price comparison** - Compare multiple routes to maximize output
- **Status tracking** - Monitor cross-chain transaction progress
<Callout variant="info">
**Gas Optimization Tip:** For frequently traded pairs, consider caching route data to reduce API calls and improve UI responsiveness.
</Callout>
## Going Further
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

@@ -41,14 +41,6 @@ import { createWallet, injectedProvider } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId });
const metamask = createWallet("io.metamask"); // pass the wallet id
// if user has metamask installed, connect to it
if (injectedProvider("io.metamask")) {
await metamask.connect({ client });
}
// open wallet connect modal so user can scan the QR code and connect
else {
await metamask.connect({ client, walletConnect: { showQrModal: true } });
}
// if user has metamask installed, connect to it, otherwise opens a WalletConnect modal
await metamask.connect({ client });
```
@@ -0,0 +1,301 @@
import { Stack, InstallTabs, Callout, Steps, Step, GithubTemplateCard } from "@doc";
# Third party library adapters
The thirdweb SDK can work side by side with:
- any library that supports EIP1193 (wagmi, privy, etc.)
- ethers.js v5 & v6
- viem
- older versions of the @thirdweb-dev/sdk (using the ethers.js v5 adapter)
Adapters allow you to use contracts, providers and wallets from these libraries with the thirdweb SDK and vice versa.
## Wagmi
### Using thirdweb in-app wallets with wagmi
View the demo repo for using thirdweb in-app / smart wallets with wagmi:
<Stack>
<GithubTemplateCard
title="wagmi + thirdweb demo repo"
description="A demo repo for using thirdweb in-app / smart wallets with wagmi"
href="https://github.com/thirdweb-example/wagmi-inapp-smart-wallets"
/>
</Stack>
You can use thirdweb's in-app, ecosystem and smart wallets in your wagmi application by using the `@thirdweb-dev/wagmi-adapter` package.
```shell
npm install wagmi thirdweb @thirdweb-dev/wagmi-adapter
```
Make sure you're running wagmi 2.14.1 or above.
You probably already have a wagmi config with some connectors, simply add the inAppWalletConnector to the list, along with the desired options.
```ts
import { http, createConfig } from "wagmi";
import { inAppWalletConnector } from "@thirdweb-dev/wagmi-adapter";
import { createThirdwebClient } from "thirdweb";
const client = createThirdwebClient({
clientId: "your-client-id",
});
export const config = createConfig({
chains: [sepolia],
connectors: [
// add the in-app wallet connector
inAppWalletConnector({
client,
// optional: turn on gasless transactions!
executionMode: {
mode: "EIP7702",
sponsorGas: true,
},
}),
],
transports: {
[sepolia.id]: http(),
},
});
```
Then in your application, you can use the connector to trigger the in-app wallet connection.
```ts
const { connect, connectors } = useConnect();
const onClick = () => {
// grab the connector
const inAppWallet = connectors.find((x) => x.id === "in-app-wallet");
// call connect with the desired strategy
connect({
connector: inAppWallet,
strategy: "google",
});
};
```
### Converting a wagmi wallet client to a thirdweb wallet
You can use the thirdweb SDK within a wagmi application by setting the wagmi connected account as the thirdweb 'active wallet'. After that, you can use all of the react components and hooks normally, including `BuyWidget`, `TransactionButton`, etc.
```ts
// Assumes you've wrapped your application in a `<ThirdwebProvider>`
import { useEffect } from 'react'
import { defineChain } from 'thirdweb'
import { useSetActiveWallet } from 'thirdweb/react'
import { EIP1193 } from 'thirdweb/wallets'
import { useDisconnect, useSwitchChain, useWalletClient } from 'wagmi'
import { client } from './client'
const account = useAccount(); // from wagmi
const setActiveWallet = useSetActiveWallet(); // from thirdweb/react
// whenever the wagmi account changes,
// we adapt it to a thirdweb wallet and set it as the active wallet
useEffect(() => {
const setActive = async () => {
if (account?.connector?.getProvider) {
const provider = await account?.connector?.getProvider({
chainId,
});
const thirdwebWallet = EIP1193.fromProvider({
provider,
});
await thirdwebWallet.connect({
client,
});
setActiveWallet(thirdwebWallet);
}
};
setActive();
}, [account?.connector?.getProvider, setActiveWallet]);
```
You can view a fully functioning wagmi + thirdweb app in this [github repository](https://github.com/thirdweb-example/wagmi-thirdweb).
## Privy
Similarly, you can use the thirdweb SDK with privy by setting the privy wallet as the thirdweb 'active wallet'. After that, you can use all of the react components, functions and hooks normally, including `BuyWidget`, `TransactionButton`, etc.
```ts
// Assumes you've wrapped your application in a `<ThirdwebProvider>`
import { useEffect } from 'react'
import { defineChain } from 'thirdweb'
import { useSetActiveWallet } from 'thirdweb/react'
import { EIP1193 } from 'thirdweb/wallets'
import { ethers5Adapter } from 'thirdweb/adapters/ethers5';
import { client } from './client'
import { useWallets } from "@privy-io/react-auth";
const { wallets } = useWallets(); // from privy
const setActiveWallet = useSetActiveWallet(); // from thirdweb/react
// whenever the privy wallet changes,
// we adapt it to a thirdweb account and set it as the active wallet
useEffect(() => {
const setActive = async () => {
const privyWallet = wallets[0];
if (privyWallet) {
const ethersProvider = await privyWallet.getEthereumProvider();
// create a thirdweb wallet with the privy provider
const thirdwebWallet = EIP1193.fromProvider({
provider: ethersProvider,
});
await thirdwebWallet.connect({
client,
});
setActiveWallet(thirdwebWallet);
}
};
setActive();
}, [wallets]);
```
## EIP1193
You can use any wallet that supports EIP1193 with the thirdweb SDK by converting it using `EIP1193.fromProvider`:
```ts
import { EIP1193 } from "thirdweb/wallets";
// Create a Thirdweb wallet from a EIP1193 provider
const wallet = EIP1193.fromProvider({
provider: yourProvider, // any EIP1193 provider
});
// Use like any other Thirdweb wallet
const account = await wallet.connect({
client: createThirdwebClient({ clientId: "..." }),
});
// Sign messages
await account.signMessage({ message: "Hello World" });
```
You can also convert a thirdweb account to an EIP1193 provider using `EIP1193.toProvider`, which can then be used with other libraries:
```ts
import { EIP1193 } from "thirdweb/wallets";
// Create an EIP-1193 provider from a Thirdweb wallet
const provider = EIP1193.toProvider({
wallet,
chain: ethereum,
client: createThirdwebClient({ clientId: "..." }),
});
// Use with any EIP-1193 compatible library
const accounts = await provider.request({
method: "eth_requestAccounts",
});
```
## viem
You can use an existing wallet client from viem with the thirdweb SDK by converting it using the `viemAdapter`:
```ts
import { viemAdapter } from "thirdweb/adapters/viem";
// convert a viem wallet client to a thirdweb wallet
const walletClient = createWalletClient(...);
const thirdwebWallet = await viemAdapter.wallet.fromViem({
walletClient,
});
// convert a thirdweb account to viem wallet client
const viemClientWallet = viemAdapter.wallet.toViem({
client,
chain,
wallet,
});
```
You can also convert viem public clients and contracts from and to the thirdweb SDK.
View the [viemAdapter](/references/typescript/v5/viemAdapter) reference for more details.
## Ethers v6
You can use an existing ethers.js v6 Signer with the thirdweb SDK by converting it using the `ethers6Adapter`:
```ts
import { ethers6Adapter } from "thirdweb/adapters/ethers6";
import { sendTransaction } from "thirdweb";
// convert a ethers signer
const signer: ethers.Signer = ...;
const account = await ethers6Adapter.signer.fromEthers({
signer,
});
// use it with the thirdweb SDK
await sendTransaction({
transaction,
account,
});
```
Similarly, you can use any wallets created with the thirdweb SDK with ethers.js v6 by converting them using the `ethers6Adapter`:
```ts
import { ethers6Adapter } from "thirdweb/adapters/ethers6";
import { createThirdwebClient, inAppWallet } from "thirdweb/wallets";
import { sepolia } from "thirdweb/chains";
const client = createThirdwebClient({ clientId });
const wallet = inAppWallet();
const chain = sepolia;
const account = await wallet.connect({
client,
strategy: "google",
});
// convert a thirdweb account to ethers signer
const ethersSigner = await ethers6Adapter.signer.toEthers({
client,
chain,
account,
});
```
You can also convert ethers.js providers and contracts from and to the thirdweb SDK.
View the [ethers6Adapter](/references/typescript/v5/ethers6Adapter) reference for more details.
## Ethers v5
You can use an existing ethers.js v5 Signer with the thirdweb SDK by converting it using the `ethers5Adapter`:
```ts
import { ethers5Adapter } from "thirdweb/adapters/ethers5";
// convert an ethers signer to a thirdweb account
const signer: ethers.Signer = ...;
const account = await ethers5Adapter.signer.fromEthers({
signer,
});
// convert a thirdweb account to ethers signer
const ethersSigner = await ethers5Adapter.signer.toEthers({
client,
chain,
account
});
```
You can also convert ethers.js providers and contracts from and to the thirdweb SDK.
View the [ethers5Adapter](/references/typescript/v5/ethers5Adapter) reference for more details.
@@ -188,16 +188,7 @@ import { createWallet, injectedProvider } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId });
const wallet = createWallet("${id}"); // pass the wallet id
// if the wallet extension is installed, connect to it
if (injectedProvider("${id}")) {
await wallet.connect({ client });
}
// show error message to user that wallet is not installed
else {
alert('wallet is not installed');
}
await wallet.connect({ client });
`;
}
@@ -209,12 +200,7 @@ import { createWallet } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId });
const wallet = createWallet("${id}"); // pass the wallet id
// open WalletConnect modal so user can scan the QR code and connect
await wallet.connect({
client,
walletConnect: { showQrModal: true },
});
await wallet.connect({ client });
`;
}
@@ -226,19 +212,8 @@ import { createWallet, injectedProvider } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId });
const wallet = createWallet("${id}"); // pass the wallet id
// if user has wallet installed, connect to it
if (injectedProvider("${id}")) {
await wallet.connect({ client });
}
// open WalletConnect modal so user can scan the QR code and connect
else {
await wallet.connect({
client,
walletConnect: { showQrModal: true },
});
}
// if user has wallet installed, connects to it, otherwise opens a WalletConnect modal
await wallet.connect({ client });
`;
}
@@ -257,20 +232,7 @@ function Example() {
onClick={() =>
connect(async () => {
const wallet = createWallet("${id}"); // pass the wallet id
// if user has wallet installed, connect to it
if (injectedProvider("${id}")) {
await wallet.connect({ client });
}
// open WalletConnect modal so user can scan the QR code and connect
else {
await wallet.connect({
client,
walletConnect: { showQrModal: true },
});
}
await wallet.connect({ client });
// return the wallet to set it as active wallet
return wallet;
})
@@ -298,13 +260,7 @@ function Example() {
onClick={() =>
connect(async () => {
const wallet = createWallet("${id}"); // pass the wallet id
// open WalletConnect modal so user can scan the QR code and connect
await wallet.connect({
client,
walletConnect: { showQrModal: true },
});
await wallet.connect({ client });
// return the wallet to set it as active wallet
return wallet;
})
@@ -330,19 +286,12 @@ function Example() {
return (
<button
onClick={() => {
// if the wallet extension is installed, connect to it
if (injectedProvider("${id}")) {
connect(async () => {
const wallet = createWallet("${id}");
await wallet.connect({ client });
return wallet;
});
}
// show error message to user that wallet is not installed
else {
alert("wallet is not installed");
}
// if the wallet extension is installed, connect to it, otherwise opens a WalletConnect modal
connect(async () => {
const wallet = createWallet("${id}");
await wallet.connect({ client });
return wallet;
});
}}
>
{isConnecting ? "Connecting..." : "Connect"}
@@ -0,0 +1,204 @@
/* eslint-disable @next/next/no-img-element */
import { GlobeIcon } from "lucide-react";
import Image from "next/image";
import { getWalletInfo } from "thirdweb/wallets";
import {
Breadcrumb,
CodeBlock,
createMetadata,
DocLink,
Heading,
Paragraph,
} from "@/components/Document";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const walletId = "xyz.abs";
export async function generateMetadata() {
const walletMetadata = await getWalletInfo(walletId);
return createMetadata({
description: `Connect ${walletMetadata.name} with thirdweb TypeScript SDK`,
image: {
icon: "wallets",
title: walletMetadata.name,
},
title: walletMetadata.name,
});
}
export default async function Page() {
const [walletMetadata, walletImage] = await Promise.all([
getWalletInfo(walletId),
getWalletInfo(walletId, true),
]);
return (
<div>
<Breadcrumb
crumbs={[
{
href: "/wallets/external-wallets",
name: "External Wallets",
},
{
href: `/wallets/external-wallets/${walletId}`,
name: walletMetadata.name,
},
]}
/>
<div className="mb-10 flex items-center gap-3 [&_h1]:m-0">
<Image
alt=""
className="rounded-lg"
height={36}
src={walletImage}
width={36}
/>
<Heading anchorId="title" level={1}>
{walletMetadata.name}
</Heading>
</div>
<DocLink
className="flex items-center gap-2"
href={walletMetadata.homepage}
>
<GlobeIcon className="size-5" />
{walletMetadata.homepage}
</DocLink>
<Heading anchorId="wallet-id" level={2}>
Wallet ID
</Heading>
<CodeBlock code={`"${walletId}"`} lang="ts" />
<Heading anchorId="connect-wallet" level={2}>
Installation
</Heading>
<CodeBlock
code={`npm i thirdweb @abstract-foundation/agw-react`}
lang="bash"
/>
<Heading anchorId="connect-wallet" level={2}>
Connect Wallet
</Heading>
<Tabs defaultValue="tab-1">
<TabsList>
<TabsTrigger value="tab-1"> TypeScript </TabsTrigger>
<TabsTrigger value="tab-2"> React (Custom UI) </TabsTrigger>
<TabsTrigger value="tab-3"> React (Component) </TabsTrigger>
</TabsList>
<TabsContent value="tab-1">
<CodeBlock code={injectedSupportedTS()} lang="ts" />
</TabsContent>
<TabsContent value="tab-2">
<CodeBlock code={injectedSupportedCodeReact()} lang="ts" />
</TabsContent>
<TabsContent value="tab-3">
<Paragraph>
You can add this wallet in the{" "}
<DocLink href="/react/v5/ConnectButton" target="_blank">
ConnectButton
</DocLink>{" "}
or{" "}
<DocLink href="/react/v5/ConnectEmbed" target="_blank">
ConnectEmbed
</DocLink>{" "}
component to get a pre-built UI for connecting the wallet.{" "}
<Heading anchorId="connect-component" level={3}>
Example
</Heading>{" "}
<CodeBlock code={componentCode()} lang="tsx" />
</Paragraph>
</TabsContent>
</Tabs>
<Heading anchorId="reference" level={2}>
Reference
</Heading>
<DocLink href="/references/typescript/v5/AbsWalletCreationOptions">
View All Creation Options
</DocLink>
</div>
);
}
function injectedSupportedTS() {
return `\
import { createThirdwebClient } from "thirdweb";
import { createWallet } from "thirdweb/wallets";
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
const client = createThirdwebClient({ clientId });
const wallet = ${createWallet()};
await wallet.connect({ client });
`;
}
function injectedSupportedCodeReact() {
return `\
import { createThirdwebClient } from "thirdweb";
import { useConnect } from "thirdweb/react";
import { createWallet } from "thirdweb/wallets";
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
const client = createThirdwebClient({ clientId });
const wallet = ${createWallet()};
function Example() {
const { connect, isConnecting, error } = useConnect();
return (
<button
onClick={() => {
// if the wallet extension is installed, connect to it
connect(async () => {
await wallet.connect({ client });
return wallet;
});
}}
>
{isConnecting ? "Connecting..." : "Connect"}
</button>
);
}
`;
}
function componentCode() {
return `\
import { ConnectButton } from "thirdweb/react";
import { createWallet } from "thirdweb/wallets";
import { abstractWallet } from "@abstract-foundation/agw-react/thirdweb";
const wallets = [
// Add your wallet in wallet list
${createWallet()},
// add other wallets...
];
function Example() {
return (
<div>
{/* Use ConnectButton */}
<ConnectButton client={client} wallets={wallets} />
{/* Or Use ConnectEmbed */}
<ConnectEmbed client={client} wallets={wallets} />
</div>
);
}`;
}
function createWallet() {
return `abstractWallet()`;
}
+4
View File
@@ -65,6 +65,10 @@ export const sidebar: SideBar = {
href: `${walletSlug}/export-private-key`,
name: "Export Private Keys",
},
{
href: `${walletSlug}/adapters`,
name: "Usage with other libraries",
},
],
name: "Guides",
},
+56
View File
@@ -1,5 +1,61 @@
# @thirdweb-dev/nebula
## 0.2.50
### Patch Changes
- Updated dependencies [[`32980f8`](https://github.com/thirdweb-dev/js/commit/32980f854b9d53f0039d7bf913a2e5d8ba03704e), [`7b8ceeb`](https://github.com/thirdweb-dev/js/commit/7b8ceebb63ccc7b4b055e3b463bca0e1932d67b8)]:
- thirdweb@5.106.1
## 0.2.49
### Patch Changes
- Updated dependencies [[`e160deb`](https://github.com/thirdweb-dev/js/commit/e160deb8b73d0d7ba5f6bcf47c0675230efc4dfb), [`e0738d6`](https://github.com/thirdweb-dev/js/commit/e0738d68d2aa6fcf0cfbdebc4b5603887e3233a1), [`0e3f346`](https://github.com/thirdweb-dev/js/commit/0e3f3460ad30d5a1c52552ce9d786a9cc814dfee), [`a6f846f`](https://github.com/thirdweb-dev/js/commit/a6f846fc1ead20734a52ff69263e6407f7ba3f9e), [`15f0839`](https://github.com/thirdweb-dev/js/commit/15f083912c053f98eb30a28cddcf0114450129e6), [`f276dcf`](https://github.com/thirdweb-dev/js/commit/f276dcfd766c79149c5302b0207777bcc9f267a0)]:
- thirdweb@5.106.0
## 0.2.48
### Patch Changes
- Updated dependencies [[`557f0e2`](https://github.com/thirdweb-dev/js/commit/557f0e29e86d2ef3e3b72988f714203b4ede1009)]:
- thirdweb@5.105.48
## 0.2.47
### Patch Changes
- Updated dependencies [[`97e5080`](https://github.com/thirdweb-dev/js/commit/97e5080fdfd5b8b1106141012bdee2db2d27ae4c)]:
- thirdweb@5.105.47
## 0.2.46
### Patch Changes
- Updated dependencies [[`224c9d0`](https://github.com/thirdweb-dev/js/commit/224c9d034f274c3e6cf510d3508856fc36501c63)]:
- thirdweb@5.105.46
## 0.2.45
### Patch Changes
- Updated dependencies [[`0b45405`](https://github.com/thirdweb-dev/js/commit/0b454050b14806c103ea446e3667d0c970739912), [`dc1f7ab`](https://github.com/thirdweb-dev/js/commit/dc1f7ab2ee820be512776a413f7de362ff6b98ef), [`ea80610`](https://github.com/thirdweb-dev/js/commit/ea8061048adddb9fb7648f29ce1f3041f250ddfb), [`3ad9f7d`](https://github.com/thirdweb-dev/js/commit/3ad9f7deba850b0a50e355111af11fd419db2efd)]:
- thirdweb@5.105.45
## 0.2.44
### Patch Changes
- Updated dependencies [[`ef15ba7`](https://github.com/thirdweb-dev/js/commit/ef15ba7c434f8c9bde3b6d45d43d90e74f20616b)]:
- thirdweb@5.105.44
## 0.2.43
### Patch Changes
- Updated dependencies [[`1759112`](https://github.com/thirdweb-dev/js/commit/1759112d722e11d2d9a902f2221e70636188015e)]:
- thirdweb@5.105.43
## 0.2.42
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.42"
"version": "0.2.50"
}
+5
View File
@@ -10,3 +10,8 @@ body {
font-optical-sizing: auto;
font-variation-settings: "slnt" 0;
}
/* Use this to debug the elements having tw- classes */
/* [class*="tw-"] {
outline: 1px dotted yellow !important;
} */
+72
View File
@@ -1,5 +1,77 @@
# thirdweb
## 5.106.1
### Patch Changes
- [#8069](https://github.com/thirdweb-dev/js/pull/8069) [`32980f8`](https://github.com/thirdweb-dev/js/commit/32980f854b9d53f0039d7bf913a2e5d8ba03704e) Thanks [@MananTank](https://github.com/MananTank)! - Add `persistTokenSelections` prop on `SwapWidget` to allow disabling token selection persistence to local storage
- [#8077](https://github.com/thirdweb-dev/js/pull/8077) [`7b8ceeb`](https://github.com/thirdweb-dev/js/commit/7b8ceebb63ccc7b4b055e3b463bca0e1932d67b8) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Show policy error for 7702 transactions
## 5.106.0
### Minor Changes
- [#8044](https://github.com/thirdweb-dev/js/pull/8044) [`0e3f346`](https://github.com/thirdweb-dev/js/commit/0e3f3460ad30d5a1c52552ce9d786a9cc814dfee) Thanks [@MananTank](https://github.com/MananTank)! - Add `SwapWidget` component for swapping tokens using thirdweb Bridge
```tsx
<SwapWidget client={thirdwebClient} />
```
### Patch Changes
- [#8066](https://github.com/thirdweb-dev/js/pull/8066) [`e160deb`](https://github.com/thirdweb-dev/js/commit/e160deb8b73d0d7ba5f6bcf47c0675230efc4dfb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Always reconnect any previously connected wallet properly
- [#8067](https://github.com/thirdweb-dev/js/pull/8067) [`e0738d6`](https://github.com/thirdweb-dev/js/commit/e0738d68d2aa6fcf0cfbdebc4b5603887e3233a1) Thanks [@MananTank](https://github.com/MananTank)! - Fix SwapWidget setting same token for buy and sell in some cases when using last used token from storage
- [#8065](https://github.com/thirdweb-dev/js/pull/8065) [`a6f846f`](https://github.com/thirdweb-dev/js/commit/a6f846fc1ead20734a52ff69263e6407f7ba3f9e) Thanks [@MananTank](https://github.com/MananTank)! - Add `tw-` class names in connect ui
- [#8054](https://github.com/thirdweb-dev/js/pull/8054) [`15f0839`](https://github.com/thirdweb-dev/js/commit/15f083912c053f98eb30a28cddcf0114450129e6) Thanks [@MananTank](https://github.com/MananTank)! - Fix `onSuccess` callback was not called correctly on `CheckoutWidget`, `BuyWidget` components
- [#8062](https://github.com/thirdweb-dev/js/pull/8062) [`f276dcf`](https://github.com/thirdweb-dev/js/commit/f276dcfd766c79149c5302b0207777bcc9f267a0) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Filter out AGW from searchable wallets (needs explicit adding)
## 5.105.48
### Patch Changes
- [#8052](https://github.com/thirdweb-dev/js/pull/8052) [`557f0e2`](https://github.com/thirdweb-dev/js/commit/557f0e29e86d2ef3e3b72988f714203b4ede1009) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Prevents incorrect failures in widget onramps.
## 5.105.47
### Patch Changes
- [#8048](https://github.com/thirdweb-dev/js/pull/8048) [`97e5080`](https://github.com/thirdweb-dev/js/commit/97e5080fdfd5b8b1106141012bdee2db2d27ae4c) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix fiat payments with no wallets connected
## 5.105.46
### Patch Changes
- [#8046](https://github.com/thirdweb-dev/js/pull/8046) [`224c9d0`](https://github.com/thirdweb-dev/js/commit/224c9d034f274c3e6cf510d3508856fc36501c63) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Improve WalletConnect chain switching reliability
## 5.105.45
### Patch Changes
- [#8038](https://github.com/thirdweb-dev/js/pull/8038) [`0b45405`](https://github.com/thirdweb-dev/js/commit/0b454050b14806c103ea446e3667d0c970739912) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Accept pre signed authorizations for 7702 account transactions
- [#8039](https://github.com/thirdweb-dev/js/pull/8039) [`dc1f7ab`](https://github.com/thirdweb-dev/js/commit/dc1f7ab2ee820be512776a413f7de362ff6b98ef) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Support fully disconnecting from metamask on disconnect
- [#8042](https://github.com/thirdweb-dev/js/pull/8042) [`ea80610`](https://github.com/thirdweb-dev/js/commit/ea8061048adddb9fb7648f29ce1f3041f250ddfb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Fix signAuthorization implementation for 1193 provider
- [#8041](https://github.com/thirdweb-dev/js/pull/8041) [`3ad9f7d`](https://github.com/thirdweb-dev/js/commit/3ad9f7deba850b0a50e355111af11fd419db2efd) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Allow passing overrides to common extension functions
## 5.105.44
### Patch Changes
- [#8036](https://github.com/thirdweb-dev/js/pull/8036) [`ef15ba7`](https://github.com/thirdweb-dev/js/commit/ef15ba7c434f8c9bde3b6d45d43d90e74f20616b) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - pass through authorizationList for injected providers
## 5.105.43
### Patch Changes
- [#8029](https://github.com/thirdweb-dev/js/pull/8029) [`1759112`](https://github.com/thirdweb-dev/js/commit/1759112d722e11d2d9a902f2221e70636188015e) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add signAuthorization support to 1193 provider
## 5.105.42
### Patch Changes
+4 -3
View File
@@ -25,8 +25,8 @@
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/engine": "workspace:*",
"@thirdweb-dev/insight": "workspace:*",
"@walletconnect/sign-client": "2.20.1",
"@walletconnect/universal-provider": "2.21.4",
"@walletconnect/sign-client": "2.21.8",
"@walletconnect/universal-provider": "2.21.8",
"abitype": "1.0.8",
"cross-spawn": "7.0.6",
"fuse.js": "7.1.0",
@@ -341,6 +341,7 @@
"test:ui": "NODE_OPTIONS=--max-old-space-size=8192 vitest dev -c ./test/vitest.config.ts --coverage --ui",
"test:watch": "vitest -c ./test/vitest.config.ts dev",
"typedoc": "node scripts/typedoc.mjs && node scripts/parse.mjs",
"typecheck": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --noEmit",
"update-version": "node scripts/version.mjs"
},
"sideEffects": false,
@@ -417,5 +418,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.105.42"
"version": "5.106.1"
}
@@ -38,3 +38,5 @@ export interface Chain {
decimals: number;
};
}
export type BridgeChain = Chain;
+4
View File
@@ -141,6 +141,10 @@ export {
CheckoutWidget,
type CheckoutWidgetProps,
} from "../react/web/ui/Bridge/CheckoutWidget.js";
export {
SwapWidget,
type SwapWidgetProps,
} from "../react/web/ui/Bridge/swap-widget/SwapWidget.js";
export {
TransactionWidget,
type TransactionWidgetProps,
@@ -1,4 +1,7 @@
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import { getClaimParams } from "../../../../utils/extensions/drops/get-claim-params.js";
import { isGetContractMetadataSupported } from "../../../common/read/getContractMetadata.js";
import * as GeneratedClaim from "../../__generated__/IDrop1155/write/claim.js";
@@ -51,7 +54,9 @@ export type ClaimToParams = {
* @throws If no claim condition is set
* @returns The prepared transaction
*/
export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
export function claimTo(
options: BaseTransactionOptions<WithOverrides<ClaimToParams>>,
) {
return GeneratedClaim.claim({
async asyncParams() {
const params = await getClaimParams({
@@ -70,6 +75,7 @@ export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { Hex } from "viem";
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import type { ClaimCondition } from "../../../../utils/extensions/drops/types.js";
import {
isSetClaimConditionsSupported,
@@ -40,7 +43,9 @@ export type ResetClaimEligibilityParams = GetClaimConditionsParams;
* ```
*/
export function resetClaimEligibility(
options: BaseTransactionOptions<ResetClaimEligibilityParams> & {
options: BaseTransactionOptions<
WithOverrides<ResetClaimEligibilityParams>
> & {
singlePhaseDrop?: boolean;
},
) {
@@ -79,6 +84,7 @@ export function resetClaimEligibility(
};
},
contract: options.contract,
overrides: options.overrides,
});
}
// download existing conditions
@@ -101,6 +107,7 @@ export function resetClaimEligibility(
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,4 +1,7 @@
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import { getMulticallSetClaimConditionTransactions } from "../../../../utils/extensions/drops/get-multicall-set-claim-claim-conditon-transactions.js";
import type { ClaimConditionsInput } from "../../../../utils/extensions/drops/types.js";
import { isSetContractURISupported } from "../../../common/__generated__/IContractMetadata/write/setContractURI.js";
@@ -49,7 +52,7 @@ export type SetClaimConditionsParams = {
* ```
*/
export function setClaimConditions(
options: BaseTransactionOptions<SetClaimConditionsParams>,
options: BaseTransactionOptions<WithOverrides<SetClaimConditionsParams>>,
) {
return multicall({
asyncParams: async () => {
@@ -65,6 +68,7 @@ export function setClaimConditions(
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,4 +1,7 @@
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import type { NFT, NFTInput } from "../../../../utils/nft/parseNft.js";
import * as BaseURICount from "../../../erc721/__generated__/IBatchMintMetadata/read/getBaseURICount.js";
import * as BatchAtIndex from "../../__generated__/BatchMintMetadata/read/getBatchIdAtIndex.js";
@@ -116,12 +119,13 @@ async function getUpdateMetadataParams(
* ```
*/
export function updateMetadata(
options: BaseTransactionOptions<UpdateMetadataParams>,
options: BaseTransactionOptions<WithOverrides<UpdateMetadataParams>>,
) {
const { contract } = options;
return BatchBaseURI.updateBatchBaseURI({
asyncParams: async () => getUpdateMetadataParams(options),
contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { FileOrBufferOrString } from "../../../storage/upload/types.js";
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../transaction/types.js";
import {
getBaseUriFromBatch,
uploadOrExtractURIs,
@@ -31,9 +34,9 @@ type NFTInput = Prettify<
/**
* @extension ERC1155
*/
export type LazyMintParams = {
export type LazyMintParams = WithOverrides<{
nfts: (NFTInput | string)[];
};
}>;
/**
* Lazily mints ERC1155 tokens.
@@ -83,6 +86,7 @@ export function lazyMint(options: BaseTransactionOptions<LazyMintParams>) {
} as const;
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,15 +1,18 @@
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../transaction/types.js";
import * as URI from "../__generated__/IERC1155/read/uri.js";
import * as MintTo from "../__generated__/IMintableERC1155/write/mintTo.js";
/**
* @extension ERC1155
*/
export type MintAdditionalSupplyToParams = {
export type MintAdditionalSupplyToParams = WithOverrides<{
to: string;
tokenId: bigint;
supply: bigint;
};
}>;
/**
* Mints a "supply" number of additional ERC1155 tokens to the specified "to" address.
@@ -46,6 +49,7 @@ export function mintAdditionalSupplyTo(
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import { upload } from "../../../storage/upload.js";
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../transaction/types.js";
import type { NFTInput } from "../../../utils/nft/parseNft.js";
import {
type SetTokenURIParams,
@@ -10,10 +13,10 @@ export { isSetTokenURISupported as isUpdateTokenURISupported } from "../../erc11
/**
* @extension ERC1155
*/
export type UpdateTokenURIParams = {
export type UpdateTokenURIParams = WithOverrides<{
tokenId: bigint;
newMetadata: NFTInput;
};
}>;
/**
* This function is an abstracted layer of the [`setTokenURI` extension](https://portal.thirdweb.com/references/typescript/v5/erc1155/setTokenURI),
@@ -47,6 +50,7 @@ export function updateTokenURI(
return setTokenURI({
asyncParams: async () => getUpdateTokenParams(options),
contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { Address } from "abitype";
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import { getClaimParams } from "../../../../utils/extensions/drops/get-claim-params.js";
import { isGetContractMetadataSupported } from "../../../common/read/getContractMetadata.js";
import * as GeneratedClaim from "../../__generated__/IDropERC20/write/claim.js";
@@ -51,7 +54,9 @@ export type ClaimToParams = {
* @throws If no claim condition is set
* @returns A promise that resolves with the submitted transaction hash.
*/
export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
export function claimTo(
options: BaseTransactionOptions<WithOverrides<ClaimToParams>>,
) {
return GeneratedClaim.claim({
asyncParams: async () => {
const quantity = await (async () => {
@@ -77,6 +82,7 @@ export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
});
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { Hex } from "viem";
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import type { ClaimCondition } from "../../../../utils/extensions/drops/types.js";
import {
isSetClaimConditionsSupported,
@@ -28,7 +31,9 @@ import {
* await sendTransaction({ transaction, account });
* ```
*/
export function resetClaimEligibility(options: BaseTransactionOptions) {
export function resetClaimEligibility(
options: BaseTransactionOptions<WithOverrides<{}>>,
) {
// download existing conditions
return setClaimConditions({
asyncParams: async () => {
@@ -48,6 +53,7 @@ export function resetClaimEligibility(options: BaseTransactionOptions) {
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,4 +1,7 @@
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import { getMulticallSetClaimConditionTransactions } from "../../../../utils/extensions/drops/get-multicall-set-claim-claim-conditon-transactions.js";
import type { ClaimConditionsInput } from "../../../../utils/extensions/drops/types.js";
import { isSetContractURISupported } from "../../../common/__generated__/IContractMetadata/write/setContractURI.js";
@@ -47,7 +50,7 @@ export type SetClaimConditionsParams = {
* ```
*/
export function setClaimConditions(
options: BaseTransactionOptions<SetClaimConditionsParams>,
options: BaseTransactionOptions<WithOverrides<SetClaimConditionsParams>>,
) {
return multicall({
asyncParams: async () => {
@@ -62,6 +65,7 @@ export function setClaimConditions(
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { Address } from "abitype";
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../transaction/types.js";
import type { Prettify } from "../../../utils/type-utils.js";
import { toUnits } from "../../../utils/units.js";
import { approve as generatedApprove } from "../__generated__/IERC20/write/approve.js";
@@ -9,14 +12,16 @@ import { approve as generatedApprove } from "../__generated__/IERC20/write/appro
* @extension ERC20
*/
export type ApproveParams = Prettify<
{ spender: Address } & (
| {
amount: number | string;
}
| {
amountWei: bigint;
}
)
WithOverrides<
{ spender: Address } & (
| {
amount: number | string;
}
| {
amountWei: bigint;
}
)
>
>;
/**
@@ -58,6 +63,7 @@ export function approve(options: BaseTransactionOptions<ApproveParams>) {
amountWei: amount,
tokenAddress: options.contract.address,
},
...options.overrides,
},
spender: options.spender,
value: amount,
@@ -1,16 +1,20 @@
import { prepareContractCall } from "../../../transaction/prepare-contract-call.js";
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../transaction/types.js";
import { toWei } from "../../../utils/units.js";
import { FN_SELECTOR } from "../__generated__/IWETH/write/deposit.js";
/**
* @extension ERC20
*/
export type DepositParams =
export type DepositParams = WithOverrides<
| {
amount: string;
}
| { amountWei: bigint };
| { amountWei: bigint }
>;
/**
* Calls the "deposit" function on the contract (useful to wrap ETH).
@@ -38,5 +42,6 @@ export function deposit(options: BaseTransactionOptions<DepositParams>) {
},
method: [FN_SELECTOR, [], []] as const,
value,
...options.overrides,
});
}
@@ -1,4 +1,7 @@
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../transaction/types.js";
import type { Prettify } from "../../../utils/type-utils.js";
import { toUnits } from "../../../utils/units.js";
import { multicall } from "../../common/__generated__/IMulticall/write/multicall.js";
@@ -8,18 +11,20 @@ import { encodeTransfer } from "../__generated__/IERC20/write/transfer.js";
* Represents the parameters for a batch transfer operation.
* @extension ERC20
*/
export type TransferBatchParams = Prettify<{
batch: Array<
{ to: string } & (
| {
amount: number | string;
}
| {
amountWei: bigint;
}
)
>;
}>;
export type TransferBatchParams = Prettify<
WithOverrides<{
batch: Array<
{ to: string } & (
| {
amount: number | string;
}
| {
amountWei: bigint;
}
)
>;
}>
>;
/**
* Transfers a batch of ERC20 tokens from the sender's address to the specified recipient address.
@@ -69,6 +74,7 @@ export function transferBatch(
};
},
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { Address } from "abitype";
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import { getClaimParams } from "../../../../utils/extensions/drops/get-claim-params.js";
import { isGetContractMetadataSupported } from "../../../common/read/getContractMetadata.js";
import {
@@ -54,7 +57,9 @@ export type ClaimToParams = {
* @throws If no claim condition is set
* @returns A promise that resolves with the submitted transaction hash.
*/
export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
export function claimTo(
options: BaseTransactionOptions<WithOverrides<ClaimToParams>>,
) {
return claim({
asyncParams: () =>
getClaimParams({
@@ -66,6 +71,7 @@ export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
type: "erc721",
}),
contract: options.contract,
overrides: options.overrides,
});
}
@@ -1,5 +1,8 @@
import type { Hex } from "viem";
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
import type {
BaseTransactionOptions,
WithOverrides,
} from "../../../../transaction/types.js";
import type { ClaimCondition } from "../../../../utils/extensions/drops/types.js";
import {
isSetClaimConditionsSupported,
@@ -28,7 +31,9 @@ import {
* await sendTransaction({ transaction, account });
* ```
*/
export function resetClaimEligibility(options: BaseTransactionOptions) {
export function resetClaimEligibility(
options: BaseTransactionOptions<WithOverrides<{}>>,
) {
// download existing conditions
return setClaimConditions({
asyncParams: async () => {
@@ -48,6 +53,7 @@ export function resetClaimEligibility(options: BaseTransactionOptions) {
};
},
contract: options.contract,
overrides: options.overrides,
});
}

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