From b2239c91c978379ec4dc54cac1b54a620b87d861 Mon Sep 17 00:00:00 2001 From: MananTank Date: Tue, 30 Sep 2025 14:51:10 +0000 Subject: [PATCH] [MNY-214] Dashboard: ERC1155 set claim conditions step UX improvements (#8146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR introduces functionality for handling insufficient funds when creating NFTs, enhancing the user experience by allowing users to buy funds directly if their balance is low. ### Detailed summary - Added `onNotEnoughFunds` callback to handle insufficient funds. - Implemented logic to calculate total transaction costs. - Integrated `BuyWidget` to enable users to purchase funds directly. - Enhanced error handling and user feedback for fund-related issues. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit - New Features - Gas-cost–aware NFT minting with total cost estimation and balance pre-checks. - Insufficient funds handling with a user-facing prompt showing required vs. available balance. - Integrated Buy Funds widget with theme and chain awareness, plus retry and back controls. - Optional transaction path that skips the pay modal in eligible scenarios. - Enhanced batch processing for ERC1155/721 flows with a callback to surface funding issues. --- .../tokens/create/nft/_common/form.ts | 4 + .../tokens/create/nft/create-nft-page.tsx | 98 +++++++- .../tokens/create/nft/launch/launch-nft.tsx | 212 +++++++++++++++--- 3 files changed, 277 insertions(+), 37 deletions(-) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts index 66ba225f465..71cbd343e23 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts @@ -74,6 +74,10 @@ export type CreateNFTCollectionFunctions = { count: number; }; gasless: boolean; + onNotEnoughFunds: (data: { + requiredAmount: string; + balance: string; + }) => void; }) => Promise; lazyMintNFTs: (params: { values: CreateNFTCollectionAllValues; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx index be9735e5760..4bbb5b33145 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx @@ -1,6 +1,14 @@ "use client"; import { useRef } from "react"; -import { encode, getContract, type ThirdwebClient } from "thirdweb"; +import { + encode, + estimateGasCost, + getContract, + getGasPrice, + type PreparedTransaction, + type ThirdwebClient, + toEther, +} from "thirdweb"; import { deployERC721Contract, deployERC1155Contract } from "thirdweb/deploys"; import { multicall } from "thirdweb/extensions/common"; import { @@ -14,7 +22,8 @@ import { } from "thirdweb/extensions/erc1155"; import { grantRole } from "thirdweb/extensions/permissions"; import { useActiveAccount } from "thirdweb/react"; -import { maxUint256 } from "thirdweb/utils"; +import { maxUint256, resolvePromisedValue } from "thirdweb/utils"; +import { getWalletBalance } from "thirdweb/wallets"; import { create7702MinimalAccount } from "thirdweb/wallets/smart"; import { revalidatePathAction } from "@/actions/revalidate"; import { reportContractDeployed } from "@/analytics/report"; @@ -39,6 +48,10 @@ export function CreateNFTPage(props: { const contractAddressRef = useRef(undefined); const getChain = useGetV5DashboardChain(); const sendAndConfirmTx = useSendAndConfirmTx(); + const sendAndConfirmTxNoPayModal = useSendAndConfirmTx({ + payModal: false, + }); + function getAccount(params: { gasless: boolean }) { if (!activeAccount) { throw new Error("Wallet is not connected"); @@ -218,6 +231,10 @@ export function CreateNFTPage(props: { count: number; }; gasless: boolean; + onNotEnoughFunds: (data: { + requiredAmount: string; + balance: string; + }) => void; }) { const { values, batch } = params; const contract = getDeployedContract({ @@ -277,7 +294,44 @@ export function CreateNFTPage(props: { data: encodedTransactions, }); - await sendAndConfirmTx.mutateAsync(tx); + // if there are more than one batches, on the first batch, we check if the user has enough funds to cover the cost of all the batches -> + // calculate the cost of one batch, multiply by the number of batches to get the total cost + // if the user does not have enough funds, call the onNotEnoughFunds callback + + const totalBatches = Math.ceil(values.nfts.length / batch.count); + + if (batch.startIndex === 0 && totalBatches > 1 && !params.gasless) { + if (!activeAccount) { + throw new Error("Wallet is not connected"); + } + + const costPerBatch = await getTotalTransactionCost({ + tx: tx, + from: activeAccount.address, + }); + + const totalCost = costPerBatch * BigInt(totalBatches); + + const walletBalance = await getWalletBalance({ + address: activeAccount.address, + chain: contract.chain, + client: contract.client, + }); + + if (walletBalance.value < totalCost) { + params.onNotEnoughFunds({ + balance: toEther(walletBalance.value), + requiredAmount: toEther(totalCost), + }); + throw new Error( + `Not enough funds: Required ${toEther(totalCost)}, Balance ${toEther(walletBalance.value)}`, + ); + } + + await sendAndConfirmTxNoPayModal.mutateAsync(tx); + } else { + await sendAndConfirmTx.mutateAsync(tx); + } } async function handleSetAdmins(params: { @@ -390,3 +444,41 @@ function transformSocialUrls( {} as Record, ); } + +async function getTransactionGasCost(tx: PreparedTransaction, from?: string) { + try { + const gasCost = await estimateGasCost({ + from, + transaction: tx, + }); + + const bufferCost = gasCost.wei / 10n; + + // Note: get tx.value AFTER estimateGasCost + // add 10% extra gas cost to the estimate to ensure user buys enough to cover the tx cost + return gasCost.wei + bufferCost; + } catch { + if (from) { + // try again without passing from + return await getTransactionGasCost(tx); + } + // fallback if both fail, use the tx value + 1M * gas price + const gasPrice = await getGasPrice({ + chain: tx.chain, + client: tx.client, + }); + + return 1_000_000n * gasPrice; + } +} + +async function getTotalTransactionCost(params: { + tx: PreparedTransaction; + from?: string; +}) { + const [txValue, txGasCost] = await Promise.all([ + resolvePromisedValue(params.tx.value), + getTransactionGasCost(params.tx, params.from), + ]); + return (txValue || 0n) + txGasCost; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx index 9200b9d2300..4007f0319f8 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx @@ -1,14 +1,19 @@ "use client"; import { + ArrowLeftIcon, ArrowRightIcon, ArrowUpFromLineIcon, ImageOffIcon, + RefreshCcwIcon, + ShoppingBagIcon, } from "lucide-react"; import Link from "next/link"; +import { useTheme } from "next-themes"; import { useMemo, useRef, useState } from "react"; import type { ThirdwebClient } from "thirdweb"; import { + BuyWidget, TokenProvider, TokenSymbol, useActiveAccount, @@ -37,6 +42,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; import { parseError } from "@/utils/errorParser"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; import { ChainOverview } from "../../_common/chain-overview"; import { StepCard } from "../../_common/step-card"; import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell"; @@ -71,6 +77,10 @@ export function LaunchNFT(props: { const walletRequiresApproval = activeWallet?.id !== "inApp"; const account = useActiveAccount(); const contractAddressRef = useRef(null); + const { theme } = useTheme(); + const chainMeta = useV5DashboardChain( + Number(formValues.collectionInfo.chain), + ); function updateStatus( index: number, @@ -161,6 +171,18 @@ export function LaunchNFT(props: { const [isGasless, setIsGasless] = useState(canEnableGasless); const showGaslessSection = false; // activeWallet?.id === "inApp"; + const [notEnoughFunds, setNotEnoughFunds] = useState< + | false + | { + values: { + requiredAmount: string; + balance: string; + }; + isBuySuccess: boolean; + showBuyWidget: boolean; + } + >(false); + const contractLink = contractAddressRef.current ? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}` : null; @@ -204,6 +226,16 @@ export function LaunchNFT(props: { }, gasless: isGasless, values: formValues, + onNotEnoughFunds: (data) => { + setNotEnoughFunds({ + values: { + balance: data.balance, + requiredAmount: data.requiredAmount, + }, + isBuySuccess: false, + showBuyWidget: false, + }); + }, }); batchesProcessedRef.current += 1; @@ -216,6 +248,16 @@ export function LaunchNFT(props: { }, gasless: isGasless, values: formValues, + onNotEnoughFunds: (data) => { + setNotEnoughFunds({ + values: { + balance: data.balance, + requiredAmount: data.requiredAmount, + }, + isBuySuccess: false, + showBuyWidget: false, + }); + }, }); } } @@ -357,41 +399,143 @@ export function LaunchNFT(props: { className="gap-0 overflow-hidden p-0 md:max-w-[480px]" dialogCloseClassName="hidden" > -
- - - Status - - {walletRequiresApproval && ( - - Each step will prompt a signature request in your wallet - - )} - - - { - if ( - props.teamPlan === "free" && - errorMessage.toLowerCase().includes("storage limit") - ) { - return ( - handleRetry(step)} - teamSlug={props.teamSlug} - trackingCampaign="create-nft" - /> - ); - } - - return null; - }} - steps={steps} - /> -
+ {/** biome-ignore lint/complexity/useOptionalChain: can't change it to optional chain */} + {notEnoughFunds && notEnoughFunds.showBuyWidget ? ( +
+
+ +
+ { + setNotEnoughFunds({ + ...notEnoughFunds, + isBuySuccess: true, + showBuyWidget: false, + }); + }} + /> +
+ ) : ( +
+ + + Status + + {walletRequiresApproval && ( + + Each step will prompt a signature request in your wallet + + )} + + + { + if ( + props.teamPlan === "free" && + errorMessage.toLowerCase().includes("storage limit") + ) { + return ( + handleRetry(step)} + teamSlug={props.teamSlug} + trackingCampaign="create-nft" + /> + ); + } + + if (notEnoughFunds) { + return ( +
+ {notEnoughFunds.isBuySuccess ? ( +

+ Funds bought successfully +

+ ) : ( +
+

+ Not enough funds +

+

+ Required Funds:{" "} + {notEnoughFunds.values.requiredAmount}{" "} + {chainMeta?.nativeCurrency?.symbol || "ETH"} +

+

+ Balance: {notEnoughFunds.values.balance}{" "} + {chainMeta?.nativeCurrency?.symbol || "ETH"} +

+
+ )} + +
+ + + {!notEnoughFunds.isBuySuccess && ( + + )} +
+
+ ); + } + + return null; + }} + steps={steps} + /> +
+ )} -
+
{!isComplete ? (