diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx index 6bc97b71620..3b6e4240920 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx @@ -18,7 +18,7 @@ import { CreateListingsForm } from "./list-form"; interface CreateListingButtonProps { contract: ThirdwebContract; createText?: string; - type?: "direct-listings" | "english-auctions"; + type: "direct-listings" | "english-auctions"; } export const CreateListingButton: React.FC = ({ diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx index 50507f5b34a..1c9473f6ed7 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx @@ -1,4 +1,5 @@ import { Alert, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useDashboardOwnedNFTs } from "@3rdweb-sdk/react/hooks/useDashboardOwnedNFTs"; @@ -23,11 +24,13 @@ import { isSimpleHashSupported } from "lib/wallet/nfts/simpleHash"; import type { WalletNFT } from "lib/wallet/nfts/types"; import { CircleAlertIcon, InfoIcon } from "lucide-react"; import Link from "next/link"; -import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useMemo, useState } from "react"; +import { type UseFormReturn, useForm } from "react-hook-form"; import { toast } from "sonner"; import { + type Chain, NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, type ThirdwebContract, getContract, toUnits, @@ -35,10 +38,12 @@ import { } from "thirdweb"; import { decimals } from "thirdweb/extensions/erc20"; import { + getNFT as getNFT721, isApprovedForAll as isApprovedForAll721, setApprovalForAll as setApprovalForAll721, } from "thirdweb/extensions/erc721"; import { + getNFT as getNFT1155, isApprovedForAll as isApprovedForAll1155, setApprovalForAll as setApprovalForAll1155, } from "thirdweb/extensions/erc1155"; @@ -47,7 +52,12 @@ import type { CreateAuctionParams, CreateListingParams, } from "thirdweb/extensions/marketplace"; -import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react"; +import { + MediaRenderer, + useActiveAccount, + useReadContract, + useSendAndConfirmTransaction, +} from "thirdweb/react"; import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components"; import { NFTMediaWithEmptyState } from "tw-components/nft-media"; import { shortenIfAddress } from "utils/usedapp-external"; @@ -76,8 +86,13 @@ type ListForm = type CreateListingsFormProps = { contract: ThirdwebContract; actionText: string; - setOpen: Dispatch>; - type?: "direct-listings" | "english-auctions"; + setOpen: (isOpen: boolean) => void; + type: "direct-listings" | "english-auctions"; + prefilledNFT?: { + id: string; + type: "ERC721" | "ERC1155"; + contractAddress: string; + }; }; const auctionTimes = [ @@ -95,32 +110,21 @@ export const CreateListingsForm: React.FC = ({ type, actionText, setOpen, + prefilledNFT, }) => { const trackEvent = useTrack(); const chainId = contract.chain.id; const { idToChain } = useAllChainsData(); const network = idToChain.get(chainId); const [isFormLoading, setIsFormLoading] = useState(false); - - const isSupportedChain = - chainId && - (isSimpleHashSupported(chainId) || - isAlchemySupported(chainId) || - isMoralisSupported(chainId)); - - const account = useActiveAccount(); - - const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({ - chainId, - walletAddress: account?.address, - }); const sendAndConfirmTx = useSendAndConfirmTransaction(); + const account = useActiveAccount(); const form = useForm({ defaultValues: type === "direct-listings" ? { - selected: undefined, + selected: prefilledNFT, currencyContractAddress: NATIVE_TOKEN_ADDRESS, quantity: "1", pricePerToken: "0", @@ -130,7 +134,7 @@ export const CreateListingsForm: React.FC = ({ listingDurationInSeconds: (60 * 60 * 24 * 30).toString(), } : { - selected: undefined, + selected: prefilledNFT, currencyContractAddress: NATIVE_TOKEN_ADDRESS, quantity: "1", buyoutPricePerToken: "0", @@ -142,70 +146,22 @@ export const CreateListingsForm: React.FC = ({ }, }); - const selectedContract = form.watch("selected.contractAddress") + const selectedNFT = prefilledNFT || form.watch("selected"); + + const selectedContract = selectedNFT?.contractAddress ? getContract({ - address: form.watch("selected.contractAddress"), + address: selectedNFT.contractAddress, chain: contract.chain, client: contract.client, }) : undefined; - const { data: ownedNFTs, isPending: isOwnedNFTsLoading } = - useDashboardOwnedNFTs({ - contract: selectedContract, - owner: account?.address, - // Only run this hook as the last resort if this chain is not supported by the API services we are using - disabled: - !selectedContract || - isSupportedChain || - isWalletNFTsLoading || - (walletNFTs?.result || []).length > 0, - }); - - const isSelected = (nft: WalletNFT) => { - return ( - form.watch("selected")?.id === nft.id && - form.watch("selected")?.contractAddress === nft.contractAddress - ); - }; - - const ownedWalletNFTs: WalletNFT[] = useMemo(() => { - return ownedNFTs?.map((nft) => { - if (nft.type === "ERC721") { - return { - id: String(nft.id), - metadata: nft.metadata, - supply: "1", - contractAddress: form.watch("selected.contractAddress"), - tokenId: nft.id.toString(), - owner: nft.owner, - type: "ERC721", - tokenURI: nft.tokenURI, - }; - } - return { - id: String(nft.id), - metadata: nft.metadata, - supply: String(nft.supply), - contractAddress: form.watch("selected.contractAddress"), - tokenId: nft.id.toString(), - owner: nft.owner, - type: "ERC1155", - tokenURI: nft.tokenURI, - }; - }) as WalletNFT[]; - }, [ownedNFTs, form]); - - const nfts = ownedWalletNFTs || walletNFTs?.result; - - const noNfts = !nfts?.length; - return (
{ - if (!formData.selected || !selectedContract) { + if (!selectedNFT || !selectedContract) { return; } @@ -217,7 +173,7 @@ export const CreateListingsForm: React.FC = ({ try { const isNftApproved = - formData.selected.type === "ERC1155" + selectedNFT.type === "ERC1155" ? isApprovedForAll1155 : isApprovedForAll721; const isApproved = await isNftApproved({ @@ -228,7 +184,7 @@ export const CreateListingsForm: React.FC = ({ if (!isApproved) { const setNftApproval = - formData.selected.type === "ERC1155" + selectedNFT.type === "ERC1155" ? setApprovalForAll1155 : setApprovalForAll721; const approveTx = setNftApproval({ @@ -253,8 +209,8 @@ export const CreateListingsForm: React.FC = ({ ); const transaction = createListing({ contract, - assetContractAddress: formData.selected.contractAddress, - tokenId: BigInt(formData.selected.id), + assetContractAddress: selectedNFT.contractAddress, + tokenId: BigInt(selectedNFT.id), currencyContractAddress: formData.currencyContractAddress, quantity: BigInt(formData.quantity), startTimestamp: formData.startTimestamp, @@ -302,8 +258,8 @@ export const CreateListingsForm: React.FC = ({ const transaction = createAuction({ contract, - assetContractAddress: formData.selected.contractAddress, - tokenId: BigInt(formData.selected.id), + assetContractAddress: selectedNFT.contractAddress, + tokenId: BigInt(selectedNFT.id), startTimestamp: formData.startTimestamp, currencyContractAddress: formData.currencyContractAddress, endTimestamp: new Date( @@ -350,6 +306,227 @@ export const CreateListingsForm: React.FC = ({ setIsFormLoading(false); })} > + {prefilledNFT ? ( + + ) : ( + + )} + + Listing Currency + + form.setValue("currencyContractAddress", e.target.value) + } + /> + + The currency you want to sell your tokens for. + + + + + {form.watch("listingType") === "auction" + ? "Buyout Price Per Token" + : "Listing Price"} + + + + {form.watch("listingType") === "auction" + ? "The price per token a buyer can pay to instantly buyout the auction." + : "The price of each token you are listing for sale."} + + + {form.watch("selected")?.type?.toLowerCase() !== "erc721" && ( + +
+ Quantity +
+ + + The number of tokens to list for sale. + +
+ )} + {form.watch("listingType") === "auction" && ( + <> + + Reserve Price Per Token + + + The minimum price per token necessary to bid on this auction + + + + Auction Duration + + The duration of this auction. + + + )} + + {!selectedNFT && ( + + + No NFT selected + + )} + + {/* Need to pin these at the bottom because this is a very long form */} +
+ + + {actionText} + +
+ + ); +}; + +// todo: Change this component to use the new headless UI once published +function PrefilledNFTInfo({ + prefilledNFT, + chain, + client, +}: { + prefilledNFT: { + id: string; + type: "ERC721" | "ERC1155"; + contractAddress: string; + }; + chain: Chain; + client: ThirdwebClient; +}) { + const nftQuery = useReadContract( + prefilledNFT.type === "ERC1155" ? getNFT1155 : getNFT721, + { + contract: getContract({ + address: prefilledNFT.contractAddress, + chain, + client, + }), + tokenId: BigInt(prefilledNFT.id), + }, + ); + const src = + nftQuery.data?.metadata.animation_url || nftQuery.data?.metadata.image; + return ( +
+

Selected NFT

+ + {nftQuery.data?.metadata.name && ( +
+ {nftQuery.data.metadata.name} {prefilledNFT.type} +
+ )} +
+ ); +} + +type NFTPickerProps = { + // biome-ignore lint/suspicious/noExplicitAny: + form: UseFormReturn; + marketplaceContract: ThirdwebContract; +}; + +function NFTPicker(props: NFTPickerProps) { + const chainId = props.marketplaceContract.chain.id; + const isSupportedChain = + chainId && + (isSimpleHashSupported(chainId) || + isAlchemySupported(chainId) || + isMoralisSupported(chainId)); + + const account = useActiveAccount(); + + const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({ + chainId, + walletAddress: account?.address, + }); + const selectedContract = props.form.watch("selected.contractAddress") + ? getContract({ + address: props.form.watch("selected.contractAddress"), + chain: props.marketplaceContract.chain, + client: props.marketplaceContract.client, + }) + : undefined; + + const { data: ownedNFTs, isPending: isOwnedNFTsLoading } = + useDashboardOwnedNFTs({ + contract: selectedContract, + owner: account?.address, + // Only run this hook as the last resort if this chain is not supported by the API services we are using + disabled: + !selectedContract || + isSupportedChain || + isWalletNFTsLoading || + (walletNFTs?.result || []).length > 0, + }); + + const isSelected = (nft: WalletNFT) => { + return ( + props.form.watch("selected")?.id === nft.id && + props.form.watch("selected")?.contractAddress === nft.contractAddress + ); + }; + const ownedWalletNFTs: WalletNFT[] = useMemo(() => { + return ownedNFTs?.map((nft) => { + if (nft.type === "ERC721") { + return { + id: String(nft.id), + metadata: nft.metadata, + supply: "1", + contractAddress: props.form.watch("selected.contractAddress"), + tokenId: nft.id.toString(), + owner: nft.owner, + type: "ERC721", + tokenURI: nft.tokenURI, + }; + } + return { + id: String(nft.id), + metadata: nft.metadata, + supply: String(nft.supply), + contractAddress: props.form.watch("selected.contractAddress"), + tokenId: nft.id.toString(), + owner: nft.owner, + type: "ERC1155", + tokenURI: nft.tokenURI, + }; + }) as WalletNFT[]; + }, [ownedNFTs, props.form]); + + const nfts = ownedWalletNFTs || walletNFTs?.result; + + return ( + <> Select NFT @@ -365,19 +542,21 @@ export const CreateListingsForm: React.FC = ({

Contract address - {form.formState.errors.selected?.contractAddress?.message} + {props.form.formState.errors.selected?.contractAddress?.message} This will display all the NFTs you own from this contract. @@ -388,7 +567,7 @@ export const CreateListingsForm: React.FC = ({ {isWalletNFTsLoading || (isOwnedNFTsLoading && !isSupportedChain && - form.watch("selected.contractAddress")) ? ( + props.form.watch("selected.contractAddress")) ? (
@@ -427,8 +606,8 @@ export const CreateListingsForm: React.FC = ({ cursor="pointer" onClick={() => isSelected(nft) - ? form.setValue("selected", undefined) - : form.setValue("selected", nft) + ? props.form.setValue("selected", undefined) + : props.form.setValue("selected", nft) } outline={isSelected(nft) ? "3px solid" : undefined} outlineColor={isSelected(nft) ? "purple.500" : undefined} @@ -459,93 +638,6 @@ export const CreateListingsForm: React.FC = ({ ) : null}
- - Listing Currency - - form.setValue("currencyContractAddress", e.target.value) - } - /> - - The currency you want to sell your tokens for. - - - - - {form.watch("listingType") === "auction" - ? "Buyout Price Per Token" - : "Listing Price"} - - - - {form.watch("listingType") === "auction" - ? "The price per token a buyer can pay to instantly buyout the auction." - : "The price of each token you are listing for sale."} - - - {form.watch("selected")?.type?.toLowerCase() !== "erc721" && ( - -
- Quantity -
- - - The number of tokens to list for sale. - -
- )} - {form.watch("listingType") === "auction" && ( - <> - - Reserve Price Per Token - - - The minimum price per token necessary to bid on this auction - - - - Auction Duration - - The duration of this auction. - - - )} - - {!form.watch("selected.id") && ( - - - No NFT selected - - )} - - {/* Need to pin these at the bottom because this is a very long form */} -
- - - {actionText} - -
- + ); -}; +} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/useNftDrawerTabs.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/useNftDrawerTabs.tsx index e2b0c639637..df0b48aca29 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/useNftDrawerTabs.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/useNftDrawerTabs.tsx @@ -6,6 +6,7 @@ import type { ThirdwebContract } from "thirdweb"; import * as ERC721Ext from "thirdweb/extensions/erc721"; import * as ERC1155Ext from "thirdweb/extensions/erc1155"; import { useActiveAccount, useReadContract } from "thirdweb/react"; +import { ListMarketplaceButton } from "../components/list-marketplace-button"; import type { NFTDrawerTab } from "./types"; type UseNFTDrawerTabsParams = { @@ -114,6 +115,8 @@ export function useNFTDrawerTabs({ return false; })(); + const isListable = isERC1155 || isERC721; + let tabs: NFTDrawerTab[] = []; if (hasERC1155ClaimConditions) { tabs = tabs.concat([ @@ -200,6 +203,22 @@ export function useNFTDrawerTabs({ ]); } + if (isListable) { + tabs = tabs.concat([ + { + title: "Marketplace", + isDisabled: false, + children: ( + + ), + }, + ]); + } + return tabs; }, [ isERC1155, diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/list-marketplace-button.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/list-marketplace-button.tsx new file mode 100644 index 00000000000..3104b9009ff --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/list-marketplace-button.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { useThirdwebClient } from "@/constants/thirdweb.client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { type ThirdwebContract, getContract, isAddress } from "thirdweb"; +import { z } from "zod"; +import { CreateListingsForm } from "../../(marketplace)/components/list-form"; + +type Props = { + type: "ERC1155" | "ERC721"; + contract: ThirdwebContract; + tokenId: bigint; +}; + +type Action = { + value: "direct-listings" | "english-auctions"; + text: string; +}; +const actions: Action[] = [ + { + value: "direct-listings", + text: "Create Direct Listing", + }, + { + value: "english-auctions", + text: "Create English Auction", + }, +]; + +const formSchema = z.object({ + contractAddress: z.string().refine((value) => isAddress(value), { + message: "Invalid Ethereum address", + }), +}); + +export function ListMarketplaceButton(props: Props) { + const [selectedAction, setSelectedAction] = + useState<(typeof actions)[number]>(); + const handleOpenChange = (isOpen: boolean) => { + setSelectedAction(isOpen ? actions[0] : undefined); + }; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + contractAddress: "", + }, + }); + const client = useThirdwebClient(); + const [marketplaceContract, setMarketplaceContract] = + useState(); + + const handleSubmit = (action: (typeof actions)[number]) => { + form.handleSubmit((d) => { + const marketplaceContract = getContract({ + address: d.contractAddress, + chain: props.contract.chain, + client, + }); + setMarketplaceContract(marketplaceContract); + setSelectedAction(action); + })(); + }; + + return ( + <> +
+ + ( + + + Marketplace Contract on{" "} + {props.contract.chain?.name || + `chainId: ${props.contract.chain.id}`} + + + + + + + )} + /> +
+ {actions.map((item) => ( + + ))} +
+ + + + + + + + {selectedAction?.text} + + + {marketplaceContract && selectedAction && ( + + )} + + + + ); +}