diff --git a/.changeset/great-rockets-scream.md b/.changeset/great-rockets-scream.md new file mode 100644 index 00000000000..8b9f3f7dcf2 --- /dev/null +++ b/.changeset/great-rockets-scream.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/react": patch +--- + +thirdweb Pay UI minor improvements diff --git a/.changeset/little-coins-thank.md b/.changeset/little-coins-thank.md new file mode 100644 index 00000000000..10e38f216d6 --- /dev/null +++ b/.changeset/little-coins-thank.md @@ -0,0 +1,50 @@ +--- +"thirdweb": minor +--- + +### "Credit Card" payment method added in thirdweb Pay for Fiat on-ramp + +### `PayEmbed` component added to embed thirdweb Pay UI + +```tsx + +``` + +### thirdweb Pay UI customization available in `PayEmbed` and `ConnectButton` + +`payOptions` prop in `PayEmbed` and `ConnectButton > detailsModal` allows you custimize : + +- Enable/Disable payment methods +- Set default amount for Buy token +- Set Buy token/chain to be selected by default +- Set Source token/chain to be selected by default for Crypto payment method +- Disable editing for Buy token/chain/amount and Source token/chain + +```tsx + + + +``` + +### Fiat on-ramp functions and hooks added + +- `getBuyWithFiatQuote`, `useBuyWithFiatQuote` to get a quote for buying crypto with fiat currency +- `getBuyWithFiatStatus`, `useBuyWithFiatStatus` to get status of "Buy with fiat" transaction +- `getBuyWithFiatHistory`, `useBuyWithFiatHistory` to get "Buy with fiat" transaction history +- `getPostOnRampQuote`, `usePostOnRampQuote` to get quote for swapping on-ramp token to destination token after doing on-ramp +- Add `getBuyHistory` and `useBuyHistory` to get both "Buy with Fiat" and "Buy with Crypto" transaction history in a single list diff --git a/.vscode/settings.json b/.vscode/settings.json index a8bae8a1cab..4c3d31af01a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,11 +5,15 @@ "quickfix.biome": "explicit", "source.organizeImports.biome": "explicit" }, - "typescript.preferences.importModuleSpecifier": "relative", "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.preferences.autoImportFileExcludePatterns": ["/exports"], + "typescript.preferences.importModuleSpecifier": "relative" } diff --git a/legacy_packages/react/src/wallet/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts b/legacy_packages/react/src/wallet/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts index 52532b87adc..3000fc136b3 100644 --- a/legacy_packages/react/src/wallet/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts +++ b/legacy_packages/react/src/wallet/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts @@ -1,11 +1,12 @@ -import { getBuyWithCryptoStatus } from "@thirdweb-dev/sdk"; -import type { BuyWithCryptoStatus } from "@thirdweb-dev/sdk"; +import { getBuyWithCryptoStatus, type BuyWithCryptoStatus } from "@thirdweb-dev/sdk"; import { wait } from "../../../../../utils/wait"; +type ValidBuyWithCryptoStatus = Exclude + type SwapTxInfo = { transactionHash: string; - status: BuyWithCryptoStatus["status"]; - subStatus?: BuyWithCryptoStatus["subStatus"]; + status: ValidBuyWithCryptoStatus["status"]; + subStatus?: ValidBuyWithCryptoStatus["subStatus"]; source: { symbol: string; value: string; diff --git a/legacy_packages/react/src/wallet/ConnectWallet/screens/SwapTransactionsScreen.tsx b/legacy_packages/react/src/wallet/ConnectWallet/screens/SwapTransactionsScreen.tsx index 626361eeee2..bfdeddc07cc 100644 --- a/legacy_packages/react/src/wallet/ConnectWallet/screens/SwapTransactionsScreen.tsx +++ b/legacy_packages/react/src/wallet/ConnectWallet/screens/SwapTransactionsScreen.tsx @@ -24,13 +24,16 @@ import { BuyIcon } from "../icons/BuyIcon"; import { Text } from "../../../components/text"; import { CryptoIcon } from "../icons/CryptoIcon"; +type ValidBuyWithCryptoStatus = Exclude + + type TxStatusInfo = { boughChainId: number; transactionHash: string; boughtTokenAmount: string; boughtTokenSymbol: string; - status: BuyWithCryptoStatus["status"]; - subStatus?: BuyWithCryptoStatus["subStatus"]; + status: ValidBuyWithCryptoStatus["status"]; + subStatus?: ValidBuyWithCryptoStatus["subStatus"]; }; const PAGE_SIZE = 10; @@ -51,7 +54,11 @@ export function SwapTransactionsScreen(props: { onBack: () => void }) { const txHashSet = new Set(); _historyQuery.data?.page.forEach((tx) => { - txHashSet.add(tx.source.transactionHash); + if (tx.status !== "NOT_FOUND" && tx.status !== 'NONE') { + if (tx.source?.transactionHash) { + txHashSet.add(tx.source?.transactionHash); + } + } }); // add in-memory pending transactions @@ -76,15 +83,20 @@ export function SwapTransactionsScreen(props: { onBack: () => void }) { // Add data from endpoint _historyQuery.data?.page.forEach((tx) => { - txInfosToShow.push({ - boughChainId: tx.destination?.token.chainId || tx.quote.toToken.chainId, - transactionHash: tx.source.transactionHash, - boughtTokenAmount: tx.destination?.amount || tx.quote.toAmount, - boughtTokenSymbol: - tx.destination?.token.symbol || tx.quote.toToken.symbol || "", - status: tx.status, - subStatus: tx.subStatus, - }); + if (tx.status !== "NOT_FOUND" && tx.status !== 'NONE') { + if (tx.source?.transactionHash) { + txInfosToShow.push({ + boughChainId: + tx.destination?.token.chainId || tx.quote.toToken.chainId, + transactionHash: tx.source?.transactionHash, + boughtTokenAmount: tx.destination?.amount || tx.quote.toAmount, + boughtTokenSymbol: + tx.destination?.token.symbol || tx.quote.toToken.symbol || "", + status: tx.status, + subStatus: tx.subStatus, + }); + } + } }); const activeChainId = useChainId(); @@ -367,8 +379,8 @@ const TxHashLink = /* @__PURE__ */ StyledAnchor(() => { }); function getStatusMeta( - status: BuyWithCryptoStatus["status"], - subStatus?: BuyWithCryptoStatus["subStatus"], + status: ValidBuyWithCryptoStatus["status"], + subStatus?: ValidBuyWithCryptoStatus["subStatus"], ) { if (subStatus === "WAITING_BRIDGE") { return { diff --git a/packages/thirdweb/src/exports/pay.ts b/packages/thirdweb/src/exports/pay.ts index 6b485a1eed0..c8b463af4b9 100644 --- a/packages/thirdweb/src/exports/pay.ts +++ b/packages/thirdweb/src/exports/pay.ts @@ -4,17 +4,56 @@ export { type QuoteApprovalParams, type QuoteTokenInfo, type GetBuyWithCryptoQuoteParams, -} from "../pay/buyWithCrypto/actions/getQuote.js"; +} from "../pay/buyWithCrypto/getQuote.js"; export { getBuyWithCryptoStatus, type BuyWithCryptoStatus, type BuyWithCryptoTransaction, - type BuyWithCryptoTransactionDetails, -} from "../pay/buyWithCrypto/actions/getStatus.js"; +} from "../pay/buyWithCrypto/getStatus.js"; export { getBuyWithCryptoHistory, type BuyWithCryptoHistoryData, type BuyWithCryptoHistoryParams, -} from "../pay/buyWithCrypto/actions/getHistory.js"; +} from "../pay/buyWithCrypto/getHistory.js"; + +// fiat ------------------------------------------------ + +export { + getBuyWithFiatQuote, + type BuyWithFiatQuote, + type GetBuyWithFiatQuoteParams, +} from "../pay/buyWithFiat/getQuote.js"; + +export { + getBuyWithFiatStatus, + type BuyWithFiatStatus, + type GetBuyWithFiatStatusParams, +} from "../pay/buyWithFiat/getStatus.js"; + +export { + getBuyWithFiatHistory, + type BuyWithFiatHistoryData, + type BuyWithFiatHistoryParams, +} from "../pay/buyWithFiat/getHistory.js"; + +export { + getPostOnRampQuote, + type GetPostOnRampQuoteParams, +} from "../pay/buyWithFiat/getPostOnRampQuote.js"; + +export { + getBuyHistory, + type BuyHistoryData, + type BuyHistoryParams, +} from "../pay/getBuyHistory.js"; + +export { isSwapRequiredPostOnramp } from "../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; + +// types ------------------------------------------------ + +export type { + PayTokenInfo, + PayOnChainTransactionDetails, +} from "../pay/utils/commonTypes.js"; diff --git a/packages/thirdweb/src/exports/react-native.ts b/packages/thirdweb/src/exports/react-native.ts index d530c9ceba3..c9208e6d23e 100644 --- a/packages/thirdweb/src/exports/react-native.ts +++ b/packages/thirdweb/src/exports/react-native.ts @@ -32,19 +32,33 @@ export { export { createContractQuery } from "../react/core/utils/createQuery.js"; export { useInvalidateContractQuery } from "../react/core/hooks/others/useInvalidateQueries.js"; -// Buy with crypto +// pay export { useBuyWithCryptoQuote, - type BuyWithCryptoQuoteQueryParams, + type BuyWithCryptoQuoteQueryOptions, } from "../react/core/hooks/pay/useBuyWithCryptoQuote.js"; -export { - useBuyWithCryptoStatus, - type BuyWithCryptoStatusQueryParams, -} from "../react/core/hooks/pay/useBuyWithCryptoStatus.js"; +export { useBuyWithCryptoStatus } from "../react/core/hooks/pay/useBuyWithCryptoStatus.js"; export { useBuyWithCryptoHistory, - type BuyWithCryptoHistoryQueryParams, + type BuyWithCryptoHistoryQueryOptions, } from "../react/core/hooks/pay/useBuyWithCryptoHistory.js"; +export { + useBuyWithFiatQuote, + type BuyWithFiatQuoteQueryOptions, +} from "../react/core/hooks/pay/useBuyWithFiatQuote.js"; +export { useBuyWithFiatStatus } from "../react/core/hooks/pay/useBuyWithFiatStatus.js"; +export { + useBuyWithFiatHistory, + type BuyWithFiatHistoryQueryOptions, +} from "../react/core/hooks/pay/useBuyWithFiatHistory.js"; +export { + useBuyHistory, + type BuyHistoryQueryOptions, +} from "../react/core/hooks/pay/useBuyHistory.js"; +export { + usePostOnRampQuote, + type PostOnRampQuoteQueryOptions, +} from "../react/core/hooks/pay/usePostOnrampQuote.js"; import { useSendTransactionCore } from "../react/core/hooks/contract/useSendTransaction.js"; diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index c83b80b056c..5606a641f27 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -80,20 +80,33 @@ export { export { createContractQuery } from "../react/core/utils/createQuery.js"; export { useInvalidateContractQuery } from "../react/core/hooks/others/useInvalidateQueries.js"; -// Buy with crypto +// pay export { useBuyWithCryptoQuote, - type BuyWithCryptoQuoteQueryParams, + type BuyWithCryptoQuoteQueryOptions, } from "../react/core/hooks/pay/useBuyWithCryptoQuote.js"; - -export { - useBuyWithCryptoStatus, - type BuyWithCryptoStatusQueryParams, -} from "../react/core/hooks/pay/useBuyWithCryptoStatus.js"; +export { useBuyWithCryptoStatus } from "../react/core/hooks/pay/useBuyWithCryptoStatus.js"; export { useBuyWithCryptoHistory, - type BuyWithCryptoHistoryQueryParams, + type BuyWithCryptoHistoryQueryOptions, } from "../react/core/hooks/pay/useBuyWithCryptoHistory.js"; +export { + useBuyWithFiatQuote, + type BuyWithFiatQuoteQueryOptions, +} from "../react/core/hooks/pay/useBuyWithFiatQuote.js"; +export { useBuyWithFiatStatus } from "../react/core/hooks/pay/useBuyWithFiatStatus.js"; +export { + useBuyWithFiatHistory, + type BuyWithFiatHistoryQueryOptions, +} from "../react/core/hooks/pay/useBuyWithFiatHistory.js"; +export { + useBuyHistory, + type BuyHistoryQueryOptions, +} from "../react/core/hooks/pay/useBuyHistory.js"; +export { + usePostOnRampQuote, + type PostOnRampQuoteQueryOptions, +} from "../react/core/hooks/pay/usePostOnrampQuote.js"; export { AutoConnect, @@ -102,3 +115,6 @@ export { // auth export { type SiweAuthOptions } from "../react/core/hooks/auth/useSiweAuth.js"; + +export { PayEmbed, type PayEmbedProps } from "../react/web/ui/PayEmbed.js"; +export type { PayUIOptions } from "../react/web/ui/ConnectWallet/ConnectButtonProps.js"; diff --git a/packages/thirdweb/src/exports/thirdweb.ts b/packages/thirdweb/src/exports/thirdweb.ts index c95fa95c319..bd7c6ffd3bd 100644 --- a/packages/thirdweb/src/exports/thirdweb.ts +++ b/packages/thirdweb/src/exports/thirdweb.ts @@ -156,20 +156,24 @@ export { type QuoteApprovalParams, type QuoteTokenInfo, type GetBuyWithCryptoQuoteParams, -} from "../pay/buyWithCrypto/actions/getQuote.js"; +} from "../pay/buyWithCrypto/getQuote.js"; export { getBuyWithCryptoStatus, type BuyWithCryptoStatus, type BuyWithCryptoTransaction, - type BuyWithCryptoTransactionDetails, -} from "../pay/buyWithCrypto/actions/getStatus.js"; +} from "../pay/buyWithCrypto/getStatus.js"; export { getBuyWithCryptoHistory, type BuyWithCryptoHistoryData, type BuyWithCryptoHistoryParams, -} from "../pay/buyWithCrypto/actions/getHistory.js"; +} from "../pay/buyWithCrypto/getHistory.js"; + +export type { + PayOnChainTransactionDetails, + PayTokenInfo, +} from "../pay/utils/commonTypes.js"; // ------------------------------------------------ // encoding diff --git a/packages/thirdweb/src/pay/buyWithCrypto/actions/getHistory.ts b/packages/thirdweb/src/pay/buyWithCrypto/getHistory.ts similarity index 96% rename from packages/thirdweb/src/pay/buyWithCrypto/actions/getHistory.ts rename to packages/thirdweb/src/pay/buyWithCrypto/getHistory.ts index bd242378a6f..a49a7fb7732 100644 --- a/packages/thirdweb/src/pay/buyWithCrypto/actions/getHistory.ts +++ b/packages/thirdweb/src/pay/buyWithCrypto/getHistory.ts @@ -1,5 +1,5 @@ -import type { ThirdwebClient } from "../../../client/client.js"; -import { getClientFetch } from "../../../utils/fetch.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { getClientFetch } from "../../utils/fetch.js"; import { getPayBuyWithCryptoHistoryEndpoint } from "../utils/definitions.js"; import type { BuyWithCryptoStatus } from "./getStatus.js"; diff --git a/packages/thirdweb/src/pay/buyWithCrypto/actions/getQuote.ts b/packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts similarity index 90% rename from packages/thirdweb/src/pay/buyWithCrypto/actions/getQuote.ts rename to packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts index 3bd36a86488..af447124ce5 100644 --- a/packages/thirdweb/src/pay/buyWithCrypto/actions/getQuote.ts +++ b/packages/thirdweb/src/pay/buyWithCrypto/getQuote.ts @@ -1,14 +1,14 @@ import type { Hash } from "viem"; -import { defineChain } from "../../../chains/utils.js"; -import type { ThirdwebClient } from "../../../client/client.js"; -import { getContract } from "../../../contract/contract.js"; +import { defineChain } from "../../chains/utils.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { getContract } from "../../contract/contract.js"; import { type ApproveParams, approve, -} from "../../../extensions/erc20/write/approve.js"; -import type { PrepareTransactionOptions } from "../../../transaction/prepare-transaction.js"; -import type { BaseTransactionOptions } from "../../../transaction/types.js"; -import { getClientFetch } from "../../../utils/fetch.js"; +} from "../../extensions/erc20/write/approve.js"; +import type { PrepareTransactionOptions } from "../../transaction/prepare-transaction.js"; +import type { BaseTransactionOptions } from "../../transaction/types.js"; +import { getClientFetch } from "../../utils/fetch.js"; import { getPayBuyWithCryptoQuoteEndpoint } from "../utils/definitions.js"; // TODO: add JSDoc description for all properties @@ -27,6 +27,15 @@ export type GetBuyWithCryptoQuoteParams = { */ client: ThirdwebClient; + /** + * This is only relevant if the buy-with-crypto transaction is part of buy-with-fiat flow. + * + * When a swap is required after an onramp transaction, the intentId is used to link the buy-with-crypto transaction to the onramp transaction. + * Refer to [`getPostOnRampQuote`](https://portal.thirdweb.com/references/typescript/v5/getPostOnRampQuote) for more information.` + * + */ + intentId?: string; + /** * The address of the wallet from which the tokens will be sent. */ @@ -240,6 +249,10 @@ export async function getBuyWithCryptoQuote( queryParams.append("maxSlippageBPS", params.maxSlippageBPS.toString()); } + if (params.intentId) { + queryParams.append("intentId", params.intentId); + } + const queryString = queryParams.toString(); const url = `${getPayBuyWithCryptoQuoteEndpoint()}?${queryString}`; diff --git a/packages/thirdweb/src/pay/buyWithCrypto/actions/getStatus.ts b/packages/thirdweb/src/pay/buyWithCrypto/getStatus.ts similarity index 66% rename from packages/thirdweb/src/pay/buyWithCrypto/actions/getStatus.ts rename to packages/thirdweb/src/pay/buyWithCrypto/getStatus.ts index 6ad90a9bc4e..9761d0ff980 100644 --- a/packages/thirdweb/src/pay/buyWithCrypto/actions/getStatus.ts +++ b/packages/thirdweb/src/pay/buyWithCrypto/getStatus.ts @@ -1,23 +1,16 @@ -import type { ThirdwebClient } from "../../../client/client.js"; -import { getClientFetch } from "../../../utils/fetch.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import { getClientFetch } from "../../utils/fetch.js"; +import type { + PayOnChainTransactionDetails, + PayTokenInfo, +} from "../utils/commonTypes.js"; import { getPayBuyWithCryptoStatusUrl } from "../utils/definitions.js"; -import type { QuoteTokenInfo } from "./getQuote.js"; // TODO: add JSDoc description for all properties -export type BuyWithCryptoTransactionDetails = { - transactionHash: string; - token: QuoteTokenInfo; - amountWei: string; - amount: string; - amountUSDCents: number; - completedAt?: string; // ISO DATE - explorerLink?: string; -}; - export type BuyWithCryptoQuoteSummary = { - fromToken: QuoteTokenInfo; - toToken: QuoteTokenInfo; + fromToken: PayTokenInfo; + toToken: PayTokenInfo; fromAmountWei: string; fromAmount: string; @@ -46,12 +39,7 @@ export type BuyWithCryptoTransaction = { transactionHash: string; }; -export type BuyWithCryptoStatuses = - | "NOT_FOUND" - | "NONE" - | "PENDING" - | "FAILED" - | "COMPLETED"; +export type BuyWithCryptoStatuses = "NONE" | "PENDING" | "FAILED" | "COMPLETED"; export type BuyWithCryptoSubStatuses = | "NONE" @@ -67,18 +55,27 @@ export type SwapType = "SAME_CHAIN" | "CROSS_CHAIN"; * The object returned by the [`getBuyWithCryptoStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoStatus) function to represent the status of a quoted transaction * @buyCrypto */ -export type BuyWithCryptoStatus = { - quote: BuyWithCryptoQuoteSummary; - swapType: SwapType; - source: BuyWithCryptoTransactionDetails; - destination?: BuyWithCryptoTransactionDetails; - status: BuyWithCryptoStatuses; - subStatus: BuyWithCryptoSubStatuses; - fromAddress: string; - toAddress: string; - failureMessage?: string; - bridge?: string; -}; +export type BuyWithCryptoStatus = + | { + status: "NOT_FOUND"; + } + | { + quote: BuyWithCryptoQuoteSummary; + swapType: SwapType; + source?: PayOnChainTransactionDetails; + destination?: PayOnChainTransactionDetails; + status: BuyWithCryptoStatuses; + subStatus: BuyWithCryptoSubStatuses; + fromAddress: string; + toAddress: string; + failureMessage?: string; + bridge?: string; + }; + +export type ValidBuyWithCryptoStatus = Exclude< + BuyWithCryptoStatus, + { status: "NOT_FOUND" } +>; /** * Gets the status of a buy with crypto transaction @@ -86,7 +83,7 @@ export type BuyWithCryptoStatus = { * @example * * ```ts - * import { sendTransaction, prepareTransaction } from "thirdweb"; + * import { sendTransaction } from "thirdweb"; * import { getBuyWithCryptoStatus, getBuyWithCryptoQuote } from "thirdweb/pay"; * * // get a quote between two tokens @@ -94,23 +91,26 @@ export type BuyWithCryptoStatus = { * * // if approval is required, send the approval transaction * if (quote.approval) { - * const preparedApproval = prepareTransaction(quote.approval); - * await sendTransaction({ - * transaction, - * wallet, + * const txResult = await sendTransaction({ + * transaction: quote.approval, + * account: account, // account from connected wallet * }); + * + * await waitForReceipt(txResult); * } * * // send the quoted transaction - * const preparedTransaction = prepareTransaction(quote.transactionRequest); - * const transactionResult = await sendTransaction({ - * transaction, - * wallet, + * const swapTxResult = await sendTransaction({ + * transaction: quote.transactionRequest, + * account: account, // account from connected wallet * }); + * + * await waitForReceipt(swapTxResult); + * * // keep polling the status of the quoted transaction until it returns a success or failure status * const status = await getBuyWithCryptoStatus({ * client, - * transactionHash: transactionResult.transactionHash, + * transactionHash: swapTxResult.transactionHash, * }}); * ``` * @returns Object of type [`BuyWithCryptoStatus`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoStatus) @@ -130,7 +130,7 @@ export async function getBuyWithCryptoStatus( const response = await getClientFetch(buyWithCryptoTransaction.client)(url); - // Assuming the response directly matches the SwapResponse interface + // Assuming the response directly matches the BuyWithCryptoStatus interface if (!response.ok) { response.body?.cancel(); throw new Error(`HTTP error! status: ${response.status}`); diff --git a/packages/thirdweb/src/pay/buyWithCrypto/utils/definitions.ts b/packages/thirdweb/src/pay/buyWithCrypto/utils/definitions.ts deleted file mode 100644 index b93cd1f49d4..00000000000 --- a/packages/thirdweb/src/pay/buyWithCrypto/utils/definitions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getThirdwebDomains } from "../../../utils/domains.js"; - -/** - * Constructs the endpoint to get the status of a quote. - * @param client - The Thirdweb client containing the baseUrl config - * @internal - */ -export const getPayBuyWithCryptoStatusUrl = () => - `https://${getThirdwebDomains().pay}/buy-with-crypto/status/v1`; -/** - * Constructs the endpoint to get a pay quote. - * @param client - The Thirdweb client containing the baseUrl config - * @internal - */ -export const getPayBuyWithCryptoQuoteEndpoint = () => - `https://${getThirdwebDomains().pay}/buy-with-crypto/quote/v1`; - -/** - * Constructs the endpoint to get a wallet address swap history. - * @param client - The Thirdweb client containing the baseUrl config - * @internal - */ -export const getPayBuyWithCryptoHistoryEndpoint = () => - `https://${getThirdwebDomains().pay}/buy-with-crypto/history/v1`; - -/** - * Constructs the endpoint to get the pay endpoint - * @internal - */ -export const getPayChainsEndpoint = () => - `https://${getThirdwebDomains().pay}/chains`; diff --git a/packages/thirdweb/src/pay/buyWithFiat/getHistory.ts b/packages/thirdweb/src/pay/buyWithFiat/getHistory.ts new file mode 100644 index 00000000000..a1aa37047e5 --- /dev/null +++ b/packages/thirdweb/src/pay/buyWithFiat/getHistory.ts @@ -0,0 +1,90 @@ +import type { ThirdwebClient } from "../../client/client.js"; +import { getClientFetch } from "../../utils/fetch.js"; +import { getPayBuyWithFiatHistoryEndpoint } from "../utils/definitions.js"; +import type { BuyWithFiatStatus } from "./getStatus.js"; + +/** + * The parameters for [`getBuyWithFiatHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatHistory) function + * @buyCrypto + */ +export type BuyWithFiatHistoryParams = { + /** + * 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. + */ + client: ThirdwebClient; + /** + * The address of the wallet to get the wallet history for + */ + walletAddress: string; + /** + * The number of results to return in a single page. The default value is `10`. + */ + count: number; + /** + * index of the first result to return. The default value is `0`. + * + * If you want to start the list from nth item, you can set the start value to (n-1). + */ + start: number; +}; + +/** + * The results for [`getBuyWithFiatHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatHistory) function + * @buyFiat + */ +export type BuyWithFiatHistoryData = { + page: BuyWithFiatStatus[]; + hasNextPage: boolean; +}; + +/** + * Get the "Buy with fiat" transaction history for a given wallet address + * @param params Object of type [`BuyWithFiatHistoryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatHistoryParams) + * @example + * + * ```ts + * import { createThirdwebClient } from "thirdweb"; + * import { getBuyWithFiatHistory } from "thirdweb/pay"; + * + * const client = createThirdwebClient({ clientId: "..." }); + * + * // get the 10 latest "Buy with fiat" transactions dony by the wallet + * const history = await getBuyWithFiatHistory({ + * client: client, + * walletAddress: '0x...', + * start: 0, + * count: 10, + * }) + * ``` + * @returns Object of type [`BuyWithFiatHistoryData`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatHistoryData) + * @buyFiat + */ +export async function getBuyWithFiatHistory( + params: BuyWithFiatHistoryParams, +): Promise { + try { + const queryParams = new URLSearchParams(); + queryParams.append("walletAddress", params.walletAddress); + queryParams.append("start", params.start.toString()); + queryParams.append("count", params.count.toString()); + + const queryString = queryParams.toString(); + const url = `${getPayBuyWithFiatHistoryEndpoint()}?${queryString}`; + + const response = await getClientFetch(params.client)(url); + + // Assuming the response directly matches the BuyWithFiatStatus response interface + if (!response.ok) { + response.body?.cancel(); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: BuyWithFiatHistoryData = (await response.json()).result; + return data; + } catch (error) { + throw new Error(`Fetch failed: ${error}`); + } +} diff --git a/packages/thirdweb/src/pay/buyWithFiat/getPostOnRampQuote.ts b/packages/thirdweb/src/pay/buyWithFiat/getPostOnRampQuote.ts new file mode 100644 index 00000000000..346d0628747 --- /dev/null +++ b/packages/thirdweb/src/pay/buyWithFiat/getPostOnRampQuote.ts @@ -0,0 +1,77 @@ +import type { ThirdwebClient } from "../../client/client.js"; +import { + type BuyWithCryptoQuote, + getBuyWithCryptoQuote, +} from "../buyWithCrypto/getQuote.js"; +import type { BuyWithFiatStatus } from "./getStatus.js"; + +/** + * The parameters for [`getPostOnRampQuote`](https://portal.thirdweb.com/references/typescript/v5/getPostOnRampQuote) function + */ +export type GetPostOnRampQuoteParams = { + /** + * 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. + */ + client: ThirdwebClient; + /** + * The "Buy with fiat" transaction status object returned by [`getBuyWithFiatStatus`](https://portal.thirdweb.com/typescript/v5/getBuyWithFiatStatus) function + */ + buyWithFiatStatus: BuyWithFiatStatus; +}; + +/** + * When buying a token with fiat currency - It only involes doing on-ramp if the on-ramp provider supports buying the given destination token directly. + * + * If the on-ramp provider does not support buying the destination token directly, user can be sent an intermediate token with fiat currency from the on-ramp provider which + * can be swapped to destination token onchain. + * + * `getPostOnRampQuote` function is used to get the quote for swapping the on-ramp token to destination token. + * + * When you get a "Buy with Fiat" status of type "CRYPTO_SWAP_REQUIRED" from the [`getBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatStatus) function, + * you can use `getPostOnRampQuote` function to get the quote of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote) for swapping the on-ramp token to destination token + * + * Once you have the quote, you can start the Swap process by following the same steps as mentioned in the [`getBuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoQuote) documentation. + * + * @param params - object of type [`GetPostOnRampQuoteParams`](https://portal.thirdweb.com/references/typescript/v5/GetPostOnRampQuoteParams) + * @returns Object of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote) which contains the information about the quote such as processing fees, estimated time, converted token amounts, etc. + * @example + * ```ts + * import { getPostOnRampQuote, getBuyWithFiatStatus } from "thirdweb/pay"; + * + * // previous steps + * const fiatQuote = await getBuyWithFiatQuote(fiatQuoteParams); + * window.open(fiatQuote.onRampLink, "_blank"); + * const buyWithFiatStatus = await getBuyWithFiatStatus({ client, intentId }); // keep calling this until status is "settled" state + * + * // when a swap is required after onramp + * if (buyWithFiatStatus.status === "CRYPTO_SWAP_REQUIRED") { + * const buyWithCryptoQuote = await getPostOnRampQuote({ + * client, + * buyWithFiatStatus + * }); + * } + * ``` + * @buyFiat + */ +export async function getPostOnRampQuote({ + client, + buyWithFiatStatus, +}: GetPostOnRampQuoteParams): Promise { + if (buyWithFiatStatus.status === "NOT_FOUND") { + throw new Error("Invalid buyWithFiatStatus"); + } + + return getBuyWithCryptoQuote({ + client, + intentId: buyWithFiatStatus.intentId, + fromAddress: buyWithFiatStatus.toAddress, + fromChainId: buyWithFiatStatus.quote.onRampToken.chainId, + fromTokenAddress: buyWithFiatStatus.quote.onRampToken.tokenAddress, + toChainId: buyWithFiatStatus.quote.toToken.chainId, + toTokenAddress: buyWithFiatStatus.quote.toToken.tokenAddress, + toAmount: buyWithFiatStatus.quote.estimatedToTokenAmount, + }); +} diff --git a/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts b/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts new file mode 100644 index 00000000000..2a975985139 --- /dev/null +++ b/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts @@ -0,0 +1,283 @@ +import type { ThirdwebClient } from "../../client/client.js"; +import { getClientFetch } from "../../utils/fetch.js"; +import { getPayBuyWithFiatQuoteEndpoint } from "../utils/definitions.js"; + +/** + * Parameters for [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) function + */ +export type GetBuyWithFiatQuoteParams = { + /** + * 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. + */ + client: ThirdwebClient; + + /** + * The address of the wallet to which the tokens will be sent. + */ + toAddress: string; + + /** + * Chain id of the token to buy. + */ + toChainId: number; + + /** + * Token address of the token to buy. + */ + toTokenAddress: string; + + /** + * Symbol of the fiat currency to buy the token with. + * + * Currently, only `USD` is supported. + */ + fromCurrencySymbol: "USD"; + + /** + * The maximum slippage in basis points (bps) allowed for the transaction. + * For example, if you want to allow a maximum slippage of 0.5%, you should specify `50` bps. + */ + maxSlippageBPS?: number; + + /** + * The amount of fiat currency to spend to buy the token. + * This is useful if you want to buy whatever amount of token you can get for a certain amount of fiat currency. + * + * If you want a certain amount of token, you can provide `toAmount` instead of `fromAmount`. + */ + fromAmount?: string; + + /** + * The amount of token to buy + * This is useful if you want to get a certain amount of token. + * + * If you want to buy however much token you can get for a certain amount of fiat currency, you can provide `fromAmount` instead of `toAmount`. + */ + toAmount?: string; + + /** + * Whether to use on-ramp provider in test mode for testing purpose or not. + * + * Defaults to `false` + */ + isTestMode?: boolean; +}; + +/** + * The response object returned by the [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) function. + * + * This includes various information for buying a token using a fiat currency: + * - on-ramp provider UI link + * - The estimated time for the transaction to complete. + * - The on-ramp and destination token information. + * - Processing fees + */ +export type BuyWithFiatQuote = { + /** + * Estimated time for the transaction to complete in seconds. + */ + estimatedDurationSeconds: number; + /** + * Minimum amount of token that is expected to be received in units. + */ + estimatedToAmountMin: string; + /** + * Minimum amount of token that is expected to be received in wei. + */ + estimatedToAmountMinWei: string; + /** + * Amount of token that is expected to be received in units. + * + * (estimatedToAmountMinWei - maxSlippageWei) + */ + toAmountMinWei: string; + /** + * Amount of token that is expected to be received in wei. + * + * (estimatedToAmountMin - maxSlippageWei) + */ + toAmountMin: string; + /** + * fiat currency used to buy the token - excluding the fees. + */ + fromCurrency: { + amount: string; + amountUnits: string; + decimals: number; + currencySymbol: string; + }; + /** + * Fiat currency used to buy the token - including the fees. + */ + fromCurrencyWithFees: { + amount: string; + amountUnits: string; + decimals: number; + currencySymbol: string; + }; + /** + * Token information for the desired token. (token the user wants to buy) + */ + toToken: { + symbol?: string | undefined; + priceUSDCents?: number | undefined; + name?: string | undefined; + chainId: number; + tokenAddress: string; + decimals: number; + }; + /** + * Address of the wallet to which the tokens will be sent. + */ + toAddress: string; + /** + * The maximum slippage in basis points (bps) allowed for the transaction. + */ + maxSlippageBPS: number; + /** + * Id of transaction + */ + intentId: string; + /** + * Array of processing fees for the transaction. + * + * This includes the processing fees for on-ramp and swap (if required). + */ + processingFees: { + amount: string; + amountUnits: string; + decimals: number; + currencySymbol: string; + feeType: "ON_RAMP" | "NETWORK"; + }[]; + /** + * Token that will be sent to the user's wallet address by the on-ramp provider. + * + * If the token is same as `toToken` - the user can directly buy the token from the on-ramp provider. + * If not, the user will receive this token and a swap is required to convert it `toToken`. + */ + onRampToken: { + amount: string; + amountWei: string; + amountUSDCents: number; + token: { + chainId: number; + decimals: number; + name: string; + priceUSDCents: number; + symbol: string; + tokenAddress: string; + }; + }; + + /** + * Link to the on-ramp provider UI that will prompt the user to buy the token with fiat currency. + * + * This link should be opened in a new tab. + * @example + * ```ts + * window.open(quote.onRampLink, "_blank"); + * ``` + * + */ + onRampLink: string; +}; + +/** + * Get a quote of type [`BuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatQuote) to buy given token with fiat currency. + * This quote contains the information about the swap such as token amounts, processing fees, estimated time etc. + * + * ### Rendering the On-Ramp provider UI + * Once you have the `quote`, you can open the `quote.onRampLink` in a new tab - This will prompt the user to buy the token with fiat currency + * + * ### Determining the steps required + * If `quote.onRampToken.token` is same as `quote.toToken` ( same chain + same token address ) - This means that the token can be directly bought from the on-ramp provider. + * But if they are different, On-ramp provider will send the `quote.onRampToken` to the user's wallet address and a swap is required to swap it to the desired token onchain. + * + * You can use the [`isSwapRequiredPostOnramp`](https://portal.thirdweb.com/references/typescript/v5/isSwapRequiredPostOnramp) utility function to check if a swap is required after the on-ramp is done. + * + * ### Polling for the status + * Once you open the `quote.onRampLink` in a new tab, you can start polling for the status using [`getBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatStatus) to get the status of the transaction. + * + * `getBuyWithFiatStatus` returns a status object of type [`BuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatStatus). + * + * - If no swap is required - the status will become `"ON_RAMP_TRANSFER_COMPLETED"` once the on-ramp provider has sent the desired token to the user's wallet address. Once you receive this status, the process is complete. + * - If a swap is required - the status will become `"CRYPTO_SWAP_REQUIRED"` once the on-ramp provider has sent the tokens to the user's wallet address. Once you receive this status, you need to start the swap process. + * + * ### Swap Process + * On receiving the `"CRYPTO_SWAP_REQUIRED"` status, you can use the [`getPostOnRampQuote`](https://portal.thirdweb.com/references/typescript/v5/getPostOnRampQuote) function to get the quote for the swap of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote). + * + * Once you have this quote - You can follow the same steps as mentioned in the [`getBuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoQuote) documentation to perform the swap. + * + * @param params - object of type [`GetBuyWithFiatQuoteParams`](https://portal.thirdweb.com/references/typescript/v5/GetBuyWithFiatQuoteParams) + * @returns Object of type [`BuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatQuote) which contains the information about the quote such as processing fees, estimated time, converted token amounts, etc. + * @example + * Get a quote for buying 10 USDC on polygon chain (chainId: 137) with USD fiat currency: + * + * ```ts + * import { getBuyWithFiatQuote } from "thirdweb/pay"; + * + * const quote = await getBuyWithFiatQuote({ + * client: client, // thirdweb client + * fromCurrencySymbol: "USD", // fiat currency symbol + * toChainId: 137, // polygon chain id + * toAmount: "10", // amount of USDC to buy + * toTokenAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" // USDC token address in polygon chain + * toAddress: "0x...", // user's wallet address + * isTestMode: false, // whether to use onramp in test mode for testing purpose (defaults to false) + * }); + * + * window.open(quote.onRampLink, "_blank"); + * ``` + * @buyFiat + */ +export async function getBuyWithFiatQuote( + params: GetBuyWithFiatQuoteParams, +): Promise { + try { + const queryParams = new URLSearchParams({ + toAddress: params.toAddress, + fromCurrencySymbol: params.fromCurrencySymbol, + toChainId: params.toChainId.toString(), + toTokenAddress: params.toTokenAddress.toLowerCase(), + }); + + if (params.fromAmount) { + queryParams.append("fromAmount", params.fromAmount); + } + + if (params.toAmount) { + queryParams.append("toAmount", params.toAmount); + } + + if (params.maxSlippageBPS) { + queryParams.append("maxSlippageBPS", params.maxSlippageBPS.toString()); + } + + if (params.isTestMode) { + queryParams.append("isTestMode", params.isTestMode.toString()); + } + + const queryString = queryParams.toString(); + const url = `${getPayBuyWithFiatQuoteEndpoint()}?${queryString}`; + + const response = await getClientFetch(params.client)(url); + + // Assuming the response directly matches the SwapResponse interface + if (!response.ok) { + const errorObj = await response.json(); + if (errorObj && "error" in errorObj) { + throw errorObj; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + return (await response.json()).result; + } catch (error) { + console.error("Fetch error:", error); + throw error; + } +} diff --git a/packages/thirdweb/src/pay/buyWithFiat/getStatus.ts b/packages/thirdweb/src/pay/buyWithFiat/getStatus.ts new file mode 100644 index 00000000000..b26d9150c0b --- /dev/null +++ b/packages/thirdweb/src/pay/buyWithFiat/getStatus.ts @@ -0,0 +1,186 @@ +import type { ThirdwebClient } from "../../client/client.js"; +import { getClientFetch } from "../../utils/fetch.js"; +import type { + PayOnChainTransactionDetails, + PayTokenInfo, +} from "../utils/commonTypes.js"; +import { getPayBuyWithFiatStatusEndpoint } from "../utils/definitions.js"; + +/** + * Parameters for the [`getBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatStatus) function + */ +export type GetBuyWithFiatStatusParams = { + /** + * 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. + */ + client: ThirdwebClient; + /** + * Intent ID of the "Buy with fiat" transaction. You can get the intent ID from the quote object returned by the [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) function + */ + intentId: string; +}; + +export type ValidBuyWithFiatStatus = Exclude< + BuyWithFiatStatus, + { status: "NOT_FOUND" } +>; + +/** + * The returned object from [`getBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatStatus) function + * + * If the in invalid intentId is provided, the object will have a status of "NOT_FOUND" and no other fields. + */ +export type BuyWithFiatStatus = + | { + status: "NOT_FOUND"; + } + | { + /** + * Intent ID of the "Buy with fiat" transaction. You can get the intent ID from the quote object returned by the [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) function + */ + intentId: string; + /** + * The status of the transaction + * - `NONE` - No status + * - `PENDING_PAYMENT` - Payment is not done yet in the on-ramp provider + * - `PAYMENT_FAILED` - Payment failed in the on-ramp provider + * - `PENDING_ON_RAMP_TRANSFER` - Payment is done but the on-ramp provider is yet to transfer the tokens to the user's wallet + * - `ON_RAMP_TRANSFER_IN_PROGRESS` - On-ramp provider is transferring the tokens to the user's wallet + * - `ON_RAMP_TRANSFER_COMPLETED` - On-ramp provider has transferred the tokens to the user's wallet + * - `ON_RAMP_TRANSFER_FAILED` - On-ramp provider failed to transfer the tokens to the user's wallet + * - `CRYPTO_SWAP_REQUIRED` - On-ramp provider has sent the tokens to the user's wallet but a swap is required to convert it to the desired token + * - `CRYPTO_SWAP_IN_PROGRESS` - Swap is in progress + * - `CRYPTO_SWAP_COMPLETED` - Swap is completed and the user has received the desired token + * - `CRYPTO_SWAP_FALLBACK` - Swap failed and the user has received a fallback token which is not the desired token + */ + status: + | "NONE" + | "PENDING_PAYMENT" + | "PAYMENT_FAILED" + | "PENDING_ON_RAMP_TRANSFER" + | "ON_RAMP_TRANSFER_IN_PROGRESS" + | "ON_RAMP_TRANSFER_COMPLETED" + | "ON_RAMP_TRANSFER_FAILED" + | "CRYPTO_SWAP_REQUIRED" + | "CRYPTO_SWAP_COMPLETED" + | "CRYPTO_SWAP_FALLBACK" + | "CRYPTO_SWAP_IN_PROGRESS" + | "CRYPTO_SWAP_FAILED"; + /** + * The wallet address to which the tokens are sent to + */ + toAddress: string; + /** + * The quote object for the transaction + */ + quote: { + estimatedOnRampAmount: string; + estimatedOnRampAmountWei: string; + + estimatedToTokenAmount: string; + estimatedToTokenAmountWei: string; + + fromCurrency: { + amount: string; + amountUnits: string; + decimals: number; + currencySymbol: string; + }; + fromCurrencyWithFees: { + amount: string; + amountUnits: string; + decimals: number; + currencySymbol: string; + }; + onRampToken: PayTokenInfo; + toToken: PayTokenInfo; + estimatedDurationSeconds?: number; + createdAt: string; + }; + /** + * The on-ramp transaction details + * + * This field is only present when on-ramp transaction is completed or failed + */ + source?: PayOnChainTransactionDetails; + /** + * The destination transaction details + * + * This field is only present when swap transaction is completed or failed + */ + destination?: PayOnChainTransactionDetails; + /** + * Message indicating the reason for failure + */ + failureMessage?: string; + }; + +/** + * Once you get a `quote` from [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) + * and open the `quote.onRampLink` in a new tab, you can start polling for the transaction status using `getBuyWithFiatStatus` + * + * You should keep calling this function at regular intervals while the status is in one of the pending states such as - "PENDING_PAYMENT", "PENDING_ON_RAMP_TRANSFER", "ON_RAMP_TRANSFER_IN_PROGRESS", "CRYPTO_SWAP_IN_PROGRESS" etc.. + * + * If `quote.onRampToken` is same as `quote.toToken` (same chain + same token address) - This means that the token can be directly bought from the on-ramp provider. + * But if they are different - On-ramp provider will send the `quote.onRampToken` to the user's wallet address and a swap is required to convert it to the desired token. + * You can use the [`isSwapRequiredPostOnramp`](https://portal.thirdweb.com/references/typescript/v5/isSwapRequiredPostOnramp) utility function to check if a swap is required after the on-ramp is done. + * + * #### When no swap is required + * If there is no swap required - the status will become `"ON_RAMP_TRANSFER_COMPLETED"` once the on-ramp provider has sent the tokens to the user's wallet address. + * Once you receive this status, the process is complete. + * + * ### When a swap is required + * If a swap is required - the status will become `"CRYPTO_SWAP_REQUIRED"` once the on-ramp provider has sent the tokens to the user's wallet address. + * Once you receive this status, you need to start the swap process. + * + * On receiving the `"CRYPTO_SWAP_REQUIRED"` status, you can use the [`getPostOnRampQuote`](https://portal.thirdweb.com/references/typescript/v5/getPostOnRampQuote) function to get the quote for the swap of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote). + * + * Once you have this quote - You can follow the same steps as mentioned in the [`getBuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoQuote) documentation to perform the swap. + * + * @param params - Object of type [`GetBuyWithFiatStatusParams`](https://portal.thirdweb.com/references/typescript/v5/GetBuyWithFiatStatusParams) + * @example + * ```ts + * // step 1 - get a quote + * const fiatQuote = await getBuyWithFiatQuote(fiatQuoteParams) + * + * // step 2 - open the on-ramp provider UI + * window.open(quote.onRampLink, "_blank"); + * + * // step 3 - keep calling getBuyWithFiatStatus while the status is in one of the pending states + * const fiatStatus = await getBuyWithFiatStatus({ + * client, + * intentId: fiatQuote.intentId, + * }) + * + * // when the fiatStatus.status is "ON_RAMP_TRANSFER_COMPLETED" - the process is complete + * // when the fiatStatus.status is "CRYPTO_SWAP_REQUIRED" - start the swap process + * ``` + * @buyFiat + */ +export async function getBuyWithFiatStatus( + params: GetBuyWithFiatStatusParams, +): Promise { + try { + const queryParams = new URLSearchParams({ + intentId: params.intentId, + }); + + const queryString = queryParams.toString(); + const url = `${getPayBuyWithFiatStatusEndpoint()}?${queryString}`; + + const response = await getClientFetch(params.client)(url); + + if (!response.ok) { + response.body?.cancel(); + throw new Error(`HTTP error! status: ${response.status}`); + } + + return (await response.json()).result; + } catch (error) { + console.error("Fetch error:", error); + throw new Error(`Fetch failed: ${error}`); + } +} diff --git a/packages/thirdweb/src/pay/buyWithFiat/isSwapRequiredPostOnramp.ts b/packages/thirdweb/src/pay/buyWithFiat/isSwapRequiredPostOnramp.ts new file mode 100644 index 00000000000..d995c7d6304 --- /dev/null +++ b/packages/thirdweb/src/pay/buyWithFiat/isSwapRequiredPostOnramp.ts @@ -0,0 +1,28 @@ +import { getAddress } from "../../utils/address.js"; +import type { BuyWithFiatQuote } from "./getQuote.js"; + +/** + * Check if a Swap is required after on-ramp when buying a token with fiat currency. + * + * If `quote.toToken` and `quote.onRampToken` are the same (same token and chain), + * it means on-ramp provider can directly send the desired token to the user's wallet and no swap is required. + * + * If `quote.toToken` and `quote.onRampToken` are different (different token or chain), A swap is required to swap the on-ramp token to the desired token. + * + * @param buyWithFiatQuote - The quote of type [`BuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatQuote) returned + * by the [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) function. + * @buyFiat + */ +export function isSwapRequiredPostOnramp( + buyWithFiatQuote: Pick, +) { + const sameChain = + buyWithFiatQuote.toToken.chainId === + buyWithFiatQuote.onRampToken.token.chainId; + + const sameToken = + getAddress(buyWithFiatQuote.toToken.tokenAddress) === + getAddress(buyWithFiatQuote.onRampToken.token.tokenAddress); + + return !(sameChain && sameToken); +} diff --git a/packages/thirdweb/src/pay/getBuyHistory.ts b/packages/thirdweb/src/pay/getBuyHistory.ts new file mode 100644 index 00000000000..9b5b5d2cbb8 --- /dev/null +++ b/packages/thirdweb/src/pay/getBuyHistory.ts @@ -0,0 +1,100 @@ +import type { ThirdwebClient } from "../client/client.js"; +import type { BuyWithCryptoStatus, BuyWithFiatStatus } from "../exports/pay.js"; +import { getClientFetch } from "../utils/fetch.js"; +import { getPayBuyHistoryEndpoint } from "./utils/definitions.js"; + +/** + * The parameters for [`getBuyHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyHistory) function + */ +export type BuyHistoryParams = { + /** + * 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. + */ + client: ThirdwebClient; + /** + * The wallet address to get the buy history for. + */ + walletAddress: string; + /** + * The number of results to return. + * + * The default value is `10`. + */ + count: number; + /** + * Index of the first result to return. The default value is `0`. + */ + start: number; +}; + +/** + * The result for [`getBuyHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoHistory) function + * + * It includes both "Buy with Crypto" and "Buy with Fiat" transactions + */ +export type BuyHistoryData = { + /** + * The list of buy transactions. + */ + page: Array< + | { + buyWithFiatStatus: BuyWithFiatStatus; + } + | { + buyWithCryptoStatus: BuyWithCryptoStatus; + } + >; + /** + * Whether there are more pages of results. + */ + hasNextPage: boolean; +}; + +/** + * Get Buy transaction history for a given wallet address. + * + * This includes both "Buy with Cryto" and "Buy with Fiat" transactions + * + * @param params Object of type [`BuyHistoryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyHistoryParams) + * @example + * ```ts + * import { createThirdwebClient } from "thirdweb"; + * import { getBuyHistory } from "thirdweb/pay"; + * + * const client = createThirdwebClient({ clientId: "..." }); + * + * const history = await getBuyHistory({ + * client, + * walletAddress: "0x...", + * }) + * ``` + */ +export async function getBuyHistory( + params: BuyHistoryParams, +): Promise { + try { + const queryParams = new URLSearchParams(); + queryParams.append("walletAddress", params.walletAddress); + queryParams.append("start", params.start.toString()); + queryParams.append("count", params.count.toString()); + + const queryString = queryParams.toString(); + const url = `${getPayBuyHistoryEndpoint()}?${queryString}`; + + const response = await getClientFetch(params.client)(url); + + // Assuming the response directly matches the SwapResponse interface + if (!response.ok) { + response.body?.cancel(); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: BuyHistoryData = (await response.json()).result; + return data; + } catch (error) { + throw new Error(`Fetch failed: ${error}`); + } +} diff --git a/packages/thirdweb/src/pay/utils/commonTypes.ts b/packages/thirdweb/src/pay/utils/commonTypes.ts new file mode 100644 index 00000000000..44196d57e3e --- /dev/null +++ b/packages/thirdweb/src/pay/utils/commonTypes.ts @@ -0,0 +1,18 @@ +export type PayTokenInfo = { + chainId: number; + tokenAddress: string; + decimals: number; + priceUSDCents: number; + name?: string; + symbol?: string; +}; + +export type PayOnChainTransactionDetails = { + transactionHash: string; + token: PayTokenInfo; + amountWei: string; + amount: string; + amountUSDCents: number; + completedAt?: string; // ISO DATE + explorerLink?: string; +}; diff --git a/packages/thirdweb/src/pay/utils/definitions.ts b/packages/thirdweb/src/pay/utils/definitions.ts new file mode 100644 index 00000000000..8480540eec7 --- /dev/null +++ b/packages/thirdweb/src/pay/utils/definitions.ts @@ -0,0 +1,64 @@ +import { getThirdwebDomains } from "../../utils/domains.js"; + +/** + * Endpoint to get the status of a "Buy with Crypto" quote. + * @internal + */ +export const getPayBuyWithCryptoStatusUrl = () => + `https://${getThirdwebDomains().pay}/buy-with-crypto/status/v1`; +/** + * Endpoint to get "Buy with Crypto" quote. + * @internal + */ +export const getPayBuyWithCryptoQuoteEndpoint = () => + `https://${getThirdwebDomains().pay}/buy-with-crypto/quote/v1`; + +/** + * Endpoint to get a "Buy with Fiat" quote. + * @internal + */ +export const getPayBuyWithFiatQuoteEndpoint = () => + `https://${getThirdwebDomains().pay}/buy-with-fiat/quote/v1`; + +/** + * Endpoint to get the status of a "Buy with Fiat" transaction status. + * @internal + */ +export const getPayBuyWithFiatStatusEndpoint = () => + `https://${getThirdwebDomains().pay}/buy-with-fiat/status/v1`; + +/** + * Endpoint to get history of "Buy with Fiat" transactions for given wallet address. + * @internal + */ +export const getPayBuyWithFiatHistoryEndpoint = () => + `https://${getThirdwebDomains().pay}/buy-with-fiat/history/v1`; + +/** + * Endpoint to get a "Buy with Crypto" transaction history for a given wallet address. + * @internal + */ +export const getPayBuyWithCryptoHistoryEndpoint = () => + `https://${getThirdwebDomains().pay}/buy-with-crypto/history/v1`; + +/** + * Endpoint to get a list of supported destination chains and tokens for thirdweb pay. + * @internal + */ +export const getPaySupportedDestinations = () => + `https://${getThirdwebDomains().pay}/destination-tokens/v1`; + +/** + * Endpoint to get a list of supported source chains + tokens for thirdweb pay. + * @internal + */ +export const getPaySupportedSources = () => + `https://${getThirdwebDomains().pay}/buy-with-crypto/source-tokens/v1`; + +/** + * Endpoint to get buy history for a given wallet address. + * This includes both "Buy with Crypto" and "Buy with Fiat" transactions. + * @internal + */ +export const getPayBuyHistoryEndpoint = () => + `https://${getThirdwebDomains().pay}/wallet/history/v1`; diff --git a/packages/thirdweb/src/react/core/hooks/contract/useSendTransaction.ts b/packages/thirdweb/src/react/core/hooks/contract/useSendTransaction.ts index c385d4b3741..c14ceab2175 100644 --- a/packages/thirdweb/src/react/core/hooks/contract/useSendTransaction.ts +++ b/packages/thirdweb/src/react/core/hooks/contract/useSendTransaction.ts @@ -9,7 +9,7 @@ import { type GetWalletBalanceResult, getWalletBalance, } from "../../../../wallets/utils/getWalletBalance.js"; -import { fetchSwapSupportedChains } from "../../../web/ui/ConnectWallet/screens/Buy/swap/useSwapSupportedChains.js"; +import { fetchBuySupportedDestinations } from "../../../web/ui/ConnectWallet/screens/Buy/swap/useSwapSupportedChains.js"; import { useActiveAccount } from "../wallets/wallet-hooks.js"; type ShowModalData = { @@ -70,12 +70,10 @@ export function useSendTransactionCore( (async () => { try { - const swapSupportedChains = await fetchSwapSupportedChains( - tx.client, - ); + const destinations = await fetchBuySupportedDestinations(tx.client); - const isBuySupported = swapSupportedChains.find( - (c) => c.id === tx.chain.id, + const isBuySupported = destinations.find( + (c) => c.chain.id === tx.chain.id, ); // buy not supported, can't show modal - send tx directly @@ -125,9 +123,8 @@ export function useSendTransactionCore( } export async function getTotalTxCostForBuy(tx: PreparedTransaction) { - // Must pass 0 otherwise it will throw on some chains const gasCost = await estimateGasCost({ - transaction: { ...tx, value: 0n }, + transaction: tx, }); const bufferCost = gasCost.wei / 10n; diff --git a/packages/thirdweb/src/react/core/hooks/others/useTokenInfo.ts b/packages/thirdweb/src/react/core/hooks/others/useTokenInfo.ts new file mode 100644 index 00000000000..1fea20e42b9 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/others/useTokenInfo.ts @@ -0,0 +1,57 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Chain } from "../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../client/client.js"; +import { getContract } from "../../../../contract/contract.js"; + +type GetTokenInfoOptions = { + client: ThirdwebClient; + chain: Chain; + tokenAddress?: string; +}; + +type GetTokenInfoResult = { + name: string; + symbol: string; + decimals: number; +}; + +/** + * @internal + */ +export function useTokenInfo(options: GetTokenInfoOptions) { + const { chain, tokenAddress, client } = options; + return useQuery({ + queryKey: ["tokenInfo", chain?.id || -1, { tokenAddress }] as const, + queryFn: async () => { + // erc20 case + if (tokenAddress) { + const { getCurrencyMetadata } = await import( + "../../../../extensions/erc20/read/getCurrencyMetadata.js" + ); + const result: GetTokenInfoResult = await getCurrencyMetadata({ + contract: getContract({ client, chain, address: tokenAddress }), + }); + + return result; + } + + const { getChainDecimals, getChainNativeCurrencyName, getChainSymbol } = + await import("../../../../chains/utils.js"); + + const [nativeSymbol, nativeDecimals, nativeName] = await Promise.all([ + getChainSymbol(chain), + getChainDecimals(chain), + getChainNativeCurrencyName(chain), + ]); + + const result: GetTokenInfoResult = { + decimals: nativeDecimals, + symbol: nativeSymbol, + name: nativeName, + }; + + return result; + }, + enabled: !!chain && !!client, + }); +} diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyHistory.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyHistory.ts new file mode 100644 index 00000000000..097b41afef8 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyHistory.ts @@ -0,0 +1,60 @@ +import { + type UseQueryOptions, + type UseQueryResult, + useQuery, +} from "@tanstack/react-query"; +import { + type BuyHistoryData, + type BuyHistoryParams, + getBuyHistory, +} from "../../../../pay/getBuyHistory.js"; + +/** + * @internal + */ +export type BuyHistoryQueryOptions = Omit< + UseQueryOptions, + "queryFn" | "queryKey" | "enabled" +>; + +/** + * Hook to get the history of Buy transactions for a given wallet - This includes both "buy with crypto" and "buy with fiat" transactions. + * + * This hook is a React Query wrapper of the [`getBuyHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyHistory) function. + * You can also use that function directly + * + * @param params - object of type [`BuyHistoryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyHistoryParams) + * @param queryParams - options to configure the react query + * @returns A React Query object which contains the data of type [`BuyHistoryData`](https://portal.thirdweb.com/references/typescript/v5/BuyHistoryData) + * @example + * ```tsx + * import { useBuyHistory } from "thirdweb/react"; + * + * function Component() { + * const buyWithCryptoHistory = useBuyHistory(params); + * return
...
+ * } + * ``` + */ +export function useBuyHistory( + params?: BuyHistoryParams, + queryParams?: BuyHistoryQueryOptions, +): UseQueryResult { + return useQuery({ + ...queryParams, + queryKey: ["getBuyHistory", params], + queryFn: () => { + if (!params) { + throw new Error("params are required"); + } + if (!params?.client) { + throw new Error("Client is required"); + } + return getBuyHistory({ + ...params, + client: params.client, + }); + }, + enabled: !!params, + }); +} diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoHistory.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoHistory.ts index cceea07ac52..dc5acda172a 100644 --- a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoHistory.ts +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoHistory.ts @@ -7,20 +7,22 @@ import { type BuyWithCryptoHistoryData, type BuyWithCryptoHistoryParams, getBuyWithCryptoHistory, -} from "../../../../pay/buyWithCrypto/actions/getHistory.js"; +} from "../../../../pay/buyWithCrypto/getHistory.js"; -export type BuyWithCryptoHistoryQueryParams = BuyWithCryptoHistoryParams; -export type BuyWithCryptoQuoteQueryOptions = Omit< +/** + * @internal + */ +export type BuyWithCryptoHistoryQueryOptions = Omit< UseQueryOptions, "queryFn" | "queryKey" | "enabled" >; /** - * Hook to get the history of purchases a given wallet has performed. + * Hook to get the "Buy with crypto" transaction history for a given wallet. * * This hook is a React Query wrapper of the [`getBuyWithCryptoHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoHistory) function. * You can also use that function directly - * @param buyWithCryptoHistoryParams - object of type [`BuyWithCryptoHistoryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoHistoryParams) + * @param params - object of type [`BuyWithCryptoHistoryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoHistoryParams) * @param queryParams - options to configure the react query * @returns A React Query object which contains the data of type [`BuyWithCryptoHistoryData`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoHistoryData) * @example @@ -29,31 +31,24 @@ export type BuyWithCryptoQuoteQueryOptions = Omit< * * function Component() { * const buyWithCryptoHistory = useBuyWithCryptoHistory(params); - * return
{JSON.stringify(buyWithCryptoHistory.data, null, 2)}
+ * return
...
* } * ``` * @buyCrypto */ export function useBuyWithCryptoHistory( - buyWithCryptoHistoryParams?: BuyWithCryptoHistoryQueryParams, - queryParams?: BuyWithCryptoQuoteQueryOptions, + params?: BuyWithCryptoHistoryParams, + queryParams?: BuyWithCryptoHistoryQueryOptions, ): UseQueryResult { return useQuery({ ...queryParams, - - queryKey: ["buyWithCryptoHistory", buyWithCryptoHistoryParams], + queryKey: ["getBuyWithCryptoHistory", params], queryFn: () => { - if (!buyWithCryptoHistoryParams) { + if (!params) { throw new Error("Swap params are required"); } - if (!buyWithCryptoHistoryParams?.client) { - throw new Error("Client is required"); - } - return getBuyWithCryptoHistory({ - ...buyWithCryptoHistoryParams, - client: buyWithCryptoHistoryParams.client, - }); + return getBuyWithCryptoHistory(params); }, - enabled: !!buyWithCryptoHistoryParams, + enabled: !!params, }); } diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoQuote.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoQuote.ts index 451e5417400..21bb3203c42 100644 --- a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoQuote.ts +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoQuote.ts @@ -7,15 +7,20 @@ import { type BuyWithCryptoQuote, type GetBuyWithCryptoQuoteParams, getBuyWithCryptoQuote, -} from "../../../../pay/buyWithCrypto/actions/getQuote.js"; +} from "../../../../pay/buyWithCrypto/getQuote.js"; +/** + * @internal + */ export type BuyWithCryptoQuoteQueryOptions = Omit< UseQueryOptions, "queryFn" | "queryKey" | "enabled" >; -export type BuyWithCryptoQuoteQueryParams = GetBuyWithCryptoQuoteParams; + /** - * Hook to get a quote of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote) for buying tokens with crypto. + * Hook to get a price quote for performing a "Buy with crypto" transaction that allows users to buy a token with another token - aka a swap. + * + * The price quote is an object of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote). * This quote contains the information about the purchase such as token amounts, processing fees, estimated time etc. * * This hook is a React Query wrapper of the [`getBuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoQuote) function. @@ -23,16 +28,16 @@ export type BuyWithCryptoQuoteQueryParams = GetBuyWithCryptoQuoteParams; * * Once you have the quote, you can use the [`useSendTransaction`](https://portal.thirdweb.com/references/typescript/v5/useSendTransaction) function to send the purchase * and [`useBuyWithCryptoStatus`](https://portal.thirdweb.com/references/typescript/v5/useBuyWithCryptoStatus) function to get the status of the swap transaction. - * @param buyWithCryptoParams - object of type [`BuyWithCryptoQuoteQueryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuoteQueryParams) + * @param params - object of type [`BuyWithCryptoQuoteQueryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuoteQueryParams) * @param queryParams - options to configure the react query * @returns A React Query object which contains the data of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote) * @example * ```tsx - * import { useSendTransaction, useBuyWithCryptoQuote, useBuyWithCryptoStatus, type BuyWithCryptoStatusQueryParams } from "thirdweb/react"; + * import { useBuyWithCryptoQuote, useBuyWithCryptoStatus, type BuyWithCryptoStatusQueryParams, useActiveAccount } from "thirdweb/react"; + * import { sendTransaction } from 'thirdweb'; * * function Component() { * const buyWithCryptoQuoteQuery = useBuyWithCryptoQuote(swapParams); - * const sendTransactionMutation = useSendTransaction(); * const [buyTxHash, setBuyTxHash] = useState(); * const buyWithCryptoStatusQuery = useBuyWithCryptoStatus(buyTxHash ? { * client, @@ -40,16 +45,23 @@ export type BuyWithCryptoQuoteQueryParams = GetBuyWithCryptoQuoteParams; * }: undefined); * * async function handleBuyWithCrypto() { + * const account = useActiveAccount(); * * // if approval is required * if (buyWithCryptoQuoteQuery.data.approval) { - * const approveTx = await sendTransactionMutation.mutateAsync(swapQuote.data.approval); + * const approveTx = await sendTransaction({ + * transaction: swapQuote.data.approval, + * account: account, + * }); * await waitForApproval(approveTx); * } * * // send the transaction to buy crypto * // this promise is resolved when user confirms the transaction in the wallet and the transaction is sent to the blockchain - * const buyTx = await sendTransactionMutation.mutateAsync(swapQuote.data.transactionRequest); + * const buyTx = await sendTransaction({ + * transaction: swapQuote.data.transactionRequest, + * account: account, + * }); * await waitForApproval(buyTx); * * // set buyTx.transactionHash to poll the status of the swap transaction @@ -62,26 +74,30 @@ export type BuyWithCryptoQuoteQueryParams = GetBuyWithCryptoQuoteParams; * @buyCrypto */ export function useBuyWithCryptoQuote( - buyWithCryptoParams?: BuyWithCryptoQuoteQueryParams, + params?: GetBuyWithCryptoQuoteParams, queryParams?: BuyWithCryptoQuoteQueryOptions, ): UseQueryResult { return useQuery({ ...queryParams, - - queryKey: ["buyWithCryptoQuote", buyWithCryptoParams], + queryKey: ["buyWithCryptoQuote", params], queryFn: () => { - if (!buyWithCryptoParams) { + if (!params) { throw new Error("Swap params are required"); } - if (!buyWithCryptoParams?.client) { - throw new Error("Client is required in swap params"); + + return getBuyWithCryptoQuote(params); + }, + enabled: !!params, + retry(failureCount, error) { + if (failureCount > 3) { + return false; } - return getBuyWithCryptoQuote({ - // typescript limitation with discriminated unions are collapsed - ...(buyWithCryptoParams as GetBuyWithCryptoQuoteParams), - client: buyWithCryptoParams.client, - }); + + if (error.message.includes("Minimum purchase")) { + return false; + } + + return true; }, - enabled: !!buyWithCryptoParams, }); } diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoStatus.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoStatus.ts index 2042cc21765..452b4c847f7 100644 --- a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoStatus.ts +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithCryptoStatus.ts @@ -1,47 +1,48 @@ import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { type BuyWithCryptoStatus, type BuyWithCryptoTransaction, getBuyWithCryptoStatus, -} from "../../../../pay/buyWithCrypto/actions/getStatus.js"; - -// TODO: use the estimate to vary the polling interval -const DEFAULT_POLL_INTERVAL = 5000; - -export type BuyWithCryptoStatusQueryParams = BuyWithCryptoTransaction; +} from "../../../../pay/buyWithCrypto/getStatus.js"; /** - * A hook to get a status of swap transaction. + * A hook to get a status of a "Buy with crypto" transaction to determine if the transaction is completed, failed or pending. * * This hook is a React Query wrapper of the [`getBuyWithCryptoStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoStatus) function. * You can also use that function directly. - * @param buyWithCryptoStatusParams - object of type [`BuyWithCryptoTransaction`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoTransaction) + * @param params - object of type [`BuyWithCryptoTransaction`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoTransaction) * @returns A react query object which contains the data of type [`BuyWithCryptoStatus`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoStatus) * @example * ```tsx - * import { useSendTransaction, useBuyWithCryptoQuote, useBuyWithCryptoStatus, type BuyWithCryptoStatusQueryParams } from "thirdweb/react"; + * import { useSendTransaction, useBuyWithCryptoQuote, useBuyWithCryptoStatus, type BuyWithCryptoStatusQueryParams, useActiveAccount } from "thirdweb/react"; + * import { sendTransaction } from 'thirdweb'; * * function Component() { * const buyWithCryptoQuoteQuery = useBuyWithCryptoQuote(swapParams); - * const sendTransactionMutation = useSendTransaction(); * const [buyTxHash, setBuyTxHash] = useState(); * const buyWithCryptoStatusQuery = useBuyWithCryptoStatus(buyTxHash ? { * client, * transactionHash: buyTxHash, * }: undefined); + * const account = useActiveAccount(); * * async function handleBuyWithCrypto() { * * // if approval is required * if (buyWithCryptoQuoteQuery.data.approval) { - * const approveTx = await sendTransactionMutation.mutateAsync(swapQuote.data.approval); + * const approveTx = await sendTransaction({ + * account: account, + * transaction: swapQuote.data.approval, + * }); * await waitForApproval(approveTx); * } * * // send the transaction to buy crypto * // this promise is resolved when user confirms the transaction in the wallet and the transaction is sent to the blockchain - * const buyTx = await sendTransactionMutation.mutateAsync(swapQuote.data.transactionRequest); + * const buyTx = await sendTransactionMutation.mutateAsync({ + * transaction: swapQuote.data.transactionRequest, + * account: account, + * }); * await waitForApproval(buyTx); * * // set buyTx.transactionHash to poll the status of the swap transaction @@ -53,40 +54,17 @@ export type BuyWithCryptoStatusQueryParams = BuyWithCryptoTransaction; * ``` * @buyCrypto */ -export function useBuyWithCryptoStatus( - buyWithCryptoStatusParams?: BuyWithCryptoStatusQueryParams, -) { - const [refetchInterval, setRefetchInterval] = useState( - DEFAULT_POLL_INTERVAL, - ); - +export function useBuyWithCryptoStatus(params?: BuyWithCryptoTransaction) { return useQuery({ - queryKey: [ - "swapStatus", - buyWithCryptoStatusParams?.transactionHash, - ] as const, + queryKey: ["getBuyWithCryptoStatus", params?.transactionHash] as const, queryFn: async () => { - if (!buyWithCryptoStatusParams) { - throw new Error("Missing swap status params"); - } - if (!buyWithCryptoStatusParams?.client) { - throw new Error("Missing client in swap status params"); - } - - const swapStatus_ = await getBuyWithCryptoStatus({ - ...buyWithCryptoStatusParams, - client: buyWithCryptoStatusParams.client, - }); - if ( - swapStatus_.status === "COMPLETED" || - swapStatus_.status === "FAILED" - ) { - setRefetchInterval(0); + if (!params) { + throw new Error("No params"); } - return swapStatus_; + return getBuyWithCryptoStatus(params); }, - enabled: !!buyWithCryptoStatusParams, - refetchInterval: refetchInterval, + enabled: !!params, + refetchInterval: 5000, refetchIntervalInBackground: true, retry: true, }); diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatHistory.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatHistory.ts new file mode 100644 index 00000000000..cad0c064da2 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatHistory.ts @@ -0,0 +1,54 @@ +import { + type UseQueryOptions, + type UseQueryResult, + useQuery, +} from "@tanstack/react-query"; +import { + type BuyWithFiatHistoryData, + type BuyWithFiatHistoryParams, + getBuyWithFiatHistory, +} from "../../../../pay/buyWithFiat/getHistory.js"; + +/** + * @internal + */ +export type BuyWithFiatHistoryQueryOptions = Omit< + UseQueryOptions, + "queryFn" | "queryKey" | "enabled" +>; + +/** + * Hook to get the "Buy with Fiat" transaction history for a given wallet. + * + * This hook is a React Query wrapper of the [`getBuyWithFiatHistory`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatHistory) function. + * You can also use that function directly + * @param params - object of type [`BuyWithFiatHistoryParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatHistoryParams) + * @param queryParams - options to configure the react query + * @returns A React Query object which contains the data of type [`BuyWithFiatHistoryData`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatHistoryData) + * @example + * ```tsx + * import { useBuyWithFiatHistory } from "thirdweb/react"; + * + * function Component() { + * const historyQuery = useBuyWithFiatHistory(params); + * return
...
+ * } + * ``` + * @buyFiat + */ +export function useBuyWithFiatHistory( + params?: BuyWithFiatHistoryParams, + queryParams?: BuyWithFiatHistoryQueryOptions, +): UseQueryResult { + return useQuery({ + ...queryParams, + queryKey: ["buyWithFiatHistory", params], + queryFn: () => { + if (!params) { + throw new Error("params are required"); + } + return getBuyWithFiatHistory(params); + }, + enabled: !!params, + }); +} diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatQuote.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatQuote.ts new file mode 100644 index 00000000000..dee064b962d --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatQuote.ts @@ -0,0 +1,94 @@ +import { + type UseQueryOptions, + type UseQueryResult, + useQuery, +} from "@tanstack/react-query"; +import { + type BuyWithFiatQuote, + type GetBuyWithFiatQuoteParams, + getBuyWithFiatQuote, +} from "../../../../pay/buyWithFiat/getQuote.js"; + +/** + * @internal + */ +export type BuyWithFiatQuoteQueryOptions = Omit< + UseQueryOptions, + "queryFn" | "queryKey" | "enabled" +>; + +/** + * Hook to get a price quote for performing a "Buy with Fiat" transaction that allows users to buy a token with fiat currency. + * + * The price quote is an object of type [`BuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatQuote). + * This quote contains the information about the purchase such as token amounts, processing fees, estimated time etc. + * + * This hook is a React Query wrapper of the [`getBuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithFiatQuote) function. + * You can also use that function directly + * + * Once you have the quote, you can open a new window with `onRampLink` as window location to allow the user to buy the token with fiat currency. + * and [`useBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/useBuyWithFiatStatus) function to start polling for the status of this transaction. + * + * @param params - object of type [`GetBuyWithFiatQuoteParams`](https://portal.thirdweb.com/references/typescript/v5/GetBuyWithFiatQuoteParams) + * @param queryParams - options to configure the react query + * @returns A React Query object which contains the data of type [`BuyWithFiatQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithFiatQuote) + * @example + * ```ts + * import { NATIVE_TOKEN_ADDRESS } from "thirdweb"; + * import { base } from "thirdweb/chains"; + * import { useBuyWithFiatQuote } from "thirdweb/react"; + * + * function Example() { + * const quote = useBuyWithFiatQuote({ + * client: client, // thirdweb client + * fromCurrencySymbol: "USD", // fiat currency symbol + * toChainId: base.id, // base chain id + * toAmount: "10", // amount of token to buy + * toTokenAddress: NATIVE_TOKEN_ADDRESS, // native token + * toAddress: "0x...", // user's wallet address + * }); + * + * return ( + *
+ * {quote.data && ( + * + * open onramp provider + * + * )} + *
+ * ); + * } + * ``` + * @buyFiat + */ +export function useBuyWithFiatQuote( + params?: GetBuyWithFiatQuoteParams, + queryOptions?: BuyWithFiatQuoteQueryOptions, +): UseQueryResult { + return useQuery({ + ...queryOptions, + queryKey: ["useBuyWithFiatQuote", params], + queryFn: async () => { + if (!params) { + throw new Error("No params provided"); + } + return getBuyWithFiatQuote(params); + }, + enabled: !!params, + retry(failureCount, error) { + if (failureCount > 3) { + return false; + } + try { + // biome-ignore lint/suspicious/noExplicitAny: + if ((error as any).error.code === "MINIMUM_PURCHASE_AMOUNT") { + return false; + } + } catch { + return true; + } + + return true; + }, + }); +} diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatStatus.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatStatus.ts new file mode 100644 index 00000000000..02b47209544 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatStatus.ts @@ -0,0 +1,52 @@ +import { type UseQueryResult, useQuery } from "@tanstack/react-query"; +import { + type BuyWithFiatStatus, + type GetBuyWithFiatStatusParams, + getBuyWithFiatStatus, +} from "../../../../pay/buyWithFiat/getStatus.js"; + +/** + * A hook to get a status of a "Buy with Fiat" transaction to determine if the transaction is completed, failed or pending. + * + * This hook is a React Query wrapper of the [`getBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/getBuyWithCryptoStatus) function. + * You can also use that function directly. + * + * `useBuyWithFiatStatus` refetches the status using `getBuyWithFiatStatus` every 5 seconds. + * + * @param params - object of type [`GetBuyWithFiatStatusParams`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoTransaction) + * @returns A react query object which contains the data of type [`BuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoStatus) + * @example + * ```tsx + * import { useBuyWithFiatStatus } from "thirdweb/react"; + * import { client } from "./client"; + * + * function Example() { + * const fiatStatus = useBuyWithFiatStatus({ + * client: client, // thirdweb client + * intentId: "....", // get the intentId from quote ( quote.intentId ) + * }); + * + * console.log(fiatStatus.data); + * + * return
...
; + * } + * ``` + * @buyFiat + */ +export function useBuyWithFiatStatus( + params?: GetBuyWithFiatStatusParams, +): UseQueryResult { + return useQuery({ + queryKey: ["useBuyWithFiatStatus", params], + queryFn: async () => { + if (!params) { + throw new Error("No params provided"); + } + return getBuyWithFiatStatus(params); + }, + enabled: !!params, + refetchInterval: 5000, + refetchIntervalInBackground: true, + retry: true, + }); +} diff --git a/packages/thirdweb/src/react/core/hooks/pay/usePostOnrampQuote.ts b/packages/thirdweb/src/react/core/hooks/pay/usePostOnrampQuote.ts new file mode 100644 index 00000000000..011768c8603 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/pay/usePostOnrampQuote.ts @@ -0,0 +1,48 @@ +import { + type UseQueryOptions, + type UseQueryResult, + useQuery, +} from "@tanstack/react-query"; +import type { BuyWithCryptoQuote } from "../../../../pay/buyWithCrypto/getQuote.js"; +import { + type GetPostOnRampQuoteParams, + getPostOnRampQuote, +} from "../../../../pay/buyWithFiat/getPostOnRampQuote.js"; + +/** + * @internal + */ +export type PostOnRampQuoteQueryOptions = Omit< + UseQueryOptions, + "queryFn" | "queryKey" | "enabled" +>; + +/** + * When buying a token with fiat currency, It may be a 2 step process where the user is sent an intermediate token from the on-ramp provider ( known as "On-ramp" token ) + * and then it needs to be swapped to destination token. + * + * When you get a "Buy with Fiat" status of type "CRYPTO_SWAP_REQUIRED" from the [`useBuyWithFiatStatus`](https://portal.thirdweb.com/references/typescript/v5/useBuyWithFiatStatus) hook, + * you can use `usePostOnRampQuote` hook to get the quote of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote) for swapping the on-ramp token to destination token to complete the step-2 of the process. + * + * Once you have the quote, you can start the Swap process by following the same steps as mentioned in the [`useBuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/useBuyWithCryptoQuote) documentation. + * + * @param params - object of type [`GetPostOnRampQuoteParams`](https://portal.thirdweb.com/references/typescript/v5/GetPostOnRampQuoteParams) + * @returns Object of type [`BuyWithCryptoQuote`](https://portal.thirdweb.com/references/typescript/v5/BuyWithCryptoQuote) which contains the information about the quote such as processing fees, estimated time, converted token amounts, etc. + * @buyFiat + */ +export function usePostOnRampQuote( + params?: GetPostOnRampQuoteParams, + queryOptions?: PostOnRampQuoteQueryOptions, +): UseQueryResult { + return useQuery({ + ...queryOptions, + queryKey: ["getPostOnRampQuote", params], + queryFn: async () => { + if (!params) { + throw new Error("No params provided"); + } + return getPostOnRampQuote(params); + }, + enabled: !!params, + }); +} diff --git a/packages/thirdweb/src/react/core/providers/invalidateWalletBalance.ts b/packages/thirdweb/src/react/core/providers/invalidateWalletBalance.ts new file mode 100644 index 00000000000..76550712eff --- /dev/null +++ b/packages/thirdweb/src/react/core/providers/invalidateWalletBalance.ts @@ -0,0 +1,12 @@ +import type { QueryClient } from "@tanstack/react-query"; + +export function invalidateWalletBalance( + queryClient: QueryClient, + chainId?: number, +) { + return queryClient.invalidateQueries({ + // invalidate any walletBalance queries for this chainId + // TODO: add wallet address in here if we can get it somehow + queryKey: ["walletBalance", chainId] as const, + }); +} diff --git a/packages/thirdweb/src/react/core/providers/thirdweb-provider.tsx b/packages/thirdweb/src/react/core/providers/thirdweb-provider.tsx index 1351361158a..2398061ffe8 100644 --- a/packages/thirdweb/src/react/core/providers/thirdweb-provider.tsx +++ b/packages/thirdweb/src/react/core/providers/thirdweb-provider.tsx @@ -8,6 +8,7 @@ import { import { isBaseTransactionOptions } from "../../../transaction/types.js"; import { isObjectWithKeys } from "../../../utils/type-guards.js"; import { SetRootElementContext } from "./RootElementContext.js"; +import { invalidateWalletBalance } from "./invalidateWalletBalance.js"; /** * The ThirdwebProvider is component is a provider component that sets up the React Query client. @@ -58,14 +59,10 @@ export function ThirdwebProvider(props: React.PropsWithChildren) { variables.__contract?.address, ] as const, }), - queryClient.invalidateQueries({ - // invalidate any walletBalance queries for this chainId - // TODO: add wallet address in here if we can get it somehow - queryKey: [ - "walletBalance", - variables.__contract?.chain.id, - ] as const, - }), + invalidateWalletBalance( + queryClient, + variables.__contract?.chain.id, + ), ]); }); } diff --git a/packages/thirdweb/src/react/web/hooks/useSendTransaction.tsx b/packages/thirdweb/src/react/web/hooks/useSendTransaction.tsx index e68b8c4a92e..ce4709f29b5 100644 --- a/packages/thirdweb/src/react/web/hooks/useSendTransaction.tsx +++ b/packages/thirdweb/src/react/web/hooks/useSendTransaction.tsx @@ -1,4 +1,5 @@ -import { useContext, useState } from "react"; +import { CheckCircledIcon } from "@radix-ui/react-icons"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import type { ThirdwebClient } from "../../../client/client.js"; import type { GaslessOptions } from "../../../transaction/actions/gasless/types.js"; import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js"; @@ -6,16 +7,20 @@ import type { Wallet } from "../../../wallets/interfaces/wallet.js"; import { useSendTransactionCore } from "../../core/hooks/contract/useSendTransaction.js"; import { useActiveWallet } from "../../core/hooks/wallets/wallet-hooks.js"; import { SetRootElementContext } from "../../core/providers/RootElementContext.js"; -import { - type SupportedTokens, - defaultTokens, -} from "../ui/ConnectWallet/defaultTokens.js"; +import type { PayUIOptions } from "../ui/ConnectWallet/ConnectButtonProps.js"; +import type { SupportedTokens } from "../ui/ConnectWallet/defaultTokens.js"; +import { AccentFailIcon } from "../ui/ConnectWallet/icons/AccentFailIcon.js"; import { useConnectLocale } from "../ui/ConnectWallet/locale/getConnectLocale.js"; -import { BuyScreen } from "../ui/ConnectWallet/screens/Buy/SwapScreen.js"; -import { SwapTransactionsScreen } from "../ui/ConnectWallet/screens/SwapTransactionsScreen.js"; +import { LazyBuyScreen } from "../ui/ConnectWallet/screens/Buy/LazyBuyScreen.js"; +import { BuyTxHistory } from "../ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistory.js"; import { Modal } from "../ui/components/Modal.js"; +import { Spacer } from "../ui/components/Spacer.js"; +import { Spinner } from "../ui/components/Spinner.js"; +import { Container, ModalHeader } from "../ui/components/basic.js"; +import { Button } from "../ui/components/buttons.js"; +import { Text } from "../ui/components/text.js"; import { CustomThemeProvider } from "../ui/design-system/CustomThemeProvider.js"; -import type { Theme } from "../ui/design-system/index.js"; +import { type Theme, iconSize } from "../ui/design-system/index.js"; import type { LocaleId } from "../ui/types.js"; import { LoadingScreen } from "../wallets/shared/LoadingScreen.js"; @@ -44,6 +49,12 @@ export type SendTransactionPayModalConfig = locale?: LocaleId; supportedTokens?: SupportedTokens; theme?: Theme | "light" | "dark"; + buyWithCrypto?: false; + buyWithFiat?: + | false + | { + testMode?: boolean; + }; } | false; @@ -52,26 +63,10 @@ export type SendTransactionPayModalConfig = */ export type SendTransactionConfig = { /** - * Configuration for the "Pay Modal" that opens when the user doesn't have enough funds to send a transaction. - * Set `payModal: false` to disable the "Pay Modal" popup - * - * This configuration object includes the following properties to configure the "Pay Modal" UI: - * - * ### `locale` - * The language to use for the "Pay Modal" UI. Defaults to `"en_US"`. - * - * ### `supportedTokens` - * An object of type [`SupportedTokens`](https://portal.thirdweb.com/references/typescript/v5/SupportedTokens) to configure the tokens to show for a chain. - * - * ### `theme` - * The theme to use for the "Pay Modal" UI. Defaults to `"dark"`. - * - * It can be set to `"light"` or `"dark"` or an object of type [`Theme`](https://portal.thirdweb.com/references/typescript/v5/Theme) for a custom theme. - * - * Refer to [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme) - * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) helper functions to use the default light or dark theme and customize it. + * Refer to [`SendTransactionPayModalConfig`](https://portal.thirdweb.com/references/typescript/v5/SendTransactionPayModalConfig) for more details. */ payModal?: SendTransactionPayModalConfig; + /** * Configuration for gasless transactions. * Refer to [`GaslessOptions`](https://portal.thirdweb.com/references/typescript/v5/GaslessOptions) for more details. @@ -133,11 +128,15 @@ export function useSendTransaction(config: SendTransactionConfig = {}) { }} client={data.tx.client} localeId={payModal?.locale || "en_US"} - supportedTokens={payModal?.supportedTokens || defaultTokens} + supportedTokens={payModal?.supportedTokens} theme={payModal?.theme || "dark"} txCostWei={data.totalCostWei} walletBalanceWei={data.walletBalance.value} nativeTokenSymbol={data.walletBalance.symbol} + payOptions={{ + buyWithCrypto: payModal?.buyWithCrypto, + buyWithFiat: payModal?.buyWithFiat, + }} />, ); }, @@ -150,12 +149,13 @@ type ModalProps = { onClose: () => void; client: ThirdwebClient; localeId: LocaleId; - supportedTokens: SupportedTokens; + supportedTokens?: SupportedTokens; theme: Theme | "light" | "dark"; txCostWei: bigint; walletBalanceWei: bigint; nativeTokenSymbol: string; tx: PreparedTransaction; + payOptions: PayUIOptions; }; function TxModal(props: ModalProps) { @@ -178,29 +178,38 @@ function TxModal(props: ModalProps) { function ModalContent(props: ModalProps) { const localeQuery = useConnectLocale(props.localeId); - const [screen, setScreen] = useState<"buy" | "tx-history">("buy"); + const [screen, setScreen] = useState<"buy" | "tx-history" | "execute-tx">( + "buy", + ); if (!localeQuery.data) { return ; } + if (screen === "execute-tx") { + return ; + } + if (screen === "tx-history") { return ( - { - props.onClose(); + setScreen("buy"); + }} + onDone={() => { + setScreen("execute-tx"); }} + isBuyForTx={true} + isEmbed={false} /> ); } return ( - { - props.onClose(); - }} onViewPendingTx={() => { setScreen("tx-history"); }} @@ -212,6 +221,86 @@ function ModalContent(props: ModalProps) { tx: props.tx, tokenSymbol: props.nativeTokenSymbol, }} + theme={typeof props.theme === "string" ? props.theme : props.theme.type} + payOptions={props.payOptions} + onDone={() => { + setScreen("execute-tx"); + }} /> ); } + +function ExecutingTxScreen(props: { + tx: PreparedTransaction; + closeModal: () => void; +}) { + const sendTxCore = useSendTransactionCore(); + const [status, setStatus] = useState<"loading" | "failed" | "sent">( + "loading", + ); + + const sendTx = useCallback(async () => { + setStatus("loading"); + try { + await sendTxCore.mutateAsync(props.tx); + setStatus("sent"); + } catch (e) { + console.error(e); + setStatus("failed"); + } + }, [sendTxCore, props.tx]); + + const done = useRef(false); + useEffect(() => { + if (done.current) { + return; + } + + done.current = true; + sendTx(); + }, [sendTx]); + + return ( + + + + + + + {status === "loading" && } + {status === "failed" && } + {status === "sent" && ( + + + + )} + + + + + + {status === "loading" && "Sending transaction"} + {status === "failed" && "Transaction failed"} + {status === "sent" && "Transaction sent"} + + + + + + {status === "failed" && ( + + )} + + {status === "sent" && ( + + )} + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx index 09c24193074..dfba3ded8c0 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx @@ -158,7 +158,7 @@ function ConnectButtonInner( const supportedTokens = useMemo(() => { if (!props.supportedTokens) { - return defaultTokens; + return undefined; } const tokens = { ...defaultTokens }; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButtonProps.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButtonProps.ts index 3281af52927..d276c5c75c1 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButtonProps.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButtonProps.ts @@ -10,6 +10,72 @@ import type { NetworkSelectorProps } from "./NetworkSelector.js"; import type { SupportedTokens } from "./defaultTokens.js"; import type { WelcomeScreen } from "./screens/types.js"; +export type PayUIOptions = { + /** + * Prefill the Buy Token amount, chain and/or token. + * You can also disable the edits for the prefilled values using `allowEdits` - By default all are editable + * + * For example, if you want to allow changing the amount, but disable changing the token and chain, + * you can set `allowEdits` to `{ amount: true, token: false, chain: false }` + * + * If no `token` object is not specified, native token will be prefilled by default + */ + prefillBuy?: { + chain: Chain; + token?: { + name: string; + symbol: string; + address: string; + icon?: string; + }; + amount?: string; + allowEdits?: { + amount: boolean; + token: boolean; + chain: boolean; + }; + }; + + /** + * Configure options for buying tokens using other token ( aka Swap ) + * + * By default, the "Crypto" option is enabled. You can disable it by setting `buyWithCrypto` to `false` + * + * You can also prefill the source token and chain for the swap to customize the default values. + * You can also disable the edits for the prefilled values by setting `allowEdits` - By default all are editable + * + * For example, if you want to allow selecting chain and but disable selecting token, you can set `allowEdits` to `{ token: false, chain: true }` + */ + buyWithCrypto?: + | false + | { + prefillSource?: { + chain: Chain; + token?: { + name: string; + symbol: string; + address: string; + icon?: string; + }; + allowEdits?: { + token: boolean; + chain: boolean; + }; + }; + }; + + /** + * By default "Credit card" option is enabled. you can disable it by setting `buyWithFiat` to `false` + * + * You can also enable the test mode for the on-ramp provider to test on-ramp without using real credit card. + */ + buyWithFiat?: + | { + testMode?: boolean; + } + | false; +}; + /** * Options for configuring the `ConnectButton`'s Connect Button * @connectWallet @@ -109,6 +175,13 @@ export type ConnectButton_detailsModalOptions = { * ``` */ footer?: (props: { close: () => void }) => JSX.Element; + + /** + * Configure options for thirdweb Pay. + * + * thirdweb Pay allows users to buy tokens using crypto or fiat currency. + */ + payOptions?: PayUIOptions; }; /** diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx index bac30968c02..94b1343fb20 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx @@ -9,7 +9,7 @@ import { TextAlignJustifyIcon, } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useEffect, useState } from "react"; import { ethereum } from "../../../../chains/chain-definitions/ethereum.js"; import type { Chain } from "../../../../chains/types.js"; import { getContract } from "../../../../contract/contract.js"; @@ -44,7 +44,7 @@ import { Button, IconButton } from "../components/buttons.js"; import { Link, Text } from "../components/text.js"; import { useCustomTheme } from "../design-system/CustomThemeProvider.js"; import { fadeInAnimation } from "../design-system/animations.js"; -import { StyledButton, StyledDiv } from "../design-system/elements.js"; +import { StyledButton } from "../design-system/elements.js"; import { type Theme, fontSize, @@ -64,11 +64,12 @@ import type { SupportedTokens } from "./defaultTokens.js"; import { FundsIcon } from "./icons/FundsIcon.js"; import { SmartWalletBadgeIcon } from "./icons/SmartAccountBadgeIcon.js"; import { WalletIcon } from "./icons/WalletIcon.js"; -import { BuyScreen } from "./screens/Buy/SwapScreen.js"; -import { swapTransactionsStore } from "./screens/Buy/swap/pendingSwapTx.js"; +import { genericTokenIcon } from "./icons/dataUris.js"; +import { LazyBuyScreen } from "./screens/Buy/LazyBuyScreen.js"; +import { BuyTxHistory } from "./screens/Buy/tx-history/BuyTxHistory.js"; import { ReceiveFunds } from "./screens/ReceiveFunds.js"; import { SendFunds } from "./screens/SendFunds.js"; -import { SwapTransactionsScreen } from "./screens/SwapTransactionsScreen.js"; +import { ViewFunds } from "./screens/ViewFunds.js"; const TW_CONNECTED_WALLET = "tw-connected-wallet"; @@ -81,7 +82,8 @@ type WalletDetailsModalScreen = | "receive" | "buy" | "network-switcher" - | "pending-tx"; + | "pending-tx" + | "view-funds"; /** * @internal @@ -91,23 +93,18 @@ export const ConnectedWalletDetails: React.FC<{ detailsButton?: ConnectButton_detailsButtonOptions; detailsModal?: ConnectButton_detailsModalOptions; theme: "light" | "dark" | Theme; - supportedTokens: SupportedTokens; + supportedTokens?: SupportedTokens; chains: Chain[]; chain?: Chain; switchButton: ConnectButtonProps["switchButton"]; }> = (props) => { const { connectLocale: locale, client } = useConnectUI(); + const activeWallet = useActiveWallet(); const activeAccount = useActiveAccount(); const walletChain = useActiveWalletChain(); const chainQuery = useChainQuery(walletChain); const { disconnect } = useDisconnect(); - const swapTxs = useSyncExternalStore( - swapTransactionsStore.subscribe, - swapTransactionsStore.getValue, - ); - const pendingSwapTxs = swapTxs.filter((tx) => tx.status === "PENDING"); - // prefetch chains metadata with low concurrency useChainsQuery(props.chains, 5); @@ -480,12 +477,26 @@ export const ConnectedWalletDetails: React.FC<{ {locale.transactions} - {pendingSwapTxs && pendingSwapTxs.length > 0 && ( - {pendingSwapTxs.length} - )} + { + setScreen("view-funds"); + }} + style={{ + fontSize: fontSize.sm, + }} + > + + View Funds + + {/* Switch to Personal Wallet */} {/* {personalWallet && !props.detailsModal?.hideSwitchToPersonalWallet && ( @@ -585,9 +596,14 @@ export const ConnectedWalletDetails: React.FC<{ if (screen === "pending-tx") { content = ( - setScreen("main")} client={client} + onDone={() => { + setIsOpen(false); + }} /> ); } @@ -629,6 +645,19 @@ export const ConnectedWalletDetails: React.FC<{ // ); // } + // send funds + else if (screen === "view-funds") { + content = ( + { + setScreen("main"); + }} + client={client} + /> + ); + } + // send funds else if (screen === "send") { content = ( @@ -656,12 +685,18 @@ export const ConnectedWalletDetails: React.FC<{ // swap tokens else if (screen === "buy") { content = ( - setScreen("main")} supportedTokens={props.supportedTokens} onViewPendingTx={() => setScreen("pending-tx")} connectLocale={locale} + payOptions={props.detailsModal?.payOptions || {}} + theme={typeof props.theme === "string" ? props.theme : props.theme.type} + onDone={() => { + setIsOpen(false); + }} /> ); } @@ -751,22 +786,6 @@ const MenuButton = /* @__PURE__ */ StyledButton(() => { }; }); -const BadgeCount = /* @__PURE__ */ StyledDiv(() => { - const theme = useCustomTheme(); - return { - background: theme.colors.primaryButtonBg, - color: theme.colors.primaryButtonText, - fontSize: fontSize.sm, - fontWeight: 500, - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - minWidth: "22px", - minHeight: "22px", - }; -}); - const MenuLink = /* @__PURE__ */ (() => MenuButton.withComponent("a"))(); const StyledChevronRightIcon = /* @__PURE__ */ styled( diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx index 91f112516f1..32abf976bee 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx @@ -2,10 +2,10 @@ import { useEffect, useMemo } from "react"; import type { Chain } from "../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; -import type { SiweAuthOptions } from "../../../../../exports/react.js"; import type { 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 { useSiweAuth } from "../../../../core/hooks/auth/useSiweAuth.js"; import { AutoConnect } from "../../../../core/hooks/connection/useAutoConnect.js"; import { diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/defaultTokens.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/defaultTokens.ts index 6b50e2fe119..c7d1ce31515 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/defaultTokens.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/defaultTokens.ts @@ -2,7 +2,7 @@ export type TokenInfo = { name: string; symbol: string; address: string; - icon: string; + icon?: string; }; const wrappedEthIcon = diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/CADIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/CADIcon.tsx new file mode 100644 index 00000000000..3cf4eca4764 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/CADIcon.tsx @@ -0,0 +1,28 @@ +import type { IconFC } from "../types.js"; + +export const CADIcon: IconFC = (props) => { + return ( + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/EURIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/EURIcon.tsx new file mode 100644 index 00000000000..82f7ef76300 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/EURIcon.tsx @@ -0,0 +1,24 @@ +import type { IconFC } from "../types.js"; + +export const EURIcon: IconFC = (props) => { + return ( + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/GBPIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/GBPIcon.tsx new file mode 100644 index 00000000000..81332134c15 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/GBPIcon.tsx @@ -0,0 +1,75 @@ +import type { IconFC } from "../types.js"; + +export const GBPIcon: IconFC = (props) => { + return ( + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/USDIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/USDIcon.tsx new file mode 100644 index 00000000000..a204946c5ae --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/currencies/USDIcon.tsx @@ -0,0 +1,43 @@ +import type { IconFC } from "../types.js"; + +/** + * @internal + */ +export const USDIcon: IconFC = (props) => { + return ( + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/AccountSelectionScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/AccountSelectionScreen.tsx deleted file mode 100644 index 74ef66138ad..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/AccountSelectionScreen.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import styled from "@emotion/styled"; -import { useMemo, useState } from "react"; -import type { ThirdwebClient } from "../../../../../../client/client.js"; -import { isAddress } from "../../../../../../utils/address.js"; -import type { - Account, - Wallet, -} from "../../../../../../wallets/interfaces/wallet.js"; -import { Spacer } from "../../../components/Spacer.js"; -import { Container } from "../../../components/basic.js"; -import { Button } from "../../../components/buttons.js"; -import { Input } from "../../../components/formElements.js"; -import { Text } from "../../../components/text.js"; -import { useCustomTheme } from "../../../design-system/CustomThemeProvider.js"; -import { AccountSelectorButton } from "./AccountSelectorButton.js"; - -/** - * @internal - */ -export function AccountSelectionScreen(props: { - activeWallet: Wallet; - activeAccount: Account; - onSelect: (address: string) => void; - client: ThirdwebClient; -}) { - const [address, setAddress] = useState(""); - const isValidAddress = useMemo(() => isAddress(address), [address]); - const showError = !!address && !isValidAddress; - - return ( -
- - Send to - - - - setAddress(e.target.value)} - /> - - - - {showError && ( - <> - - - Invalid address - - - )} - - - Connected - - { - props.onSelect(props.activeAccount.address); - }} - /> -
- ); -} - -const StyledInput = /* @__PURE__ */ styled(Input)(() => { - const theme = useCustomTheme(); - return { - border: `1.5px solid ${theme.colors.borderColor}`, - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - height: "100%", - boxSizing: "border-box", - boxShadow: "none", - borderRight: "none", - "&:focus": { - boxShadow: "none", - borderColor: theme.colors.accentText, - }, - "&[data-is-error='true']": { - boxShadow: "none", - borderColor: theme.colors.danger, - }, - }; -}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/AccountSelectorButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/AccountSelectorButton.tsx deleted file mode 100644 index 3b95aaca220..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/AccountSelectorButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import type { ThirdwebClient } from "../../../../../../client/client.js"; -import type { - Account, - Wallet, -} from "../../../../../../wallets/interfaces/wallet.js"; -import { shortenString } from "../../../../../core/utils/addresses.js"; -import { WalletImage } from "../../../components/WalletImage.js"; -import { Container } from "../../../components/basic.js"; -import { Text } from "../../../components/text.js"; -import { iconSize } from "../../../design-system/index.js"; -import { WalletIcon } from "../../icons/WalletIcon.js"; -import { SecondaryButton } from "./buttons.js"; - -/** - * - * @internal - */ -export function AccountSelectorButton(props: { - onClick: () => void; - activeWallet: Wallet; - activeAccount: Account; - address: string; - chevron?: boolean; - client: ThirdwebClient; -}) { - return ( - - {props.activeAccount.address === props.address ? ( - - ) : ( - - - - )} - - - {shortenString(props.address, false)} - - {props.chevron && ( - - )} - - ); -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx new file mode 100644 index 00000000000..e8452c822cc --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx @@ -0,0 +1,1091 @@ +import { useMemo, useState } from "react"; +import type { Chain } from "../../../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js"; +import type { GetBuyWithCryptoQuoteParams } from "../../../../../../pay/buyWithCrypto/getQuote.js"; +import { isSwapRequiredPostOnramp } from "../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; +import { formatNumber } from "../../../../../../utils/formatNumber.js"; +import { toEther } from "../../../../../../utils/units.js"; +import type { Account } from "../../../../../../wallets/interfaces/wallet.js"; +import { + useChainQuery, + useChainsQuery, +} from "../../../../../core/hooks/others/useChainQuery.js"; +import { useWalletBalance } from "../../../../../core/hooks/others/useWalletBalance.js"; +import { useBuyWithCryptoQuote } from "../../../../../core/hooks/pay/useBuyWithCryptoQuote.js"; +import { useBuyWithFiatQuote } from "../../../../../core/hooks/pay/useBuyWithFiatQuote.js"; +import { + useActiveAccount, + useActiveWalletChain, +} from "../../../../../core/hooks/wallets/wallet-hooks.js"; +import { LoadingScreen } from "../../../../wallets/shared/LoadingScreen.js"; +import { + Drawer, + DrawerOverlay, + useDrawer, +} from "../../../components/Drawer.js"; +import { DynamicHeight } from "../../../components/DynamicHeight.js"; +import { Skeleton } from "../../../components/Skeleton.js"; +import { Spacer } from "../../../components/Spacer.js"; +import { Spinner } from "../../../components/Spinner.js"; +import { SwitchNetworkButton } from "../../../components/SwitchNetwork.js"; +import { TokenIcon } from "../../../components/TokenIcon.js"; +import { Container, Line, ModalHeader } from "../../../components/basic.js"; +import { Button } from "../../../components/buttons.js"; +import { Text } from "../../../components/text.js"; +import { type Theme, fontSize, spacing } from "../../../design-system/index.js"; +import type { PayUIOptions } from "../../ConnectButtonProps.js"; +import { ChainButton, NetworkSelectorContent } from "../../NetworkSelector.js"; +import type { SupportedTokens } from "../../defaultTokens.js"; +import type { ConnectLocale } from "../../locale/types.js"; +import { TokenSelector } from "../TokenSelector.js"; +import { + type ERC20OrNativeToken, + NATIVE_TOKEN, + isNativeToken, +} from "../nativeToken.js"; +import { EstimatedTimeAndFees } from "./EstimatedTimeAndFees.js"; +import { PayWithCreditCard } from "./PayWIthCreditCard.js"; +import { PaymentSelection } from "./PaymentSelection.js"; +import { FiatFlow } from "./fiat/FiatFlow.js"; +import type { CurrencyMeta } from "./fiat/currencies.js"; +import type { BuyForTx, SelectedScreen } from "./main/types.js"; +import { useBuyTxStates } from "./main/useBuyTxStates.js"; +import { useEnabledPaymentMethods } from "./main/useEnabledPaymentMethods.js"; +import { useUISelectionStates } from "./main/useUISelectionStates.js"; +import { openOnrampPopup } from "./openOnRamppopup.js"; +import { BuyTokenInput } from "./swap/BuyTokenInput.js"; +import { FiatFees, SwapFees } from "./swap/Fees.js"; +import { PayWithCrypto } from "./swap/PayWithCrypto.js"; +import { SwapFlow } from "./swap/SwapFlow.js"; +import { addPendingTx } from "./swap/pendingSwapTx.js"; +import { + type SupportedChainAndTokens, + useBuySupportedDestinations, + useBuySupportedSources, +} from "./swap/useSwapSupportedChains.js"; + +// NOTE: Must not use useConnectUI here because this UI can be used outside connect ui + +export type BuyScreenProps = { + onBack?: () => void; + supportedTokens?: SupportedTokens; + onViewPendingTx: () => void; + client: ThirdwebClient; + connectLocale: ConnectLocale; + buyForTx?: BuyForTx; + payOptions: PayUIOptions; + theme: "light" | "dark" | Theme; + onDone: () => void; + connectButton?: React.ReactNode; + isEmbed: boolean; +}; + +/** + * @internal + */ +export default function BuyScreen(props: BuyScreenProps) { + const supportedDestinationsQuery = useBuySupportedDestinations(props.client); + + if (!supportedDestinationsQuery.data) { + return ; + } + + return ( + + ); +} + +type BuyScreenContentProps = { + client: ThirdwebClient; + onBack?: () => void; + supportedTokens?: SupportedTokens; + onViewPendingTx: () => void; + supportedDestinations: SupportedChainAndTokens; + connectLocale: ConnectLocale; + buyForTx?: BuyForTx; + theme: "light" | "dark" | Theme; + payOptions: PayUIOptions; + onDone: () => void; + connectButton?: React.ReactNode; + isEmbed: boolean; +}; + +function useBuyScreenStates(options: { + payOptions: PayUIOptions; +}) { + const { payOptions } = options; + + const [method, setMethod] = useState<"crypto" | "creditCard">( + payOptions.buyWithCrypto === false + ? "creditCard" + : payOptions.buyWithFiat === false + ? "crypto" + : "creditCard", + ); + + const [screen, setScreen] = useState({ + type: "main", + }); + + const [drawerScreen, setDrawerScreen] = useState(); + const { drawerRef, drawerOverlayRef, onClose } = useDrawer(); + + function closeDrawer() { + onClose(() => { + setDrawerScreen(undefined); + }); + } + + function showMainScreen() { + setScreen({ + type: "main", + }); + } + + return { + method, + setMethod, + screen, + setScreen, + drawerScreen, + setDrawerScreen, + drawerRef, + drawerOverlayRef, + closeDrawer, + showMainScreen, + }; +} + +/** + * @internal + */ +function BuyScreenContent(props: BuyScreenContentProps) { + const { client, supportedDestinations, connectLocale, payOptions, buyForTx } = + props; + + const account = useActiveAccount(); + const activeChain = useActiveWalletChain(); + + // prefetch chains metadata for destination chains + useChainsQuery(supportedDestinations.map((x) => x.chain) || [], 50); + + // screen + const { + method, + setMethod, + screen, + setScreen, + drawerScreen, + setDrawerScreen, + drawerRef, + drawerOverlayRef, + closeDrawer, + showMainScreen, + } = useBuyScreenStates({ payOptions }); + + // UI selection + const { + tokenAmount, + setTokenAmount, + setHasEditedAmount, + hasEditedAmount, + toChain, + setToChain, + deferredTokenAmount, + fromChain, + setFromChain, + toToken, + setToToken, + fromToken, + setFromToken, + selectedCurrency, + } = useUISelectionStates({ + payOptions, + buyForTx, + supportedDestinations, + }); + + // Buy Transaction flow states + const { amountNeeded } = useBuyTxStates({ + setTokenAmount, + buyForTx, + hasEditedAmount, + isMainScreen: screen.type === "main", + }); + + // check if the screen is expanded or not + const isExpanded = activeChain && tokenAmount; + + // update supportedSources whenever toToken or toChain is updated + const supportedSourcesQuery = useBuySupportedSources({ + client: props.client, + destinationChainId: toChain.id, + destinationTokenAddress: isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address, + }); + + const destinationSupportedTokens: SupportedTokens = useMemo(() => { + return createSupportedTokens( + supportedDestinations, + payOptions, + props.supportedTokens, + ); + }, [props.supportedTokens, supportedDestinations, payOptions]); + + const sourceSupportedTokens: SupportedTokens | undefined = useMemo(() => { + if (!supportedSourcesQuery.data) { + return undefined; + } + + return createSupportedTokens( + supportedSourcesQuery.data, + payOptions, + props.supportedTokens, + ); + }, [props.supportedTokens, supportedSourcesQuery.data, payOptions]); + + const { showPaymentSelection } = useEnabledPaymentMethods({ + payOptions, + supportedDestinations, + toChain, + toToken, + method, + setMethod, + }); + + // screens ---------------------------- + + if (screen.type === "node") { + return screen.node; + } + + if (screen.type === "screen-id" && screen.name === "select-to-token") { + const chains = supportedDestinations.map((x) => x.chain); + // if token selection is disabled - only show network selector screen + if (payOptions.prefillBuy?.allowEdits?.token === false) { + return ( + + ); + } + + return ( + x.address !== NATIVE_TOKEN_ADDRESS)} + onTokenSelect={(tokenInfo) => { + setToToken(tokenInfo); + showMainScreen(); + }} + chain={toChain} + chainSelection={ + // hide chain selection if it's disabled + payOptions.prefillBuy?.allowEdits?.chain !== false + ? { + chains: chains, + select: (c) => { + setToChain(c); + }, + } + : undefined + } + connectLocale={connectLocale} + client={client} + /> + ); + } + + if ( + screen.type === "screen-id" && + screen.name === "select-from-token" && + supportedSourcesQuery.data && + sourceSupportedTokens + ) { + const chains = supportedSourcesQuery.data.map((x) => x.chain); + // if token selection is disabled - only show network selector screen + if ( + payOptions.buyWithCrypto !== false && + payOptions.buyWithCrypto?.prefillSource?.allowEdits?.token === false + ) { + return ( + + ); + } + + return ( + x.address !== NATIVE_TOKEN_ADDRESS)} + onTokenSelect={(tokenInfo) => { + setFromToken(tokenInfo); + setScreen({ + type: "main", + }); + }} + chain={fromChain} + chainSelection={ + // hide chain selection if it's disabled + payOptions.buyWithCrypto !== false && + payOptions.buyWithCrypto?.prefillSource?.allowEdits?.chain !== false + ? { + chains: supportedSourcesQuery.data.map((x) => x.chain), + select: (c) => setFromChain(c), + } + : undefined + } + connectLocale={connectLocale} + client={client} + /> + ); + } + + return ( + + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
{ + if ( + drawerScreen && + drawerRef.current && + !drawerRef.current.contains(e.target as Node) + ) { + e.preventDefault(); + e.stopPropagation(); + closeDrawer(); + } + }} + > + {/* Drawer */} + {drawerScreen && ( + <> + + + {drawerScreen} + + + )} + + + + + + {!isExpanded && } + + {/* Amount needed for Send Tx */} + {amountNeeded && props.buyForTx ? ( + + ) : null} + + {/* To */} + { + setHasEditedAmount(true); + setTokenAmount(value); + }} + freezeAmount={payOptions.prefillBuy?.allowEdits?.amount === false} + freezeChainAndToken={ + payOptions.prefillBuy?.allowEdits?.chain === false && + payOptions.prefillBuy?.allowEdits?.token === false + } + token={toToken} + chain={toChain} + onSelectToken={() => { + setScreen({ + type: "screen-id", + name: "select-to-token", + }); + }} + client={props.client} + hideTokenSelector={!!props.buyForTx} + /> + + + {showPaymentSelection ? : } + + {isExpanded && ( + <> + {showPaymentSelection && ( + + + + + )} + + {method === "crypto" && account && activeChain && ( + { + setScreen({ + type: "screen-id", + name: "select-from-token", + }); + }} + account={account} + activeChain={activeChain} + /> + )} + + {method === "creditCard" && account && ( + { + // currently disabled because we are only using Stripe + }} + account={account} + /> + )} + + + + )} + + + + {!isExpanded && ( + <> + {!account && props.connectButton ? ( +
{props.connectButton}
+ ) : ( + + )} + + )} +
+ + +
+
+ ); +} + +function SwapScreenContent( + props: BuyScreenContentProps & { + setDrawerScreen: (screen: React.ReactNode) => void; + setScreen: (screen: SelectedScreen) => void; + tokenAmount: string; + toToken: ERC20OrNativeToken; + toChain: Chain; + fromChain: Chain; + fromToken: ERC20OrNativeToken; + showFromTokenSelector: () => void; + account: Account; + activeChain: Chain; + }, +) { + const { + setDrawerScreen, + setScreen, + account, + client, + toChain, + tokenAmount, + toToken, + fromChain, + fromToken, + showFromTokenSelector, + payOptions, + } = props; + + const fromTokenBalanceQuery = useWalletBalance({ + address: account.address, + chain: fromChain, + tokenAddress: isNativeToken(fromToken) ? undefined : fromToken.address, + client, + }); + + const quoteParams: GetBuyWithCryptoQuoteParams | undefined = + tokenAmount && !(fromChain.id === toChain.id && fromToken === toToken) + ? { + // wallet + fromAddress: account.address, + // from token + fromChainId: fromChain.id, + fromTokenAddress: isNativeToken(fromToken) + ? NATIVE_TOKEN_ADDRESS + : fromToken.address, + toChainId: toChain.id, + // to + toTokenAddress: isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address, + toAmount: tokenAmount, + client, + } + : undefined; + + const quoteQuery = useBuyWithCryptoQuote(quoteParams, { + // refetch every 30 seconds + staleTime: 30 * 1000, + refetchInterval: 30 * 1000, + gcTime: 30 * 1000, + }); + + const sourceTokenAmount = quoteQuery.data?.swapDetails.fromAmount; + + const isNotEnoughBalance = + !!sourceTokenAmount && + !!fromTokenBalanceQuery.data && + Number(fromTokenBalanceQuery.data.displayValue) < Number(sourceTokenAmount); + + const disableContinue = !quoteQuery.data || isNotEnoughBalance; + const switchChainRequired = props.activeChain.id !== fromChain.id; + + function getErrorMessage(err: Error) { + const defaultMessage = "Unable to get price quote"; + try { + if (err instanceof Error) { + if (err.message.includes("Minimum")) { + const msg = err.message; + return msg.replace("Fetch failed: Error: ", ""); + } + } + return defaultMessage; + } catch { + return defaultMessage; + } + } + + function showSwapFlow() { + if (!quoteQuery.data) { + return; + } + + setScreen({ + type: "node", + node: ( + { + setScreen({ + type: "main", + }); + }} + buyWithCryptoQuote={quoteQuery.data} + account={account} + onViewPendingTx={props.onViewPendingTx} + isFiatFlow={false} + onDone={props.onDone} + onTryAgain={() => { + setScreen({ + type: "main", + }); + quoteQuery.refetch(); + }} + /> + ), + }); + } + + function showFees() { + if (!quoteQuery.data) { + return; + } + + setDrawerScreen( +
+ + Fees + + + +
, + ); + } + + const prefillSource = + payOptions.buyWithCrypto !== false + ? payOptions.buyWithCrypto?.prefillSource + : undefined; + + return ( + + {/* Quote info */} +
+ + +
+ + {/* Error */} + {quoteQuery.error && ( + + {getErrorMessage(quoteQuery.error)} + + )} + + {/* Button */} + {switchChainRequired && + !quoteQuery.isLoading && + !isNotEnoughBalance && + !quoteQuery.error ? ( + + ) : ( + + )} +
+ ); +} + +function FiatScreenContent( + props: BuyScreenContentProps & { + setDrawerScreen: (screen: React.ReactNode) => void; + setScreen: (screen: SelectedScreen) => void; + tokenAmount: string; + toToken: ERC20OrNativeToken; + toChain: Chain; + closeDrawer: () => void; + selectedCurrency: CurrencyMeta; + showCurrencySelector: () => void; + account: Account; + }, +) { + const { + toToken, + tokenAmount, + account, + client, + setScreen, + setDrawerScreen, + toChain, + showCurrencySelector, + selectedCurrency, + } = props; + + const buyWithFiatOptions = props.payOptions.buyWithFiat; + + const fiatQuoteQuery = useBuyWithFiatQuote( + buyWithFiatOptions !== false && tokenAmount + ? { + fromCurrencySymbol: "USD", // STRIPE only supports USD + toChainId: toChain.id, + toAddress: account.address, + toTokenAddress: isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address, + toAmount: tokenAmount, + client, + isTestMode: buyWithFiatOptions?.testMode, + } + : undefined, + ); + + function handleSubmit() { + if (!fiatQuoteQuery.data) { + return; + } + + const hasTwoSteps = isSwapRequiredPostOnramp(fiatQuoteQuery.data); + let openedWindow: Window | null = null; + + if (!hasTwoSteps) { + openedWindow = openOnrampPopup( + fiatQuoteQuery.data.onRampLink, + typeof props.theme === "string" ? props.theme : props.theme.type, + ); + + addPendingTx({ + type: "fiat", + intentId: fiatQuoteQuery.data.intentId, + }); + } + + setScreen({ + type: "node", + node: ( + { + setScreen({ + type: "main", + }); + }} + client={client} + testMode={ + buyWithFiatOptions !== false + ? buyWithFiatOptions?.testMode || false + : false + } + theme={ + typeof props.theme === "string" ? props.theme : props.theme.type + } + onViewPendingTx={props.onViewPendingTx} + openedWindow={openedWindow} + onDone={props.onDone} + isEmbed={props.isEmbed} + /> + ), + }); + } + + function showFees() { + if (!fiatQuoteQuery.data) { + return; + } + + setDrawerScreen( +
+ + Fees + + + + +
, + ); + } + + // biome-ignore lint/suspicious/noExplicitAny: + function getErrorMessage(err: any): string[] { + type AmountTooLowError = { + code: "MINIMUM_PURCHASE_AMOUNT"; + data: { + minimumAmountUSDCents: 250; + requestedAmountUSDCents: 7; + }; + }; + + const defaultMessage = "Unable to get price quote"; + try { + if (err.error.code === "MINIMUM_PURCHASE_AMOUNT") { + const obj = err.error as AmountTooLowError; + return [ + `Minimum purchase amount is $${obj.data.minimumAmountUSDCents / 100}`, + `Requested amount is $${obj.data.requestedAmountUSDCents / 100}`, + ]; + } + } catch {} + + return [defaultMessage]; + } + + const disableSubmit = !fiatQuoteQuery.data; + + return ( + + {/* Show Currency Selector + Calculated Amount */} +
+ + {/* Estimated time + View fees button */} + +
+ + {/* Error message */} + {fiatQuoteQuery.error && ( +
+ {getErrorMessage(fiatQuoteQuery.error).map((msg) => ( + + {msg} + + ))} +
+ )} + + {/* Continue */} + +
+ ); +} + +function BuyForTxUI(props: { + amountNeeded: string; + buyForTx: BuyForTx; + client: ThirdwebClient; +}) { + const chainQuery = useChainQuery(props.buyForTx.tx.chain); + + return ( + + + + Amount Needed + + + + {props.amountNeeded} {props.buyForTx.tokenSymbol} + + + + + {chainQuery.data ? ( + {chainQuery.data.name} + ) : ( + + )} + + + + + + + + + Your Balance + + + {formatNumber(Number(toEther(props.buyForTx.balance)), 4)}{" "} + {props.buyForTx.tokenSymbol} + + + + + + + + + + + Purchase + + + + ); +} + +function createSupportedTokens( + data: SupportedChainAndTokens, + payOptions: PayUIOptions, + supportedTokensOverrides?: SupportedTokens, +): SupportedTokens { + const tokens: SupportedTokens = {}; + + const isBuyWithFiatDisabled = payOptions.buyWithFiat === false; + const isBuyWithCryptoDisabled = payOptions.buyWithCrypto === false; + + for (const x of data) { + tokens[x.chain.id] = x.tokens.filter((t) => { + // it token supports both - include it + if (t.buyWithCryptoEnabled && t.buyWithFiatEnabled) { + return true; + } + + // if buyWithFiat is disabled, and buyWithCrypto is not supported by token - exclude the token + if (!t.buyWithCryptoEnabled && isBuyWithFiatDisabled) { + return false; + } + + // if buyWithCrypto is disabled, and buyWithFiat is not supported by token - exclude the token + if (!t.buyWithFiatEnabled && isBuyWithCryptoDisabled) { + return false; + } + + return true; // include the token + }); + } + + // override with props.supportedTokens + if (supportedTokensOverrides) { + for (const k in supportedTokensOverrides) { + const key = Number(k); + const tokenList = supportedTokensOverrides[key]; + + if (tokenList) { + tokens[key] = tokenList; + } + } + } + + return tokens; +} + +function ChainSelectionScreen(props: { + showMainScreen: () => void; + chains: Chain[]; + client: ThirdwebClient; + connectLocale: ConnectLocale; + setChain: (chain: Chain) => void; +}) { + return ( + { + props.setChain(renderChainProps.chain); + props.showMainScreen(); + }} + client={props.client} + connectLocale={props.connectLocale} + /> + ); + }, + }} + /> + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/EstimatedTimeAndFees.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/EstimatedTimeAndFees.tsx new file mode 100644 index 00000000000..4716b4627a3 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/EstimatedTimeAndFees.tsx @@ -0,0 +1,81 @@ +import { ClockIcon } from "@radix-ui/react-icons"; +import { Skeleton } from "../../../components/Skeleton.js"; +import { Container } from "../../../components/basic.js"; +import { Button } from "../../../components/buttons.js"; +import { Text } from "../../../components/text.js"; +import { fontSize, iconSize, radius } from "../../../design-system/index.js"; +import type { IconFC } from "../../icons/types.js"; +import { formatSeconds } from "./swap/formatSeconds.js"; + +export function EstimatedTimeAndFees(props: { + estimatedSeconds?: number | undefined; + quoteIsLoading: boolean; + onViewFees: () => void; +}) { + const { estimatedSeconds, quoteIsLoading } = props; + + return ( + + + + {quoteIsLoading ? ( + + ) : ( + + {estimatedSeconds !== undefined + ? `~${formatSeconds(estimatedSeconds)}` + : "--"} + + )} + + + + + ); +} + +const ViewFeeIcon: IconFC = (props) => { + return ( + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/LazyBuyScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/LazyBuyScreen.tsx new file mode 100644 index 00000000000..fe062b249ff --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/LazyBuyScreen.tsx @@ -0,0 +1,13 @@ +import { Suspense, lazy } from "react"; +import { LoadingScreen } from "../../../../wallets/shared/LoadingScreen.js"; +import type { BuyScreenProps } from "./BuyScreen.js"; + +const BuyScreen = lazy(() => import("./BuyScreen.js")); + +export function LazyBuyScreen(props: BuyScreenProps) { + return ( + }> + + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PayWIthCreditCard.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PayWIthCreditCard.tsx new file mode 100644 index 00000000000..38b2096f725 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PayWIthCreditCard.tsx @@ -0,0 +1,103 @@ +import styled from "@emotion/styled"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import type { ThirdwebClient } from "../../../../../../client/client.js"; +import { formatNumber } from "../../../../../../utils/formatNumber.js"; +import { Skeleton } from "../../../components/Skeleton.js"; +import { Container } from "../../../components/basic.js"; +import { Button } from "../../../components/buttons.js"; +import { Text } from "../../../components/text.js"; +import { + fontSize, + iconSize, + radius, + spacing, +} from "../../../design-system/index.js"; +import type { CurrencyMeta } from "./fiat/currencies.js"; + +/** + * Shows an amount "value" and renders the selected token and chain + * It also renders the buttons to select the token and chain + * It also renders the balance of active wallet for the selected token in selected chain + * @internal + */ +export function PayWithCreditCard(props: { + value?: string; + isLoading: boolean; + client: ThirdwebClient; + currency: CurrencyMeta; + onSelectCurrency: () => void; + disableCurrencySelection?: boolean; +}) { + return ( + + {/* Left */} + + + + {props.currency.shorthand} + {!props.disableCurrencySelection && ( + + )} + + + + {/* Right */} +
+ {props.isLoading ? ( + + ) : ( + + {props.value ? `${formatNumber(Number(props.value), 4)}` : "--"} + + )} +
+
+ ); +} + +const CurrencyButton = /* @__PURE__ */ styled(Button)(() => { + return { + "&[disabled]:hover": { + borderColor: "transparent", + }, + }; +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PaymentSelection.tsx index 84b69e62a1b..97651b191f6 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/PaymentSelection.tsx @@ -4,13 +4,15 @@ import { Container } from "../../../components/basic.js"; import { Button } from "../../../components/buttons.js"; import { Text } from "../../../components/text.js"; import { useCustomTheme } from "../../../design-system/CustomThemeProvider.js"; -import { StyledDiv } from "../../../design-system/elements.js"; -import { fontSize, radius, spacing } from "../../../design-system/index.js"; +import { fontSize, spacing } from "../../../design-system/index.js"; /** * @internal */ -export function PaymentSelection() { +export function PaymentSelection(props: { + selected: "crypto" | "creditCard"; + onSelect: (method: "crypto" | "creditCard") => void; +}) { return (
Pay with @@ -22,28 +24,25 @@ export function PaymentSelection() { gridGap: spacing.sm, }} > - - - Crypto - + { + props.onSelect("creditCard"); + }} + > + Credit Card -
{ + props.onSelect("crypto"); }} > - - Credit Card - - Coming Soon -
+ Crypto +
); @@ -59,6 +58,11 @@ const CheckButton = /* @__PURE__ */ styled(Button)( borderColor: props.isChecked ? theme.colors.accentText : theme.colors.borderColor, + "&:hover": { + borderColor: props.isChecked + ? theme.colors.accentText + : theme.colors.secondaryText, + }, gap: spacing.xs, paddingInline: spacing.xxs, paddingBlock: spacing.sm, @@ -66,19 +70,3 @@ const CheckButton = /* @__PURE__ */ styled(Button)( }; }, ); - -const FloatingBadge = /* @__PURE__ */ StyledDiv(() => { - const theme = useCustomTheme(); - return { - position: "absolute", - top: 0, - right: 0, - transform: "translate(10%, -60%)", - backgroundColor: theme.colors.secondaryButtonBg, - paddingBlock: "3px", - paddingInline: spacing.xs, - fontSize: fontSize.xs, - borderRadius: radius.sm, - color: theme.colors.accentText, - }; -}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/Stepper.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/Stepper.tsx new file mode 100644 index 00000000000..bba9a33efee --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/Stepper.tsx @@ -0,0 +1,94 @@ +import { keyframes } from "@emotion/react"; +import { CheckIcon } from "@radix-ui/react-icons"; +import { Container } from "../../../components/basic.js"; +import { StyledDiv } from "../../../design-system/elements.js"; +import { fontSize, iconSize } from "../../../design-system/index.js"; + +export function StepIcon(props: { + isDone: boolean; + isActive: boolean; +}) { + return ( + + + {props.isDone ? ( + + ) : ( + + )} + + + ); +} + +export function Step(props: { + isDone: boolean; + label: string; + isActive: boolean; +}) { + return ( + + + {props.label} + + ); +} + +const pulseAnimation = keyframes` +0% { + opacity: 1; + transform: scale(0.5); +} +100% { + opacity: 0; + transform: scale(1.5); +} +`; + +const PulsingDot = /* @__PURE__ */ StyledDiv(() => { + return { + background: "currentColor", + width: "9px", + height: "9px", + borderRadius: "50%", + '&[data-active="true"]': { + animation: `${pulseAnimation} 1s infinite`, + }, + }; +}); + +const Circle = /* @__PURE__ */ StyledDiv(() => { + return { + border: "1px solid currentColor", + width: "20px", + height: "20px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + }; +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/SwapScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/SwapScreen.tsx deleted file mode 100644 index 1c4beaa7cd5..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/SwapScreen.tsx +++ /dev/null @@ -1,800 +0,0 @@ -import { ClockIcon, CrossCircledIcon } from "@radix-ui/react-icons"; -import { useEffect, useMemo, useState } from "react"; -import { polygon } from "../../../../../../chains/chain-definitions/polygon.js"; -import type { Chain } from "../../../../../../chains/types.js"; -import type { ThirdwebClient } from "../../../../../../client/client.js"; -import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js"; -import type { BuyWithCryptoQuote } from "../../../../../../pay/buyWithCrypto/actions/getQuote.js"; -import type { PreparedTransaction } from "../../../../../../transaction/prepare-transaction.js"; -import { formatNumber } from "../../../../../../utils/formatNumber.js"; -import { toEther } from "../../../../../../utils/units.js"; -import type { - Account, - Wallet, -} from "../../../../../../wallets/interfaces/wallet.js"; -import { getTotalTxCostForBuy } from "../../../../../core/hooks/contract/useSendTransaction.js"; -import { - useChainQuery, - useChainsQuery, -} from "../../../../../core/hooks/others/useChainQuery.js"; -import { useWalletBalance } from "../../../../../core/hooks/others/useWalletBalance.js"; -import { - type BuyWithCryptoQuoteQueryParams, - useBuyWithCryptoQuote, -} from "../../../../../core/hooks/pay/useBuyWithCryptoQuote.js"; -import { - useActiveAccount, - useActiveWallet, - useActiveWalletChain, - useSwitchActiveWalletChain, -} from "../../../../../core/hooks/wallets/wallet-hooks.js"; -import { wait } from "../../../../../core/utils/wait.js"; -import { LoadingScreen } from "../../../../wallets/shared/LoadingScreen.js"; -import { - Drawer, - DrawerOverlay, - useDrawer, -} from "../../../components/Drawer.js"; -import { DynamicHeight } from "../../../components/DynamicHeight.js"; -import { Skeleton } from "../../../components/Skeleton.js"; -import { Spacer } from "../../../components/Spacer.js"; -import { Spinner } from "../../../components/Spinner.js"; -import { TokenIcon } from "../../../components/TokenIcon.js"; -import { Container, Line, ModalHeader } from "../../../components/basic.js"; -import { Button } from "../../../components/buttons.js"; -import { Text } from "../../../components/text.js"; -import { fontSize, iconSize, radius } from "../../../design-system/index.js"; -import { useDebouncedValue } from "../../../hooks/useDebouncedValue.js"; -import type { SupportedTokens } from "../../defaultTokens.js"; -import type { IconFC } from "../../icons/types.js"; -import type { ConnectLocale } from "../../locale/types.js"; -import { TokenSelector } from "../TokenSelector.js"; -import { - type ERC20OrNativeToken, - NATIVE_TOKEN, - isNativeToken, -} from "../nativeToken.js"; -import { AccountSelectionScreen } from "./AccountSelectionScreen.js"; -import { AccountSelectorButton } from "./AccountSelectorButton.js"; -import { PaymentSelection } from "./PaymentSelection.js"; -import { FeesButton } from "./buttons.js"; -import { BuyTokenInput } from "./swap/BuyTokenInput.js"; -import { SwapConfirmationScreen } from "./swap/ConfirmationScreen.js"; -import { SwapFees } from "./swap/Fees.js"; -import { PayWithCrypto } from "./swap/PayWithCrypto.js"; -import { formatSeconds } from "./swap/formatSeconds.js"; -import { useSwapSupportedChains } from "./swap/useSwapSupportedChains.js"; - -// NOTE: Must not use useConnectUI here because this UI can be used outside connect ui - -type BuyForTx = { - cost: bigint; - balance: bigint; - tx: PreparedTransaction; - tokenSymbol: string; -}; - -/** - * @internal - */ -export function BuyScreen(props: { - onBack: () => void; - supportedTokens: SupportedTokens; - onViewPendingTx: () => void; - client: ThirdwebClient; - connectLocale: ConnectLocale; - buyForTx?: BuyForTx; -}) { - const activeChain = useActiveWalletChain(); - const activeWallet = useActiveWallet(); - const account = useActiveAccount(); - const supportedChainsQuery = useSwapSupportedChains(props.client); - - if (!activeChain || !account || !activeWallet || !supportedChainsQuery.data) { - return ; - } - - return ( - - ); -} - -type Screen = - | "main" - | "select-from-token" - | "select-to-token" - | "confirmation" - | "usd-confirmation" - | "kado-iframe"; -type DrawerScreen = "fees" | "address" | undefined; - -/** - * - * @internal - */ -export function BuyScreenContent(props: { - client: ThirdwebClient; - onBack: () => void; - supportedTokens: SupportedTokens; - activeChain: Chain; - activeWallet: Wallet; - account: Account; - onViewPendingTx: () => void; - supportedChains: Chain[]; - connectLocale: ConnectLocale; - buyForTx?: BuyForTx; -}) { - const { - activeChain, - account, - client, - activeWallet, - supportedChains, - connectLocale, - } = props; - const [isSwitching, setIsSwitching] = useState(false); - const switchActiveWalletChain = useSwitchActiveWalletChain(); - const [method] = useState<"crypto" | "creditCard">("crypto"); - - // prefetch chains metadata - useChainsQuery(supportedChains || [], 50); - - // screens - const [screen, setScreen] = useState("main"); - const [drawerScreen, setDrawerScreen] = useState(); - const { drawerRef, drawerOverlayRef, onClose } = useDrawer(); - - const closeDrawer = () => { - onClose(() => { - setDrawerScreen(undefined); - }); - }; - - const initialTokenAmount = props.buyForTx - ? formatNumber( - Number(toEther(props.buyForTx.cost - props.buyForTx.balance)), - 4, - ) - : undefined; - - // token amount - const [tokenAmount, setTokenAmount] = useState( - initialTokenAmount ? String(initialTokenAmount) : "", - ); - - // once the user edits the tokenInput or confirms the Buy - stop updating the token amount - const [stopUpdatingTokenAmount, setStopUpdatingTokenAmount] = useState( - !props.buyForTx, - ); - - const [amountNeeded, setAmountNeeded] = useState( - props.buyForTx?.cost, - ); - - // update amount needed every 30 seconds - // also update the token amount if allowed - // ( Can't use useQuery because tx can't be added to queryKey ) - useEffect(() => { - const buyTx = props.buyForTx; - if (!buyTx || stopUpdatingTokenAmount) { - return; - } - - let mounted = true; - - async function pollTxCost() { - if (!buyTx || !mounted) { - return; - } - - try { - const totalCost = await getTotalTxCostForBuy(buyTx.tx); - - if (!mounted) { - return; - } - - setAmountNeeded(totalCost); - - if (totalCost > buyTx.balance) { - const _tokenAmount = String( - formatNumber(Number(toEther(totalCost - buyTx.balance)), 4), - ); - setTokenAmount(_tokenAmount); - } - } catch { - // no op - } - - await wait(30000); - pollTxCost(); - } - - pollTxCost(); - - return () => { - mounted = false; - }; - }, [props.buyForTx, stopUpdatingTokenAmount]); - - const [hasEditedAmount, setHasEditedAmount] = useState(false); - const isMiniScreen = props.buyForTx ? false : !hasEditedAmount; - - const isChainSupported = useMemo( - () => supportedChains?.find((c) => c.id === activeChain.id), - [activeChain.id, supportedChains], - ); - - // selected chain - const defaultChain = isChainSupported ? activeChain : polygon; - const [fromChain, setFromChain] = useState( - props.buyForTx ? props.buyForTx.tx.chain : defaultChain, - ); - - const [toChain, setToChain] = useState( - props.buyForTx ? props.buyForTx.tx.chain : defaultChain, - ); - const [address, setAddress] = useState(account.address); - - // selected tokens - const [fromToken, setFromToken] = useState( - (props.buyForTx ? props.supportedTokens[toChain.id]?.[0] : undefined) || - NATIVE_TOKEN, - ); - - const [toToken, setToToken] = useState( - props.buyForTx - ? NATIVE_TOKEN - : props.supportedTokens[toChain.id]?.[0] || NATIVE_TOKEN, - ); - - const deferredTokenAmount = useDebouncedValue(tokenAmount, 300); - - const fromTokenBalanceQuery = useWalletBalance({ - address: account.address, - chain: fromChain, - tokenAddress: isNativeToken(fromToken) ? undefined : fromToken.address, - client, - }); - - // when a quote is finalized ( approve sent if required or swap sent ) - // we save it here to stop refetching the quote query - const [finalizedQuote, setFinalizedQuote] = useState< - BuyWithCryptoQuote | undefined - >(); - - const buyWithCryptoParams: BuyWithCryptoQuoteQueryParams | undefined = - deferredTokenAmount && - !finalizedQuote && - !(fromChain.id === toChain.id && fromToken === toToken) - ? { - // wallet - fromAddress: address, - // from token - fromChainId: fromChain.id, - fromTokenAddress: isNativeToken(fromToken) - ? NATIVE_TOKEN_ADDRESS - : fromToken.address, - toChainId: toChain.id, - // to - toTokenAddress: isNativeToken(toToken) - ? NATIVE_TOKEN_ADDRESS - : toToken.address, - toAmount: deferredTokenAmount, - client, - } - : undefined; - - const buyWithCryptoQuoteQuery = useBuyWithCryptoQuote(buyWithCryptoParams, { - // refetch every 30 seconds - staleTime: 30 * 1000, - refetchInterval: 30 * 1000, - gcTime: 30 * 1000, - }); - - if (screen === "select-from-token") { - return ( - setScreen("main")} - tokenList={ - (fromChain?.id ? props.supportedTokens[fromChain.id] : undefined) || - [] - } - onTokenSelect={(tokenInfo) => { - setFromToken(tokenInfo); - setScreen("main"); - }} - chain={fromChain} - chainSelection={{ - chains: supportedChains, - select: (c) => setFromChain(c), - }} - connectLocale={connectLocale} - client={client} - /> - ); - } - - if (screen === "select-to-token") { - return ( - setScreen("main")} - tokenList={ - (toChain?.id ? props.supportedTokens[toChain.id] : undefined) || [] - } - onTokenSelect={(tokenInfo) => { - setToToken(tokenInfo); - setScreen("main"); - }} - chain={toChain} - chainSelection={{ - chains: supportedChains, - select: (c) => setToChain(c), - }} - connectLocale={connectLocale} - client={client} - /> - ); - } - - const swapQuote = buyWithCryptoQuoteQuery.data; - const isSwapQuoteError = buyWithCryptoQuoteQuery.isError; - - const getErrorMessage = () => { - const defaultMessage = "Unable to get price quote"; - try { - if (buyWithCryptoQuoteQuery.error instanceof Error) { - if (buyWithCryptoQuoteQuery.error.message.includes("Minimum")) { - const msg = buyWithCryptoQuoteQuery.error.message; - return msg.replace("Fetch failed: Error: ", ""); - } - } - return defaultMessage; - } catch { - return defaultMessage; - } - }; - - const sourceTokenAmount = swapQuote?.swapDetails.fromAmount || ""; - const quoteToConfirm = finalizedQuote || buyWithCryptoQuoteQuery.data; - - if (screen === "confirmation" && quoteToConfirm) { - return ( - { - // remove finalized quote when going back - setFinalizedQuote(undefined); - setStopUpdatingTokenAmount(true); - setScreen("main"); - }} - buyWithCryptoQuote={quoteToConfirm} - onQuoteFinalized={(_quote) => { - setFinalizedQuote(_quote); - }} - fromAmount={quoteToConfirm.swapDetails.fromAmount} - toAmount={tokenAmount} - fromChain={fromChain} - toChain={toChain} - account={account} - fromToken={fromToken} - toToken={toToken} - onViewPendingTx={props.onViewPendingTx} - /> - ); - } - - const isNotEnoughBalance = - !!sourceTokenAmount && - !!fromTokenBalanceQuery.data && - Number(fromTokenBalanceQuery.data.displayValue) < Number(sourceTokenAmount); - - const disableContinue = !swapQuote || isNotEnoughBalance; - const switchChainRequired = props.activeChain.id !== fromChain.id; - - return ( - - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
{ - if ( - drawerScreen && - drawerRef.current && - !drawerRef.current.contains(e.target as Node) - ) { - e.preventDefault(); - e.stopPropagation(); - closeDrawer(); - } - }} - > - {/* Drawer */} - {drawerScreen && ( - <> - - - - {drawerScreen === "address" && ( - { - setAddress(v); - closeDrawer(); - }} - activeAccount={account} - activeWallet={props.activeWallet} - client={client} - /> - )} - - {method === "crypto" && ( - <> - {drawerScreen === "fees" && ( -
- - Fees - - - - {swapQuote && ( - - )} -
- )} - - )} -
-
- - )} - - - - - {isMiniScreen && } - - {amountNeeded && props.buyForTx ? ( - - ) : null} - - {/* To */} - { - setHasEditedAmount(true); - setStopUpdatingTokenAmount(true); - setTokenAmount(value); - }} - token={toToken} - chain={toChain} - onSelectToken={() => setScreen("select-to-token")} - client={props.client} - hideTokenSelector={!!props.buyForTx} - /> - - - - - {!isMiniScreen && ( -
- - - - {method === "crypto" && ( - <> - { - setScreen("select-from-token"); - }} - chain={fromChain} - token={fromToken} - isLoading={ - buyWithCryptoQuoteQuery.isLoading && !sourceTokenAmount - } - client={client} - /> - setDrawerScreen("fees")} - /> - - )} - - - - {/* Send To */} - {!props.buyForTx && ( - <> - - Send To - - { - setDrawerScreen("address"); - }} - chevron - client={client} - /> - - - - - )} - - - {method === "crypto" && isSwapQuoteError && ( -
- - - - {getErrorMessage()} - - - -
- )} -
-
- )} - - {method === "crypto" && ( - <> - {switchChainRequired && ( - - )} - - {!switchChainRequired && ( - - )} - - )} - - {method === "creditCard" && ( - - )} -
- -
-
- ); -} - -const ViewFeeIcon: IconFC = (props) => { - return ( - - ); -}; - -function SecondaryInfo(props: { - estimatedSeconds?: number | undefined; - quoteIsLoading: boolean; - onViewFees: () => void; -}) { - const { estimatedSeconds, quoteIsLoading } = props; - - return ( - - - - {quoteIsLoading ? ( - - ) : ( - - {estimatedSeconds !== undefined - ? `~${formatSeconds(estimatedSeconds)}` - : "--"} - - )} - - - - - - - - View Fees - - - - ); -} - -function BuyForTxUI(props: { - amountNeeded: string; - buyForTx: BuyForTx; - client: ThirdwebClient; -}) { - const chainQuery = useChainQuery(props.buyForTx.tx.chain); - - return ( - - - - Amount Needed - - - - {props.amountNeeded} {props.buyForTx.tokenSymbol} - - - - - {chainQuery.data ? ( - {chainQuery.data.name} - ) : ( - - )} - - - - - - - - - Your Balance - - - {formatNumber(Number(toEther(props.buyForTx.balance)), 4)}{" "} - {props.buyForTx.tokenSymbol} - - - - - - - - - - - Purchase - - - - ); -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/buttons.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/buttons.tsx deleted file mode 100644 index 4445436845b..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/buttons.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import styled from "@emotion/styled"; -import { Button } from "../../../components/buttons.js"; -import { useCustomTheme } from "../../../design-system/CustomThemeProvider.js"; -import { radius, spacing } from "../../../design-system/index.js"; - -export const FeesButton = /* @__PURE__ */ styled(Button)(() => { - const theme = useCustomTheme(); - return { - background: "transparent", - border: "1px solid transparent", - "&:hover": { - background: "transparent", - borderColor: theme.colors.accentText, - }, - justifyContent: "flex-start", - transition: "background 0.3s, border-color 0.3s", - gap: spacing.xs, - padding: spacing.sm, - color: theme.colors.primaryText, - borderRadius: radius.md, - }; -}); - -export const SecondaryButton = /* @__PURE__ */ styled(Button)(() => { - const theme = useCustomTheme(); - return { - background: theme.colors.tertiaryBg, - border: `1px solid ${theme.colors.borderColor}`, - "&:hover": { - background: theme.colors.tertiaryBg, - borderColor: theme.colors.accentText, - }, - justifyContent: "flex-start", - transition: "background 0.3s, border-color 0.3s", - gap: spacing.sm, - padding: spacing.sm, - borderRadius: radius.md, - }; -}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/CurrencySelection.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/CurrencySelection.tsx new file mode 100644 index 00000000000..c334fa81767 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/CurrencySelection.tsx @@ -0,0 +1,44 @@ +import { Spacer } from "../../../../components/Spacer.js"; +import { Container, Line, ModalHeader } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { iconSize } from "../../../../design-system/index.js"; +import { type CurrencyMeta, currencies } from "./currencies.js"; + +export function CurrencySelection(props: { + onSelect: (currency: CurrencyMeta) => void; + onBack: () => void; +}) { + return ( + + + + + + + + + + {currencies.map((c) => { + return ( + + ); + })} + + + + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatFlow.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatFlow.tsx new file mode 100644 index 00000000000..3002ee76d05 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatFlow.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js"; +import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { isSwapRequiredPostOnramp } from "../../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; +import { openOnrampPopup } from "../openOnRamppopup.js"; +import { addPendingTx } from "../swap/pendingSwapTx.js"; +import { OnrampStatusScreen } from "./FiatStatusScreen.js"; +import { FiatSteps, fiatQuoteToPartialQuote } from "./FiatSteps.js"; +import { PostOnRampSwapFlow } from "./PostOnRampSwapFlow.js"; + +// Flows + +// If a Swap is required after doing onramp +// 1. show the 2 steps ui first +// 2. opwn provider window, show onramp status screen +// 3. show the 2 steps ui with step 2 highlighted +// 4. show swap flow + +// If a Swap is not required after doing onramp +// 1. open provider window, show onramp status screen + +type Screen = + | { + id: "step-1"; + } + | { + id: "onramp-status"; + } + | { + id: "postonramp-swap"; + data: BuyWithFiatStatus; + } + | { + id: "step-2"; + }; + +export function FiatFlow(props: { + quote: BuyWithFiatQuote; + onBack: () => void; + client: ThirdwebClient; + testMode: boolean; + theme: "light" | "dark"; + onViewPendingTx: () => void; + openedWindow: Window | null; + onDone: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const hasTwoSteps = isSwapRequiredPostOnramp(props.quote); + const [screen, setScreen] = useState( + hasTwoSteps + ? { + id: "step-1", + } + : { + id: "onramp-status", + }, + ); + + const [popupWindow, setPopupWindow] = useState( + props.openedWindow, + ); + + if (screen.id === "step-1") { + return ( + { + const popup = openOnrampPopup(props.quote.onRampLink, props.theme); + addPendingTx({ + type: "fiat", + intentId: props.quote.intentId, + }); + setPopupWindow(popup); + setScreen({ id: "onramp-status" }); + }} + /> + ); + } + + if (screen.id === "onramp-status") { + return ( + { + setScreen({ id: "postonramp-swap", data: _status }); + }} + isBuyForTx={props.isBuyForTx} + isEmbed={props.isEmbed} + /> + ); + } + + if (screen.id === "postonramp-swap") { + return ( + { + // no op + }} + isBuyForTx={props.isBuyForTx} + isEmbed={props.isEmbed} + /> + ); + } + + // never + return null; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatStatusScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatStatusScreen.tsx new file mode 100644 index 00000000000..e36e83d4e60 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatStatusScreen.tsx @@ -0,0 +1,245 @@ +import { CheckCircledIcon } from "@radix-ui/react-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js"; +import type { + BuyWithFiatStatus, + ValidBuyWithFiatStatus, +} from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { isMobile } from "../../../../../../../utils/web/isMobile.js"; +import { useBuyWithFiatStatus } from "../../../../../../core/hooks/pay/useBuyWithFiatStatus.js"; +import { invalidateWalletBalance } from "../../../../../../core/providers/invalidateWalletBalance.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { StepBar } from "../../../../components/StepBar.js"; +import { Container, ModalHeader } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { iconSize } from "../../../../design-system/index.js"; +import { AccentFailIcon } from "../../../icons/AccentFailIcon.js"; +import { getBuyWithFiatStatusMeta } from "../tx-history/statusMeta.js"; +import { OnRampTxDetailsTable } from "./FiatTxDetailsTable.js"; + +type UIStatus = "loading" | "failed" | "completed" | "partialSuccess"; + +export function OnrampStatusScreen(props: { + client: ThirdwebClient; + onBack: () => void; + intentId: string; + onViewPendingTx: () => void; + hasTwoSteps: boolean; + openedWindow: Window | null; + quote: BuyWithFiatQuote; + onDone: () => void; + onShowSwapFlow: (status: BuyWithFiatStatus) => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const queryClient = useQueryClient(); + const { openedWindow } = props; + const statusQuery = useBuyWithFiatStatus({ + intentId: props.intentId, + client: props.client, + }); + + // determine UI status + let uiStatus: UIStatus = "loading"; + if ( + statusQuery.data?.status === "ON_RAMP_TRANSFER_FAILED" || + statusQuery.data?.status === "PAYMENT_FAILED" + ) { + uiStatus = "failed"; + } else if (statusQuery.data?.status === "CRYPTO_SWAP_FALLBACK") { + uiStatus = "partialSuccess"; + } else if (statusQuery.data?.status === "ON_RAMP_TRANSFER_COMPLETED") { + uiStatus = "completed"; + } + + // determine step + + // close the onramp popup if onramp is completed + useEffect(() => { + if (!openedWindow || !statusQuery.data) { + return; + } + + if ( + statusQuery.data?.status === "CRYPTO_SWAP_REQUIRED" || + statusQuery.data?.status === "ON_RAMP_TRANSFER_COMPLETED" + ) { + openedWindow.close(); + } + }, [statusQuery.data, openedWindow]); + + // invalidate wallet balance when onramp is completed + const invalidatedBalance = useRef(false); + useEffect(() => { + if ( + !invalidatedBalance.current && + statusQuery.data?.status === "ON_RAMP_TRANSFER_COMPLETED" + ) { + invalidatedBalance.current = true; + invalidateWalletBalance(queryClient); + } + }, [statusQuery.data, queryClient]); + + // show swap flow + useEffect(() => { + if (statusQuery.data?.status === "CRYPTO_SWAP_REQUIRED") { + props.onShowSwapFlow(statusQuery.data); + } + }, [statusQuery.data, props.onShowSwapFlow]); + + return ( + + + + {props.hasTwoSteps && ( + <> + + + + + Step 1 of 2 - Buying {props.quote.onRampToken.token.symbol} with{" "} + {props.quote.fromCurrencyWithFees.currencySymbol} + + + )} + + + + ); +} + +function OnrampStatusScreenUI(props: { + uiStatus: UIStatus; + fiatStatus?: BuyWithFiatStatus; + onDone: () => void; + client: ThirdwebClient; + isBuyForTx: boolean; + isEmbed: boolean; + quote: BuyWithFiatQuote; +}) { + const { uiStatus } = props; + + const statusMeta = props.fiatStatus + ? getBuyWithFiatStatusMeta(props.fiatStatus) + : undefined; + + const fiatStatus: ValidBuyWithFiatStatus | undefined = + props.fiatStatus && props.fiatStatus.status !== "NOT_FOUND" + ? props.fiatStatus + : undefined; + + const onRampTokenQuote = props.quote.onRampToken; + + const txDetails = ( + + ); + + return ( + + + + {uiStatus === "loading" && ( + <> + + + + + + + Buy Pending + + + {!isMobile() && Complete the purchase in popup} + + {txDetails} + + )} + + {uiStatus === "failed" && ( + <> + + + + + + + Transaction Failed + + + {txDetails} + + )} + + {uiStatus === "completed" && ( + <> + + + + + + + Buy Complete + + {props.fiatStatus && props.fiatStatus.status !== "NOT_FOUND" && ( + <> + + {txDetails} + + + )} + + {!props.isEmbed && ( + + )} + + )} + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatSteps.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatSteps.tsx new file mode 100644 index 00000000000..3b959a9c8c7 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatSteps.tsx @@ -0,0 +1,604 @@ +import { + Cross1Icon, + ExternalLinkIcon, + TriangleDownIcon, +} from "@radix-ui/react-icons"; +import { useMemo } from "react"; +import { defineChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js"; +import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js"; +import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { useChainQuery } from "../../../../../../core/hooks/others/useChainQuery.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { TokenIcon } from "../../../../components/TokenIcon.js"; +import { Container, Line, ModalHeader } from "../../../../components/basic.js"; +import { Button, ButtonLink } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { TokenSymbol } from "../../../../components/token/TokenSymbol.js"; +import { + fontSize, + iconSize, + radius, + spacing, +} from "../../../../design-system/index.js"; +import type { TokenInfo } from "../../../defaultTokens.js"; +import { type ERC20OrNativeToken, NATIVE_TOKEN } from "../../nativeToken.js"; +import { StepIcon } from "../Stepper.js"; +import { + type FiatStatusMeta, + getBuyWithFiatStatusMeta, +} from "../tx-history/statusMeta.js"; +import { getCurrencyMeta } from "./currencies.js"; + +export type BuyWithFiatPartialQuote = { + fromCurrencySymbol: string; + fromCurrencyAmount: string; + onRampTokenAmount: string; + toTokenAmount: string; + onRampToken: { + tokenAddress: string; + name?: string; + symbol?: string; + chainId: number; + }; + + toToken: { + tokenAddress: string; + name?: string; + symbol?: string; + chainId: number; + }; +}; + +export function fiatQuoteToPartialQuote( + quote: BuyWithFiatQuote, +): BuyWithFiatPartialQuote { + const data: BuyWithFiatPartialQuote = { + fromCurrencyAmount: quote.fromCurrencyWithFees.amount, + fromCurrencySymbol: quote.fromCurrencyWithFees.currencySymbol, + onRampTokenAmount: quote.onRampToken.amount, + toTokenAmount: quote.estimatedToAmountMin, + onRampToken: { + chainId: quote.onRampToken.token.chainId, + tokenAddress: quote.onRampToken.token.tokenAddress, + name: quote.onRampToken.token.name, + symbol: quote.onRampToken.token.symbol, + }, + + toToken: { + chainId: quote.toToken.chainId, + tokenAddress: quote.toToken.tokenAddress, + name: quote.toToken.name, + symbol: quote.toToken.symbol, + }, + }; + + return data; +} + +export function FiatSteps(props: { + partialQuote: BuyWithFiatPartialQuote; + status?: BuyWithFiatStatus; + onBack: () => void; + client: ThirdwebClient; + step: number; + onContinue: () => void; +}) { + const statusMeta = props.status + ? getBuyWithFiatStatusMeta(props.status) + : undefined; + + const { + toToken: toTokenMeta, + onRampToken: onRampTokenMeta, + onRampTokenAmount, + fromCurrencySymbol, + fromCurrencyAmount, + toTokenAmount, + } = props.partialQuote; + + const currency = getCurrencyMeta(fromCurrencySymbol); + const isPartialSuccess = statusMeta?.progressStatus === "partialSuccess"; + + const toChain = useMemo( + () => defineChain(toTokenMeta.chainId), + [toTokenMeta.chainId], + ); + + const destinationChain = useMemo(() => { + if (props.status?.status !== "NOT_FOUND" && props.status?.destination) { + return defineChain(props.status?.destination.token.chainId); + } + + return undefined; + }, [props.status]); + + const toToken: ERC20OrNativeToken = useMemo(() => { + if (toTokenMeta.tokenAddress === NATIVE_TOKEN_ADDRESS) { + return NATIVE_TOKEN; + } + + const tokenInfo: TokenInfo = { + address: toTokenMeta.tokenAddress, + name: toTokenMeta.name || "", + symbol: toTokenMeta.symbol || "", + }; + return tokenInfo; + }, [toTokenMeta]); + + const onRampChain = useMemo( + () => defineChain(onRampTokenMeta.chainId), + [onRampTokenMeta.chainId], + ); + + const onRampToken: ERC20OrNativeToken = useMemo(() => { + if (onRampTokenMeta.tokenAddress === NATIVE_TOKEN_ADDRESS) { + return NATIVE_TOKEN; + } + + const tokenInfo: TokenInfo = { + address: onRampTokenMeta.tokenAddress, + name: onRampTokenMeta.name || "", + symbol: onRampTokenMeta.symbol || "", + }; + return tokenInfo; + }, [onRampTokenMeta]); + + const onRampChainMetaQuery = useChainQuery(onRampChain); + const toChainMetaQuery = useChainQuery(toChain); + + const destinationChainMetaQuery = useChainQuery(destinationChain); + + const onRampTokenInfo = ( +
+ + {formatNumber(Number(onRampTokenAmount), 4)}{" "} + + +
+ ); + + const fiatIcon = ; + + const onRampTokenIcon = ( + + ); + + const toTokenIcon = ( + + ); + + const onRampChainInfo = ( + {onRampChainMetaQuery.data?.name} + ); + + const partialSuccessToTokenInfo = + props.status?.status === "CRYPTO_SWAP_FALLBACK" && + props.status.destination ? ( +
+ + {formatNumber(Number(toTokenAmount), 4)}{" "} + + {" "} + + {formatNumber(Number(props.status.destination.amount), 4)}{" "} + + +
+ ) : null; + + const toTokenInfo = partialSuccessToTokenInfo || ( + + {formatNumber(Number(toTokenAmount), 4)}{" "} + + + ); + + const partialSuccessToChainInfo = + props.status?.status === "CRYPTO_SWAP_FALLBACK" && + props.status.destination && + props.status.destination.token.chainId !== + props.status.quote.toToken.chainId ? ( +
+ + {toChainMetaQuery.data?.name} + {" "} + + {destinationChainMetaQuery.data?.name} + +
+ ) : null; + + const toTokehChainInfo = partialSuccessToChainInfo || ( + {toChainMetaQuery.data?.name} + ); + + const onRampTxHash = + props.status?.status !== "NOT_FOUND" + ? props.status?.source?.transactionHash + : undefined; + + const toTokenTxHash = + props.status?.status !== "NOT_FOUND" + ? props.status?.destination?.transactionHash + : undefined; + + const showContinueBtn = + !props.status || + props.status.status === "CRYPTO_SWAP_REQUIRED" || + props.status.status === "CRYPTO_SWAP_FAILED"; + + function getStep1State(): FiatStatusMeta["progressStatus"] { + if (!statusMeta) { + if (props.step === 2) { + return "completed"; + } + return "actionRequired"; + } + + if (statusMeta.step === 2) { + return "completed"; + } + + return statusMeta.progressStatus; + } + + function getStep2State(): FiatStatusMeta["progressStatus"] | undefined { + if (!statusMeta) { + if (props.step === 2) { + return "actionRequired"; + } + return undefined; + } + + if (statusMeta.step === 2) { + return statusMeta.progressStatus; + } + + return undefined; + } + + return ( + + + + + {/* Step 1 */} + + Get{" "} + {" "} + with {props.partialQuote.fromCurrencySymbol} + + } + step={1} + from={{ + icon: fiatIcon, + primaryText: ( + + {formatNumber(Number(fromCurrencyAmount), 4)} {fromCurrencySymbol} + + ), + }} + to={{ + icon: onRampTokenIcon, + primaryText: onRampTokenInfo, + secondaryText: onRampChainInfo, + }} + state={getStep1State()} + explorer={ + onRampChainMetaQuery.data?.explorers?.[0]?.url && onRampTxHash + ? { + label: "View on Explorer", + url: `${onRampChainMetaQuery.data.explorers[0].url}/tx/${onRampTxHash}`, + } + : undefined + } + /> + + + + + Convert{" "} + {" "} + to + + } + step={2} + from={{ + icon: onRampTokenIcon, + primaryText: onRampTokenInfo, + secondaryText: onRampChainInfo, + }} + to={{ + icon: toTokenIcon, + primaryText: toTokenInfo, + secondaryText: toTokehChainInfo, + }} + state={getStep2State()} + explorer={ + toChainMetaQuery.data?.explorers?.[0]?.url && toTokenTxHash + ? { + label: "View on Explorer", + url: `${toChainMetaQuery.data.explorers[0].url}/tx/${toTokenTxHash}`, + } + : undefined + } + /> + + {isPartialSuccess && + props.status && + props.status.status !== "NOT_FOUND" && + props.status.source && + props.status.destination && ( + <> + + + Expected {props.status.source?.token.symbol}, Got{" "} + {props.status.destination?.token.symbol} instead + + + + )} + + {showContinueBtn && ( + <> + + + + )} + + ); +} + +function PaymentStep(props: { + step: number; + title: React.ReactNode; + state?: FiatStatusMeta["progressStatus"]; + from: { + icon: React.ReactNode; + primaryText: React.ReactNode; + secondaryText?: React.ReactNode; + }; + to: { + icon: React.ReactNode; + primaryText: React.ReactNode; + secondaryText?: React.ReactNode; + }; + iconText?: string; + explorer?: { + label: string; + url: string; + }; +}) { + return ( + + Step {props.step} + + {props.title} + + + + + + + + {/* TODO - replace this with SVG */} +
+ + + + + + {props.explorer && ( + <> + + + {props.explorer.label}{" "} + + + + )} + + ); +} + +function PaymentSubStep(props: { + icon: React.ReactNode; + primaryText: React.ReactNode; + secondaryText?: React.ReactNode; +}) { + return ( + + {/* icon */} + + {props.icon} + + + {props.primaryText} + {props.secondaryText} + + + ); +} + +function StepContainer(props: { + state?: FiatStatusMeta["progressStatus"]; + children: React.ReactNode; +}) { + const color = + props.state === "actionRequired" || props.state === "pending" + ? "accentText" + : props.state === "completed" + ? "success" + : props.state === "failed" + ? "danger" + : props.state === "partialSuccess" + ? "danger" + : "borderColor"; + + return ( + + {props.children} +
+ {props.state && ( + + {props.state === "completed" + ? "Completed" + : props.state === "failed" + ? "Failed" + : props.state === "pending" + ? "Pending" + : props.state === "actionRequired" + ? "" + : props.state === "partialSuccess" + ? "Incomplete" + : undefined} + + )} + + {(props.state === "actionRequired" || props.state === "completed") && ( + + )} + + {props.state === "pending" && } + + {props.state === "failed" && ( + + + + )} +
+
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatTxDetailsTable.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatTxDetailsTable.tsx new file mode 100644 index 00000000000..b484a2f1e9f --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatTxDetailsTable.tsx @@ -0,0 +1,131 @@ +import { ExternalLinkIcon } from "@radix-ui/react-icons"; +import { defineChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { useChainQuery } from "../../../../../../core/hooks/others/useChainQuery.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Container, Line } from "../../../../components/basic.js"; +import { ButtonLink } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { fontSize, iconSize } from "../../../../design-system/index.js"; +import { USDIcon } from "../../../icons/currencies/USDIcon.js"; +import { TokenInfoRow } from "../tx-history/TokenInfoRow.js"; +import type { FiatStatusMeta } from "../tx-history/statusMeta.js"; + +export function OnRampTxDetailsTable(props: { + // status: ValidBuyWithFiatStatus; + client: ThirdwebClient; + token: { + chainId: number; + address: string; + symbol: string; + amount: string; + }; + fiat: { + currencySymbol: string; + amount: string; + }; + statusMeta?: { + color: FiatStatusMeta["color"]; + text: FiatStatusMeta["status"]; + txHash?: string; + }; +}) { + const onRampChainQuery = useChainQuery(defineChain(props.token.chainId)); + const onrampTxHash = props.statusMeta?.txHash; + + const lineSpacer = ( + <> + + + + + ); + + return ( +
+ + + {lineSpacer} + + {/* Pay */} + + Pay + + + {props.fiat.currencySymbol === "USD" && ( + + )} + + {formatNumber(Number(props.fiat.amount), 4)}{" "} + {props.fiat.currencySymbol} + + + + + + {props.statusMeta && ( + <> + {lineSpacer} + + {/* Status */} + + Status + + + {props.statusMeta.text} + + + + + )} + + {lineSpacer} + + {onrampTxHash && onRampChainQuery.data?.explorers?.[0]?.url && ( + <> + + + View on Explorer + + + + )} +
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/PostOnRampSwap.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/PostOnRampSwap.tsx new file mode 100644 index 00000000000..8c17f3e15b1 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/PostOnRampSwap.tsx @@ -0,0 +1,158 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; +import { getPostOnRampQuote } from "../../../../../../../pay/buyWithFiat/getPostOnRampQuote.js"; +import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { useActiveAccount } from "../../../../../../core/hooks/wallets/wallet-hooks.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { Container, ModalHeader } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { iconSize } from "../../../../design-system/index.js"; +import { AccentFailIcon } from "../../../icons/AccentFailIcon.js"; +import { SwapFlow } from "../swap/SwapFlow.js"; + +export function PostOnRampSwap(props: { + client: ThirdwebClient; + buyWithFiatStatus: BuyWithFiatStatus; + onBack?: () => void; + onViewPendingTx: () => void; + onDone: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const account = useActiveAccount(); + + const [lockedOnRampQuote, setLockedOnRampQuote] = useState< + BuyWithCryptoQuote | undefined | null + >(undefined); + + const postOnRampQuoteQuery = useQuery({ + queryKey: ["getPostOnRampQuote", props.buyWithFiatStatus], + queryFn: async () => { + return await getPostOnRampQuote({ + client: props.client, + buyWithFiatStatus: props.buyWithFiatStatus, + }); + }, + // stop fetching if a quote is already locked + enabled: !lockedOnRampQuote, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if ( + postOnRampQuoteQuery.data && + !lockedOnRampQuote && + !postOnRampQuoteQuery.isRefetching + ) { + setLockedOnRampQuote(postOnRampQuoteQuery.data); + } + }, [ + postOnRampQuoteQuery.data, + lockedOnRampQuote, + postOnRampQuoteQuery.isRefetching, + ]); + + if (postOnRampQuoteQuery.isError) { + return ( + + + + + + + + + Failed to get a price quote + + + + + + + + ); + } + + if (!lockedOnRampQuote || !account) { + return ( + + + + + + + + + Getting price quote + + + + + ); + } + + if (lockedOnRampQuote === null) { + return ( + + + + + + + + + + + + + No transaction found + + + + + ); + } + + return ( + { + setLockedOnRampQuote(undefined); + postOnRampQuoteQuery.refetch(); + }} + isBuyForTx={props.isBuyForTx} + isEmbed={props.isEmbed} + /> + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/PostOnRampSwapFlow.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/PostOnRampSwapFlow.tsx new file mode 100644 index 00000000000..f5f8963487e --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/PostOnRampSwapFlow.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { type BuyWithFiatPartialQuote, FiatSteps } from "./FiatSteps.js"; +import { PostOnRampSwap } from "./PostOnRampSwap.js"; + +// Note: It is necessary to lock in the fiat-status in state and only pass that to so it does not suddenly change during the swap process. + +export function PostOnRampSwapFlow(props: { + status: BuyWithFiatStatus; + quote: BuyWithFiatPartialQuote; + client: ThirdwebClient; + onBack: () => void; + onViewPendingTx: () => void; + onDone: () => void; + onSwapFlowStarted: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const [statusForSwap, setStatusForSwap] = useState< + BuyWithFiatStatus | undefined + >(); + + // step 2 flow + if (statusForSwap) { + return ( + + ); + } + + // show step 1 and step 2 details + return ( + { + props.onSwapFlowStarted(); + setStatusForSwap(props.status); + }} + status={props.status} + /> + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/currencies.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/currencies.tsx new file mode 100644 index 00000000000..c867939db7d --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/currencies.tsx @@ -0,0 +1,55 @@ +import { RadiobuttonIcon } from "@radix-ui/react-icons"; +import { CADIcon } from "../../../icons/currencies/CADIcon.js"; +import { EURIcon } from "../../../icons/currencies/EURIcon.js"; +import { GBPIcon } from "../../../icons/currencies/GBPIcon.js"; +import { USDIcon } from "../../../icons/currencies/USDIcon.js"; +import type { IconFC } from "../../../icons/types.js"; + +export type CurrencyMeta = { + shorthand: string; + name: string; + icon: IconFC; +}; + +export const defaultSelectedCurrency: CurrencyMeta = { + shorthand: "USD", + name: "US Dollar", + icon: USDIcon, +}; + +export const currencies = [ + defaultSelectedCurrency, + { + shorthand: "CAD", + name: "Canadian Dollar", + icon: CADIcon, + }, + { + shorthand: "GBP", + name: "British Pound", + icon: GBPIcon, + }, + { + shorthand: "EUR", + name: "Euro", + icon: EURIcon, + }, +]; + +export function getCurrencyMeta(shorthand: string): CurrencyMeta { + return ( + currencies.find( + (currency) => + currency.shorthand.toLowerCase() === shorthand.toLowerCase(), + ) ?? { + // This should never happen + icon: UnknownCurrencyIcon, + name: shorthand, + shorthand, + } + ); +} + +const UnknownCurrencyIcon: IconFC = (props) => { + return ; +}; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts new file mode 100644 index 00000000000..bff52b7688e --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts @@ -0,0 +1,21 @@ +import type { PreparedTransaction } from "../../../../../../../transaction/prepare-transaction.js"; + +export type BuyForTx = { + cost: bigint; + balance: bigint; + tx: PreparedTransaction; + tokenSymbol: string; +}; + +export type SelectedScreen = + | { + type: "node"; + node: React.ReactNode; + } + | { + type: "screen-id"; + name: "select-from-token" | "select-to-token" | "select-currency"; + } + | { + type: "main"; + }; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts new file mode 100644 index 00000000000..51994f37b40 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { toEther } from "../../../../../../../utils/units.js"; +import { getTotalTxCostForBuy } from "../../../../../../core/hooks/contract/useSendTransaction.js"; +import { wait } from "../../../../../../core/utils/wait.js"; +import type { BuyForTx } from "./types.js"; + +export function useBuyTxStates(options: { + setTokenAmount: (value: string) => void; + buyForTx?: BuyForTx; + hasEditedAmount: boolean; + isMainScreen: boolean; +}) { + const { buyForTx, hasEditedAmount, isMainScreen, setTokenAmount } = options; + const shouldRefreshTokenAmount = !hasEditedAmount && isMainScreen; + const stopUpdatingAll = !isMainScreen; + + const [amountNeeded, setAmountNeeded] = useState( + buyForTx?.cost, + ); + + // update amount needed every 30 seconds + // also update the token amount if allowed + // ( Can't use useQuery because tx can't be added to queryKey ) + useEffect(() => { + if (!buyForTx || stopUpdatingAll) { + return; + } + + let mounted = true; + + async function pollTxCost() { + if (!buyForTx || !mounted) { + return; + } + + try { + const totalCost = await getTotalTxCostForBuy(buyForTx.tx); + + if (!mounted) { + return; + } + + setAmountNeeded(totalCost); + + if (shouldRefreshTokenAmount) { + if (totalCost > buyForTx.balance) { + const _tokenAmount = String( + formatNumber(Number(toEther(totalCost - buyForTx.balance)), 4), + ); + setTokenAmount(_tokenAmount); + } + } + } catch { + // no op + } + + await wait(30000); + pollTxCost(); + } + + pollTxCost(); + + return () => { + mounted = false; + }; + }, [buyForTx, shouldRefreshTokenAmount, setTokenAmount, stopUpdatingAll]); + + return { + amountNeeded, + }; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts new file mode 100644 index 00000000000..0dbc85df6d7 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts @@ -0,0 +1,89 @@ +import { useEffect } from "react"; +import type { Chain } from "../../../../../../../chains/types.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js"; +import type { PayUIOptions } from "../../../ConnectButtonProps.js"; +import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js"; +import type { SupportedChainAndTokens } from "../swap/useSwapSupportedChains.js"; + +// Based on what toToken, toChain, and supportedDestinations are, determine which payment methods should be enabled +// change the current method if it should be disabled +// return whether the payment selection should be shown or not ( if only one payment method is enabled, don't show the selection ) + +export function useEnabledPaymentMethods(options: { + payOptions: PayUIOptions; + supportedDestinations: SupportedChainAndTokens; + toChain: Chain; + toToken: ERC20OrNativeToken; + method: "crypto" | "creditCard"; + setMethod: (method: "crypto" | "creditCard") => void; +}) { + const { + payOptions, + supportedDestinations, + toChain, + toToken, + method, + setMethod, + } = options; + + function getEnabledPayMethodsForSelectedToken(): { + fiat: boolean; + swap: boolean; + } { + const chain = supportedDestinations.find((c) => c.chain.id === toChain.id); + if (!chain) { + return { + fiat: true, + swap: true, + }; + } + + const toTokenAddress = isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address; + + const tokenInfo = chain.tokens.find( + (t) => t.address.toLowerCase() === toTokenAddress.toLowerCase(), + ); + + if (!tokenInfo) { + return { + fiat: true, + swap: true, + }; + } + + return { + fiat: tokenInfo.buyWithFiatEnabled, + swap: tokenInfo.buyWithCryptoEnabled, + }; + } + + const { fiat, swap } = getEnabledPayMethodsForSelectedToken(); + + const buyWithFiatEnabled = payOptions.buyWithFiat !== false && fiat; + const buyWithCryptoEnabled = payOptions.buyWithCrypto !== false && swap; + + useEffect(() => { + // both payment methods are disabled - do nothing + if (!buyWithFiatEnabled && !buyWithCryptoEnabled) { + return; + } + + // if credit card tab is enabled but should be disabled, switch to crypto + if (method === "creditCard" && !buyWithFiatEnabled) { + setMethod("crypto"); + } + + // if crypto tab is enabled but should be disabled, switch to credit card + if (method === "crypto" && !buyWithCryptoEnabled) { + setMethod("creditCard"); + } + }, [buyWithFiatEnabled, buyWithCryptoEnabled, method, setMethod]); + + const showPaymentSelection = buyWithFiatEnabled && buyWithCryptoEnabled; + + return { + showPaymentSelection, + }; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useUISelectionStates.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useUISelectionStates.ts new file mode 100644 index 00000000000..69c1ad14dd6 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useUISelectionStates.ts @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { polygon } from "../../../../../../../chains/chain-definitions/polygon.js"; +import type { Chain } from "../../../../../../../chains/types.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { toEther } from "../../../../../../../utils/units.js"; +import { useActiveWalletChain } from "../../../../../../core/hooks/wallets/wallet-hooks.js"; +import { useDebouncedValue } from "../../../../hooks/useDebouncedValue.js"; +import type { PayUIOptions } from "../../../ConnectButtonProps.js"; +import { type ERC20OrNativeToken, NATIVE_TOKEN } from "../../nativeToken.js"; +import { defaultSelectedCurrency } from "../fiat/currencies.js"; +import type { SupportedChainAndTokens } from "../swap/useSwapSupportedChains.js"; +import type { BuyForTx } from "./types.js"; + +// handle states for token and chain selection + +export function useUISelectionStates(options: { + payOptions: PayUIOptions; + buyForTx?: BuyForTx; + supportedDestinations: SupportedChainAndTokens; +}) { + const activeChain = useActiveWalletChain(); + const { payOptions, buyForTx, supportedDestinations } = options; + + // buy token amount --------------------------------------------------------- + const initialTokenAmount = + payOptions.prefillBuy?.amount || + (buyForTx + ? String( + formatNumber(Number(toEther(buyForTx.cost - buyForTx.balance)), 4), + ) + : ""); + + const [hasEditedAmount, setHasEditedAmount] = useState(false); + const [tokenAmount, setTokenAmount] = useState(initialTokenAmount); + const deferredTokenAmount = useDebouncedValue(tokenAmount, 300); + + // -------------------------------------------------------------------------- + + // Destination chain and token selection ----------------------------------- + const [toChain, setToChain] = useState( + // use prefill chain if available + payOptions.prefillBuy?.chain || + // use buyForTx chain if available + buyForTx?.tx.chain || + // use active chain if its supported as destination + supportedDestinations.find((x) => x.chain.id === activeChain?.id) + ?.chain || + // default to polygon + polygon, + ); + + const [toToken, setToToken] = useState( + payOptions.prefillBuy?.token || NATIVE_TOKEN, + ); + // -------------------------------------------------------------------------- + + // Source token and chain selection --------------------------------------------------- + const [fromChain, setFromChain] = useState( + // use prefill chain if available + (payOptions.buyWithCrypto !== false && + payOptions.buyWithCrypto?.prefillSource?.chain) || + // default to polygon + polygon, + ); + + const [fromToken, setFromToken] = useState( + // use prefill token if available + (payOptions.buyWithCrypto !== false && + payOptions.buyWithCrypto?.prefillSource?.token) || + // default to native token + NATIVE_TOKEN, + ); + + // -------------------------------------------------------------------------- + + // stripe only supports USD, so not using a state right now + const selectedCurrency = defaultSelectedCurrency; + + return { + tokenAmount, + setTokenAmount, + hasEditedAmount, + toChain, + setToChain, + deferredTokenAmount, + fromChain, + setFromChain, + toToken, + setToToken, + fromToken, + setFromToken, + selectedCurrency, + setHasEditedAmount, + }; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/openOnRamppopup.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/openOnRamppopup.tsx new file mode 100644 index 00000000000..7be5dcd605a --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/openOnRamppopup.tsx @@ -0,0 +1,12 @@ +export function openOnrampPopup(link: string, theme: string) { + const height = 750; + const width = 500; + const top = (window.innerHeight - height) / 2; + const left = (window.innerWidth - width) / 2; + + return window.open( + `${link}&theme=${theme}`, + "Buy", + `width=${width}, height=${height}, top=${top}, left=${left}`, + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/BuyTokenInput.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/BuyTokenInput.tsx index 00950388091..1fbc4d5cddf 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/BuyTokenInput.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/BuyTokenInput.tsx @@ -30,6 +30,8 @@ export function BuyTokenInput(props: { onSelectToken: () => void; client: ThirdwebClient; hideTokenSelector?: boolean; + freezeAmount?: boolean; + freezeChainAndToken?: boolean; }) { const chainQuery = useChainQuery(props.chain); @@ -68,6 +70,7 @@ export function BuyTokenInput(props: { type="text" data-placeholder={props.value === ""} value={props.value || "0"} + disabled={props.freezeAmount} onClick={(e) => { // put cursor at the end of the input if (props.value === "") { @@ -102,7 +105,7 @@ export function BuyTokenInput(props: { ? "26px" : props.value.length > 6 ? "34px" - : "45px", + : "50px", boxShadow: "none", padding: "0", paddingBlock: "2px", @@ -135,6 +138,7 @@ export function BuyTokenInput(props: { }} gap="xxs" onClick={props.onSelectToken} + disabled={props.freezeChainAndToken} > void; - buyWithCryptoQuote: BuyWithCryptoQuote; - fromAmount: string; + onBack?: () => void; + client: ThirdwebClient; + quote: BuyWithCryptoQuote; + setSwapTxHash: (txHash: string) => void; + onTryAgain: () => void; + toChain: Chain; toAmount: string; + toTokenSymbol: string; fromChain: Chain; - toChain: Chain; - account: Account; - fromToken: ERC20OrNativeToken; toToken: ERC20OrNativeToken; - onViewPendingTx: () => void; - onQuoteFinalized: (quote: BuyWithCryptoQuote) => void; - client: ThirdwebClient; + fromAmount: string; + fromToken: ERC20OrNativeToken; + fromTokenSymbol: string; + isFiatFlow: boolean; }) { const sendTransactionMutation = useSendTransactionCore(); + const activeChain = useActiveWalletChain(); + const activeWallet = useActiveWallet(); - const [swapTx, setSwapTx] = useState< - BuyWithCryptoStatusQueryParams | undefined - >(); + const isApprovalRequired = props.quote.approval !== undefined; + const initialStep = isApprovalRequired ? "approval" : "swap"; - const isApprovalRequired = props.buyWithCryptoQuote.approval !== undefined; - - const [step, setStep] = useState<"approval" | "swap">( - isApprovalRequired ? "approval" : "swap", - ); + const [step, setStep] = useState<"approval" | "swap">(initialStep); const [status, setStatus] = useState< "pending" | "success" | "error" | "idle" >("idle"); - const fromChain = useChainQuery(props.fromChain); - const toChain = useChainQuery(props.toChain); - - const fromTokenSymbol = isNativeToken(props.fromToken) - ? fromChain.data?.nativeCurrency?.symbol - : props.fromToken?.symbol; - - const toTokenSymbol = isNativeToken(props.toToken) - ? toChain.data?.nativeCurrency?.symbol - : props.toToken?.symbol; - - if (swapTx) { - return ( - { - props.onBack(); - }} - onViewPendingTx={props.onViewPendingTx} - destinationChain={props.toChain} - destinationToken={props.toToken} - sourceAmount={`${formatNumber(Number(props.fromAmount), 4)} ${ - fromTokenSymbol || "" - }`} - destinationAmount={`${formatNumber(Number(props.toAmount), 4)} ${ - toTokenSymbol || "" - }`} - swapTx={swapTx} - client={props.client} - /> - ); - } - return ( - - + + + {props.isFiatFlow ? ( + <> + + + + + Step 2 of 2 - Converting {props.fromTokenSymbol} to{" "} + {props.toTokenSymbol} + + + + ) : ( + + )} {/* You Receive */} - - @@ -126,14 +102,7 @@ export function SwapConfirmationScreen(props: { {/* Fees */} - - - - {/* Send to */} - - - {shortenString(props.account.address, false)} - + {/* Time */} @@ -141,12 +110,12 @@ export function SwapConfirmationScreen(props: { ~ {formatSeconds( - props.buyWithCryptoQuote.swapDetails.estimated.durationSeconds || 0, + props.quote.swapDetails.estimated.durationSeconds || 0, )} - + {/* Show 2 steps */} {isApprovalRequired && ( @@ -175,7 +144,7 @@ export function SwapConfirmationScreen(props: { {status === "error" && ( <> - + {step === "approval" ? "Failed to Approve" : "Failed to Confirm"} @@ -186,73 +155,83 @@ export function SwapConfirmationScreen(props: { )} - + }} + gap="xs" + > + {step === "approval" && + (status === "pending" ? "Approving" : "Approve")} + {step === "swap" && (status === "pending" ? "Confirming" : "Confirm")} + {status === "pending" && ( + + )} + + )} ); } @@ -266,71 +245,7 @@ const ConnectorLine = /* @__PURE__ */ StyledDiv(() => { }; }); -function Step(props: { isDone: boolean; label: string; isActive: boolean }) { - return ( - - - {props.isDone ? ( - - ) : ( - - )} - - {props.label} - - ); -} - -const pulseAnimation = keyframes` -0% { - opacity: 1; - transform: scale(0.5); -} -100% { - opacity: 0; - transform: scale(1.5); -} -`; - -const PulsingDot = /* @__PURE__ */ StyledDiv(() => { - return { - background: "currentColor", - width: "9px", - height: "9px", - borderRadius: "50%", - '&[data-active="true"]': { - animation: `${pulseAnimation} 1s infinite`, - }, - }; -}); - -const Circle = /* @__PURE__ */ StyledDiv(() => { - return { - border: "1px solid currentColor", - width: "20px", - height: "20px", - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - }; -}); - -function TokenInfo(props: { +function RenderTokenInfo(props: { chain: Chain; token: ERC20OrNativeToken; amount: string; @@ -387,124 +302,3 @@ function ConfirmItem(props: { label: string; children: React.ReactNode }) { ); } - -function WaitingForConfirmation(props: { - onBack: () => void; - onViewPendingTx: () => void; - swapTx: BuyWithCryptoStatusQueryParams; - destinationToken: ERC20OrNativeToken; - destinationChain: Chain; - sourceAmount: string; - destinationAmount: string; - client: ThirdwebClient; -}) { - const swapStatus = useBuyWithCryptoStatus(props.swapTx); - const isSuccess = swapStatus.data?.status === "COMPLETED"; - const isFailed = swapStatus.data?.status === "FAILED"; - // const isPending = !isSuccess && !isFailed; - - return ( - - - - - - - - {/* Icon */} - {isSuccess ? ( - - ) : isFailed ? ( - - ) : ( -
- -
- -
-
- )} - - - - - {isSuccess - ? "Buy Success" - : isFailed - ? "Transaction Failed" - : "Buy Pending"} - - - {/* Token info */} - {!isFailed && ( - <> - -
- - {" "} - {isSuccess ? "Bought" : "Buy"}{" "} - - - {props.destinationAmount} - - - - {" "} - for{" "} - - - {props.sourceAmount} - -
- - )} - - {isFailed && ( - <> - - Your transaction {`couldn't`} be processed - - )} -
- - - - {!isFailed && ( - - )} - - {isFailed && ( - - )} -
-
- ); -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/Fees.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/Fees.tsx index b85d1f1fda4..84e0e3acdaf 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/Fees.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/Fees.tsx @@ -1,6 +1,8 @@ -import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/actions/getQuote.js"; +import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; +import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js"; import { formatNumber } from "../../../../../../../utils/formatNumber.js"; -import { Container } from "../../../../components/basic.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Container, Line } from "../../../../components/basic.js"; import { Text } from "../../../../components/text.js"; /** @@ -39,3 +41,72 @@ export function SwapFees(props: {
); } + +/** + * @internal + */ +export function FiatFees(props: { + quote: BuyWithFiatQuote; +}) { + return ( + +
+ + Amount + + + {formatNumber(Number(props.quote.fromCurrency.amount), 4)}{" "} + {props.quote.fromCurrency.currencySymbol} + +
+ + {props.quote.processingFees.map((fee, i) => { + const feeAmount = formatNumber(Number(fee.amount), 4); + + return ( +
+ + {fee.feeType === "NETWORK" ? "Network Fee" : "Processing Fee"} + + + + {feeAmount === 0 ? "~" : ""} + {feeAmount} {fee.currencySymbol}{" "} + +
+ ); + })} + + + + + +
+ + Total + + + {formatNumber(Number(props.quote.fromCurrencyWithFees.amount), 4)}{" "} + {props.quote.fromCurrencyWithFees.currencySymbol} + +
+
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx index b6d7e2861ef..d04a4b256a0 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx @@ -1,4 +1,3 @@ -import styled from "@emotion/styled"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import type { Chain } from "../../../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js"; @@ -12,7 +11,6 @@ import { Container } from "../../../../components/basic.js"; import { Button } from "../../../../components/buttons.js"; import { Text } from "../../../../components/text.js"; import { TokenSymbol } from "../../../../components/token/TokenSymbol.js"; -import { useCustomTheme } from "../../../../design-system/CustomThemeProvider.js"; import { fontSize, iconSize, @@ -21,7 +19,7 @@ import { } from "../../../../design-system/index.js"; import type { TokenInfo } from "../../../defaultTokens.js"; import { WalletIcon } from "../../../icons/WalletIcon.js"; -import { formatTokenBalance } from "../../TokenSelector.js"; +import { formatTokenBalance } from "../../formatTokenBalance.js"; import { type NativeToken, isNativeToken } from "../../nativeToken.js"; /** @@ -37,6 +35,7 @@ export function PayWithCrypto(props: { token: TokenInfo | NativeToken; isLoading: boolean; client: ThirdwebClient; + freezeChainAndTokenSelection?: boolean; }) { const chainQuery = useChainQuery(props.chain); const activeAccount = useActiveAccount(); @@ -67,7 +66,18 @@ export function PayWithCrypto(props: { }} > {/* Left */} - + {/* Right */}
); } - -const TokenButton = /* @__PURE__ */ styled(Button)(() => { - const theme = useCustomTheme(); - return { - background: "transparent", - border: "1px solid transparent", - "&:hover": { - background: "transparent", - borderColor: theme.colors.accentText, - }, - justifyContent: "flex-start", - transition: "background 0.3s, border-color 0.3s", - gap: spacing.sm, - paddingInline: spacing.sm, - paddingBlock: spacing.sm, - color: theme.colors.primaryText, - borderRadius: radius.md, - minWidth: "50%", - }; -}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapFees.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapFees.tsx deleted file mode 100644 index b85d1f1fda4..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapFees.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/actions/getQuote.js"; -import { formatNumber } from "../../../../../../../utils/formatNumber.js"; -import { Container } from "../../../../components/basic.js"; -import { Text } from "../../../../components/text.js"; - -/** - * @internal - */ -export function SwapFees(props: { - quote: BuyWithCryptoQuote; - align: "left" | "right"; -}) { - return ( - - {props.quote.processingFees.map((fee) => { - const feeAmount = formatNumber(Number(fee.amount), 4); - return ( - - - {feeAmount === 0 ? "~" : ""} - {feeAmount} {fee.token.symbol} - - - (${(fee.amountUSDCents / 100).toFixed(2)}) - - - ); - })} - - ); -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapFlow.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapFlow.tsx new file mode 100644 index 00000000000..1c05f6a7006 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapFlow.tsx @@ -0,0 +1,109 @@ +import { useMemo, useState } from "react"; +import { defineChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js"; +import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; +import type { Account } from "../../../../../../../wallets/interfaces/wallet.js"; +import type { TokenInfo } from "../../../defaultTokens.js"; +import { type ERC20OrNativeToken, NATIVE_TOKEN } from "../../nativeToken.js"; +import { SwapConfirmationScreen } from "./ConfirmationScreen.js"; +import { SwapStatusScreen } from "./SwapStatusScreen.js"; + +type SwapFlowProps = { + onBack?: () => void; + buyWithCryptoQuote: BuyWithCryptoQuote; + account: Account; + onViewPendingTx: () => void; + client: ThirdwebClient; + isFiatFlow: boolean; + onDone: () => void; + onTryAgain: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}; + +export function SwapFlow(props: SwapFlowProps) { + const [swapTxHash, setSwapTxHash] = useState(); + + const quote = props.buyWithCryptoQuote; + + const fromChain = useMemo( + () => defineChain(quote.swapDetails.fromToken.chainId), + [quote], + ); + + const toChain = useMemo( + () => defineChain(quote.swapDetails.toToken.chainId), + [quote], + ); + + const fromTokenSymbol = quote.swapDetails.fromToken.symbol || ""; + const toTokenSymbol = quote.swapDetails.toToken.symbol || ""; + + const fromAmount = quote.swapDetails.fromAmount; + const toAmount = quote.swapDetails.toAmount; + + const _toToken = quote.swapDetails.toToken; + const _fromToken = quote.swapDetails.fromToken; + + const toToken: ERC20OrNativeToken = useMemo(() => { + if (_toToken.tokenAddress === NATIVE_TOKEN_ADDRESS) { + return NATIVE_TOKEN; + } + + const tokenInfo: TokenInfo = { + address: _toToken.tokenAddress, + name: _toToken.name || "", + symbol: _toToken.symbol || "", + }; + return tokenInfo; + }, [_toToken]); + + const fromToken: ERC20OrNativeToken = useMemo(() => { + if (_fromToken.tokenAddress === NATIVE_TOKEN_ADDRESS) { + return NATIVE_TOKEN; + } + + const tokenInfo: TokenInfo = { + address: _fromToken.tokenAddress, + name: _fromToken.name || "", + symbol: _fromToken.symbol || "", + }; + return tokenInfo; + }, [_fromToken]); + + if (swapTxHash) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapStatusScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapStatusScreen.tsx new file mode 100644 index 00000000000..49ba4939a09 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapStatusScreen.tsx @@ -0,0 +1,187 @@ +import { CheckCircledIcon } from "@radix-ui/react-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; +import { useBuyWithCryptoStatus } from "../../../../../../core/hooks/pay/useBuyWithCryptoStatus.js"; +import { invalidateWalletBalance } from "../../../../../../core/providers/invalidateWalletBalance.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { Container, ModalHeader } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { iconSize } from "../../../../design-system/index.js"; +import { AccentFailIcon } from "../../../icons/AccentFailIcon.js"; +import { SwapTxDetailsTable } from "../tx-history/SwapDetailsScreen.js"; + +type UIStatus = "pending" | "success" | "failed" | "partialSuccess"; + +export function SwapStatusScreen(props: { + onBack?: () => void; + onViewPendingTx: () => void; + swapTxHash: string; + client: ThirdwebClient; + onTryAgain: () => void; + onDone: () => void; + isBuyForTx: boolean; + isEmbed: boolean; + quote: BuyWithCryptoQuote; +}) { + const swapStatus = useBuyWithCryptoStatus({ + client: props.client, + transactionHash: props.swapTxHash, + }); + + let uiStatus: UIStatus = "pending"; + if (swapStatus.data?.status === "COMPLETED") { + uiStatus = "success"; + } else if (swapStatus.data?.status === "FAILED") { + uiStatus = "failed"; + } + + if ( + swapStatus.data?.status === "COMPLETED" && + swapStatus.data?.subStatus === "PARTIAL_SUCCESS" + ) { + uiStatus = "partialSuccess"; + } + + const queryClient = useQueryClient(); + const balanceInvalidated = useRef(false); + useEffect(() => { + if ( + (uiStatus === "success" || uiStatus === "partialSuccess") && + !balanceInvalidated.current + ) { + balanceInvalidated.current = true; + invalidateWalletBalance(queryClient); + } + }, [queryClient, uiStatus]); + + const swapDetails = + swapStatus.data && swapStatus.data.status !== "NOT_FOUND" ? ( + + ) : ( + + ); + + return ( + + + + + + {uiStatus === "success" && ( + <> + + + + + + Buy Success + + + + + {swapDetails} + + {!props.isEmbed && ( + + )} + + )} + + {uiStatus === "partialSuccess" && + swapStatus.data?.status !== "NOT_FOUND" && + swapStatus.data?.destination && ( + <> + + + + + + Incomplete + + + + Expected {swapStatus.data.quote.toToken.symbol}, Got{" "} + {swapStatus.data.destination.token.symbol} instead + + + + {swapDetails} + + )} + + {uiStatus === "failed" && ( + <> + + + + + + + + Transaction Failed + + + + + Your transaction {`couldn't`} be processed + + + + + + + + + + {swapDetails} + + + )} + + {uiStatus === "pending" && ( + <> + + +
+ +
+ + + Buy Pending + +
+ + {swapDetails} + + )} +
+
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts index 9c0354ed6ec..d024cfd13cc 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/pendingSwapTx.ts @@ -1,93 +1,24 @@ -import type { ThirdwebClient } from "../../../../../../../client/client.js"; -import { - type BuyWithCryptoStatus, - getBuyWithCryptoStatus, -} from "../../../../../../../pay/buyWithCrypto/actions/getStatus.js"; import { createStore } from "../../../../../../../reactive/store.js"; -import { wait } from "../../../../../../../utils/promise/wait.js"; -type SwapTxInfo = { - transactionHash: string; - status: BuyWithCryptoStatus["status"]; - subStatus?: BuyWithCryptoStatus["subStatus"]; - source: { - symbol: string; - value: string; - chainId: number; - }; - destination: { - symbol: string; - value: string; - chainId: number; - }; -}; +export type PendingTxInfo = + | { + type: "swap"; + txHash: string; + } + | { + type: "fiat"; + intentId: string; + }; -// array of transaction hashes -export const swapTransactionsStore = /* @__PURE__ */ createStore( +export const pendingTransactions = /* @__PURE__ */ createStore( [], ); /** * @internal */ -export const addPendingSwapTransaction = ( - client: ThirdwebClient, - txInfo: SwapTxInfo, -) => { - const currentValue = swapTransactionsStore.getValue(); - const indexAdded = currentValue.length; - - // add it - swapTransactionsStore.setValue([...currentValue, txInfo]); - - // poll for it's status and update the store when we know it's status - const maxRetries = 50; - let retryCount = 0; - - async function tryToGetStatus() { - // keep polling for the transaction status every 5 seconds until maxRetries - await wait(5000); - try { - retryCount++; - const res = await getBuyWithCryptoStatus({ - client: client, - transactionHash: txInfo.transactionHash, - }); - - if (res.status === "COMPLETED" || res.status === "FAILED") { - const value = swapTransactionsStore.getValue(); - const updatedValue = [...value]; - const oldValue = value[indexAdded]; - if (oldValue) { - const newValue = { - ...oldValue, - status: res.status, - subStatus: res.subStatus, - }; - updatedValue[indexAdded] = newValue; - - // in case - the destination token is different ( happens when tx is partially successful ) - if (res.destination) { - newValue.destination = { - symbol: res.destination.token.symbol || "", - value: res.destination.amount, - chainId: res.destination.token.chainId, - }; - } - - swapTransactionsStore.setValue(updatedValue); - } - - return; // stop - } - } catch { - // ignore - } - - if (retryCount < maxRetries) { - await tryToGetStatus(); - } - } - - tryToGetStatus(); +export const addPendingTx = (txInfo: PendingTxInfo) => { + const currentValue = pendingTransactions.getValue(); + // prepend the new tx to list + pendingTransactions.setValue([txInfo, ...currentValue]); }; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/useSwapSupportedChains.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/useSwapSupportedChains.ts index 9cbc3adf221..e2e8fbc0570 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/useSwapSupportedChains.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/useSwapSupportedChains.ts @@ -1,21 +1,56 @@ import { useQuery } from "@tanstack/react-query"; +import type { Chain } from "../../../../../../../chains/types.js"; import { defineChain } from "../../../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js"; -import { getPayChainsEndpoint } from "../../../../../../../pay/buyWithCrypto/utils/definitions.js"; +import { + getPaySupportedDestinations, + getPaySupportedSources, +} from "../../../../../../../pay/utils/definitions.js"; import { getClientFetch } from "../../../../../../../utils/fetch.js"; import { withCache } from "../../../../../../../utils/promise/withCache.js"; -export async function fetchSwapSupportedChains(client: ThirdwebClient) { +type Response = { + result: Array<{ + chainId: number; + tokens: Array<{ + address: string; + buyWithCryptoEnabled: boolean; + buyWithFiatEnabled: boolean; + name: string; + symbol: string; + }>; + }>; +}; + +export type SupportedChainAndTokens = Array<{ + chain: Chain; + tokens: Array<{ + address: string; + buyWithCryptoEnabled: boolean; + buyWithFiatEnabled: boolean; + name: string; + symbol: string; + icon?: string; + }>; +}>; + +export async function fetchBuySupportedDestinations( + client: ThirdwebClient, +): Promise { return withCache( async () => { const fetchWithHeaders = getClientFetch(client); - const res = await fetchWithHeaders(getPayChainsEndpoint()); - const data = await res.json(); - const chainIds = data.result.chainIds as number[]; - return chainIds.map(defineChain); + const res = await fetchWithHeaders(getPaySupportedDestinations()); + const data = (await res.json()) as Response; + return data.result.map((item) => ({ + chain: defineChain({ + id: item.chainId, + }), + tokens: item.tokens, + })); }, { - cacheKey: "swapSupportedChains", + cacheKey: "destination-tokens", cacheTime: 5 * 60 * 1000, }, ); @@ -24,11 +59,44 @@ export async function fetchSwapSupportedChains(client: ThirdwebClient) { /** * @internal */ -export function useSwapSupportedChains(client: ThirdwebClient) { +export function useBuySupportedDestinations(client: ThirdwebClient) { + return useQuery({ + queryKey: ["destination-tokens", client], + queryFn: async () => { + return fetchBuySupportedDestinations(client); + }, + }); +} + +export function useBuySupportedSources(options: { + client: ThirdwebClient; + destinationChainId: number; + destinationTokenAddress: string; +}) { return useQuery({ - queryKey: ["swapSupportedChains", client], + queryKey: ["source-tokens", options], queryFn: async () => { - return fetchSwapSupportedChains(client); + const fetchWithHeaders = getClientFetch(options.client); + const baseUrl = getPaySupportedSources(); + + const url = new URL(baseUrl); + url.searchParams.append( + "destinationChainId", + options.destinationChainId.toString(), + ); + url.searchParams.append( + "destinationTokenAddress", + options.destinationTokenAddress, + ); + + const res = await fetchWithHeaders(url.toString()); + const data = (await res.json()) as Response; + return data.result.map((item) => ({ + chain: defineChain({ + id: item.chainId, + }), + tokens: item.tokens, + })); }, }); } diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistory.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistory.tsx new file mode 100644 index 00000000000..c46724b7152 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistory.tsx @@ -0,0 +1,227 @@ +"use client"; +import { + ArrowRightIcon, + CrossCircledIcon, + ExternalLinkIcon, +} from "@radix-ui/react-icons"; +import { useState } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { useChainQuery } from "../../../../../../core/hooks/others/useChainQuery.js"; +import { + useActiveAccount, + useActiveWalletChain, +} from "../../../../../../core/hooks/wallets/wallet-hooks.js"; +import { Skeleton } from "../../../../components/Skeleton.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { Container, Line, ModalHeader } from "../../../../components/basic.js"; +import { Button, ButtonLink } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { + fontSize, + iconSize, + spacing, +} from "../../../../design-system/index.js"; +import { + BuyTxHistoryButton, + BuyTxHistoryButtonHeight, +} from "./BuyTxHistoryButton.js"; +import { TxDetailsScreen } from "./TxDetailsScreen.js"; +import { + type TxStatusInfo, + useBuyTransactionsToShow, +} from "./useBuyTransactionsToShow.js"; + +/** + * @internal + */ +export function BuyTxHistory(props: { + onBack?: () => void; + client: ThirdwebClient; + onDone: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const [selectedTx, setSelectedTx] = useState(null); + + if (selectedTx) { + return ( + setSelectedTx(null)} + onDone={props.onDone} + isBuyForTx={props.isBuyForTx} + isEmbed={props.isEmbed} + /> + ); + } + + return ; +} + +/** + * @internal + */ +export function BuyTxHistoryList(props: { + onBack?: () => void; + client: ThirdwebClient; + onDone: () => void; + onSelectTx: (tx: TxStatusInfo) => void; +}) { + const { + pageIndex, + setPageIndex, + txInfosToShow, + hidePagination, + isLoading, + pagination, + } = useBuyTransactionsToShow(props.client); + + const activeChain = useActiveWalletChain(); + const chainQuery = useChainQuery(activeChain); + const activeAccount = useActiveAccount(); + + const noTransactions = txInfosToShow.length === 0; + + return ( + + + + + + + + {noTransactions && !isLoading && ( + + + No Transactions + + )} + + {noTransactions && isLoading && ( + + + + )} + + {txInfosToShow.map((txInfo) => { + return ( + { + props.onSelectTx(txInfo); + }} + /> + ); + })} + + {isLoading && txInfosToShow.length > 0 && ( + <> + + + + + )} + + + + {pagination && !hidePagination && ( +
+ + +
+ )} +
+
+ + + + + View on Explorer{" "} + + + +
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistoryButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistoryButton.tsx new file mode 100644 index 00000000000..257ee9bfaec --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/BuyTxHistoryButton.tsx @@ -0,0 +1,127 @@ +import styled from "@emotion/styled"; +import { defineChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { ChainName } from "../../../../components/ChainName.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { TokenIcon } from "../../../../components/TokenIcon.js"; +import { Container } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { useCustomTheme } from "../../../../design-system/CustomThemeProvider.js"; +import { spacing } from "../../../../design-system/index.js"; +import { + getBuyWithCryptoStatusMeta, + getBuyWithFiatStatusMeta, +} from "./statusMeta.js"; +import type { TxStatusInfo } from "./useBuyTransactionsToShow.js"; + +export const BuyTxHistoryButtonHeight = "62px"; + +export function BuyTxHistoryButton(props: { + txInfo: TxStatusInfo; + client: ThirdwebClient; + onClick?: () => void; +}) { + const statusMeta = + props.txInfo.type === "swap" + ? getBuyWithCryptoStatusMeta(props.txInfo.status) + : getBuyWithFiatStatusMeta(props.txInfo.status); + + return ( + + + + +
+ {/* Row 1 */} + + + Buy{" "} + {formatNumber( + Number( + props.txInfo.type === "swap" + ? props.txInfo.status.quote.toAmount + : props.txInfo.status.quote.estimatedToTokenAmount, + ), + 4, + )}{" "} + {props.txInfo.status.quote.toToken.symbol} + {" "} + + + + + {/* Row 2 */} + + + +
+
+ + {/* Status */} + + + {statusMeta.status} + + +
+ ); +} + +const TxButton = /* @__PURE__ */ styled(Button)(() => { + const theme = useCustomTheme(); + return { + background: theme.colors.tertiaryBg, + "&:hover": { + background: theme.colors.secondaryButtonBg, + }, + height: BuyTxHistoryButtonHeight, + }; +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/FiatDetailsScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/FiatDetailsScreen.tsx new file mode 100644 index 00000000000..1c3fea795bb --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/FiatDetailsScreen.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { + BuyWithFiatStatus, + ValidBuyWithFiatStatus, +} from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { useBuyWithFiatStatus } from "../../../../../../core/hooks/pay/useBuyWithFiatStatus.js"; +import { Container, Line, ModalHeader } from "../../../../components/basic.js"; +import { OnRampTxDetailsTable } from "../fiat/FiatTxDetailsTable.js"; +import { PostOnRampSwapFlow } from "../fiat/PostOnRampSwapFlow.js"; +import { getBuyWithFiatStatusMeta } from "./statusMeta.js"; + +export function FiatDetailsScreen(props: { + status: ValidBuyWithFiatStatus; + onBack: () => void; + client: ThirdwebClient; + onDone: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const initialStatus = props.status; + const [stopPolling, setStopPolling] = useState(false); + + const statusQuery = useBuyWithFiatStatus( + stopPolling + ? undefined + : { + client: props.client, + intentId: initialStatus.intentId, + }, + ); + + const status: ValidBuyWithFiatStatus = + (statusQuery.data?.status === "NOT_FOUND" ? undefined : statusQuery.data) || + initialStatus; + + const hasTwoSteps = isSwapRequiredAfterOnRamp(status); + const statusMeta = getBuyWithFiatStatusMeta(status); + + if (hasTwoSteps) { + const fiatQuote = status.quote; + return ( + { + setStopPolling(true); + }} + /> + ); + } + + return ( + + + + + + + + + + + + ); +} + +// if the toToken is the same as the onRampToken, no swap is required +export function isSwapRequiredAfterOnRamp( + buyWithFiatStatus: BuyWithFiatStatus, +) { + if (buyWithFiatStatus.status === "NOT_FOUND") { + return false; + } + + const sameChain = + buyWithFiatStatus.quote.toToken.chainId === + buyWithFiatStatus.quote.onRampToken.chainId; + + const sameToken = + buyWithFiatStatus.quote.toToken.tokenAddress === + buyWithFiatStatus.quote.onRampToken.tokenAddress; + + return !(sameChain && sameToken); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/SwapDetailsScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/SwapDetailsScreen.tsx new file mode 100644 index 00000000000..5e15cefaf36 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/SwapDetailsScreen.tsx @@ -0,0 +1,307 @@ +import { ExternalLinkIcon } from "@radix-ui/react-icons"; +import { defineChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; +import type { ValidBuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js"; +import { useChainQuery } from "../../../../../../core/hooks/others/useChainQuery.js"; +import { useBuyWithCryptoStatus } from "../../../../../../core/hooks/pay/useBuyWithCryptoStatus.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Container, Line, ModalHeader } from "../../../../components/basic.js"; +import { ButtonLink } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { + fontSize, + iconSize, + spacing, +} from "../../../../design-system/index.js"; +import { formatSeconds } from "../swap/formatSeconds.js"; +import { TokenInfoRow } from "./TokenInfoRow.js"; +import { type StatusMeta, getBuyWithCryptoStatusMeta } from "./statusMeta.js"; + +export function SwapDetailsScreen(props: { + status: ValidBuyWithCryptoStatus; + onBack: () => void; + client: ThirdwebClient; +}) { + const { status: initialStatus, client } = props; + const statusQuery = useBuyWithCryptoStatus( + initialStatus.source?.transactionHash + ? { + client: client, + transactionHash: initialStatus.source.transactionHash, + } + : undefined, + ); + + const status: ValidBuyWithCryptoStatus = + (statusQuery.data?.status !== "NOT_FOUND" ? statusQuery.data : undefined) || + initialStatus; + + return ( + + + + + + + + + + + + ); +} + +type SwapTxDetailsData = { + fromToken: { + chainId: number; + symbol: string; + address: string; + amount: string; + }; + quotedToToken: { + chainId: number; + symbol: string; + address: string; + amount: string; + }; + gotToken?: { + chainId: number; + symbol: string; + address: string; + amount: string; + }; + statusMeta?: StatusMeta; + sourceTxHash?: string; + destinationTxHash?: string; + isPartialSuccess: boolean; + estimatedDuration: number; +}; + +export function SwapTxDetailsTable( + props: + | { + type: "quote"; + quote: BuyWithCryptoQuote; + client: ThirdwebClient; + } + | { + client: ThirdwebClient; + type: "status"; + status: ValidBuyWithCryptoStatus; + hideStatusRow?: boolean; + }, +) { + let uiData: SwapTxDetailsData; + let showStatusRow = true; + if (props.type === "status") { + const status = props.status; + if (props.hideStatusRow) { + showStatusRow = false; + } + + const isPartialSuccess = + status.status === "COMPLETED" && status.subStatus === "PARTIAL_SUCCESS"; + + uiData = { + fromToken: { + chainId: status.quote.fromToken.chainId, + symbol: status.quote.fromToken.symbol || "", + address: status.quote.fromToken.tokenAddress, + amount: status.quote.fromAmount, + }, + quotedToToken: { + chainId: status.quote.toToken.chainId, + symbol: status.quote.toToken.symbol || "", + address: status.quote.toToken.tokenAddress, + amount: status.quote.toAmount, + }, + gotToken: status.destination + ? { + chainId: status.destination.token.chainId, + symbol: status.destination.token.symbol || "", + address: status.destination.token.tokenAddress, + amount: status.destination.amount, + } + : undefined, + statusMeta: getBuyWithCryptoStatusMeta(status), + estimatedDuration: status.quote.estimated.durationSeconds || 0, + isPartialSuccess, + destinationTxHash: status.destination?.transactionHash, + sourceTxHash: status.source?.transactionHash, + }; + } else { + const quote = props.quote; + uiData = { + fromToken: { + chainId: quote.swapDetails.fromToken.chainId, + symbol: quote.swapDetails.fromToken.symbol || "", + address: quote.swapDetails.fromToken.tokenAddress, + amount: quote.swapDetails.fromAmount, + }, + quotedToToken: { + chainId: quote.swapDetails.toToken.chainId, + symbol: quote.swapDetails.toToken.symbol || "", + address: quote.swapDetails.toToken.tokenAddress, + amount: quote.swapDetails.toAmount, + }, + isPartialSuccess: false, + estimatedDuration: quote.swapDetails.estimated.durationSeconds || 0, + }; + } + + const { client } = props; + + const { + fromToken, + quotedToToken: toToken, + statusMeta, + sourceTxHash, + destinationTxHash, + isPartialSuccess, + gotToken, + estimatedDuration, + } = uiData; + + const fromChainId = fromToken.chainId; + const toChainId = toToken.chainId; + + const fromChainQuery = useChainQuery(defineChain(fromChainId)); + const toChainQuery = useChainQuery(defineChain(toChainId)); + + const lineSpacer = ( + <> + + + + + ); + + return ( +
+ {isPartialSuccess && gotToken ? ( + <> + {/* Expected */} + + + {lineSpacer} + + + + ) : ( + + )} + + {lineSpacer} + + + + {lineSpacer} + + {/* Duration */} + + Time + + + ~{formatSeconds(estimatedDuration || 0)} + + + + + {statusMeta && showStatusRow && ( + <> + {lineSpacer} + + {/* Status */} + + Status + + {statusMeta.status} + + + + )} + + {lineSpacer} + + {fromChainQuery.data?.explorers?.[0]?.url && sourceTxHash && ( + + View on {fromChainQuery.data.name} Explorer + + + )} + + {destinationTxHash && + sourceTxHash !== destinationTxHash && + toChainQuery.data?.explorers?.[0]?.url && ( + <> + + + View on {toChainQuery.data.name} Explorer + + + + )} +
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/TokenInfoRow.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/TokenInfoRow.tsx new file mode 100644 index 00000000000..71c55552811 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/TokenInfoRow.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { defineChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { useChainQuery } from "../../../../../../core/hooks/others/useChainQuery.js"; +import { TokenIcon } from "../../../../components/TokenIcon.js"; +import { Container } from "../../../../components/basic.js"; +import { Text } from "../../../../components/text.js"; + +export function TokenInfoRow(props: { + tokenSymbol: string; + tokenAmount: string; + tokenAddress: string; + chainId: number; + label: string; + client: ThirdwebClient; +}) { + const chainObj = useMemo(() => defineChain(props.chainId), [props.chainId]); + const chainQuery = useChainQuery(chainObj); + + return ( + + {props.label} + + + + + {formatNumber(Number(props.tokenAmount), 4)} {props.tokenSymbol} + + + + {chainQuery.data?.name} + + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/TxDetailsScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/TxDetailsScreen.tsx new file mode 100644 index 00000000000..2e5d74f5d24 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/TxDetailsScreen.tsx @@ -0,0 +1,40 @@ +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { FiatDetailsScreen } from "./FiatDetailsScreen.js"; +import { SwapDetailsScreen } from "./SwapDetailsScreen.js"; +import type { TxStatusInfo } from "./useBuyTransactionsToShow.js"; + +export function TxDetailsScreen(props: { + client: ThirdwebClient; + statusInfo: TxStatusInfo; + onBack: () => void; + onDone: () => void; + isBuyForTx: boolean; + isEmbed: boolean; +}) { + const { statusInfo } = props; + + if (statusInfo.type === "swap") { + return ( + + ); + } + + if (statusInfo.type === "fiat") { + return ( + + ); + } + + return null; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/statusMeta.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/statusMeta.ts new file mode 100644 index 00000000000..f92f59c3054 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/statusMeta.ts @@ -0,0 +1,146 @@ +import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js"; +import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import type { Theme } from "../../../../design-system/index.js"; + +export type StatusMeta = { + status: string; + color: keyof Theme["colors"]; + loading?: true; +}; + +export function getBuyWithCryptoStatusMeta( + cryptoStatus: BuyWithCryptoStatus, +): StatusMeta { + if (cryptoStatus.status === "NOT_FOUND") { + return { + status: "Unknown", + color: "secondaryText", + }; + } + + const subStatus = cryptoStatus.subStatus; + const status = cryptoStatus.status; + + if (subStatus === "WAITING_BRIDGE") { + return { + status: "Bridging", + color: "accentText", + loading: true, + }; + } + + if (subStatus === "PARTIAL_SUCCESS") { + return { + status: "Incomplete", + color: "secondaryText", + }; + } + + if (status === "PENDING") { + return { + status: "Pending", + color: "accentText", + loading: true, + }; + } + + if (status === "FAILED") { + return { + status: "Failed", + color: "danger", + }; + } + + if (status === "COMPLETED") { + return { + status: "Completed", + color: "success", + }; + } + + return { + status: "Unknown", + color: "secondaryText", + }; +} + +export type FiatStatusMeta = { + status: string; + color: keyof Theme["colors"]; + loading?: true; + step: 1 | 2; + progressStatus: + | "pending" + | "completed" + | "failed" + | "actionRequired" + | "partialSuccess" + | "unknown"; +}; +export function getBuyWithFiatStatusMeta( + fiatStatus: BuyWithFiatStatus, +): FiatStatusMeta { + const status = fiatStatus.status; + + switch (status) { + case "CRYPTO_SWAP_FALLBACK": { + return { + status: "Incomplete", + color: "danger", + step: 2, + progressStatus: "partialSuccess", + }; + } + + case "CRYPTO_SWAP_IN_PROGRESS": + case "PENDING_ON_RAMP_TRANSFER": + case "ON_RAMP_TRANSFER_IN_PROGRESS": + case "PENDING_PAYMENT": { + return { + status: "Pending", + color: "accentText", + loading: true, + step: status === "CRYPTO_SWAP_IN_PROGRESS" ? 2 : 1, + progressStatus: "pending", + }; + } + + case "ON_RAMP_TRANSFER_COMPLETED": + case "CRYPTO_SWAP_COMPLETED": { + return { + status: "Completed", // Is this actually completed though? + color: "success", + loading: true, + step: status === "CRYPTO_SWAP_COMPLETED" ? 2 : 1, + progressStatus: "completed", + }; + } + + case "CRYPTO_SWAP_FAILED": + case "CRYPTO_SWAP_REQUIRED": { + return { + status: "Action Required", + color: "accentText", + step: 2, + progressStatus: "actionRequired", + }; + } + + case "PAYMENT_FAILED": + case "ON_RAMP_TRANSFER_FAILED": { + return { + status: "Failed", + color: "danger", + step: 1, + progressStatus: "failed", + }; + } + } + + return { + status: "Unknown", + color: "secondaryText", + step: 1, + progressStatus: "unknown", + }; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/useBuyTransactionsToShow.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/useBuyTransactionsToShow.ts new file mode 100644 index 00000000000..9f5fc7376c4 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/tx-history/useBuyTransactionsToShow.ts @@ -0,0 +1,184 @@ +import { type UseQueryOptions, useQueries } from "@tanstack/react-query"; +import { useState, useSyncExternalStore } from "react"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { + type ValidBuyWithCryptoStatus, + getBuyWithCryptoStatus, +} from "../../../../../../../pay/buyWithCrypto/getStatus.js"; +import { + type ValidBuyWithFiatStatus, + getBuyWithFiatStatus, +} from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { useBuyHistory } from "../../../../../../core/hooks/pay/useBuyHistory.js"; +import { useActiveAccount } from "../../../../../../core/hooks/wallets/wallet-hooks.js"; +import { pendingTransactions } from "../swap/pendingSwapTx.js"; + +export type TxStatusInfo = + | { + type: "swap"; + status: ValidBuyWithCryptoStatus; + } + | { + type: "fiat"; + status: ValidBuyWithFiatStatus; + }; + +export function useBuyTransactionsToShow(client: ThirdwebClient) { + const account = useActiveAccount(); + const [pageIndex, setPageIndex] = useState(0); + const txStatusList: TxStatusInfo[] = []; + const PAGE_SIZE = 10; + + const buyHistory = useBuyHistory( + { + walletAddress: account?.address || "", + start: pageIndex * PAGE_SIZE, + count: PAGE_SIZE, + client, + }, + { + refetchInterval: 10 * 1000, // 10 seconds + }, + ); + + const pendingTxStoreValue = useSyncExternalStore( + pendingTransactions.subscribe, + pendingTransactions.getValue, + ); + + const pendingStatusQueries = useQueries< + UseQueryOptions[] + >({ + queries: pendingTxStoreValue.map((tx) => { + return { + queryKey: ["pending-tx-status", tx], + queryFn: async () => { + if (tx.type === "swap") { + const swapStatus = await getBuyWithCryptoStatus({ + client: client, + transactionHash: tx.txHash, + }); + + if ( + swapStatus.status === "NOT_FOUND" || + swapStatus.status === "NONE" + ) { + return null; + } + + return { + type: "swap", + status: swapStatus, + }; + } + + const fiatStatus = await getBuyWithFiatStatus({ + client: client, + intentId: tx.intentId, + }); + + if ( + fiatStatus.status === "NOT_FOUND" || + fiatStatus.status === "NONE" + ) { + return null; + } + + return { + type: "fiat", + status: fiatStatus, + }; + }, + refetchInterval: 10 * 1000, // 10 seconds + }; + }), + }); + + if (pendingStatusQueries.length > 0 && pageIndex === 0) { + for (const query of pendingStatusQueries) { + if (query.data) { + const txStatusInfo = query.data; + + // if already present - don't add it + if (buyHistory.data) { + if (txStatusInfo.type === "swap") { + const isPresent = buyHistory.data.page.find((tx) => { + if ( + "buyWithCryptoStatus" in tx && + tx.buyWithCryptoStatus.status !== "NOT_FOUND" + ) { + return ( + tx.buyWithCryptoStatus.source?.transactionHash === + txStatusInfo.status.source?.transactionHash + ); + } + return false; + }); + + if (!isPresent) { + txStatusList.push(txStatusInfo); + } + } + + if (txStatusInfo.type === "fiat") { + const isPresent = buyHistory.data.page.find((tx) => { + if ( + "buyWithFiatStatus" in tx && + tx.buyWithFiatStatus.status !== "NOT_FOUND" + ) { + return ( + tx.buyWithFiatStatus.intentId === txStatusInfo.status.intentId + ); + } + return false; + }); + + if (!isPresent) { + txStatusList.push(txStatusInfo); + } + } + } else { + // if no history - add without duplicate check + txStatusList.push(txStatusInfo); + } + } + } + } + + if (buyHistory.data) { + for (const tx of buyHistory.data.page) { + if ("buyWithCryptoStatus" in tx) { + if (tx.buyWithCryptoStatus.status !== "NOT_FOUND") { + txStatusList.push({ + type: "swap", + status: tx.buyWithCryptoStatus, + }); + } + } else { + if (tx.buyWithFiatStatus.status !== "NOT_FOUND") { + txStatusList.push({ + type: "fiat", + status: tx.buyWithFiatStatus, + }); + } + } + } + } + + const hidePagination = + !buyHistory.data || + (buyHistory.data && !buyHistory.data.hasNextPage && pageIndex === 0); + + return { + pageIndex, + setPageIndex, + txInfosToShow: txStatusList, + hidePagination, + isLoading: buyHistory.isLoading, + pagination: buyHistory.data + ? { + hasNextPage: buyHistory.data.hasNextPage, + } + : undefined, + }; +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx index 4cd3878a6a2..ceac841e604 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx @@ -20,7 +20,8 @@ import { StyledDiv } from "../../design-system/elements.js"; import { fontSize, iconSize, spacing } from "../../design-system/index.js"; import { useSendToken } from "../../hooks/useSendToken.js"; import { type SupportedTokens, defaultTokens } from "../defaultTokens.js"; -import { TokenSelector, formatTokenBalance } from "./TokenSelector.js"; +import { TokenSelector } from "./TokenSelector.js"; +import { formatTokenBalance } from "./formatTokenBalance.js"; import { type ERC20OrNativeToken, NATIVE_TOKEN } from "./nativeToken.js"; type TXError = Error & { data?: { message?: string } }; @@ -29,7 +30,7 @@ type TXError = Error & { data?: { message?: string } }; * @internal */ export function SendFunds(props: { - supportedTokens: SupportedTokens; + supportedTokens?: SupportedTokens; onBack: () => void; }) { const [screen, setScreen] = useState<"base" | "tokenSelector">("base"); @@ -38,16 +39,17 @@ export function SendFunds(props: { const { connectLocale, client } = useConnectUI(); let defaultToken: ERC20OrNativeToken = NATIVE_TOKEN; + const supportedTokens = props.supportedTokens || defaultTokens; if ( // if we know chainId chainId && // if there is a list of tokens for this chain - props.supportedTokens[chainId] && + supportedTokens[chainId] && // if the list of tokens is not the default list - props.supportedTokens[chainId] !== defaultTokens[chainId] + supportedTokens[chainId] !== defaultTokens[chainId] ) { // use the first token in the list as default selected - const tokensForChain = props.supportedTokens[chainId]; + const tokensForChain = supportedTokens[chainId]; const firstToken = tokensForChain?.[0]; if (firstToken) { defaultToken = firstToken; @@ -61,8 +63,7 @@ export function SendFunds(props: { const chain = useActiveWalletChain(); - const tokenList = - (chain?.id ? props.supportedTokens[chain.id] : undefined) || []; + const tokenList = (chain?.id ? supportedTokens[chain.id] : undefined) || []; if (screen === "tokenSelector" && chain) { return ( diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SwapTransactionsScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SwapTransactionsScreen.tsx deleted file mode 100644 index bdbc902e978..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SwapTransactionsScreen.tsx +++ /dev/null @@ -1,433 +0,0 @@ -"use client"; -import { ArrowRightIcon, CrossCircledIcon } from "@radix-ui/react-icons"; -import { useMemo, useState, useSyncExternalStore } from "react"; -import { defineChain } from "../../../../../chains/utils.js"; -import type { ThirdwebClient } from "../../../../../client/client.js"; -import type { - BuyWithCryptoStatus, - BuyWithCryptoStatuses, - BuyWithCryptoSubStatuses, -} from "../../../../../pay/buyWithCrypto/actions/getStatus.js"; -import { formatNumber } from "../../../../../utils/formatNumber.js"; -import { useChainQuery } from "../../../../core/hooks/others/useChainQuery.js"; -import { useBuyWithCryptoHistory } from "../../../../core/hooks/pay/useBuyWithCryptoHistory.js"; -import { - useActiveAccount, - useActiveWalletChain, -} from "../../../../core/hooks/wallets/wallet-hooks.js"; -import { Skeleton } from "../../components/Skeleton.js"; -import { Spacer } from "../../components/Spacer.js"; -import { Spinner } from "../../components/Spinner.js"; -import { Container, Line, ModalHeader } from "../../components/basic.js"; -import { Button } from "../../components/buttons.js"; -import { Text } from "../../components/text.js"; -import { useCustomTheme } from "../../design-system/CustomThemeProvider.js"; -import { fadeInAnimation } from "../../design-system/animations.js"; -import { StyledAnchor, StyledDiv } from "../../design-system/elements.js"; -import { - fontSize, - iconSize, - radius, - spacing, -} from "../../design-system/index.js"; -import { BuyIcon } from "../icons/BuyIcon.js"; -import { CryptoIcon } from "../icons/CryptoIcon.js"; -import { swapTransactionsStore } from "./Buy/swap/pendingSwapTx.js"; - -type TxStatusInfo = { - boughChainId: number; - transactionHash: string; - boughtTokenAmount: string; - boughtTokenSymbol: string; - status: BuyWithCryptoStatus["status"]; - subStatus?: BuyWithCryptoStatus["subStatus"]; -}; - -// Note: Do not use useConnectUI here - -const PAGE_SIZE = 10; - -/** - * @internal - */ -export function SwapTransactionsScreen(props: { - onBack: () => void; - client: ThirdwebClient; -}) { - const [pageIndex, setPageIndex] = useState(0); - const _historyQuery = useSwapTransactions(pageIndex, props.client); - - const inMemoryPendingTxs = useSyncExternalStore( - swapTransactionsStore.subscribe, - swapTransactionsStore.getValue, - ); - - const txInfosToShow: TxStatusInfo[] = []; - - const txHashSet = new Set(); - for (const tx of _historyQuery.data?.page || []) { - txHashSet.add(tx.source.transactionHash); - } - - // add in-memory pending transactions - for (const tx of inMemoryPendingTxs) { - if (pageIndex > 0) { - continue; - } - - // if tx is already in history endpoint, don't add it - if (txHashSet.has(tx.transactionHash)) { - continue; - } - - txInfosToShow.push({ - boughChainId: tx.destination.chainId, - transactionHash: tx.transactionHash, - boughtTokenAmount: tx.destination.value, - boughtTokenSymbol: tx.destination.symbol, - status: "PENDING", - }); - } - - // Add data from endpoint - for (const tx of _historyQuery.data?.page || []) { - txInfosToShow.push({ - boughChainId: tx.destination?.token.chainId || tx.quote.toToken.chainId, - transactionHash: tx.source.transactionHash, - boughtTokenAmount: tx.destination?.amount || tx.quote.toAmount, - boughtTokenSymbol: - tx.destination?.token.symbol || tx.quote.toToken.symbol || "", - status: tx.status, - subStatus: tx.subStatus, - }); - } - - const activeChain = useActiveWalletChain(); - const chainQuery = useChainQuery(activeChain); - const activeAccount = useActiveAccount(); - - const noTransactions = txInfosToShow.length === 0; - - const hidePagination = - !_historyQuery.data || - (_historyQuery.data && !_historyQuery.data.hasNextPage && pageIndex === 0); - - return ( - - - - - - - - {noTransactions && !_historyQuery.isLoading && ( - - - No Transactions - - )} - - {noTransactions && _historyQuery.isLoading && ( - - - - )} - - {txInfosToShow.map((txInfo) => { - return ( - - ); - })} - - {_historyQuery.isLoading && txInfosToShow.length > 0 && ( - <> - - - - - )} - - - - {_historyQuery.data && !hidePagination && ( -
- - -
- )} -
-
- - - - - View all transactions - - -
- ); -} - -/** - * @internal - */ -export function useSwapTransactions(pageIndex: number, client: ThirdwebClient) { - const account = useActiveAccount(); - const historyQuery = useBuyWithCryptoHistory( - { - walletAddress: account?.address || "", - start: pageIndex * PAGE_SIZE, - count: PAGE_SIZE, - client, - }, - { - // 30 seconds - refetchInterval: 30 * 1000, - }, - ); - - return historyQuery; -} - -function TransactionInfo(props: { txInfo: TxStatusInfo }) { - const { - boughChainId, - transactionHash, - boughtTokenAmount, - boughtTokenSymbol, - status, - } = props.txInfo; - - const fromChain = useMemo(() => defineChain(boughChainId), [boughChainId]); - - const chainQuery = useChainQuery(fromChain); - const statusMeta = getStatusMeta(status, props.txInfo.subStatus); - - return ( - - - - -
- -
-
-
- {/* Row 1 */} - - Buy - - + {formatNumber(Number(boughtTokenAmount), 4)} {boughtTokenSymbol} - {" "} - - - - - {/* Row 2 */} - - {/* Status */} - - - {statusMeta.status} - - {statusMeta.loading && } - - - {/* Network */} - {chainQuery.data?.name ? ( - {chainQuery.data.name} - ) : ( - - )} - -
-
-
- ); -} - -const ButtonLink = /* @__PURE__ */ (() => Button.withComponent("a"))(); - -const IconBox = /* @__PURE__ */ StyledDiv(() => { - const theme = useCustomTheme(); - return { - color: theme.colors.secondaryText, - padding: spacing.sm, - border: `2px solid ${theme.colors.borderColor}`, - borderRadius: radius.lg, - display: "flex", - justifyContent: "center", - alignItems: "center", - position: "relative", - }; -}); - -const TxHashLink = /* @__PURE__ */ StyledAnchor(() => { - const theme = useCustomTheme(); - return { - padding: spacing.sm, - borderRadius: radius.lg, - cursor: "pointer", - animation: `${fadeInAnimation} 300ms ease`, - background: theme.colors.tertiaryBg, - "&:hover": { - transition: "background 250ms ease", - background: theme.colors.secondaryButtonBg, - }, - height: "68px", - }; -}); - -function getStatusMeta( - status: BuyWithCryptoStatuses, - subStatus?: BuyWithCryptoSubStatuses, -) { - if (subStatus === "WAITING_BRIDGE") { - return { - status: "Bridging", - color: "accentText", - loading: true, - } as const; - } - - if (subStatus === "PARTIAL_SUCCESS") { - return { - status: "Incomplete", - color: "secondaryText", - loading: false, - } as const; - } - - if (status === "PENDING") { - return { - status: "Pending", - color: "accentText", - loading: true, - } as const; - } - - if (status === "FAILED") { - return { - status: "Failed", - color: "danger", - loading: false, - } as const; - } - - if (status === "COMPLETED") { - return { - status: "Completed", - color: "success", - loading: false, - } as const; - } - - return { - status: "Unknown", - color: "secondaryText", - } as const; -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/TokenSelector.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/TokenSelector.tsx index 8a920bf3cc7..63c0e2af88d 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/TokenSelector.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/TokenSelector.tsx @@ -1,12 +1,15 @@ import styled from "@emotion/styled"; import { ChevronDownIcon, CrossCircledIcon } from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import type { Chain } from "../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import type { Account } from "../../../../../wallets/interfaces/wallet.js"; +import { getTokenBalance } from "../../../../../wallets/utils/getTokenBalance.js"; import { useChainQuery } from "../../../../core/hooks/others/useChainQuery.js"; -import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; +import { useTokenInfo } from "../../../../core/hooks/others/useTokenInfo.js"; import { useActiveAccount } from "../../../../core/hooks/wallets/wallet-hooks.js"; -import { ChainIcon, fallbackChainIcon } from "../../components/ChainIcon.js"; +import { ChainIcon } from "../../components/ChainIcon.js"; import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Spinner } from "../../components/Spinner.js"; @@ -20,6 +23,7 @@ import { fontSize, iconSize, spacing } from "../../design-system/index.js"; import { ChainButton, NetworkSelectorContent } from "../NetworkSelector.js"; import type { TokenInfo } from "../defaultTokens.js"; import type { ConnectLocale } from "../locale/types.js"; +import { formatTokenBalance } from "./formatTokenBalance.js"; import { type ERC20OrNativeToken, NATIVE_TOKEN, @@ -27,6 +31,7 @@ import { } from "./nativeToken.js"; // NOTE: MUST NOT USE useConnectUI here because this UI can be used outside connect ui +// Note: TokenSelector can be used when wallet may or may not be connected /** * @@ -46,14 +51,12 @@ export function TokenSelector(props: { }) { const [screen, setScreen] = useState<"base" | "select-chain">("base"); const [input, setInput] = useState(""); - const activeAccount = useActiveAccount(); const chain = props.chain; const chainQuery = useChainQuery(chain); // if input is undefined, it loads the native token // otherwise it loads the token with given address - const tokenQuery = useWalletBalance({ - address: activeAccount?.address, + const tokenQuery = useTokenInfo({ chain: chain, tokenAddress: input, client: props.client, @@ -67,7 +70,6 @@ export function TokenSelector(props: { tokenList = [ { ...tokenQuery.data, - icon: "", address: input, }, ...tokenList, @@ -154,7 +156,6 @@ export function TokenSelector(props: { @@ -232,7 +233,7 @@ export function TokenSelector(props: { )} - {filteredList.length === 0 && tokenQuery.isLoading && ( + {filteredList.length === 0 && tokenQuery.isLoading && input && ( )} - {tokenBalanceQuery.data ? ( - {formatTokenBalance(tokenBalanceQuery.data)} - ) : ( - + {account && ( + )} ); } +function TokenBalance(props: { + account: Account; + chain: Chain; + client: ThirdwebClient; + tokenAddress?: string; +}) { + const tokenBalanceQuery = useQuery({ + queryKey: ["tokenBalance", props], + queryFn: async () => { + return getTokenBalance({ + account: props.account, + chain: props.chain, + client: props.client, + tokenAddress: props.tokenAddress, + }); + }, + }); + + if (tokenBalanceQuery.data) { + return {formatTokenBalance(tokenBalanceQuery.data)}; + } + + return ; +} + const SelectTokenBtn = /* @__PURE__ */ styled(Button)(() => { const theme = useCustomTheme(); return { @@ -334,23 +364,3 @@ const SelectTokenBtn = /* @__PURE__ */ styled(Button)(() => { transition: "background 200ms ease, transform 150ms ease", }; }); - -/** - * @internal - * @param balanceData - * @returns - */ -export function formatTokenBalance( - balanceData: { - symbol: string; - name: string; - decimals: number; - displayValue: string; - }, - showSymbol = true, -) { - return ( - Number(balanceData.displayValue).toFixed(3) + - (showSymbol ? ` ${balanceData.symbol}` : "") - ); -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewFunds.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewFunds.tsx new file mode 100644 index 00000000000..0fcdeec06e2 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewFunds.tsx @@ -0,0 +1,124 @@ +import type { Chain } from "../../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; +import { + useActiveAccount, + useActiveWalletChain, +} from "../../../../core/hooks/wallets/wallet-hooks.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { TokenIcon } from "../../components/TokenIcon.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { Text } from "../../components/text.js"; +import { fontSize } from "../../design-system/index.js"; +import { type SupportedTokens, defaultTokens } from "../defaultTokens.js"; +import { formatTokenBalance } from "./formatTokenBalance.js"; +import { + type ERC20OrNativeToken, + NATIVE_TOKEN, + isNativeToken, +} from "./nativeToken.js"; + +/** + * @internal + */ +export function ViewFunds(props: { + supportedTokens?: SupportedTokens; + onBack: () => void; + client: ThirdwebClient; +}) { + const activeChain = useActiveWalletChain(); + const supportedTokens = props.supportedTokens || defaultTokens; + + if (!activeChain) { + return null; + } + + const tokenList = + (activeChain?.id ? supportedTokens[activeChain.id] : undefined) || []; + + return ( + + + + + + + + + + + {tokenList.map((token) => { + return ( + + ); + })} + + + + ); +} + +function TokenInfo(props: { + token: ERC20OrNativeToken; + chain: Chain; + client: ThirdwebClient; +}) { + const account = useActiveAccount(); + const tokenBalanceQuery = useWalletBalance({ + address: account?.address, + chain: props.chain, + tokenAddress: isNativeToken(props.token) ? undefined : props.token.address, + client: props.client, + }); + + const tokenName = isNativeToken(props.token) + ? tokenBalanceQuery.data?.name + : props.token.name; + + return ( + + + + + {tokenName ? ( + + {tokenName} + + ) : ( + + )} + + {tokenBalanceQuery.data ? ( + {formatTokenBalance(tokenBalanceQuery.data)} + ) : ( + + )} + + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts new file mode 100644 index 00000000000..d9bfc12a361 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts @@ -0,0 +1,19 @@ +/** + * @internal + * @param balanceData + * @returns + */ +export function formatTokenBalance( + balanceData: { + symbol: string; + name: string; + decimals: number; + displayValue: string; + }, + showSymbol = true, +) { + return ( + Number(balanceData.displayValue).toFixed(3) + + (showSymbol ? ` ${balanceData.symbol}` : "") + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.ts index dd79a82d7b7..0b9d9a2486a 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.ts @@ -8,7 +8,7 @@ export const NATIVE_TOKEN: NativeToken = { nativeToken: true }; * @internal */ export function isNativeToken( - token: TokenInfo | NativeToken, + token: Partial | NativeToken, ): token is NativeToken { return "nativeToken" in token; } diff --git a/packages/thirdweb/src/react/web/ui/PayEmbed.tsx b/packages/thirdweb/src/react/web/ui/PayEmbed.tsx new file mode 100644 index 00000000000..542c3b37cb3 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/PayEmbed.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import type { Chain } from "../../../chains/types.js"; +import type { ThirdwebClient } from "../../../client/client.js"; +import type { 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 { ConnectButton } from "./ConnectWallet/ConnectButton.js"; +import type { + ConnectButton_connectModalOptions, + PayUIOptions, +} from "./ConnectWallet/ConnectButtonProps.js"; +import type { SupportedTokens } from "./ConnectWallet/defaultTokens.js"; +import { useConnectLocale } from "./ConnectWallet/locale/getConnectLocale.js"; +import BuyScreen from "./ConnectWallet/screens/Buy/BuyScreen.js"; +import { BuyTxHistory } from "./ConnectWallet/screens/Buy/tx-history/BuyTxHistory.js"; +import { DynamicHeight } from "./components/DynamicHeight.js"; +import { Spinner } from "./components/Spinner.js"; +import { Container } from "./components/basic.js"; +import { CustomThemeProvider } from "./design-system/CustomThemeProvider.js"; +import { type Theme, radius } from "./design-system/index.js"; +import type { LocaleId } from "./types.js"; + +// TODO - JS doc + +export type PayEmbedProps = { + supportedTokens?: SupportedTokens; + client: ThirdwebClient; + locale?: LocaleId; + payOptions?: PayUIOptions; + theme?: "light" | "dark" | Theme; + connectOptions?: PayEmbedConnectOptions; +}; + +export function PayEmbed(props: PayEmbedProps) { + const localeQuery = useConnectLocale(props.locale || "en_US"); + const [screen, setScreen] = useState<"buy" | "tx-history">("buy"); + + let content = null; + + if (!localeQuery.data) { + content = ( +
+ +
+ ); + } else if (screen === "tx-history") { + content = ( + { + setScreen("buy"); + }} + onDone={() => { + // noop + }} + isBuyForTx={false} + isEmbed={true} + /> + ); + } else { + content = ( + { + setScreen("tx-history"); + }} + payOptions={props.payOptions || {}} + onDone={() => { + // noop + }} + connectButton={ + + } + /> + ); + } + + return ( + + + {content} + + + ); +} + +export type PayEmbedConnectOptions = { + /** + * 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 + * @example + * ```tsx + * + */ + 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. + * + * ```tsx + * + */ + 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. + * ```tsx + * + * ``` + */ + 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 + * + * ```tsx + * + * ``` + * + * 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 350+ 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; +}; diff --git a/packages/thirdweb/src/react/web/ui/components/ChainIcon.tsx b/packages/thirdweb/src/react/web/ui/components/ChainIcon.tsx index 06ce6f5f767..fc8f76c69ce 100644 --- a/packages/thirdweb/src/react/web/ui/components/ChainIcon.tsx +++ b/packages/thirdweb/src/react/web/ui/components/ChainIcon.tsx @@ -18,7 +18,6 @@ export const ChainIcon: React.FC<{ active?: boolean; className?: string; loading?: "lazy" | "eager"; - fallbackImage?: string; client: ThirdwebClient; }> = (props) => { const getSrc = () => { diff --git a/packages/thirdweb/src/react/web/ui/components/ChainName.tsx b/packages/thirdweb/src/react/web/ui/components/ChainName.tsx new file mode 100644 index 00000000000..0af01fcbb03 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/components/ChainName.tsx @@ -0,0 +1,23 @@ +import type { Chain } from "../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../client/client.js"; +import { useChainQuery } from "../../../core/hooks/others/useChainQuery.js"; +import { fontSize } from "../design-system/index.js"; +import { Skeleton } from "./Skeleton.js"; +import { Text } from "./text.js"; + +/** + * @internal + */ +export const ChainName: React.FC<{ + chain: Chain; + size: "xs" | "sm" | "md" | "lg"; + client: ThirdwebClient; +}> = (props) => { + const chainQuery = useChainQuery(props.chain); + + if (chainQuery.data) { + return {chainQuery.data.name}; + } + + return ; +}; diff --git a/packages/thirdweb/src/react/web/ui/components/Drawer.tsx b/packages/thirdweb/src/react/web/ui/components/Drawer.tsx index 1d285a38c93..001d2e6f854 100644 --- a/packages/thirdweb/src/react/web/ui/components/Drawer.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Drawer.tsx @@ -49,7 +49,7 @@ export const DrawerContainer = /* @__PURE__ */ StyledDiv(() => { borderTopLeftRadius: radius.xl, borderTopRightRadius: radius.xl, background: theme.colors.modalBg, - position: "fixed", + position: "absolute", bottom: 0, left: 0, right: 0, diff --git a/packages/thirdweb/src/react/web/ui/components/StepBar.tsx b/packages/thirdweb/src/react/web/ui/components/StepBar.tsx new file mode 100644 index 00000000000..489cc99d1ab --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/components/StepBar.tsx @@ -0,0 +1,25 @@ +import { radius } from "../design-system/index.js"; +import { Container } from "./basic.js"; + +export function StepBar(props: { steps: number; currentStep: number }) { + return ( + + + {null} + + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/components/SwitchNetwork.tsx b/packages/thirdweb/src/react/web/ui/components/SwitchNetwork.tsx new file mode 100644 index 00000000000..f86b142407d --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/components/SwitchNetwork.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Chain } from "../../../../chains/types.js"; +import { useSwitchActiveWalletChain } from "../../../core/hooks/wallets/wallet-hooks.js"; +import { Spinner } from "./Spinner.js"; +import { Button, type ButtonProps } from "./buttons.js"; + +export function SwitchNetworkButton( + props: ButtonProps & { + chain: Chain; + fullWidth?: boolean; + }, +) { + const [isSwitching, setIsSwitching] = useState(false); + const switchActiveWalletChain = useSwitchActiveWalletChain(); + + return ( + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx b/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx index d6f6c1f2468..1337011ef58 100644 --- a/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx +++ b/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx @@ -4,7 +4,6 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import { useChainQuery } from "../../../core/hooks/others/useChainQuery.js"; import { genericTokenIcon } from "../ConnectWallet/icons/dataUris.js"; import { - type ERC20OrNativeToken, type NativeToken, isNativeToken, } from "../ConnectWallet/screens/nativeToken.js"; @@ -17,46 +16,24 @@ import { Img } from "./Img.js"; * @internal */ export function TokenIcon(props: { - token: ERC20OrNativeToken; + token: + | { + address: string; + icon?: string; + } + | NativeToken; chain: Chain; size: keyof typeof iconSize; client: ThirdwebClient; -}) { - const token = props.token; - - if (isNativeToken(token)) { - return ( - - ); - } - - return ( - - ); -} - -function NativeTokenIcon(props: { - chain: Chain; - nativeToken: NativeToken; - size: keyof typeof iconSize; - client: ThirdwebClient; }) { const chainQuery = useChainQuery(props.chain); return ( { }; } + if (props.variant === "ghost") { + return { + border: "1.5px solid transparent", + "&:hover": { + borderColor: theme.colors.accentText, + }, + }; + } + if (props.variant === "secondary") { return { "&:hover": { @@ -111,6 +120,8 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => { }; }); +export const ButtonLink = /* @__PURE__ */ (() => Button.withComponent("a"))(); + export const IconButton = /* @__PURE__ */ StyledButton(() => { const theme = useCustomTheme(); return { diff --git a/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx b/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx index c2673b0c911..5da6d33161b 100644 --- a/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx +++ b/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx @@ -17,10 +17,15 @@ export function TokenSymbol(props: { chain: Chain; size: "sm" | "md" | "lg"; color?: keyof Theme["colors"]; + inline?: boolean; }) { if (!isNativeToken(props.token)) { return ( - + {props.token.symbol} ); @@ -31,6 +36,7 @@ export function TokenSymbol(props: { chain={props.chain} size={props.size} color={props.color} + inline={props.inline} /> ); } @@ -39,6 +45,7 @@ function NativeTokenSymbol(props: { chain: Chain; size: "sm" | "md" | "lg"; color?: keyof Theme["colors"]; + inline?: boolean; }) { const chainQuery = useChainQuery(props.chain); const data = chainQuery.data; @@ -48,7 +55,11 @@ function NativeTokenSymbol(props: { } return ( - + {data.nativeCurrency.symbol} ); diff --git a/packages/thirdweb/src/react/web/ui/design-system/elements.ts b/packages/thirdweb/src/react/web/ui/design-system/elements.ts index 655aadde7ac..11f4c035d9e 100644 --- a/packages/thirdweb/src/react/web/ui/design-system/elements.ts +++ b/packages/thirdweb/src/react/web/ui/design-system/elements.ts @@ -11,5 +11,6 @@ export const StyledInput = /* @__PURE__ */ styled.input; export const StyledH2 = /* @__PURE__ */ styled.h2; export const StyledP = /* @__PURE__ */ styled.p; export const StyledUl = /* @__PURE__ */ styled.ul; +export const StyledIframe = /* @__PURE__ */ styled.iframe; export const StyledSelect = /* @__PURE__ */ styled.select; export const StyledOption = /* @__PURE__ */ styled.option;