Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1732e63bb8 | ||
|
|
7b8ceebb63 | ||
|
|
1d90437d34 | ||
|
|
59616d0082 | ||
|
|
32980f854b | ||
|
|
f3b3b89fca | ||
|
|
44826925d0 | ||
|
|
00dcc346bf | ||
|
|
e0738d68d2 | ||
|
|
e160deb8b7 | ||
|
|
a6f846fc1e | ||
|
|
0e3f3460ad | ||
|
|
552f7025c1 | ||
|
|
15f083912c | ||
|
|
04862de9e4 | ||
|
|
ea46572947 | ||
|
|
f276dcfd76 | ||
|
|
6495b94910 | ||
|
|
210f631cba | ||
|
|
557f0e29e8 | ||
|
|
f9bbf8101c | ||
|
|
59d226512c | ||
|
|
778bf2cc4b | ||
|
|
97e5080fdf |
@@ -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=""
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+12
-12
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+13
@@ -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;
|
||||
}
|
||||
+94
-13
@@ -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>
|
||||
|
||||
-48
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+15
-27
@@ -3,7 +3,8 @@ 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";
|
||||
@@ -14,7 +15,6 @@ import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
import { DexScreener } from "./_components/dex-screener";
|
||||
import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains";
|
||||
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
|
||||
import { RecentTransfers } from "./_components/RecentTransfers";
|
||||
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
|
||||
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
|
||||
@@ -104,31 +104,17 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
{showBuyEmbed && (
|
||||
<div className="container max-w-7xl pb-10">
|
||||
<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)",
|
||||
}}
|
||||
<GridPatternEmbedContainer>
|
||||
<BuyEmbed
|
||||
chainMetadata={props.chainMetadata}
|
||||
claimConditionMeta={claimConditionMeta}
|
||||
clientContract={props.clientContract}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenDecimals={tokenDecimals}
|
||||
tokenName={contractMetadata.name}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
<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>{" "}
|
||||
</GridPatternEmbedContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,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" />
|
||||
|
||||
+452
@@ -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>
|
||||
);
|
||||
}
|
||||
+31
@@ -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>
|
||||
|
||||
+39
-37
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()`;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# @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
|
||||
|
||||
@@ -57,5 +57,5 @@
|
||||
"type": "module",
|
||||
"types": "./dist/types/exports/thirdweb.d.ts",
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "0.2.46"
|
||||
"version": "0.2.50"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
} */
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# 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
|
||||
|
||||
@@ -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.46"
|
||||
"version": "5.106.1"
|
||||
}
|
||||
|
||||
@@ -38,3 +38,5 @@ export interface Chain {
|
||||
decimals: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type BridgeChain = Chain;
|
||||
|
||||
@@ -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,48 +1,31 @@
|
||||
const CURRENCIES = [
|
||||
"USD",
|
||||
"EUR",
|
||||
"GBP",
|
||||
"JPY",
|
||||
"KRW",
|
||||
"CNY",
|
||||
"INR",
|
||||
"NOK",
|
||||
"SEK",
|
||||
"CHF",
|
||||
"AUD",
|
||||
"CAD",
|
||||
"NZD",
|
||||
"MXN",
|
||||
"BRL",
|
||||
"CLP",
|
||||
"CZK",
|
||||
"DKK",
|
||||
"HKD",
|
||||
"HUF",
|
||||
"IDR",
|
||||
"ILS",
|
||||
"ISK",
|
||||
] as const;
|
||||
const currencySymbol = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
KRW: "₩",
|
||||
CNY: "¥",
|
||||
INR: "₹",
|
||||
NOK: "kr",
|
||||
SEK: "kr",
|
||||
CHF: "CHF",
|
||||
AUD: "$",
|
||||
CAD: "$",
|
||||
NZD: "$",
|
||||
MXN: "$",
|
||||
BRL: "R$",
|
||||
CLP: "$",
|
||||
CZK: "Kč",
|
||||
DKK: "kr",
|
||||
HKD: "$",
|
||||
HUF: "Ft",
|
||||
IDR: "Rp",
|
||||
ILS: "₪",
|
||||
ISK: "kr",
|
||||
} as const;
|
||||
|
||||
export type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
|
||||
export type SupportedFiatCurrency = keyof typeof currencySymbol;
|
||||
|
||||
export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) {
|
||||
switch (showBalanceInFiat) {
|
||||
case "USD":
|
||||
return "$";
|
||||
case "CAD":
|
||||
return "$";
|
||||
case "GBP":
|
||||
return "£";
|
||||
case "EUR":
|
||||
return "€";
|
||||
case "JPY":
|
||||
return "¥";
|
||||
case "AUD":
|
||||
return "$";
|
||||
case "NZD":
|
||||
return "$";
|
||||
default:
|
||||
return "$";
|
||||
}
|
||||
return currencySymbol[showBalanceInFiat] || "$";
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function createThemeObj(type: "dark" | "light", colors: ThemeColors): Theme {
|
||||
selectedTextColor: colors.base1,
|
||||
|
||||
separatorLine: colors.base4,
|
||||
skeletonBg: colors.base3,
|
||||
skeletonBg: colors.base4,
|
||||
success: colors.success,
|
||||
tertiaryBg: colors.base2,
|
||||
|
||||
@@ -177,6 +177,7 @@ export const spacing = {
|
||||
"4xs": "2px",
|
||||
lg: "24px",
|
||||
md: "16px",
|
||||
"md+": "20px",
|
||||
sm: "12px",
|
||||
xl: "32px",
|
||||
xs: "8px",
|
||||
@@ -191,6 +192,7 @@ export const radius = {
|
||||
xl: "20px",
|
||||
xs: "4px",
|
||||
xxl: "32px",
|
||||
full: "9999px",
|
||||
};
|
||||
|
||||
export const iconSize = {
|
||||
|
||||
@@ -124,7 +124,10 @@ export function usePaymentMethods(options: {
|
||||
),
|
||||
)
|
||||
: sufficientBalanceQuotes;
|
||||
return finalQuotes;
|
||||
return finalQuotes.map((x) => ({
|
||||
...x,
|
||||
action: "buy",
|
||||
}));
|
||||
},
|
||||
queryKey: [
|
||||
"payment-methods",
|
||||
|
||||
@@ -38,7 +38,7 @@ interface StepExecutorOptions {
|
||||
/** Prepared quote returned by Bridge.prepare */
|
||||
request: BridgePrepareRequest;
|
||||
/** Wallet instance providing getAccount() & sendTransaction */
|
||||
wallet: Wallet;
|
||||
wallet?: Wallet;
|
||||
/** Window adapter for opening on-ramp URLs (web / RN) */
|
||||
windowAdapter: WindowAdapter;
|
||||
/** Thirdweb client for API calls */
|
||||
@@ -372,6 +372,11 @@ export function useStepExecutor(
|
||||
|
||||
const status = statusResult.status;
|
||||
if (status === "COMPLETED") {
|
||||
/*
|
||||
* The occasional race condition can happen where the onramp provider gives us completed status before the token balance has updated in our RPC.
|
||||
* We add this pause so the simulation doesn't fail on the next step.
|
||||
*/
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setOnrampStatus("completed");
|
||||
// Add type field for discriminated union
|
||||
const typedStatusResult = {
|
||||
@@ -408,6 +413,14 @@ export function useStepExecutor(
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
if (flatTxs.length > 0 && !wallet) {
|
||||
throw new ApiError({
|
||||
code: "INVALID_INPUT",
|
||||
message: "No wallet provided to execute transactions",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute onramp first if configured and not already completed
|
||||
if (preparedQuote.type === "onramp" && onrampStatus === "pending") {
|
||||
await executeOnramp(
|
||||
@@ -417,89 +430,98 @@ export function useStepExecutor(
|
||||
);
|
||||
}
|
||||
|
||||
// Then execute transactions
|
||||
const account = wallet.getAccount();
|
||||
if (!account) {
|
||||
throw new ApiError({
|
||||
code: "INVALID_INPUT",
|
||||
message: "Wallet not connected",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Start from where we left off, or from the beginning
|
||||
const startIndex = currentTxIndex ?? 0;
|
||||
|
||||
for (let i = startIndex; i < flatTxs.length; i++) {
|
||||
if (abortController.signal.aborted) {
|
||||
break;
|
||||
if (flatTxs.length > 0) {
|
||||
// Then execute transactions
|
||||
if (!wallet) {
|
||||
throw new ApiError({
|
||||
code: "INVALID_INPUT",
|
||||
message: "No wallet provided to execute transactions",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
const account = wallet.getAccount();
|
||||
if (!account) {
|
||||
throw new ApiError({
|
||||
code: "INVALID_INPUT",
|
||||
message: "Wallet not connected",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const currentTx = flatTxs[i];
|
||||
if (!currentTx) {
|
||||
continue; // Skip invalid index
|
||||
}
|
||||
// Start from where we left off, or from the beginning
|
||||
const startIndex = currentTxIndex ?? 0;
|
||||
|
||||
setCurrentTxIndex(i);
|
||||
const currentStepData = preparedQuote.steps[currentTx._stepIndex];
|
||||
if (!currentStepData) {
|
||||
throw new Error(`Invalid step index: ${currentTx._stepIndex}`);
|
||||
}
|
||||
|
||||
// switch chain if needed
|
||||
if (currentTx.chainId !== wallet.getChain()?.id) {
|
||||
await wallet.switchChain(getCachedChain(currentTx.chainId));
|
||||
}
|
||||
|
||||
// Check if we can batch transactions
|
||||
const canBatch =
|
||||
account.sendBatchTransaction !== undefined && i < flatTxs.length - 1; // Not the last transaction
|
||||
|
||||
if (canBatch) {
|
||||
// Find consecutive transactions on the same chain
|
||||
const batchTxs: FlattenedTx[] = [currentTx];
|
||||
let j = i + 1;
|
||||
while (j < flatTxs.length) {
|
||||
const nextTx = flatTxs[j];
|
||||
if (!nextTx || nextTx.chainId !== currentTx.chainId) {
|
||||
break;
|
||||
}
|
||||
batchTxs.push(nextTx);
|
||||
j++;
|
||||
for (let i = startIndex; i < flatTxs.length; i++) {
|
||||
if (abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute batch if we have multiple transactions
|
||||
if (batchTxs.length > 1) {
|
||||
await executeBatch(
|
||||
batchTxs,
|
||||
account,
|
||||
completedStatusResults,
|
||||
abortController.signal,
|
||||
);
|
||||
const currentTx = flatTxs[i];
|
||||
if (!currentTx) {
|
||||
continue; // Skip invalid index
|
||||
}
|
||||
|
||||
// Mark all batched transactions as completed
|
||||
for (const tx of batchTxs) {
|
||||
setCompletedTxs((prev) => new Set(prev).add(tx._index));
|
||||
setCurrentTxIndex(i);
|
||||
const currentStepData = preparedQuote.steps[currentTx._stepIndex];
|
||||
if (!currentStepData) {
|
||||
throw new Error(`Invalid step index: ${currentTx._stepIndex}`);
|
||||
}
|
||||
|
||||
// switch chain if needed
|
||||
if (currentTx.chainId !== wallet.getChain()?.id) {
|
||||
await wallet.switchChain(getCachedChain(currentTx.chainId));
|
||||
}
|
||||
|
||||
// Check if we can batch transactions
|
||||
const canBatch =
|
||||
account.sendBatchTransaction !== undefined &&
|
||||
i < flatTxs.length - 1; // Not the last transaction
|
||||
|
||||
if (canBatch) {
|
||||
// Find consecutive transactions on the same chain
|
||||
const batchTxs: FlattenedTx[] = [currentTx];
|
||||
let j = i + 1;
|
||||
while (j < flatTxs.length) {
|
||||
const nextTx = flatTxs[j];
|
||||
if (!nextTx || nextTx.chainId !== currentTx.chainId) {
|
||||
break;
|
||||
}
|
||||
batchTxs.push(nextTx);
|
||||
j++;
|
||||
}
|
||||
|
||||
// Skip ahead
|
||||
i = j - 1;
|
||||
continue;
|
||||
// Execute batch if we have multiple transactions
|
||||
if (batchTxs.length > 1) {
|
||||
await executeBatch(
|
||||
batchTxs,
|
||||
account,
|
||||
completedStatusResults,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Mark all batched transactions as completed
|
||||
for (const tx of batchTxs) {
|
||||
setCompletedTxs((prev) => new Set(prev).add(tx._index));
|
||||
}
|
||||
|
||||
// Skip ahead
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute single transaction
|
||||
await executeSingleTx(
|
||||
currentTx,
|
||||
account,
|
||||
completedStatusResults,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Mark transaction as completed
|
||||
setCompletedTxs((prev) => new Set(prev).add(currentTx._index));
|
||||
}
|
||||
|
||||
// Execute single transaction
|
||||
await executeSingleTx(
|
||||
currentTx,
|
||||
account,
|
||||
completedStatusResults,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Mark transaction as completed
|
||||
setCompletedTxs((prev) => new Set(prev).add(currentTx._index));
|
||||
}
|
||||
|
||||
// All done - check if we actually completed everything
|
||||
if (!abortController.signal.aborted) {
|
||||
setCurrentTxIndex(undefined);
|
||||
|
||||
@@ -14,7 +14,6 @@ export function useAutoConnectCore(
|
||||
storage: AsyncStorage,
|
||||
props: AutoConnectProps & { wallets: Wallet[] },
|
||||
createWalletFn: (id: WalletId) => Wallet,
|
||||
getInstalledWallets?: () => Wallet[],
|
||||
) {
|
||||
const manager = useConnectionManagerCtx("useAutoConnect");
|
||||
const { connect } = useConnect({
|
||||
@@ -28,7 +27,6 @@ export function useAutoConnectCore(
|
||||
autoConnectCore({
|
||||
connectOverride: connect,
|
||||
createWalletFn,
|
||||
getInstalledWallets,
|
||||
manager,
|
||||
props,
|
||||
setLastAuthProvider,
|
||||
|
||||
@@ -22,6 +22,7 @@ type PaymentMode = "fund_wallet" | "direct_payment" | "transaction";
|
||||
export type PaymentMethod =
|
||||
| {
|
||||
type: "wallet";
|
||||
action: "buy" | "sell";
|
||||
payerWallet: Wallet;
|
||||
originToken: TokenWithPrices;
|
||||
balance: bigint;
|
||||
@@ -29,7 +30,7 @@ export type PaymentMethod =
|
||||
}
|
||||
| {
|
||||
type: "fiat";
|
||||
payerWallet: Wallet;
|
||||
payerWallet?: Wallet;
|
||||
currency: string;
|
||||
onramp: "stripe" | "coinbase" | "transak";
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { Chain } from "../../../../chains/types.js";
|
||||
import type { ThirdwebClient } from "../../../../client/client.js";
|
||||
import walletInfos from "../../../../wallets/__generated__/wallet-infos.js";
|
||||
import { NON_SEARCHABLE_WALLETS } from "../../../../wallets/constants.js";
|
||||
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
|
||||
import { createWallet } from "../../../../wallets/native/create-wallet.js";
|
||||
import type { WalletId } from "../../../../wallets/wallet-types.js";
|
||||
@@ -100,10 +101,7 @@ export function AllWalletsList(
|
||||
.filter(
|
||||
(info) => !externalWallets.find((wallet) => wallet.id === info.id),
|
||||
)
|
||||
.filter(
|
||||
(info) =>
|
||||
info.id !== "inApp" && info.id !== "embedded" && info.id !== "smart",
|
||||
)
|
||||
.filter((info) => !NON_SEARCHABLE_WALLETS.includes(info.id))
|
||||
.filter((info) => info.hasMobileSupport);
|
||||
|
||||
const fuse = new Fuse(filteredWallets, {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
|
||||
import type { AutoConnectProps } from "../../../../wallets/connection/types.js";
|
||||
import { createWallet } from "../../../../wallets/create-wallet.js";
|
||||
import { getDefaultWallets } from "../../../../wallets/defaultWallets.js";
|
||||
import { getInstalledWalletProviders } from "../../../../wallets/injected/mipdStore.js";
|
||||
import { useAutoConnectCore } from "../../../core/hooks/wallets/useAutoConnect.js";
|
||||
|
||||
/**
|
||||
@@ -34,15 +33,5 @@ export function useAutoConnect(props: AutoConnectProps) {
|
||||
wallets,
|
||||
},
|
||||
createWallet,
|
||||
() => {
|
||||
const specifiedWalletIds = new Set(wallets.map((x) => x.id));
|
||||
|
||||
// pass the wallets that are not already specified but are installed by the user
|
||||
const installedWallets = getInstalledWalletProviders()
|
||||
.filter((x) => !specifiedWalletIds.has(x.info.rdns))
|
||||
.map((x) => createWallet(x.info.rdns));
|
||||
|
||||
return installedWallets;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,14 +180,13 @@ export function BridgeOrchestrator({
|
||||
const [state, send] = usePaymentMachine(adapters, uiOptions.mode);
|
||||
|
||||
// Handle buy completion
|
||||
const handleBuyComplete = useCallback(() => {
|
||||
const handleDoneOrContinueClick = useCallback(() => {
|
||||
if (uiOptions.mode === "transaction") {
|
||||
send({ type: "CONTINUE_TO_TRANSACTION" });
|
||||
} else {
|
||||
onComplete?.();
|
||||
send({ type: "RESET" });
|
||||
}
|
||||
}, [onComplete, send, uiOptions.mode]);
|
||||
}, [send, uiOptions.mode]);
|
||||
|
||||
// Handle post-buy transaction completion
|
||||
const handlePostBuyTransactionComplete = useCallback(() => {
|
||||
@@ -230,8 +229,11 @@ export function BridgeOrchestrator({
|
||||
const handleExecutionComplete = useCallback(
|
||||
(completedStatuses: CompletedStatusResult[]) => {
|
||||
send({ completedStatuses, type: "EXECUTION_COMPLETE" });
|
||||
if (uiOptions.mode !== "transaction") {
|
||||
onComplete?.();
|
||||
}
|
||||
},
|
||||
[send],
|
||||
[send, onComplete, uiOptions.mode],
|
||||
);
|
||||
|
||||
// Handle retry
|
||||
@@ -369,8 +371,7 @@ export function BridgeOrchestrator({
|
||||
|
||||
{state.value === "execute" &&
|
||||
state.context.quote &&
|
||||
state.context.request &&
|
||||
state.context.selectedPaymentMethod?.payerWallet && (
|
||||
state.context.request && (
|
||||
<StepRunner
|
||||
autoStart={true}
|
||||
client={client}
|
||||
@@ -391,7 +392,7 @@ export function BridgeOrchestrator({
|
||||
<SuccessScreen
|
||||
client={client}
|
||||
completedStatuses={state.context.completedStatuses}
|
||||
onDone={handleBuyComplete}
|
||||
onDone={handleDoneOrContinueClick}
|
||||
preparedQuote={state.context.quote}
|
||||
uiOptions={uiOptions}
|
||||
windowAdapter={webWindowAdapter}
|
||||
|
||||
@@ -128,7 +128,7 @@ export type BuyWidgetProps = {
|
||||
amount: string;
|
||||
|
||||
/**
|
||||
* The title to display in the widget.
|
||||
* The title to display in the widget. If `title` is explicity set to an empty string, the title will not be displayed.
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
@@ -193,6 +193,11 @@ export type BuyWidgetProps = {
|
||||
* Custom label for the main action button.
|
||||
*/
|
||||
buttonLabel?: string;
|
||||
|
||||
/**
|
||||
* The receiver address for the purchased funds.
|
||||
*/
|
||||
receiverAddress?: Address;
|
||||
};
|
||||
|
||||
// Enhanced UIOptions to handle unsupported token state
|
||||
@@ -455,7 +460,7 @@ export function BuyWidget(props: BuyWidgetProps) {
|
||||
paymentMethods={props.paymentMethods}
|
||||
presetOptions={props.presetOptions}
|
||||
purchaseData={props.purchaseData}
|
||||
receiverAddress={undefined}
|
||||
receiverAddress={props.receiverAddress}
|
||||
uiOptions={bridgeDataQuery.data.data}
|
||||
showThirdwebBranding={props.showThirdwebBranding}
|
||||
/>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function DirectPayment({
|
||||
size="xl"
|
||||
token={uiOptions.paymentInfo.token}
|
||||
tokenAmount={uiOptions.paymentInfo.amount}
|
||||
weight={700}
|
||||
weight={600}
|
||||
/>
|
||||
<Container flex="row" gap="3xs">
|
||||
<Text
|
||||
@@ -190,7 +190,7 @@ export function DirectPayment({
|
||||
<PoweredByThirdweb />
|
||||
</div>
|
||||
) : null}
|
||||
<Spacer y="lg" />
|
||||
<Spacer y="md" />
|
||||
</Container>
|
||||
</WithHeader>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ErrorBanner({
|
||||
});
|
||||
|
||||
return (
|
||||
<Container flex="column" fullHeight gap="md" p="lg">
|
||||
<Container flex="column" fullHeight gap="md" p="md">
|
||||
{/* Error Icon and Message */}
|
||||
<Container flex="row" gap="md" style={{ alignItems: "flex-start" }}>
|
||||
<Container
|
||||
|
||||
@@ -150,7 +150,7 @@ export function FundWallet({
|
||||
style={{
|
||||
backgroundColor: theme.colors.tertiaryBg,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
borderRadius: radius.md,
|
||||
borderRadius: radius.lg,
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
>
|
||||
@@ -251,11 +251,11 @@ export function FundWallet({
|
||||
{/* Quick Amount Buttons */}
|
||||
{presetOptions && (
|
||||
<>
|
||||
<Spacer y="md" />
|
||||
<Spacer y="sm" />
|
||||
<Container
|
||||
center="x"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
gap="sm"
|
||||
style={{
|
||||
justifyContent: "space-evenly",
|
||||
}}
|
||||
@@ -268,6 +268,7 @@ export function FundWallet({
|
||||
backgroundColor: theme.colors.tertiaryBg,
|
||||
flex: 1,
|
||||
fontSize: fontSize.sm,
|
||||
borderRadius: radius.lg,
|
||||
padding: `${spacing.sm} ${spacing.md}`,
|
||||
}}
|
||||
variant="outline"
|
||||
@@ -279,7 +280,7 @@ export function FundWallet({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer y="md" />
|
||||
<Spacer y="sm" />
|
||||
|
||||
<Container
|
||||
center="y"
|
||||
@@ -291,7 +292,7 @@ export function FundWallet({
|
||||
style={{
|
||||
backgroundColor: theme.colors.tertiaryBg,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
borderRadius: radius.md,
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
>
|
||||
{receiver ? (
|
||||
@@ -336,7 +337,7 @@ export function FundWallet({
|
||||
}}
|
||||
style={{
|
||||
fontSize: fontSize.md,
|
||||
padding: `${spacing.sm} ${spacing.md}`,
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
@@ -359,10 +360,10 @@ export function FundWallet({
|
||||
{showThirdwebBranding ? (
|
||||
<div>
|
||||
<Spacer y="md" />
|
||||
<PoweredByThirdweb />
|
||||
<PoweredByThirdweb link="https://playground.thirdweb.com/payments/fund-wallet" />
|
||||
</div>
|
||||
) : null}
|
||||
<Spacer y="lg" />
|
||||
<Spacer y="md" />
|
||||
</WithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export function QuoteLoader({
|
||||
toChainId: destinationToken.chainId,
|
||||
toToken: destinationToken.address,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
queryKey: ["loading_quote", paymentMethod.type],
|
||||
});
|
||||
@@ -162,7 +163,7 @@ export function QuoteLoader({
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
<Spacer y="md" />
|
||||
<Text center color="primaryText" size="lg" style={{ fontWeight: 600 }}>
|
||||
Finding the best route...
|
||||
Finding the best route
|
||||
</Text>
|
||||
<Spacer y="sm" />
|
||||
<Text center color="secondaryText" size="sm">
|
||||
@@ -228,7 +229,10 @@ function getBridgeParams(args: {
|
||||
}
|
||||
|
||||
return {
|
||||
amount: toUnits(amount, destinationToken.decimals),
|
||||
amount:
|
||||
paymentMethod.action === "buy"
|
||||
? toUnits(amount, destinationToken.decimals)
|
||||
: toUnits(amount, paymentMethod.originToken.decimals),
|
||||
client,
|
||||
destinationChainId: destinationToken.chainId,
|
||||
destinationTokenAddress: destinationToken.address,
|
||||
@@ -240,7 +244,7 @@ function getBridgeParams(args: {
|
||||
receiver,
|
||||
sender:
|
||||
sender || paymentMethod.payerWallet.getAccount()?.address || receiver,
|
||||
type: "buy",
|
||||
type: paymentMethod.action,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ import { Spinner } from "../components/Spinner.js";
|
||||
import { Text } from "../components/text.js";
|
||||
|
||||
interface StepRunnerProps {
|
||||
title?: string;
|
||||
request: BridgePrepareRequest;
|
||||
|
||||
/**
|
||||
* Wallet instance for executing transactions
|
||||
*/
|
||||
wallet: Wallet;
|
||||
wallet?: Wallet;
|
||||
|
||||
/**
|
||||
* Thirdweb client for API calls
|
||||
@@ -64,6 +65,7 @@ interface StepRunnerProps {
|
||||
}
|
||||
|
||||
export function StepRunner({
|
||||
title,
|
||||
request,
|
||||
wallet,
|
||||
client,
|
||||
@@ -247,8 +249,8 @@ export function StepRunner({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container flex="column" fullHeight p="lg">
|
||||
<ModalHeader onBack={onBack} title="Processing Payment" />
|
||||
<Container flex="column" fullHeight px="md" pb="md" pt="md+">
|
||||
<ModalHeader onBack={onBack} title={title || "Processing Payment"} />
|
||||
|
||||
<Spacer y="xl" />
|
||||
|
||||
@@ -374,7 +376,7 @@ export function StepRunner({
|
||||
</Container>
|
||||
|
||||
<Spacer y="md" />
|
||||
<Text center color="secondaryText" size="xs">
|
||||
<Text center color="secondaryText" size="xs" multiline>
|
||||
Keep this window open until all
|
||||
<br /> transactions are complete.
|
||||
</Text>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function TransactionPayment({
|
||||
}}
|
||||
>
|
||||
{/* USD Value */}
|
||||
<Text color="primaryText" size="xl" weight={700}>
|
||||
<Text color="primaryText" size="xl" weight={600}>
|
||||
{transactionDataQuery.data?.usdValueDisplay ||
|
||||
transactionDataQuery.data?.txCostDisplay}
|
||||
</Text>
|
||||
@@ -431,7 +431,7 @@ export function TransactionPayment({
|
||||
<PoweredByThirdweb />
|
||||
</div>
|
||||
) : null}
|
||||
<Spacer y="lg" />
|
||||
<Spacer y="md" />
|
||||
</WithHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) {
|
||||
size="sm"
|
||||
style={{ lineHeight: 1.5, maxWidth: "280px" }}
|
||||
>
|
||||
The Universal Bridge does not support testnets at this time.
|
||||
Bridge does not support testnets at this time.
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
@@ -94,7 +94,7 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) {
|
||||
size="sm"
|
||||
style={{ lineHeight: 1.5, maxWidth: "280px" }}
|
||||
>
|
||||
This token or chain is not supported by the Universal Bridge.
|
||||
This token or chain is not supported by the Bridge
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function TokenAndChain({
|
||||
height:
|
||||
size === "lg" || size === "xl" ? iconSize.sm : iconSize.xs,
|
||||
position: "absolute",
|
||||
right: "-6px",
|
||||
right: "-2px",
|
||||
width: size === "lg" || size === "xl" ? iconSize.sm : iconSize.xs,
|
||||
}}
|
||||
>
|
||||
@@ -73,7 +73,7 @@ export function TokenAndChain({
|
||||
)}
|
||||
</Container>
|
||||
|
||||
<Container flex="column" gap="3xs" style={{ minWidth: 0 }}>
|
||||
<Container flex="column" gap="4xs" style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
color="primaryText"
|
||||
size={size === "xl" ? "lg" : "sm"}
|
||||
@@ -83,6 +83,7 @@ export function TokenAndChain({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
weight={600}
|
||||
trackingTight
|
||||
>
|
||||
{token.name}
|
||||
</Text>
|
||||
|
||||
@@ -19,6 +19,8 @@ export function WithHeader({
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
const showTitle = uiOptions.metadata?.title !== "";
|
||||
|
||||
return (
|
||||
<Container flex="column">
|
||||
{/* image */}
|
||||
@@ -36,25 +38,33 @@ export function WithHeader({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Container flex="column" px="lg">
|
||||
<Spacer y="lg" />
|
||||
|
||||
{/* title */}
|
||||
<Text color="primaryText" size="lg" weight={700}>
|
||||
{uiOptions.metadata?.title || defaultTitle}
|
||||
</Text>
|
||||
<Container flex="column" px="md">
|
||||
<Spacer y="md+" />
|
||||
|
||||
{/* Description */}
|
||||
{uiOptions.metadata?.description && (
|
||||
{(showTitle || uiOptions.metadata?.description) && (
|
||||
<>
|
||||
<Spacer y="xs" />
|
||||
<Text color="secondaryText" size="sm">
|
||||
{uiOptions.metadata?.description}
|
||||
</Text>
|
||||
{/* title */}
|
||||
{showTitle && (
|
||||
<Text color="primaryText" size="lg" weight={600}>
|
||||
{uiOptions.metadata?.title || defaultTitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{uiOptions.metadata?.description && (
|
||||
<>
|
||||
<Spacer y="xxs" />
|
||||
<Text color="secondaryText" size="sm" multiline>
|
||||
{uiOptions.metadata?.description}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer y="md" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer y="lg" />
|
||||
{children}
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
@@ -21,6 +21,9 @@ import type { UIOptions } from "../BridgeOrchestrator.js";
|
||||
import { PaymentOverview } from "./PaymentOverview.js";
|
||||
|
||||
export interface PaymentDetailsProps {
|
||||
title?: string;
|
||||
confirmButtonLabel?: string;
|
||||
|
||||
/**
|
||||
* The UI mode to use
|
||||
*/
|
||||
@@ -55,6 +58,8 @@ export interface PaymentDetailsProps {
|
||||
}
|
||||
|
||||
export function PaymentDetails({
|
||||
title,
|
||||
confirmButtonLabel,
|
||||
uiOptions,
|
||||
client,
|
||||
paymentMethod,
|
||||
@@ -101,6 +106,7 @@ export function PaymentDetails({
|
||||
: preparedQuote.intent.destinationTokenAddress,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
queryKey: ["payment_details", preparedQuote.type],
|
||||
});
|
||||
@@ -184,6 +190,43 @@ export function PaymentDetails({
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "sell": {
|
||||
const method =
|
||||
paymentMethod.type === "wallet" ? paymentMethod : undefined;
|
||||
if (!method) {
|
||||
// can never happen
|
||||
onError(new Error("Invalid payment method"));
|
||||
return {
|
||||
destinationAmount: "0",
|
||||
destinationToken: undefined,
|
||||
estimatedTime: 0,
|
||||
originAmount: "0",
|
||||
originToken: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
destinationAmount: formatTokenAmount(
|
||||
preparedQuote.destinationAmount,
|
||||
preparedQuote.steps[preparedQuote.steps.length - 1]
|
||||
?.destinationToken?.decimals ?? 18,
|
||||
),
|
||||
destinationToken:
|
||||
preparedQuote.steps[preparedQuote.steps.length - 1]
|
||||
?.destinationToken,
|
||||
estimatedTime: preparedQuote.estimatedExecutionTimeMs,
|
||||
originAmount: formatTokenAmount(
|
||||
preparedQuote.originAmount,
|
||||
method.originToken.decimals,
|
||||
),
|
||||
originToken:
|
||||
paymentMethod.type === "wallet"
|
||||
? paymentMethod.originToken
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "onramp": {
|
||||
const method =
|
||||
paymentMethod.type === "fiat" ? paymentMethod : undefined;
|
||||
@@ -214,7 +257,7 @@ export function PaymentDetails({
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unsupported bridge prepare type: ${preparedQuote.type}`,
|
||||
`Unsupported bridge prepare type: ${(preparedQuote as unknown as { type: string }).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -223,10 +266,10 @@ export function PaymentDetails({
|
||||
const displayData = getDisplayData();
|
||||
|
||||
return (
|
||||
<Container flex="column" fullHeight p="lg">
|
||||
<ModalHeader onBack={onBack} title="Payment Details" />
|
||||
<Container flex="column" fullHeight px="md" pb="md" pt="md+">
|
||||
<ModalHeader onBack={onBack} title={title || "Payment Details"} />
|
||||
|
||||
<Spacer y="xl" />
|
||||
<Spacer y="lg" />
|
||||
|
||||
<Container flex="column">
|
||||
{/* Quote Summary */}
|
||||
@@ -239,7 +282,7 @@ export function PaymentDetails({
|
||||
receiver={preparedQuote.intent.receiver}
|
||||
sender={
|
||||
preparedQuote.intent.sender ||
|
||||
paymentMethod.payerWallet.getAccount()?.address
|
||||
paymentMethod.payerWallet?.getAccount()?.address
|
||||
}
|
||||
toAmount={displayData.destinationAmount}
|
||||
toToken={displayData.destinationToken}
|
||||
@@ -389,7 +432,7 @@ export function PaymentDetails({
|
||||
{/* Action Buttons */}
|
||||
<Container flex="column" gap="sm">
|
||||
<Button fullWidth onClick={handleConfirm} variant="accent">
|
||||
Confirm Payment
|
||||
{confirmButtonLabel || "Confirm Payment"}
|
||||
</Button>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
+93
-87
@@ -79,9 +79,15 @@ export function FiatProviderSelection({
|
||||
return quoteQueries.map((q) => q.data).filter((q) => !!q);
|
||||
}, [quoteQueries]);
|
||||
|
||||
const isPending = quoteQueries.some((q) => q.isLoading);
|
||||
|
||||
if (quoteQueries.every((q) => q.isError)) {
|
||||
return (
|
||||
<Container center="both" flex="column" style={{ minHeight: "120px" }}>
|
||||
<Container
|
||||
center="both"
|
||||
flex="column"
|
||||
style={{ minHeight: "200px", flexGrow: 1 }}
|
||||
>
|
||||
<Text color="secondaryText" size="sm">
|
||||
No quotes available
|
||||
</Text>
|
||||
@@ -92,11 +98,17 @@ export function FiatProviderSelection({
|
||||
// TODO: add a "remember my choice" checkbox
|
||||
|
||||
return (
|
||||
<Container flex="column" gap="sm">
|
||||
{quotes.length > 0 ? (
|
||||
<Container
|
||||
flex="column"
|
||||
gap="xs"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{!isPending ? (
|
||||
quotes
|
||||
.sort((a, b) => a.currencyAmount - b.currencyAmount)
|
||||
.map((quote, index) => {
|
||||
.map((quote) => {
|
||||
const provider = PROVIDERS.find(
|
||||
(p) => p.id === quote.intent.onramp,
|
||||
);
|
||||
@@ -105,98 +117,92 @@ export function FiatProviderSelection({
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
animate="fadein"
|
||||
<Button
|
||||
key={provider.id}
|
||||
fullWidth
|
||||
onClick={() => onProviderSelected(provider.id)}
|
||||
style={{
|
||||
animationDelay: `${index * 100}ms`,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
borderRadius: radius.md,
|
||||
textAlign: "left",
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => onProviderSelected(provider.id)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.tertiaryBg,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
borderRadius: radius.md,
|
||||
padding: `${spacing.sm} ${spacing.md}`,
|
||||
textAlign: "left",
|
||||
}}
|
||||
variant="secondary"
|
||||
<Container
|
||||
flex="row"
|
||||
gap="sm"
|
||||
style={{ alignItems: "center", width: "100%" }}
|
||||
>
|
||||
<Container
|
||||
flex="row"
|
||||
gap="sm"
|
||||
style={{ alignItems: "center", width: "100%" }}
|
||||
>
|
||||
<Container
|
||||
style={{
|
||||
alignItems: "center",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
height: `${iconSize.md}px`,
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
padding: spacing.xs,
|
||||
width: `${iconSize.md}px`,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
alt={provider.name}
|
||||
client={client}
|
||||
height={iconSize.md}
|
||||
src={provider.iconUri}
|
||||
width={iconSize.md}
|
||||
/>
|
||||
</Container>
|
||||
<Container flex="column" gap="3xs" style={{ flex: 1 }}>
|
||||
<Text
|
||||
color="primaryText"
|
||||
size="md"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{provider.name}
|
||||
</Text>
|
||||
</Container>
|
||||
<Container
|
||||
flex="column"
|
||||
gap="3xs"
|
||||
style={{ alignItems: "flex-end" }}
|
||||
>
|
||||
<Text
|
||||
color="primaryText"
|
||||
size="sm"
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
{formatCurrencyAmount(
|
||||
currency || "US",
|
||||
quote.currencyAmount,
|
||||
)}
|
||||
</Text>
|
||||
<Text color="secondaryText" size="xs">
|
||||
{formatNumber(
|
||||
Number(
|
||||
toTokens(
|
||||
quote.destinationAmount,
|
||||
quote.destinationToken.decimals,
|
||||
),
|
||||
),
|
||||
4,
|
||||
)}{" "}
|
||||
{quote.destinationToken.symbol}
|
||||
</Text>
|
||||
</Container>
|
||||
<Img
|
||||
alt={provider.name}
|
||||
client={client}
|
||||
height={iconSize.lg}
|
||||
src={provider.iconUri}
|
||||
width={iconSize.lg}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
<Container flex="column" gap="3xs" style={{ flex: 1 }}>
|
||||
<Text color="primaryText" size="md" weight={500}>
|
||||
{provider.name}
|
||||
</Text>
|
||||
</Container>
|
||||
</Button>
|
||||
</Container>
|
||||
<Container
|
||||
flex="column"
|
||||
gap="3xs"
|
||||
style={{ alignItems: "flex-end" }}
|
||||
>
|
||||
<Text
|
||||
color="primaryText"
|
||||
size="sm"
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
{formatCurrencyAmount(
|
||||
currency || "USD",
|
||||
quote.currencyAmount,
|
||||
)}
|
||||
</Text>
|
||||
<Text color="secondaryText" size="xs">
|
||||
{formatNumber(
|
||||
Number(
|
||||
toTokens(
|
||||
quote.destinationAmount,
|
||||
quote.destinationToken.decimals,
|
||||
),
|
||||
),
|
||||
4,
|
||||
)}{" "}
|
||||
{quote.destinationToken.symbol}
|
||||
</Text>
|
||||
</Container>
|
||||
</Container>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Container center="both" flex="column" style={{ minHeight: "120px" }}>
|
||||
<Spinner color="secondaryText" size="lg" />
|
||||
<Spacer y="sm" />
|
||||
<Text center color="secondaryText" size="sm">
|
||||
Generating quotes...
|
||||
<Container
|
||||
center="both"
|
||||
flex="column"
|
||||
style={{ flexGrow: 1, paddingBottom: spacing.lg }}
|
||||
px="md"
|
||||
>
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
<Spacer y="lg" />
|
||||
<Text center color="primaryText" size="lg" weight={600} trackingTight>
|
||||
Searching Providers
|
||||
</Text>
|
||||
<Spacer y="xs" />
|
||||
<Text
|
||||
center
|
||||
color="secondaryText"
|
||||
size="sm"
|
||||
multiline
|
||||
style={{
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
Searching for the best providers for this payment
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
@@ -125,9 +125,16 @@ export function PaymentSelection({
|
||||
const connectedWallets = useConnectedWallets();
|
||||
const activeWallet = useActiveWallet();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Step>({
|
||||
type: "walletSelection",
|
||||
});
|
||||
const initialStep =
|
||||
paymentMethods.length === 1 && paymentMethods[0] === "card"
|
||||
? {
|
||||
type: "fiatProviderSelection" as const,
|
||||
}
|
||||
: {
|
||||
type: "walletSelection" as const,
|
||||
};
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Step>(initialStep);
|
||||
|
||||
useQuery({
|
||||
queryFn: () => {
|
||||
@@ -196,15 +203,17 @@ export function PaymentSelection({
|
||||
const handleOnrampProviderSelected = (
|
||||
provider: "coinbase" | "stripe" | "transak",
|
||||
) => {
|
||||
if (!payerWallet) {
|
||||
onError(new Error("No wallet available for fiat payment"));
|
||||
const recipientAddress =
|
||||
receiverAddress || payerWallet?.getAccount()?.address;
|
||||
if (!recipientAddress) {
|
||||
onError(new Error("No recipient address available for fiat payment"));
|
||||
return;
|
||||
}
|
||||
|
||||
const fiatPaymentMethod: PaymentMethod = {
|
||||
currency: "USD",
|
||||
currency: currency || "USD",
|
||||
onramp: provider,
|
||||
payerWallet, // Default to USD for now
|
||||
payerWallet,
|
||||
type: "fiat",
|
||||
};
|
||||
handlePaymentMethodSelected(fiatPaymentMethod);
|
||||
@@ -224,6 +233,9 @@ export function PaymentSelection({
|
||||
};
|
||||
|
||||
const getBackHandler = () => {
|
||||
if (paymentMethods.length === 1 && paymentMethods[0] === "card") {
|
||||
return onBack;
|
||||
}
|
||||
switch (currentStep.type) {
|
||||
case "walletSelection":
|
||||
return onBack;
|
||||
@@ -268,12 +280,11 @@ export function PaymentSelection({
|
||||
}
|
||||
|
||||
return (
|
||||
<Container flex="column" p="lg">
|
||||
<Container flex="column" px="md" pb="md" pt="md+">
|
||||
<ModalHeader onBack={getBackHandler()} title={getStepTitle()} />
|
||||
<Spacer y="lg" />
|
||||
|
||||
<Spacer y="xl" />
|
||||
|
||||
<Container flex="column">
|
||||
<Container flex="column" style={{ minHeight: "300px" }}>
|
||||
{currentStep.type === "walletSelection" && (
|
||||
<WalletFiatSelection
|
||||
client={client}
|
||||
@@ -307,7 +318,9 @@ export function PaymentSelection({
|
||||
country={country}
|
||||
client={client}
|
||||
onProviderSelected={handleOnrampProviderSelected}
|
||||
toAddress={receiverAddress || ""}
|
||||
toAddress={
|
||||
receiverAddress || payerWallet?.getAccount()?.address || ""
|
||||
}
|
||||
toAmount={destinationAmount}
|
||||
toChainId={destinationToken.chainId}
|
||||
toTokenAddress={destinationToken.address}
|
||||
|
||||
+12
-7
@@ -38,10 +38,15 @@ export function WalletFiatSelection({
|
||||
<>
|
||||
{paymentMethods.includes("crypto") && (
|
||||
<>
|
||||
<Text color="primaryText" size="md">
|
||||
Pay with Crypto
|
||||
</Text>
|
||||
<Spacer y="md" />
|
||||
{paymentMethods.length > 1 && (
|
||||
<>
|
||||
<Text color="primaryText" size="sm" weight={500}>
|
||||
Pay with Crypto
|
||||
</Text>
|
||||
<Spacer y="sm" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connected Wallets */}
|
||||
{connectedWallets.length > 0 && (
|
||||
<>
|
||||
@@ -134,13 +139,13 @@ export function WalletFiatSelection({
|
||||
|
||||
{paymentMethods.includes("card") && (
|
||||
<>
|
||||
<Spacer y="md" />
|
||||
<Spacer y="lg" />
|
||||
|
||||
<Text color="primaryText" size="md">
|
||||
<Text color="primaryText" size="sm" weight={500}>
|
||||
Pay with Card
|
||||
</Text>
|
||||
|
||||
<Spacer y="md" />
|
||||
<Spacer y="sm" />
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -364,7 +364,7 @@ export function PaymentReceipt({
|
||||
<Container
|
||||
flex="column"
|
||||
fullHeight
|
||||
p="lg"
|
||||
p="md"
|
||||
style={{ maxHeight: "500px", minHeight: "250px", overflowY: "auto" }}
|
||||
>
|
||||
<ModalHeader onBack={onBack} title="Payment Receipt" />
|
||||
@@ -374,10 +374,6 @@ export function PaymentReceipt({
|
||||
<Container flex="column" gap="lg">
|
||||
{/* Status Results */}
|
||||
<Container flex="column" gap="md">
|
||||
<Text color="primaryText" size="md">
|
||||
Transactions
|
||||
</Text>
|
||||
|
||||
{completedStatuses.map((status, index) => (
|
||||
<CompletedStepDetailCard
|
||||
key={`${status.type}-${index}`}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { trackPayEvent } from "../../../../../analytics/track/pay.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import type { WindowAdapter } from "../../../../core/adapters/WindowAdapter.js";
|
||||
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import { iconSize } from "../../../../core/design-system/index.js";
|
||||
import { iconSize, spacing } from "../../../../core/design-system/index.js";
|
||||
import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js";
|
||||
import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecutor.js";
|
||||
import { Container, ModalHeader } from "../../components/basic.js";
|
||||
@@ -91,10 +91,10 @@ export function SuccessScreen({
|
||||
}
|
||||
|
||||
return (
|
||||
<Container flex="column" fullHeight p="lg">
|
||||
<Container flex="column" fullHeight px="md" pb="md" pt="md+">
|
||||
<ModalHeader title="Payment Complete" />
|
||||
|
||||
<Spacer y="xl" />
|
||||
<Spacer y="xxl" />
|
||||
|
||||
<Container center="x" flex="column" gap="md">
|
||||
{/* Success Icon with Animation */}
|
||||
@@ -103,7 +103,6 @@ export function SuccessScreen({
|
||||
flex="row"
|
||||
style={{
|
||||
animation: "successBounce 0.6s ease-out",
|
||||
backgroundColor: theme.colors.tertiaryBg,
|
||||
border: `2px solid ${theme.colors.success}`,
|
||||
borderRadius: "50%",
|
||||
height: "64px",
|
||||
@@ -121,19 +120,31 @@ export function SuccessScreen({
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Text center color="primaryText" size="xl">
|
||||
Payment Successful!
|
||||
</Text>
|
||||
<div>
|
||||
<Text
|
||||
center
|
||||
color="primaryText"
|
||||
size="xl"
|
||||
weight={600}
|
||||
trackingTight
|
||||
style={{
|
||||
marginBottom: spacing.xxs,
|
||||
}}
|
||||
>
|
||||
Payment Successful!
|
||||
</Text>
|
||||
|
||||
<Text center color="secondaryText" size="sm">
|
||||
{hasPaymentId
|
||||
? "You can now close this page and return to the application."
|
||||
: uiOptions.mode === "transaction"
|
||||
? "Click continue to execute your transaction."
|
||||
: "Your payment has been completed successfully."}
|
||||
</Text>
|
||||
<Text center color="secondaryText" size="sm">
|
||||
{hasPaymentId
|
||||
? "You can now close this page and return to the application."
|
||||
: uiOptions.mode === "transaction"
|
||||
? "Click continue to execute your transaction."
|
||||
: "Your payment has been completed successfully."}
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
<Spacer y="lg" />
|
||||
|
||||
<Spacer y="xxl" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Container flex="column" gap="sm" style={{ width: "100%" }}>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
iconSize,
|
||||
radius,
|
||||
spacing,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { Container } from "../../components/basic.js";
|
||||
import { Input } from "../../components/formElements.js";
|
||||
|
||||
export function SearchInput(props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Container color="secondaryText">
|
||||
<MagnifyingGlassIcon
|
||||
width={iconSize.md}
|
||||
height={iconSize.md}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: spacing.sm,
|
||||
top: "50%",
|
||||
borderRadius: radius.lg,
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Input
|
||||
variant="outline"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
style={{
|
||||
paddingLeft: "44px",
|
||||
}}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
||||
import type { BridgeChain } from "../../../../../bridge/types/Chain.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
radius,
|
||||
spacing,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Img } from "../../components/Img.js";
|
||||
import { cleanedChainName } from "./utils.js";
|
||||
|
||||
export function SelectChainButton(props: {
|
||||
selectedChain: BridgeChain;
|
||||
client: ThirdwebClient;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
bg="tertiaryBg"
|
||||
fullWidth
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
borderRadius: radius.lg,
|
||||
minHeight: "48px",
|
||||
}}
|
||||
gap="sm"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Img
|
||||
src={props.selectedChain.icon}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
/>
|
||||
<span> {cleanedChainName(props.selectedChain.name)} </span>
|
||||
|
||||
<ChevronDownIcon
|
||||
width={iconSize.sm}
|
||||
height={iconSize.sm}
|
||||
style={{ marginLeft: "auto" }}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Buy, Sell } from "../../../../../bridge/index.js";
|
||||
import type { TokenWithPrices } from "../../../../../bridge/types/Token.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
|
||||
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
|
||||
import { getAddress } from "../../../../../utils/address.js";
|
||||
import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import type { Theme } from "../../../../core/design-system/index.js";
|
||||
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
|
||||
import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecutor.js";
|
||||
import { webWindowAdapter } from "../../../adapters/WindowAdapter.js";
|
||||
import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js";
|
||||
import { DynamicHeight } from "../../components/DynamicHeight.js";
|
||||
import { ErrorBanner } from "../ErrorBanner.js";
|
||||
import { PaymentDetails } from "../payment-details/PaymentDetails.js";
|
||||
import { SuccessScreen } from "../payment-success/SuccessScreen.js";
|
||||
import { StepRunner } from "../StepRunner.js";
|
||||
import { useActiveWalletInfo } from "./hooks.js";
|
||||
import { getLastUsedTokens, setLastUsedTokens } from "./storage.js";
|
||||
import { SwapUI } from "./swap-ui.js";
|
||||
import type {
|
||||
SwapPreparedQuote,
|
||||
SwapWidgetConnectOptions,
|
||||
TokenSelection,
|
||||
} from "./types.js";
|
||||
import { useBridgeChains } from "./use-bridge-chains.js";
|
||||
|
||||
export type SwapWidgetProps = {
|
||||
/**
|
||||
* A client is the entry point to the thirdweb SDK.
|
||||
* It is required for all other actions.
|
||||
* You can create a client using the `createThirdwebClient` function. Refer to the [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information.
|
||||
*
|
||||
* You must provide a `clientId` or `secretKey` in order to initialize a client. Pass `clientId` if you want for client-side usage and `secretKey` for server-side usage.
|
||||
*
|
||||
* ```tsx
|
||||
* import { createThirdwebClient } from "thirdweb";
|
||||
*
|
||||
* const client = createThirdwebClient({
|
||||
* clientId: "<your_client_id>",
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
client: ThirdwebClient;
|
||||
/**
|
||||
* The prefill Buy and/or Sell tokens for the swap widget. If `tokenAddress` is not provided, the native token will be used
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ### Set an ERC20 token as the buy token
|
||||
* ```ts
|
||||
* <SwapWidget client={client} prefill={{
|
||||
* buyToken: {
|
||||
* chainId: 8453,
|
||||
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
* ### Set a native token as the sell token
|
||||
*
|
||||
* ```ts
|
||||
* <SwapWidget client={client} prefill={{
|
||||
* sellToken: {
|
||||
* chainId: 8453,
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
* ### Set 0.1 Base USDC as the buy token
|
||||
* ```ts
|
||||
* <SwapWidget client={client} prefill={{
|
||||
* buyToken: {
|
||||
* chainId: 8453,
|
||||
* amount: "0.1",
|
||||
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
* ### Set Base USDC as the buy token and Base native token as the sell token
|
||||
* ```ts
|
||||
* <SwapWidget client={client} prefill={{
|
||||
* buyToken: {
|
||||
* chainId: 8453,
|
||||
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
* },
|
||||
* sellToken: {
|
||||
* chainId: 8453,
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*/
|
||||
prefill?: {
|
||||
buyToken?: {
|
||||
tokenAddress?: string;
|
||||
chainId: number;
|
||||
amount?: string;
|
||||
};
|
||||
sellToken?: {
|
||||
tokenAddress?: string;
|
||||
chainId: number;
|
||||
amount?: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Set the theme for the `SwapWidget` component. By default it is set to `"dark"`
|
||||
*
|
||||
* theme can be set to either `"dark"`, `"light"` or a custom theme object.
|
||||
* You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme)
|
||||
* or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme)
|
||||
* functions from `thirdweb/react` to use the default themes as base and overrides parts of it.
|
||||
* @example
|
||||
* ```ts
|
||||
* import { lightTheme } from "thirdweb/react";
|
||||
*
|
||||
* const customTheme = lightTheme({
|
||||
* colors: {
|
||||
* modalBg: 'red'
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* function Example() {
|
||||
* return <SwapWidget client={client} theme={customTheme} />
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
theme?: "light" | "dark" | Theme;
|
||||
/**
|
||||
* The currency to use for the payment.
|
||||
* @default "USD"
|
||||
*/
|
||||
currency?: SupportedFiatCurrency;
|
||||
connectOptions?: SwapWidgetConnectOptions;
|
||||
/**
|
||||
* Whether to show thirdweb branding in the widget.
|
||||
* @default true
|
||||
*/
|
||||
showThirdwebBranding?: boolean;
|
||||
/**
|
||||
* Callback to be called when the swap is successful.
|
||||
*/
|
||||
onSuccess?: (quote: SwapPreparedQuote) => void;
|
||||
/**
|
||||
* Callback to be called when user encounters an error when swapping.
|
||||
*/
|
||||
onError?: (error: Error, quote: SwapPreparedQuote) => void;
|
||||
/**
|
||||
* Callback to be called when the user cancels the purchase.
|
||||
*/
|
||||
onCancel?: (quote: SwapPreparedQuote) => void;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* Whether to persist the token selections to localStorage so that if the user revisits the widget, the last used tokens are pre-selected.
|
||||
* The last used tokens do not override the tokens specified in the `prefill` prop
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
persistTokenSelections?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A widget for swapping tokens with cross-chain support
|
||||
*
|
||||
* @param props - Props of type [`SwapWidgetProps`](https://portal.thirdweb.com/references/typescript/v5/SwapWidgetProps) to configure the SwapWidget component.
|
||||
*
|
||||
* @example
|
||||
* ### Basic usage
|
||||
*
|
||||
* By default, no tokens are selected in the widget UI.
|
||||
*
|
||||
* You can set specific tokens to buy or sell by default by passing the `prefill` prop. User can change these selections in the widget UI.
|
||||
*
|
||||
* ```tsx
|
||||
* <SwapWidget client={client} />
|
||||
* ```
|
||||
*
|
||||
* ### Set an ERC20 token to Buy by default
|
||||
*
|
||||
* ```tsx
|
||||
* <SwapWidget client={client} prefill={{
|
||||
* buyToken: {
|
||||
* // Base USDC
|
||||
* chainId: 8453,
|
||||
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
* ### Set a native token to Sell by default
|
||||
*
|
||||
* By not specifying a `tokenAddress`, the native token will be used.
|
||||
*
|
||||
* ```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: {
|
||||
* // 0.1 Base USDC
|
||||
* chainId: 8453,
|
||||
* amount: "0.1",
|
||||
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
* ### Set both buy and sell tokens by default
|
||||
*
|
||||
* ```tsx
|
||||
* <SwapWidget client={client} prefill={{
|
||||
* buyToken: {
|
||||
* // Base USDC
|
||||
* chainId: 8453,
|
||||
* tokenAddress: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
||||
* },
|
||||
* sellToken: {
|
||||
* // Polygon native token (MATIC)
|
||||
* chainId: 137,
|
||||
* },
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
export function SwapWidget(props: SwapWidgetProps) {
|
||||
return (
|
||||
<SwapWidgetContainer
|
||||
theme={props.theme}
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
>
|
||||
<SwapWidgetContent {...props} />
|
||||
</SwapWidgetContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function SwapWidgetContainer(props: {
|
||||
theme: SwapWidgetProps["theme"];
|
||||
className: string | undefined;
|
||||
style?: React.CSSProperties | undefined;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CustomThemeProvider theme={props.theme || "dark"}>
|
||||
<EmbedContainer
|
||||
className={props.className}
|
||||
modalSize="compact"
|
||||
style={{
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<DynamicHeight>{props.children}</DynamicHeight>
|
||||
</EmbedContainer>
|
||||
</CustomThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectionInfo = {
|
||||
preparedQuote: SwapPreparedQuote;
|
||||
request: BridgePrepareRequest;
|
||||
quote: Buy.quote.Result | Sell.quote.Result;
|
||||
buyToken: TokenWithPrices;
|
||||
sellToken: TokenWithPrices;
|
||||
sellTokenBalance: bigint;
|
||||
mode: "buy" | "sell";
|
||||
};
|
||||
|
||||
type Join<T, U> = T & U;
|
||||
|
||||
type SwapWidgetScreen =
|
||||
| { id: "1:swap-ui" }
|
||||
| Join<{ id: "2:preview" }, SelectionInfo>
|
||||
| Join<{ id: "3:execute" }, SelectionInfo>
|
||||
| Join<
|
||||
{
|
||||
id: "4:success";
|
||||
completedStatuses: CompletedStatusResult[];
|
||||
},
|
||||
SelectionInfo
|
||||
>
|
||||
| { id: "error"; error: Error; preparedQuote: SwapPreparedQuote };
|
||||
|
||||
function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
const [screen, setScreen] = useState<SwapWidgetScreen>({ id: "1:swap-ui" });
|
||||
const activeWalletInfo = useActiveWalletInfo();
|
||||
const isPersistEnabled = props.persistTokenSelections !== false;
|
||||
|
||||
const [amountSelection, setAmountSelection] = useState<{
|
||||
type: "buy" | "sell";
|
||||
amount: string;
|
||||
}>(() => {
|
||||
if (props.prefill?.buyToken?.amount) {
|
||||
return {
|
||||
type: "buy",
|
||||
amount: props.prefill.buyToken.amount,
|
||||
};
|
||||
}
|
||||
if (props.prefill?.sellToken?.amount) {
|
||||
return {
|
||||
type: "sell",
|
||||
amount: props.prefill.sellToken.amount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "buy",
|
||||
amount: "",
|
||||
};
|
||||
});
|
||||
|
||||
const [buyToken, setBuyToken] = useState<TokenSelection | undefined>(() => {
|
||||
if (props.prefill?.buyToken) {
|
||||
return {
|
||||
tokenAddress:
|
||||
props.prefill.buyToken.tokenAddress ||
|
||||
getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: props.prefill.buyToken.chainId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPersistEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastUsedBuyToken = getLastUsedTokens()?.buyToken;
|
||||
|
||||
// the token that will be set as initial value of sell token
|
||||
const sellToken = getInitialSellToken(
|
||||
props.prefill,
|
||||
getLastUsedTokens()?.sellToken,
|
||||
);
|
||||
|
||||
// if both tokens are same, ignore "buyToken", keep "sellToken"
|
||||
if (
|
||||
lastUsedBuyToken &&
|
||||
sellToken &&
|
||||
lastUsedBuyToken.tokenAddress.toLowerCase() ===
|
||||
sellToken.tokenAddress.toLowerCase() &&
|
||||
lastUsedBuyToken.chainId === sellToken.chainId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return lastUsedBuyToken;
|
||||
});
|
||||
|
||||
const [sellToken, setSellToken] = useState<TokenSelection | undefined>(() => {
|
||||
return getInitialSellToken(
|
||||
props.prefill,
|
||||
isPersistEnabled ? getLastUsedTokens()?.sellToken : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
// persist selections to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
if (isPersistEnabled) {
|
||||
setLastUsedTokens({ buyToken, sellToken });
|
||||
}
|
||||
}, [buyToken, sellToken, isPersistEnabled]);
|
||||
|
||||
// preload requests
|
||||
useBridgeChains(props.client);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: Error, quote: SwapPreparedQuote) => {
|
||||
console.error(error);
|
||||
props.onError?.(error, quote);
|
||||
setScreen({
|
||||
id: "error",
|
||||
preparedQuote: quote,
|
||||
error,
|
||||
});
|
||||
},
|
||||
[props.onError],
|
||||
);
|
||||
|
||||
// if wallet suddenly disconnects, show screen 1
|
||||
if (screen.id === "1:swap-ui" || !activeWalletInfo) {
|
||||
return (
|
||||
<SwapUI
|
||||
showThirdwebBranding={
|
||||
props.showThirdwebBranding === undefined
|
||||
? true
|
||||
: props.showThirdwebBranding
|
||||
}
|
||||
client={props.client}
|
||||
theme={props.theme || "dark"}
|
||||
connectOptions={props.connectOptions}
|
||||
currency={props.currency || "USD"}
|
||||
activeWalletInfo={activeWalletInfo}
|
||||
buyToken={buyToken}
|
||||
sellToken={sellToken}
|
||||
setBuyToken={setBuyToken}
|
||||
setSellToken={setSellToken}
|
||||
amountSelection={amountSelection}
|
||||
setAmountSelection={setAmountSelection}
|
||||
onSwap={(data) => {
|
||||
setScreen({
|
||||
id: "2:preview",
|
||||
buyToken: data.buyToken,
|
||||
sellToken: data.sellToken,
|
||||
sellTokenBalance: data.sellTokenBalance,
|
||||
mode: data.mode,
|
||||
preparedQuote: data.result,
|
||||
request: data.request,
|
||||
quote: data.result,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "2:preview") {
|
||||
return (
|
||||
<PaymentDetails
|
||||
title="Review Swap"
|
||||
confirmButtonLabel="Swap"
|
||||
client={props.client}
|
||||
onBack={() => {
|
||||
setScreen({ id: "1:swap-ui" });
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setScreen({
|
||||
...screen,
|
||||
id: "3:execute",
|
||||
});
|
||||
}}
|
||||
onError={(error) => handleError(error, screen.preparedQuote)}
|
||||
paymentMethod={{
|
||||
quote: screen.quote,
|
||||
type: "wallet",
|
||||
payerWallet: activeWalletInfo.activeWallet,
|
||||
balance: screen.sellTokenBalance,
|
||||
originToken: screen.sellToken,
|
||||
action: screen.mode,
|
||||
}}
|
||||
preparedQuote={screen.preparedQuote}
|
||||
uiOptions={{
|
||||
destinationToken: screen.buyToken,
|
||||
mode: "fund_wallet",
|
||||
currency: props.currency,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "3:execute") {
|
||||
return (
|
||||
<StepRunner
|
||||
title="Processing Swap"
|
||||
autoStart={true}
|
||||
client={props.client}
|
||||
onBack={() => {
|
||||
setScreen({
|
||||
...screen,
|
||||
id: "2:preview",
|
||||
sellTokenBalance: screen.sellTokenBalance,
|
||||
});
|
||||
}}
|
||||
onCancel={() => props.onCancel?.(screen.preparedQuote)}
|
||||
onComplete={(completedStatuses) => {
|
||||
props.onSuccess?.(screen.preparedQuote);
|
||||
setScreen({
|
||||
...screen,
|
||||
id: "4:success",
|
||||
completedStatuses,
|
||||
});
|
||||
}}
|
||||
request={screen.request}
|
||||
wallet={activeWalletInfo.activeWallet}
|
||||
windowAdapter={webWindowAdapter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "4:success") {
|
||||
return (
|
||||
<SuccessScreen
|
||||
client={props.client}
|
||||
completedStatuses={screen.completedStatuses}
|
||||
onDone={() => {
|
||||
setScreen({ id: "1:swap-ui" });
|
||||
// clear amounts
|
||||
setAmountSelection({
|
||||
type: "buy",
|
||||
amount: "",
|
||||
});
|
||||
}}
|
||||
preparedQuote={screen.preparedQuote}
|
||||
uiOptions={{
|
||||
destinationToken: screen.buyToken,
|
||||
mode: "fund_wallet",
|
||||
currency: props.currency,
|
||||
}}
|
||||
windowAdapter={webWindowAdapter}
|
||||
hasPaymentId={false} // TODO Question: Do we need to expose this as prop?
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "error") {
|
||||
return (
|
||||
<ErrorBanner
|
||||
client={props.client}
|
||||
error={screen.error}
|
||||
onCancel={() => {
|
||||
setScreen({ id: "1:swap-ui" });
|
||||
props.onCancel?.(screen.preparedQuote);
|
||||
}}
|
||||
onRetry={() => {
|
||||
setScreen({ id: "1:swap-ui" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialSellToken(
|
||||
prefill: SwapWidgetProps["prefill"],
|
||||
lastUsedSellToken: TokenSelection | undefined,
|
||||
) {
|
||||
if (prefill?.sellToken) {
|
||||
return {
|
||||
tokenAddress:
|
||||
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.sellToken.chainId,
|
||||
};
|
||||
}
|
||||
|
||||
return lastUsedSellToken;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Theme } from "../../../../core/design-system/index.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
|
||||
export function DecimalRenderer(props: {
|
||||
value: string;
|
||||
color: keyof Theme["colors"];
|
||||
weight: 400 | 500 | 600 | 700;
|
||||
integerSize: "md" | "sm";
|
||||
fractionSize: "sm" | "xs";
|
||||
}) {
|
||||
if (Number(props.value) > 1000) {
|
||||
return (
|
||||
<Text size={props.integerSize} color={props.color} weight={props.weight}>
|
||||
{compactFormatter.format(Number(props.value))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const [integerPart, fractionPart] = props.value.split(".");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "baseline" }}>
|
||||
<Text size={props.integerSize} color={props.color} weight={props.weight}>
|
||||
{integerPart}
|
||||
</Text>
|
||||
<Text size={props.fractionSize} color={props.color} weight={props.weight}>
|
||||
.{fractionPart || "00"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js";
|
||||
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
|
||||
import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js";
|
||||
import type { ActiveWalletInfo } from "./types.js";
|
||||
|
||||
export function useActiveWalletInfo(): ActiveWalletInfo | undefined {
|
||||
const activeAccount = useActiveAccount();
|
||||
const activeWallet = useActiveWallet();
|
||||
const activeChain = useActiveWalletChain();
|
||||
|
||||
return useMemo(() => {
|
||||
return activeAccount && activeWallet && activeChain
|
||||
? {
|
||||
activeChain,
|
||||
activeWallet,
|
||||
activeAccount,
|
||||
}
|
||||
: undefined;
|
||||
}, [activeAccount, activeWallet, activeChain]);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import type { Chain as BridgeChain } from "../../../../../bridge/index.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
spacing,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { Container, Line, ModalHeader } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Img } from "../../components/Img.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { SearchInput } from "./SearchInput.js";
|
||||
import { useBridgeChains } from "./use-bridge-chains.js";
|
||||
import { cleanedChainName } from "./utils.js";
|
||||
|
||||
type SelectBuyTokenProps = {
|
||||
onBack: () => void;
|
||||
client: ThirdwebClient;
|
||||
onSelectChain: (chain: BridgeChain) => void;
|
||||
selectedChain: BridgeChain | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function SelectBridgeChain(props: SelectBuyTokenProps) {
|
||||
const chainQuery = useBridgeChains(props.client);
|
||||
|
||||
return (
|
||||
<SelectBridgeChainUI
|
||||
{...props}
|
||||
isPending={chainQuery.isPending}
|
||||
onSelectChain={props.onSelectChain}
|
||||
chains={chainQuery.data ?? []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function SelectBridgeChainUI(
|
||||
props: SelectBuyTokenProps & {
|
||||
isPending: boolean;
|
||||
chains: BridgeChain[];
|
||||
onSelectChain: (chain: BridgeChain) => void;
|
||||
selectedChain: BridgeChain | undefined;
|
||||
},
|
||||
) {
|
||||
const [search, setSearch] = useState("");
|
||||
const filteredChains = props.chains.filter((chain) => {
|
||||
return chain.name.toLowerCase().includes(search.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Chain" />
|
||||
</Container>
|
||||
<Line />
|
||||
|
||||
<Spacer y="md" />
|
||||
|
||||
<Container
|
||||
px="md"
|
||||
style={{
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search Chain"
|
||||
/>
|
||||
</Container>
|
||||
<Spacer y="sm" />
|
||||
|
||||
<Container
|
||||
px="md"
|
||||
flex="column"
|
||||
style={{
|
||||
height: "400px",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
{filteredChains.map((chain) => (
|
||||
<ChainButton
|
||||
key={chain.chainId}
|
||||
chain={chain}
|
||||
client={props.client}
|
||||
onClick={() => props.onSelectChain(chain)}
|
||||
isSelected={chain.chainId === props.selectedChain?.chainId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.isPending &&
|
||||
new Array(20).fill(0).map(() => (
|
||||
// biome-ignore lint/correctness/useJsxKeyInIterable: ok
|
||||
<ChainButtonSkeleton />
|
||||
))}
|
||||
|
||||
{filteredChains.length === 0 && !props.isPending && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text color="secondaryText" size="md" center multiline>
|
||||
No chains found for "{search}"
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainButtonSkeleton() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
}}
|
||||
>
|
||||
<Skeleton height={`${iconSize.lg}px`} width={`${iconSize.lg}px`} />
|
||||
<Skeleton height={fontSize.md} width="160px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainButton(props: {
|
||||
chain: BridgeChain;
|
||||
client: ThirdwebClient;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={props.isSelected ? "secondary" : "ghost-solid"}
|
||||
fullWidth
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
border: "1px solid transparent",
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
}}
|
||||
gap="sm"
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Img
|
||||
src={props.chain.icon}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
/>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { DiscIcon } from "@radix-ui/react-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Token } from "../../../../../bridge/index.js";
|
||||
import type { BridgeChain } from "../../../../../bridge/types/Chain.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { toTokens } from "../../../../../utils/units.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
radius,
|
||||
spacing,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
|
||||
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
|
||||
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import { Container, Line, ModalHeader } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Img } from "../../components/Img.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Spinner } from "../../components/Spinner.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { DecimalRenderer } from "./common.js";
|
||||
import { SearchInput } from "./SearchInput.js";
|
||||
import { SelectChainButton } from "./SelectChainButton.js";
|
||||
import { SelectBridgeChain } from "./select-chain.js";
|
||||
import type { ActiveWalletInfo, TokenSelection } from "./types.js";
|
||||
import { useBridgeChains } from "./use-bridge-chains.js";
|
||||
import {
|
||||
type TokenBalance,
|
||||
useTokenBalances,
|
||||
useTokens,
|
||||
} from "./use-tokens.js";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
type SelectTokenUIProps = {
|
||||
onBack: () => void;
|
||||
client: ThirdwebClient;
|
||||
selectedToken: TokenSelection | undefined;
|
||||
setSelectedToken: (token: TokenSelection) => void;
|
||||
activeWalletInfo: ActiveWalletInfo | undefined;
|
||||
};
|
||||
|
||||
function getDefaultSelectedChain(
|
||||
chains: BridgeChain[],
|
||||
activeChainId: number | undefined,
|
||||
) {
|
||||
return chains.find((chain) => chain.chainId === (activeChainId || 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function SelectToken(props: SelectTokenUIProps) {
|
||||
const chainQuery = useBridgeChains(props.client);
|
||||
const [search, setSearch] = useState("");
|
||||
const [limit, setLimit] = useState(1000);
|
||||
|
||||
const [_selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const selectedChain =
|
||||
_selectedChain ||
|
||||
(chainQuery.data
|
||||
? getDefaultSelectedChain(
|
||||
chainQuery.data,
|
||||
props.selectedToken?.chainId ||
|
||||
props.activeWalletInfo?.activeChain.id,
|
||||
)
|
||||
: undefined);
|
||||
|
||||
// all tokens
|
||||
const tokensQuery = useTokens({
|
||||
client: props.client,
|
||||
chainId: selectedChain?.chainId,
|
||||
search,
|
||||
limit,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// owned tokens
|
||||
const ownedTokensQuery = useTokenBalances({
|
||||
clientId: props.client.clientId,
|
||||
chainId: selectedChain?.chainId,
|
||||
limit,
|
||||
page: 1,
|
||||
walletAddress: props.activeWalletInfo?.activeAccount.address,
|
||||
});
|
||||
|
||||
const filteredOwnedTokens = useMemo(() => {
|
||||
return ownedTokensQuery.data?.tokens?.filter((token) => {
|
||||
return (
|
||||
token.symbol.toLowerCase().includes(search.toLowerCase()) ||
|
||||
token.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
token.token_address.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
}, [ownedTokensQuery.data?.tokens, search]);
|
||||
|
||||
const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching;
|
||||
|
||||
return (
|
||||
<SelectTokenUI
|
||||
{...props}
|
||||
ownedTokens={filteredOwnedTokens || []}
|
||||
allTokens={tokensQuery.data || []}
|
||||
isFetching={isFetching}
|
||||
selectedChain={selectedChain}
|
||||
setSelectedChain={setSelectedChain}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
selectedToken={props.selectedToken}
|
||||
setSelectedToken={props.setSelectedToken}
|
||||
showMore={
|
||||
tokensQuery.data?.length === limit
|
||||
? () => {
|
||||
setLimit(limit * 2);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTokenUI(
|
||||
props: SelectTokenUIProps & {
|
||||
ownedTokens: TokenBalance[];
|
||||
allTokens: Token[];
|
||||
isFetching: boolean;
|
||||
selectedChain: BridgeChain | undefined;
|
||||
setSelectedChain: (chain: BridgeChain) => void;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
selectedToken: TokenSelection | undefined;
|
||||
setSelectedToken: (token: TokenSelection) => void;
|
||||
showMore: (() => void) | undefined;
|
||||
},
|
||||
) {
|
||||
const [screen, setScreen] = useState<"select-chain" | "select-token">(
|
||||
"select-token",
|
||||
);
|
||||
|
||||
// show tokens with icons first
|
||||
const sortedOwnedTokens = useMemo(() => {
|
||||
return props.ownedTokens.sort((a, b) => {
|
||||
if (a.icon_uri && !b.icon_uri) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.icon_uri && b.icon_uri) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [props.ownedTokens]);
|
||||
|
||||
const otherTokens = useMemo(() => {
|
||||
const ownedTokenSet = new Set(
|
||||
sortedOwnedTokens.map((t) =>
|
||||
`${t.token_address}-${t.chain_id}`.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return props.allTokens.filter(
|
||||
(token) =>
|
||||
!ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()),
|
||||
);
|
||||
}, [props.allTokens, sortedOwnedTokens]);
|
||||
|
||||
// show tokens with icons first
|
||||
const sortedOtherTokens = useMemo(() => {
|
||||
return otherTokens.sort((a, b) => {
|
||||
if (a.iconUri && !b.iconUri) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.iconUri && b.iconUri) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [otherTokens]);
|
||||
|
||||
const noTokensFound =
|
||||
!props.isFetching &&
|
||||
sortedOtherTokens.length === 0 &&
|
||||
props.ownedTokens.length === 0;
|
||||
|
||||
if (screen === "select-token") {
|
||||
return (
|
||||
<Container>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Token" />
|
||||
</Container>
|
||||
<Line />
|
||||
|
||||
{!props.selectedChain && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "400px",
|
||||
}}
|
||||
>
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedChain && (
|
||||
<>
|
||||
<Container p="md">
|
||||
<SelectChainButton
|
||||
onClick={() => setScreen("select-chain")}
|
||||
selectedChain={props.selectedChain}
|
||||
client={props.client}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{/* search */}
|
||||
<Container px="md">
|
||||
<SearchInput
|
||||
value={props.search}
|
||||
onChange={props.setSearch}
|
||||
placeholder="Search by Token or Address"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Spacer y="sm" />
|
||||
<Container px="md">
|
||||
<Container
|
||||
flex="column"
|
||||
style={{
|
||||
height: "400px",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
{props.isFetching &&
|
||||
new Array(20).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TokenButtonSkeleton key={i} />
|
||||
))}
|
||||
|
||||
{!props.isFetching && sortedOwnedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
>
|
||||
<WalletDotIcon size={iconSize.sm} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Your Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
sortedOwnedTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.token_address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.setSelectedToken}
|
||||
isSelected={
|
||||
!!props.selectedToken &&
|
||||
props.selectedToken.tokenAddress.toLowerCase() ===
|
||||
token.token_address.toLowerCase() &&
|
||||
token.chain_id === props.selectedToken.chainId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!props.isFetching && sortedOwnedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<CoinsIcon size={iconSize.sm} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Other Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
sortedOtherTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.setSelectedToken}
|
||||
isSelected={
|
||||
props.selectedToken?.tokenAddress.toLowerCase() ===
|
||||
token.address.toLowerCase()
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showMore && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
props.showMore?.();
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{noTokensFound && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText">
|
||||
No Tokens Found
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "select-chain") {
|
||||
return (
|
||||
<SelectBridgeChain
|
||||
onBack={() => setScreen("select-token")}
|
||||
client={props.client}
|
||||
onSelectChain={(chain) => {
|
||||
props.setSelectedChain(chain);
|
||||
setScreen("select-token");
|
||||
}}
|
||||
selectedChain={props.selectedChain}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TokenButtonSkeleton() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
height: "70px",
|
||||
}}
|
||||
>
|
||||
<Skeleton height={`${iconSize.lg}px`} width={`${iconSize.lg}px`} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<Skeleton height={fontSize.sm} width="100px" />
|
||||
<Skeleton height={fontSize.md} width="200px" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenButton(props: {
|
||||
token: TokenBalance | Token;
|
||||
client: ThirdwebClient;
|
||||
onSelect: (tokenWithPrices: TokenSelection) => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const tokenBalanceInUnits =
|
||||
"balance" in props.token
|
||||
? toTokens(BigInt(props.token.balance), props.token.decimals)
|
||||
: undefined;
|
||||
const usdValue =
|
||||
"balance" in props.token
|
||||
? props.token.price_data.price_usd * Number(tokenBalanceInUnits)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={props.isSelected ? "secondary" : "ghost-solid"}
|
||||
fullWidth
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
border: "1px solid transparent",
|
||||
padding: `${spacing.sm} ${spacing.xs}`,
|
||||
textAlign: "left",
|
||||
lineHeight: "1.5",
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
gap="sm"
|
||||
onClick={async () => {
|
||||
if ("balance" in props.token) {
|
||||
props.onSelect({
|
||||
tokenAddress: props.token.token_address,
|
||||
chainId: props.token.chain_id,
|
||||
});
|
||||
} else {
|
||||
props.onSelect({
|
||||
tokenAddress: props.token.address,
|
||||
chainId: props.token.chainId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={
|
||||
("balance" in props.token
|
||||
? props.token.icon_uri
|
||||
: props.token.iconUri) || ""
|
||||
}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
fallback={
|
||||
<Container color="secondaryText">
|
||||
<DiscIcon width={iconSize.lg} height={iconSize.lg} />
|
||||
</Container>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: spacing["3xs"],
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
flex="row"
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.token.symbol}
|
||||
</Text>
|
||||
{"balance" in props.token && (
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
value={formatTokenAmount(
|
||||
BigInt(props.token.balance),
|
||||
props.token.decimals,
|
||||
3,
|
||||
)}
|
||||
color="primaryText"
|
||||
weight={500}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Container
|
||||
flex="row"
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={400}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
>
|
||||
{props.token.name}
|
||||
</Text>
|
||||
{usdValue && (
|
||||
<Container flex="row">
|
||||
<Text size="sm" color="secondaryText" weight={400}>
|
||||
$
|
||||
</Text>
|
||||
<DecimalRenderer
|
||||
value={usdValue.toFixed(2)}
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
integerSize="sm"
|
||||
fractionSize="xs"
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const tokenSelectionSchema = z.object({
|
||||
tokenAddress: z.string().min(1),
|
||||
chainId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const lastUsedTokensSchema = z.object({
|
||||
buyToken: tokenSelectionSchema.optional(),
|
||||
sellToken: tokenSelectionSchema.optional(),
|
||||
});
|
||||
|
||||
type LastUsedTokens = z.infer<typeof lastUsedTokensSchema>;
|
||||
|
||||
const STORAGE_KEY = "tw.swap.lastUsedTokens";
|
||||
|
||||
function isBrowser() {
|
||||
return (
|
||||
typeof window !== "undefined" && typeof window.localStorage !== "undefined"
|
||||
);
|
||||
}
|
||||
|
||||
export function getLastUsedTokens(): LastUsedTokens | undefined {
|
||||
if (!isBrowser()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
const result = lastUsedTokensSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return undefined;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLastUsedTokens(update: LastUsedTokens): void {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = lastUsedTokensSchema.safeParse(update);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(result.data));
|
||||
} catch {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
import styled from "@emotion/styled";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DiscIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { prepare as BuyPrepare } from "../../../../../bridge/Buy.js";
|
||||
import { Buy, Sell } from "../../../../../bridge/index.js";
|
||||
import type { prepare as SellPrepare } from "../../../../../bridge/Sell.js";
|
||||
import type { BridgeChain } from "../../../../../bridge/types/Chain.js";
|
||||
import type { TokenWithPrices } from "../../../../../bridge/types/Token.js";
|
||||
import { defineChain } from "../../../../../chains/utils.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
|
||||
import { getToken } from "../../../../../pay/convert/get-token.js";
|
||||
import {
|
||||
getFiatSymbol,
|
||||
type SupportedFiatCurrency,
|
||||
} from "../../../../../pay/convert/type.js";
|
||||
import { getAddress } from "../../../../../utils/address.js";
|
||||
import { toTokens, toUnits } from "../../../../../utils/units.js";
|
||||
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
radius,
|
||||
spacing,
|
||||
type Theme,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js";
|
||||
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
|
||||
import { ConnectButton } from "../../ConnectWallet/ConnectButton.js";
|
||||
import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js";
|
||||
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
|
||||
import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js";
|
||||
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import { Container } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Input } from "../../components/formElements.js";
|
||||
import { Img } from "../../components/Img.js";
|
||||
import { Modal } from "../../components/Modal.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { DecimalRenderer } from "./common.js";
|
||||
import { SelectToken } from "./select-token-ui.js";
|
||||
import type {
|
||||
ActiveWalletInfo,
|
||||
SwapPreparedQuote,
|
||||
SwapWidgetConnectOptions,
|
||||
TokenSelection,
|
||||
} from "./types.js";
|
||||
import { useBridgeChains } from "./use-bridge-chains.js";
|
||||
import { cleanedChainName } from "./utils.js";
|
||||
|
||||
type SwapUIProps = {
|
||||
activeWalletInfo: ActiveWalletInfo | undefined;
|
||||
client: ThirdwebClient;
|
||||
theme: Theme | "light" | "dark";
|
||||
connectOptions: SwapWidgetConnectOptions | undefined;
|
||||
currency: SupportedFiatCurrency;
|
||||
showThirdwebBranding: boolean;
|
||||
onSwap: (data: {
|
||||
result: SwapPreparedQuote;
|
||||
request: BridgePrepareRequest;
|
||||
buyToken: TokenWithPrices;
|
||||
sellTokenBalance: bigint;
|
||||
sellToken: TokenWithPrices;
|
||||
mode: "buy" | "sell";
|
||||
}) => void;
|
||||
buyToken: TokenSelection | undefined;
|
||||
sellToken: TokenSelection | undefined;
|
||||
setBuyToken: (token: TokenSelection | undefined) => void;
|
||||
setSellToken: (token: TokenSelection | undefined) => void;
|
||||
amountSelection: {
|
||||
type: "buy" | "sell";
|
||||
amount: string;
|
||||
};
|
||||
setAmountSelection: (amountSelection: {
|
||||
type: "buy" | "sell";
|
||||
amount: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
function useTokenPrice(options: {
|
||||
token: TokenSelection | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ["token-price", options.token],
|
||||
enabled: !!options.token,
|
||||
queryFn: () => {
|
||||
if (!options.token) {
|
||||
throw new Error("Token is required");
|
||||
}
|
||||
return getToken(
|
||||
options.client,
|
||||
options.token.tokenAddress,
|
||||
options.token.chainId,
|
||||
);
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function SwapUI(props: SwapUIProps) {
|
||||
const [modalState, setModalState] = useState<
|
||||
"select-buy-token" | "select-sell-token" | undefined
|
||||
>(undefined);
|
||||
|
||||
// Token Prices ----------------------------------------------------------------------------
|
||||
const buyTokenQuery = useTokenPrice({
|
||||
token: props.buyToken,
|
||||
client: props.client,
|
||||
});
|
||||
const sellTokenQuery = useTokenPrice({
|
||||
token: props.sellToken,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
const buyTokenWithPrices = buyTokenQuery.data;
|
||||
const sellTokenWithPrices = sellTokenQuery.data;
|
||||
|
||||
// Swap Quote ----------------------------------------------------------------------------
|
||||
const preparedResultQuery = useSwapQuote({
|
||||
amountSelection: props.amountSelection,
|
||||
buyTokenWithPrices: buyTokenWithPrices,
|
||||
sellTokenWithPrices: sellTokenWithPrices,
|
||||
activeWalletInfo: props.activeWalletInfo,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
// Amount and Amount.fetching ------------------------------------------------------------
|
||||
|
||||
const sellTokenAmount =
|
||||
props.amountSelection.type === "sell"
|
||||
? props.amountSelection.amount
|
||||
: preparedResultQuery.data &&
|
||||
props.amountSelection.type === "buy" &&
|
||||
sellTokenWithPrices
|
||||
? toTokens(
|
||||
preparedResultQuery.data.result.originAmount,
|
||||
sellTokenWithPrices.decimals,
|
||||
)
|
||||
: "";
|
||||
|
||||
const buyTokenAmount =
|
||||
props.amountSelection.type === "buy"
|
||||
? props.amountSelection.amount
|
||||
: preparedResultQuery.data &&
|
||||
props.amountSelection.type === "sell" &&
|
||||
buyTokenWithPrices
|
||||
? toTokens(
|
||||
preparedResultQuery.data.result.destinationAmount,
|
||||
buyTokenWithPrices.decimals,
|
||||
)
|
||||
: "";
|
||||
|
||||
// when buy amount is set, the sell amount is fetched
|
||||
const isBuyAmountFetching =
|
||||
props.amountSelection.type === "sell" && preparedResultQuery.isFetching;
|
||||
const isSellAmountFetching =
|
||||
props.amountSelection.type === "buy" && preparedResultQuery.isFetching;
|
||||
|
||||
// token balances ------------------------------------------------------------
|
||||
const sellTokenBalanceQuery = useTokenBalance({
|
||||
chainId: sellTokenWithPrices?.chainId,
|
||||
tokenAddress: sellTokenWithPrices?.address,
|
||||
client: props.client,
|
||||
walletAddress: props.activeWalletInfo?.activeAccount.address,
|
||||
});
|
||||
|
||||
const buyTokenBalanceQuery = useTokenBalance({
|
||||
chainId: buyTokenWithPrices?.chainId,
|
||||
tokenAddress: buyTokenWithPrices?.address,
|
||||
client: props.client,
|
||||
walletAddress: props.activeWalletInfo?.activeAccount.address,
|
||||
});
|
||||
|
||||
const notEnoughBalance = !!(
|
||||
sellTokenBalanceQuery.data &&
|
||||
sellTokenWithPrices &&
|
||||
props.amountSelection.amount &&
|
||||
!!sellTokenAmount &&
|
||||
sellTokenBalanceQuery.data.value <
|
||||
Number(toUnits(sellTokenAmount, sellTokenWithPrices.decimals))
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Container p="md">
|
||||
<Modal
|
||||
className="tw-modal__swap-widget"
|
||||
size="compact"
|
||||
title="Select Token"
|
||||
open={!!modalState}
|
||||
crossContainerStyles={{
|
||||
right: spacing.md,
|
||||
top: spacing["md+"],
|
||||
transform: "none",
|
||||
}}
|
||||
setOpen={(v) => {
|
||||
if (!v) {
|
||||
setModalState(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "select-buy-token" && (
|
||||
<SelectToken
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
onBack={() => setModalState(undefined)}
|
||||
client={props.client}
|
||||
selectedToken={props.buyToken}
|
||||
setSelectedToken={(token) => {
|
||||
props.setBuyToken(token);
|
||||
setModalState(undefined);
|
||||
// if buy token is same as sell token, unset sell token
|
||||
if (
|
||||
props.sellToken &&
|
||||
token.tokenAddress.toLowerCase() ===
|
||||
props.sellToken.tokenAddress.toLowerCase() &&
|
||||
token.chainId === props.sellToken.chainId
|
||||
) {
|
||||
props.setSellToken(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalState === "select-sell-token" && (
|
||||
<SelectToken
|
||||
onBack={() => setModalState(undefined)}
|
||||
client={props.client}
|
||||
selectedToken={props.sellToken}
|
||||
setSelectedToken={(token) => {
|
||||
props.setSellToken(token);
|
||||
setModalState(undefined);
|
||||
// if sell token is same as buy token, unset buy token
|
||||
if (
|
||||
props.buyToken &&
|
||||
token.tokenAddress.toLowerCase() ===
|
||||
props.buyToken.tokenAddress.toLowerCase() &&
|
||||
token.chainId === props.buyToken.chainId
|
||||
) {
|
||||
props.setBuyToken(undefined);
|
||||
}
|
||||
}}
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Sell */}
|
||||
<TokenSection
|
||||
isConnected={!!props.activeWalletInfo}
|
||||
notEnoughBalance={notEnoughBalance}
|
||||
balance={{
|
||||
data: sellTokenBalanceQuery.data?.value,
|
||||
isFetching: sellTokenBalanceQuery.isFetching,
|
||||
}}
|
||||
amount={{
|
||||
data: sellTokenAmount,
|
||||
isFetching: isSellAmountFetching,
|
||||
}}
|
||||
label="Sell"
|
||||
setAmount={(value) => {
|
||||
props.setAmountSelection({ type: "sell", amount: value });
|
||||
}}
|
||||
selectedToken={
|
||||
props.sellToken
|
||||
? {
|
||||
data: sellTokenQuery.data,
|
||||
isFetching: sellTokenQuery.isFetching,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
client={props.client}
|
||||
currency={props.currency}
|
||||
onSelectToken={() => setModalState("select-sell-token")}
|
||||
/>
|
||||
|
||||
{/* Switch */}
|
||||
<SwitchButton
|
||||
onClick={() => {
|
||||
// switch tokens
|
||||
const temp = props.sellToken;
|
||||
props.setSellToken(props.buyToken);
|
||||
props.setBuyToken(temp);
|
||||
props.setAmountSelection({
|
||||
type: props.amountSelection.type === "buy" ? "sell" : "buy",
|
||||
amount: props.amountSelection.amount,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Buy */}
|
||||
<TokenSection
|
||||
isConnected={!!props.activeWalletInfo}
|
||||
notEnoughBalance={false}
|
||||
balance={{
|
||||
data: buyTokenBalanceQuery.data?.value,
|
||||
isFetching: buyTokenBalanceQuery.isFetching,
|
||||
}}
|
||||
amount={{
|
||||
data: buyTokenAmount,
|
||||
isFetching: isBuyAmountFetching,
|
||||
}}
|
||||
label="Buy"
|
||||
selectedToken={
|
||||
props.buyToken
|
||||
? {
|
||||
data: buyTokenQuery.data,
|
||||
isFetching: buyTokenQuery.isFetching,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setAmount={(value) => {
|
||||
props.setAmountSelection({ type: "buy", amount: value });
|
||||
}}
|
||||
client={props.client}
|
||||
currency={props.currency}
|
||||
onSelectToken={() => setModalState("select-buy-token")}
|
||||
/>
|
||||
|
||||
{/* error message */}
|
||||
{preparedResultQuery.error ? (
|
||||
<Text
|
||||
size="sm"
|
||||
color="danger"
|
||||
center
|
||||
style={{
|
||||
paddingBlock: spacing.md,
|
||||
}}
|
||||
>
|
||||
Failed to get a quote
|
||||
</Text>
|
||||
) : (
|
||||
<Spacer y="lg" />
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
{!props.activeWalletInfo ? (
|
||||
<ConnectButton
|
||||
client={props.client}
|
||||
connectButton={{
|
||||
label: "Swap",
|
||||
style: {
|
||||
width: "100%",
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
}}
|
||||
theme={props.theme}
|
||||
{...props.connectOptions}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={
|
||||
!preparedResultQuery.data ||
|
||||
preparedResultQuery.isFetching ||
|
||||
notEnoughBalance
|
||||
}
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (
|
||||
preparedResultQuery.data &&
|
||||
buyTokenWithPrices &&
|
||||
sellTokenWithPrices &&
|
||||
sellTokenBalanceQuery.data &&
|
||||
preparedResultQuery.data.type === "preparedResult"
|
||||
) {
|
||||
props.onSwap({
|
||||
result: preparedResultQuery.data.result,
|
||||
request: preparedResultQuery.data.request,
|
||||
buyToken: buyTokenWithPrices,
|
||||
sellToken: sellTokenWithPrices,
|
||||
sellTokenBalance: sellTokenBalanceQuery.data.value,
|
||||
mode: props.amountSelection.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontSize: fontSize.md,
|
||||
padding: `${spacing.sm} ${spacing.md}`,
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Swap
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{props.showThirdwebBranding ? (
|
||||
<div>
|
||||
<Spacer y="md" />
|
||||
<PoweredByThirdweb link="https://thirdweb.com/monetize/bridge" />
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function useSwapQuote(params: {
|
||||
amountSelection: {
|
||||
type: "buy" | "sell";
|
||||
amount: string;
|
||||
};
|
||||
buyTokenWithPrices: TokenWithPrices | undefined;
|
||||
sellTokenWithPrices: TokenWithPrices | undefined;
|
||||
activeWalletInfo: ActiveWalletInfo | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const {
|
||||
amountSelection,
|
||||
buyTokenWithPrices,
|
||||
sellTokenWithPrices,
|
||||
activeWalletInfo,
|
||||
client,
|
||||
} = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"swap-quote",
|
||||
amountSelection,
|
||||
buyTokenWithPrices,
|
||||
sellTokenWithPrices,
|
||||
activeWalletInfo?.activeAccount.address,
|
||||
],
|
||||
retry: false,
|
||||
enabled:
|
||||
!!buyTokenWithPrices && !!sellTokenWithPrices && !!amountSelection.amount,
|
||||
queryFn: async (): Promise<
|
||||
| {
|
||||
type: "preparedResult";
|
||||
result: SwapPreparedQuote;
|
||||
request: Extract<BridgePrepareRequest, { type: "buy" | "sell" }>;
|
||||
}
|
||||
| {
|
||||
type: "quote";
|
||||
result: Buy.quote.Result | Sell.quote.Result;
|
||||
}
|
||||
> => {
|
||||
if (
|
||||
!buyTokenWithPrices ||
|
||||
!sellTokenWithPrices ||
|
||||
!amountSelection.amount
|
||||
) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
|
||||
if (!activeWalletInfo) {
|
||||
if (amountSelection.type === "buy") {
|
||||
const res = await Buy.quote({
|
||||
amount: toUnits(
|
||||
amountSelection.amount,
|
||||
buyTokenWithPrices.decimals,
|
||||
),
|
||||
// origin = sell
|
||||
originChainId: sellTokenWithPrices.chainId,
|
||||
originTokenAddress: sellTokenWithPrices.address,
|
||||
// destination = buy
|
||||
destinationChainId: buyTokenWithPrices.chainId,
|
||||
destinationTokenAddress: buyTokenWithPrices.address,
|
||||
client: client,
|
||||
});
|
||||
|
||||
return {
|
||||
type: "quote",
|
||||
result: res,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await Sell.quote({
|
||||
amount: toUnits(amountSelection.amount, sellTokenWithPrices.decimals),
|
||||
// origin = sell
|
||||
originChainId: sellTokenWithPrices.chainId,
|
||||
originTokenAddress: sellTokenWithPrices.address,
|
||||
// destination = buy
|
||||
destinationChainId: buyTokenWithPrices.chainId,
|
||||
destinationTokenAddress: buyTokenWithPrices.address,
|
||||
client: client,
|
||||
});
|
||||
|
||||
return {
|
||||
type: "quote",
|
||||
result: res,
|
||||
};
|
||||
}
|
||||
|
||||
if (amountSelection.type === "buy") {
|
||||
const buyRequestOptions: BuyPrepare.Options = {
|
||||
amount: toUnits(amountSelection.amount, buyTokenWithPrices.decimals),
|
||||
// origin = sell
|
||||
originChainId: sellTokenWithPrices.chainId,
|
||||
originTokenAddress: sellTokenWithPrices.address,
|
||||
// destination = buy
|
||||
destinationChainId: buyTokenWithPrices.chainId,
|
||||
destinationTokenAddress: buyTokenWithPrices.address,
|
||||
client: client,
|
||||
receiver: activeWalletInfo.activeAccount.address,
|
||||
sender: activeWalletInfo.activeAccount.address,
|
||||
};
|
||||
|
||||
const buyRequest: BridgePrepareRequest = {
|
||||
type: "buy",
|
||||
...buyRequestOptions,
|
||||
};
|
||||
|
||||
const res = await Buy.prepare(buyRequest);
|
||||
|
||||
return {
|
||||
type: "preparedResult",
|
||||
result: { type: "buy", ...res },
|
||||
request: buyRequest,
|
||||
};
|
||||
} else if (amountSelection.type === "sell") {
|
||||
const sellRequestOptions: SellPrepare.Options = {
|
||||
amount: toUnits(amountSelection.amount, sellTokenWithPrices.decimals),
|
||||
// origin = sell
|
||||
originChainId: sellTokenWithPrices.chainId,
|
||||
originTokenAddress: sellTokenWithPrices.address,
|
||||
// destination = buy
|
||||
destinationChainId: buyTokenWithPrices.chainId,
|
||||
destinationTokenAddress: buyTokenWithPrices.address,
|
||||
client: client,
|
||||
receiver: activeWalletInfo.activeAccount.address,
|
||||
sender: activeWalletInfo.activeAccount.address,
|
||||
};
|
||||
|
||||
const res = await Sell.prepare(sellRequestOptions);
|
||||
|
||||
const sellRequest: BridgePrepareRequest = {
|
||||
type: "sell",
|
||||
...sellRequestOptions,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "preparedResult",
|
||||
result: { type: "sell", ...res },
|
||||
request: sellRequest,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Invalid amount selection type");
|
||||
},
|
||||
refetchInterval: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
function DecimalInput(props: {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}) {
|
||||
const handleAmountChange = (inputValue: string) => {
|
||||
let processedValue = inputValue;
|
||||
|
||||
// Replace comma with period if it exists
|
||||
processedValue = processedValue.replace(",", ".");
|
||||
|
||||
if (processedValue.startsWith(".")) {
|
||||
processedValue = `0${processedValue}`;
|
||||
}
|
||||
|
||||
const numValue = Number(processedValue);
|
||||
if (Number.isNaN(numValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedValue.startsWith("0") && !processedValue.startsWith("0.")) {
|
||||
props.setValue(processedValue.slice(1));
|
||||
} else {
|
||||
props.setValue(processedValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
handleAmountChange(e.target.value);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// put cursor at the end of the input
|
||||
if (props.value === "") {
|
||||
e.currentTarget.setSelectionRange(
|
||||
e.currentTarget.value.length,
|
||||
e.currentTarget.value.length,
|
||||
);
|
||||
}
|
||||
}}
|
||||
pattern="^[0-9]*[.,]?[0-9]*$"
|
||||
placeholder="0.0"
|
||||
style={{
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
fontSize: fontSize.xxl,
|
||||
fontWeight: 500,
|
||||
paddingInline: 0,
|
||||
paddingBlock: 0,
|
||||
}}
|
||||
type="text"
|
||||
value={props.value}
|
||||
variant="transparent"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenSection(props: {
|
||||
label: string;
|
||||
notEnoughBalance: boolean;
|
||||
amount: {
|
||||
data: string;
|
||||
isFetching: boolean;
|
||||
};
|
||||
setAmount: (amount: string) => void;
|
||||
selectedToken:
|
||||
| {
|
||||
data: TokenWithPrices | undefined;
|
||||
isFetching: boolean;
|
||||
}
|
||||
| undefined;
|
||||
currency: SupportedFiatCurrency;
|
||||
onSelectToken: () => void;
|
||||
client: ThirdwebClient;
|
||||
isConnected: boolean;
|
||||
balance: {
|
||||
data: bigint | undefined;
|
||||
isFetching: boolean;
|
||||
};
|
||||
}) {
|
||||
const chainQuery = useBridgeChains(props.client);
|
||||
const chain = chainQuery.data?.find(
|
||||
(chain) => chain.chainId === props.selectedToken?.data?.chainId,
|
||||
);
|
||||
|
||||
const fiatPricePerToken = props.selectedToken?.data?.prices[props.currency];
|
||||
const totalFiatValue = !props.amount.data
|
||||
? undefined
|
||||
: fiatPricePerToken
|
||||
? fiatPricePerToken * Number(props.amount.data)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Container
|
||||
p="sm"
|
||||
borderColor="borderColor"
|
||||
bg="tertiaryBg"
|
||||
style={{ borderRadius: radius.lg, borderWidth: 1, borderStyle: "solid" }}
|
||||
>
|
||||
{/* row1 : label */}
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.label}
|
||||
</Text>
|
||||
|
||||
{/* row2 : amount and select token */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton
|
||||
height={fontSize.xxl}
|
||||
width="140px"
|
||||
style={{
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DecimalInput value={props.amount.data} setValue={props.setAmount} />
|
||||
)}
|
||||
|
||||
{!props.selectedToken ? (
|
||||
<Button
|
||||
variant="accent"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xxs,
|
||||
paddingBlock: spacing.sm,
|
||||
paddingInline: spacing.sm,
|
||||
}}
|
||||
onClick={props.onSelectToken}
|
||||
>
|
||||
{" "}
|
||||
Select Token
|
||||
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
|
||||
</Button>
|
||||
) : (
|
||||
<SelectedTokenButton
|
||||
selectedToken={props.selectedToken}
|
||||
client={props.client}
|
||||
onSelectToken={props.onSelectToken}
|
||||
chain={chain}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* row3 : fiat value/error and balance */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* Exceeds Balance / Fiat Value */}
|
||||
{props.notEnoughBalance ? (
|
||||
<Text size="md" color="danger" weight={500}>
|
||||
{" "}
|
||||
Exceeds Balance{" "}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText" weight={500}>
|
||||
{getFiatSymbol(props.currency)}
|
||||
</Text>
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton height={fontSize.md} width="50px" />
|
||||
) : (
|
||||
<div>
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
value={totalFiatValue?.toFixed(5) || "0.00"}
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance */}
|
||||
{props.isConnected && props.selectedToken && (
|
||||
<div>
|
||||
{props.balance.data === undefined ||
|
||||
props.selectedToken.data === undefined ? (
|
||||
<Skeleton height={fontSize.md} width="50px" />
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.xxs,
|
||||
}}
|
||||
>
|
||||
<WalletDotIcon size={fontSize.xs} />
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
value={formatTokenAmount(
|
||||
props.balance.data,
|
||||
props.selectedToken.data.decimals,
|
||||
5,
|
||||
)}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedTokenButton(props: {
|
||||
selectedToken:
|
||||
| {
|
||||
data: TokenWithPrices | undefined;
|
||||
isFetching: boolean;
|
||||
}
|
||||
| undefined;
|
||||
client: ThirdwebClient;
|
||||
onSelectToken: () => void;
|
||||
chain: BridgeChain | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
bg="secondaryButtonBg"
|
||||
onClick={props.onSelectToken}
|
||||
gap="xs"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
paddingBlock: spacing.xxs,
|
||||
paddingInline: spacing.xs,
|
||||
}}
|
||||
>
|
||||
{/* icons */}
|
||||
<Container relative color="secondaryText">
|
||||
{/* token icon */}
|
||||
<Img
|
||||
key={props.selectedToken?.data?.iconUri}
|
||||
src={
|
||||
props.selectedToken?.data === undefined
|
||||
? undefined
|
||||
: props.selectedToken.data.iconUri || ""
|
||||
}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
skeletonColor="modalBg"
|
||||
fallback={<DiscIcon width={iconSize.lg} height={iconSize.lg} />}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* chain icon */}
|
||||
<Container
|
||||
bg="modalBg"
|
||||
style={{
|
||||
padding: "2px",
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
display: "flex",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={props.chain?.icon}
|
||||
client={props.client}
|
||||
width={iconSize.sm}
|
||||
height={iconSize.sm}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
{/* token symbol and chain name */}
|
||||
<Container flex="column" style={{ gap: "2px" }}>
|
||||
{props.selectedToken?.isFetching ? (
|
||||
<Skeleton width="40px" height={fontSize.sm} color="modalBg" />
|
||||
) : (
|
||||
<Text size="sm" color="primaryText" weight={500}>
|
||||
{props.selectedToken?.data?.symbol}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{props.chain ? (
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton width="70px" height={fontSize.xs} color="modalBg" />
|
||||
)}
|
||||
</Container>
|
||||
<Container color="secondaryText">
|
||||
<ChevronRightIcon width={iconSize.xs} height={iconSize.xs} />
|
||||
</Container>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchButton(props: { onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBlock: `-14px`,
|
||||
}}
|
||||
>
|
||||
<SwitchButtonInner
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
props.onClick();
|
||||
const node = e.currentTarget.querySelector("svg");
|
||||
if (node) {
|
||||
node.style.transform = "rotate(180deg)";
|
||||
node.style.transition = "transform 300ms ease";
|
||||
setTimeout(() => {
|
||||
node.style.transition = "";
|
||||
node.style.transform = "rotate(0deg)";
|
||||
}, 300);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUpDownIcon size={iconSize.md} />
|
||||
</SwitchButtonInner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.modalBg,
|
||||
},
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.xs,
|
||||
background: theme.colors.modalBg,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
};
|
||||
});
|
||||
|
||||
function useTokenBalance(props: {
|
||||
chainId: number | undefined;
|
||||
tokenAddress: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
walletAddress: string | undefined;
|
||||
}) {
|
||||
return useWalletBalance({
|
||||
address: props.walletAddress,
|
||||
chain: props.chainId ? defineChain(props.chainId) : undefined,
|
||||
client: props.client,
|
||||
tokenAddress: props.tokenAddress
|
||||
? getAddress(props.tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS)
|
||||
? undefined
|
||||
: getAddress(props.tokenAddress)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Chain } from "../../../../../chains/types.js";
|
||||
import type {
|
||||
Account,
|
||||
Wallet,
|
||||
} from "../../../../../wallets/interfaces/wallet.js";
|
||||
import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js";
|
||||
import type { AppMetadata } from "../../../../../wallets/types.js";
|
||||
import type { SiweAuthOptions } from "../../../../core/hooks/auth/useSiweAuth.js";
|
||||
import type { ConnectButton_connectModalOptions } from "../../../../core/hooks/connection/ConnectButtonProps.js";
|
||||
import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js";
|
||||
|
||||
/**
|
||||
* Connection options for the `SwapWidget` component
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SwapWidget client={client} connectOptions={{
|
||||
* connectModal: {
|
||||
* size: 'compact',
|
||||
* title: "Sign in",
|
||||
* }
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export type SwapWidgetConnectOptions = {
|
||||
/**
|
||||
* Configurations for the `ConnectButton`'s Modal that is shown for connecting a wallet
|
||||
* Refer to the [`ConnectButton_connectModalOptions`](https://portal.thirdweb.com/references/typescript/v5/ConnectButton_connectModalOptions) type for more details
|
||||
*/
|
||||
connectModal?: ConnectButton_connectModalOptions;
|
||||
|
||||
/**
|
||||
* Configure options for WalletConnect
|
||||
*
|
||||
* By default WalletConnect uses the thirdweb's default project id.
|
||||
* Setting your own project id is recommended.
|
||||
*
|
||||
* You can create a project id by signing up on [walletconnect.com](https://walletconnect.com/)
|
||||
*/
|
||||
walletConnect?: {
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options.
|
||||
*
|
||||
* This allows to sponsor gas fees for your user's transaction using the thirdweb account abstraction infrastructure.
|
||||
*
|
||||
*/
|
||||
accountAbstraction?: SmartWalletOptions;
|
||||
|
||||
/**
|
||||
* Array of wallets to show in Connect Modal. If not provided, default wallets will be used.
|
||||
*/
|
||||
wallets?: Wallet[];
|
||||
/**
|
||||
* When the user has connected their wallet to your site, this configuration determines whether or not you want to automatically connect to the last connected wallet when user visits your site again in the future.
|
||||
*
|
||||
* By default it is set to `{ timeout: 15000 }` meaning that autoConnect is enabled and if the autoConnection does not succeed within 15 seconds, it will be cancelled.
|
||||
*
|
||||
* If you want to disable autoConnect, set this prop to `false`.
|
||||
*
|
||||
* If you want to customize the timeout, you can assign an object with a `timeout` key to this prop.
|
||||
*/
|
||||
autoConnect?:
|
||||
| {
|
||||
timeout: number;
|
||||
}
|
||||
| boolean;
|
||||
|
||||
/**
|
||||
* Metadata of the app that will be passed to connected wallet. Setting this is highly recommended.
|
||||
*/
|
||||
appMetadata?: AppMetadata;
|
||||
|
||||
/**
|
||||
* The [`Chain`](https://portal.thirdweb.com/references/typescript/v5/Chain) object of the blockchain you want the wallet to connect to
|
||||
*
|
||||
* If a `chain` is not specified, Wallet will be connected to whatever is the default set in the wallet.
|
||||
*
|
||||
* If a `chain` is specified, Wallet will be prompted to switch to given chain after connection if it is not already connected to it.
|
||||
* This ensures that the wallet is connected to the correct blockchain before interacting with your app.
|
||||
*
|
||||
* The `ConnectButton` also shows a "Switch Network" button until the wallet is connected to the specified chain. Clicking on the "Switch Network" button triggers the wallet to switch to the specified chain.
|
||||
*
|
||||
* You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function.
|
||||
* At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object.
|
||||
* ```
|
||||
*/
|
||||
chain?: Chain;
|
||||
|
||||
/**
|
||||
* Array of chains that your app supports.
|
||||
*
|
||||
* This is only relevant if your app is a multi-chain app and works across multiple blockchains.
|
||||
* If your app only works on a single blockchain, you should only specify the `chain` prop.
|
||||
*
|
||||
* Given list of chains will used in various ways:
|
||||
* - They will be displayed in the network selector in the `ConnectButton`'s details modal post connection
|
||||
* - They will be sent to wallet at the time of connection if the wallet supports requesting multiple chains ( example: WalletConnect ) so that users can switch between the chains post connection easily
|
||||
*
|
||||
* You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function.
|
||||
* At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object.
|
||||
*
|
||||
* ```tsx
|
||||
* import { defineChain } from "thirdweb/react";
|
||||
*
|
||||
* const polygon = defineChain({
|
||||
* id: 137,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
chains?: Chain[];
|
||||
|
||||
/**
|
||||
* Wallets to show as recommended in the `ConnectButton`'s Modal
|
||||
*/
|
||||
recommendedWallets?: Wallet[];
|
||||
|
||||
/**
|
||||
* By default, ConnectButton modal shows a "All Wallets" button that shows a list of 500+ wallets.
|
||||
*
|
||||
* You can disable this button by setting `showAllWallets` prop to `false`
|
||||
*/
|
||||
showAllWallets?: boolean;
|
||||
|
||||
/**
|
||||
* Enable SIWE (Sign in with Ethererum) by passing an object of type `SiweAuthOptions` to
|
||||
* enforce the users to sign a message after connecting their wallet to authenticate themselves.
|
||||
*
|
||||
* Refer to the [`SiweAuthOptions`](https://portal.thirdweb.com/references/typescript/v5/SiweAuthOptions) for more details
|
||||
*/
|
||||
auth?: SiweAuthOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type ActiveWalletInfo = {
|
||||
activeChain: Chain;
|
||||
activeWallet: Wallet;
|
||||
activeAccount: Account;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type TokenSelection = {
|
||||
tokenAddress: string;
|
||||
chainId: number;
|
||||
};
|
||||
|
||||
export type SwapPreparedQuote = Extract<
|
||||
BridgePrepareResult,
|
||||
{ type: "buy" | "sell" }
|
||||
>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { chains } from "../../../../../bridge/index.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
|
||||
export function useBridgeChains(client: ThirdwebClient) {
|
||||
return useQuery({
|
||||
queryKey: ["bridge-chains"],
|
||||
queryFn: () => {
|
||||
return chains({ client });
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Token } from "../../../../../bridge/index.js";
|
||||
import { tokens } from "../../../../../bridge/Token.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { isAddress } from "../../../../../utils/address.js";
|
||||
import { getThirdwebBaseUrl } from "../../../../../utils/domains.js";
|
||||
|
||||
export function useTokens(options: {
|
||||
client: ThirdwebClient;
|
||||
chainId?: number;
|
||||
search?: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}) {
|
||||
return useQuery<Token[]>({
|
||||
queryKey: ["tokens", options],
|
||||
enabled: !!options.chainId,
|
||||
queryFn: () => {
|
||||
if (!options.chainId) {
|
||||
throw new Error("Chain ID is required");
|
||||
}
|
||||
|
||||
const isSearchAddress = options.search
|
||||
? isAddress(options.search)
|
||||
: false;
|
||||
|
||||
return tokens({
|
||||
chainId: options.chainId,
|
||||
client: options.client,
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
includePrices: false,
|
||||
name: !options.search || isSearchAddress ? undefined : options.search,
|
||||
tokenAddress: isSearchAddress ? options.search : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type TokenBalance = {
|
||||
balance: string;
|
||||
chain_id: number;
|
||||
decimals: number;
|
||||
name: string;
|
||||
icon_uri: string;
|
||||
price_data: {
|
||||
circulating_supply: number;
|
||||
market_cap_usd: number;
|
||||
percent_change_24h: number;
|
||||
price_timestamp: string;
|
||||
price_usd: number;
|
||||
total_supply: number;
|
||||
usd_value: number;
|
||||
volume_24h_usd: number;
|
||||
};
|
||||
symbol: string;
|
||||
token_address: string;
|
||||
};
|
||||
|
||||
type TokenBalancesResponse = {
|
||||
result: {
|
||||
pagination: {
|
||||
hasMore: boolean;
|
||||
limit: number;
|
||||
page: number;
|
||||
totalCount: number;
|
||||
};
|
||||
tokens: TokenBalance[];
|
||||
};
|
||||
};
|
||||
|
||||
export function useTokenBalances(options: {
|
||||
clientId: string;
|
||||
page: number;
|
||||
limit: number;
|
||||
walletAddress: string | undefined;
|
||||
chainId: number | undefined;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ["bridge/v1/wallets", options],
|
||||
enabled: !!options.chainId && !!options.walletAddress,
|
||||
queryFn: async () => {
|
||||
if (!options.chainId || !options.walletAddress) {
|
||||
throw new Error("invalid options");
|
||||
}
|
||||
const baseUrl = getThirdwebBaseUrl("bridge");
|
||||
const isDev = baseUrl.includes("thirdweb-dev");
|
||||
const url = new URL(
|
||||
`https://api.${isDev ? "thirdweb-dev" : "thirdweb"}.com/v1/wallets/${options.walletAddress}/tokens`,
|
||||
);
|
||||
url.searchParams.set("chainId", options.chainId.toString());
|
||||
url.searchParams.set("limit", options.limit.toString());
|
||||
url.searchParams.set("page", options.page.toString());
|
||||
url.searchParams.set("metadata", "true");
|
||||
url.searchParams.set("resolveMetadataLinks", "true");
|
||||
url.searchParams.set("includeSpam", "false");
|
||||
url.searchParams.set("includeNative", "true");
|
||||
url.searchParams.set("sortBy", "usd_value");
|
||||
url.searchParams.set("sortOrder", "desc");
|
||||
url.searchParams.set("includeWithoutPrice", "false"); // filter out tokens with no price
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
"x-client-id": options.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch token balances: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TokenBalancesResponse;
|
||||
return json.result;
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cleanedChainName(name: string) {
|
||||
return name.replace("Mainnet", "");
|
||||
}
|
||||
@@ -161,6 +161,19 @@ const TW_CONNECT_WALLET = "tw-connect-wallet";
|
||||
*
|
||||
* [View all available themes properties](https://portal.thirdweb.com/references/typescript/v5/Theme)
|
||||
*
|
||||
* ### Overriding styles using class names
|
||||
*
|
||||
* Some elements in this component have classes with a `tw-` prefix.
|
||||
* You can target these classes in your own CSS stylesheet to override their styles.
|
||||
*
|
||||
* In some cases, you may need to use the `!important` flag for the override to take effect. Do not use on auto-generated class names, as they may change between builds.
|
||||
*
|
||||
* ```css
|
||||
* .tw-back-button {
|
||||
* background-color: red !important;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Changing the display language
|
||||
*
|
||||
* ```tsx
|
||||
@@ -543,6 +556,8 @@ function ConnectButtonInner(
|
||||
)}
|
||||
</Button>
|
||||
<Modal
|
||||
className="tw-modal__sign-in"
|
||||
title="Sign in"
|
||||
open={showSignatureModal}
|
||||
setOpen={setShowSignatureModal}
|
||||
size="compact"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user