diff --git a/apps/dashboard/src/@/actions/getWalletNFTs.ts b/apps/dashboard/src/@/actions/getWalletNFTs.ts index 0195752c1dd..a8f061f61af 100644 --- a/apps/dashboard/src/@/actions/getWalletNFTs.ts +++ b/apps/dashboard/src/@/actions/getWalletNFTs.ts @@ -98,7 +98,7 @@ export async function getWalletNFTs(params: { type OwnedNFTInsightResponse = { name: string; description: string; - image_url: string; + image_url?: string; background_color: string; external_url: string; metadata_url: string; @@ -186,7 +186,7 @@ async function getWalletNFTsFromInsight(params: { description: nft.description, external_url: nft.external_url, image: isDev - ? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/") + ? nft.image_url?.replace("ipfscdn.io/", "thirdwebstorage-dev.com/") : nft.image_url, name: nft.name, uri: isDev diff --git a/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx b/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx index d75a2f0f26c..eac5c1bdb91 100644 --- a/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx +++ b/apps/dashboard/src/@/components/blocks/CurrencySelector.tsx @@ -1,3 +1,4 @@ +import { ArrowLeftIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { isAddress, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb"; import { Button } from "@/components/ui/button"; @@ -109,10 +110,10 @@ export function CurrencySelector({ className="rounded-r-none rounded-l-md" onClick={() => setIsAddingCurrency(false)} > - <- + setEditCustomCurrency(e.target.value)} placeholder="ERC20 Address" required @@ -156,7 +157,7 @@ export function CurrencySelector({ : value?.toLowerCase() } > - + diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/cancel-tab.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/cancel-tab.tsx index 246dbf77ba8..f1eefd58a0e 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/cancel-tab.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/cancel-tab.tsx @@ -23,29 +23,27 @@ export const CancelTab: React.FC = ({ : cancelListing({ contract, listingId: BigInt(id) }); const cancelQuery = useSendAndConfirmTransaction(); return ( -
- { - const promise = cancelQuery.mutateAsync(transaction, { - onError: (error) => { - console.error(error); - }, - }); - toast.promise(promise, { - error: "Failed to cancel", - loading: `Cancelling ${isAuction ? "auction" : "listing"}`, - success: "Item cancelled successfully", - }); - }} - transactionCount={1} - txChainID={contract.chain.id} - > - Cancel {isAuction ? "Auction" : "Listing"} - -
+ { + const promise = cancelQuery.mutateAsync(transaction, { + onError: (error) => { + console.error(error); + }, + }); + toast.promise(promise, { + error: "Failed to cancel", + loading: `Cancelling ${isAuction ? "auction" : "listing"}`, + success: "Item cancelled successfully", + }); + }} + transactionCount={1} + txChainID={contract.chain.id} + > + Cancel {isAuction ? "Auction" : "Listing"} + ); }; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx index 8cf7f8eab0d..f7d6c9113dd 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-button.tsx @@ -51,8 +51,14 @@ export const CreateListingButton: React.FC = ({ - diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx index a222cc789f7..0a34439ede8 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx @@ -1,14 +1,4 @@ -import { - Box, - Flex, - FormControl, - Input, - Select, - Spinner, - Tooltip, -} from "@chakra-ui/react"; -import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form"; -import { CircleAlertIcon, InfoIcon } from "lucide-react"; +import { InfoIcon } from "lucide-react"; import Link from "next/link"; import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; @@ -44,19 +34,34 @@ import { CurrencySelector } from "@/components/blocks/CurrencySelector"; import { NFTMediaWithEmptyState } from "@/components/blocks/nft-media"; import { SolidityInput } from "@/components/solidity-inputs"; import { TransactionButton } from "@/components/tx-button"; -import { Alert, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ToolTipLabel } from "@/components/ui/tooltip"; import { useDashboardOwnedNFTs } from "@/hooks/useDashboardOwnedNFTs"; import { useTxNotifications } from "@/hooks/useTxNotifications"; import { useWalletNFTs } from "@/hooks/useWalletNFTs"; +import { cn } from "@/lib/utils"; import { isAlchemySupported } from "@/lib/wallet/nfts/isAlchemySupported"; import { isMoralisSupported } from "@/lib/wallet/nfts/isMoralisSupported"; import type { WalletNFT } from "@/lib/wallet/nfts/types"; import { shortenIfAddress } from "@/utils/usedapp-external"; -const LIST_FORM_ID = "marketplace-list-form"; - type ListForm = | (Omit & { currencyContractAddress: string; @@ -221,284 +226,309 @@ export const CreateListingsForm: React.FC = ({ const nfts = ownedWalletNFTs || walletNFTs?.result; - const noNfts = !nfts?.length; - return ( -
{ - if (!account) { - return toast.error("No account detected"); - } - setIsFormLoading(true); - let nftType: "ERC1155" | "ERC721"; - let _selectedContract: ThirdwebContract; - let selectedTokenId: bigint; - const selectedQuantity = BigInt(formData.quantity); - try { - if (mode === "manual") { - if (!formData.assetContractAddress) { - setIsFormLoading(false); - return toast.error("Enter a valid NFT contract address"); - } - _selectedContract = getContract({ - address: formData.assetContractAddress, - chain: contract.chain, - client: contract.client, - }); - /** - * In manual mode we need to detect the NFT type ourselves - * instead of relying on the third-party providers - */ - const [is721, is1155] = await Promise.all([ - isERC721({ contract: _selectedContract }), - isERC1155({ contract: _selectedContract }), - ]); - if (!is721 && !is1155) { - setIsFormLoading(false); - return toast.error( - `Error: ${formData.assetContractAddress} is neither an ERC721 or ERC1155 contract`, - ); - } - selectedTokenId = BigInt(formData.tokenId); - nftType = is721 ? "ERC721" : "ERC1155"; - /** - * Also in manual mode we need to make sure the user owns the tokenId they entered - * For ERC1155, the owned balance must be >= the entered quantity - * For ERC721, the owner address must match theirs - */ - if (nftType === "ERC1155") { - const balance = await balanceOf({ - contract: _selectedContract, - owner: account.address, - tokenId: selectedTokenId, - }); - if (balance === 0n) { + + { + if (!account) { + return toast.error("No account detected"); + } + setIsFormLoading(true); + let nftType: "ERC1155" | "ERC721"; + let _selectedContract: ThirdwebContract; + let selectedTokenId: bigint; + const selectedQuantity = BigInt(formData.quantity); + try { + if (mode === "manual") { + if (!formData.assetContractAddress) { setIsFormLoading(false); - return toast.error( - `You do not own any tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`, - ); + return toast.error("Enter a valid NFT contract address"); } - if (balance < selectedQuantity) { + _selectedContract = getContract({ + address: formData.assetContractAddress, + chain: contract.chain, + client: contract.client, + }); + /** + * In manual mode we need to detect the NFT type ourselves + * instead of relying on the third-party providers + */ + const [is721, is1155] = await Promise.all([ + isERC721({ contract: _selectedContract }), + isERC1155({ contract: _selectedContract }), + ]); + if (!is721 && !is1155) { setIsFormLoading(false); return toast.error( - `The balance you own for tokenId #${selectedTokenId.toString()} is less than the quantity (you own ${balance.toString()})`, + `Error: ${formData.assetContractAddress} is neither an ERC721 or ERC1155 contract`, ); } - } else { - if (selectedQuantity !== 1n) { - setIsFormLoading(false); - return toast.error( - "The quantity can only be 1 for ERC721 token", - ); + selectedTokenId = BigInt(formData.tokenId); + nftType = is721 ? "ERC721" : "ERC1155"; + /** + * Also in manual mode we need to make sure the user owns the tokenId they entered + * For ERC1155, the owned balance must be >= the entered quantity + * For ERC721, the owner address must match theirs + */ + if (nftType === "ERC1155") { + const balance = await balanceOf({ + contract: _selectedContract, + owner: account.address, + tokenId: selectedTokenId, + }); + if (balance === 0n) { + setIsFormLoading(false); + return toast.error( + `You do not own any tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`, + ); + } + if (balance < selectedQuantity) { + setIsFormLoading(false); + return toast.error( + `The balance you own for tokenId #${selectedTokenId.toString()} is less than the quantity (you own ${balance.toString()})`, + ); + } + } else { + if (selectedQuantity !== 1n) { + setIsFormLoading(false); + return toast.error( + "The quantity can only be 1 for ERC721 token", + ); + } + const owner = await ownerOf({ + contract: _selectedContract, + tokenId: selectedTokenId, + }).catch(() => undefined); + if (owner?.toLowerCase() !== account.address.toLowerCase()) { + setIsFormLoading(false); + return toast.error( + `You do not own the tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`, + ); + } } - const owner = await ownerOf({ - contract: _selectedContract, - tokenId: selectedTokenId, - }).catch(() => undefined); - if (owner?.toLowerCase() !== account.address.toLowerCase()) { + } else { + if (!formData.selected || !selectedContract) { setIsFormLoading(false); - return toast.error( - `You do not own the tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`, - ); + return toast.error("Please select an NFT to list"); } + nftType = formData.selected.type; + _selectedContract = selectedContract; + selectedTokenId = BigInt(formData.selected.id); } - } else { - if (!formData.selected || !selectedContract) { - setIsFormLoading(false); - return toast.error("Please select an NFT to list"); - } - nftType = formData.selected.type; - _selectedContract = selectedContract; - selectedTokenId = BigInt(formData.selected.id); - } - /** - * Make sure the selected item is approved to be listed on the marketplace contract - * todo: We are checking "isApprovedForAll" for both erc1155 and 721. - * However for ERC721 there's also a function called "getApproved" which is used to check for approval status of a single token - * - might worth adding that logic here. - */ - const isNftApproved = - nftType === "ERC1155" ? isApprovedForAll1155 : isApprovedForAll721; - const isApproved = await isNftApproved({ - contract: _selectedContract, - operator: contract.address, - owner: account.address, - }); - - if (!isApproved) { - const setNftApproval = + /** + * Make sure the selected item is approved to be listed on the marketplace contract + * todo: We are checking "isApprovedForAll" for both erc1155 and 721. + * However for ERC721 there's also a function called "getApproved" which is used to check for approval status of a single token + * - might worth adding that logic here. + */ + const isNftApproved = nftType === "ERC1155" - ? setApprovalForAll1155 - : setApprovalForAll721; - const approveTx = setNftApproval({ - approved: true, + ? isApprovedForAll1155 + : isApprovedForAll721; + const isApproved = await isNftApproved({ contract: _selectedContract, operator: contract.address, + owner: account.address, }); - const promise = sendAndConfirmTx.mutateAsync(approveTx); - toast.promise(promise, { - error: "Failed to approve NFT", - loading: "Approving NFT for listing", - success: "NFT approved successfully", - }); - await promise; - } - - if (formData.listingType === "direct") { - // Hard code to 100 years for now - const endTimestamp = new Date( - new Date().setFullYear(new Date().getFullYear() + 100), - ); - const transaction = createListing({ - assetContractAddress: _selectedContract.address, - contract, - currencyContractAddress: formData.currencyContractAddress, - endTimestamp, - pricePerToken: String(formData.pricePerToken), - quantity: selectedQuantity, - startTimestamp: formData.startTimestamp, - tokenId: selectedTokenId, - }); + if (!isApproved) { + const setNftApproval = + nftType === "ERC1155" + ? setApprovalForAll1155 + : setApprovalForAll721; + const approveTx = setNftApproval({ + approved: true, + contract: _selectedContract, + operator: contract.address, + }); - await sendAndConfirmTx.mutateAsync(transaction, { - onSuccess: () => setOpen(false), - }); + const promise = sendAndConfirmTx.mutateAsync(approveTx); + toast.promise(promise, { + error: "Failed to approve NFT", + loading: "Approving NFT for listing", + success: "NFT approved successfully", + }); + await promise; + } - listingNotifications.onSuccess(); - } else if (formData.listingType === "auction") { - let minimumBidAmountWei: bigint; - let buyoutBidAmountWei: bigint; - if ( - formData.currencyContractAddress.toLowerCase() === - NATIVE_TOKEN_ADDRESS.toLocaleLowerCase() - ) { - minimumBidAmountWei = toWei( - formData.reservePricePerToken.toString(), + if (formData.listingType === "direct") { + // Hard code to 100 years for now + const endTimestamp = new Date( + new Date().setFullYear(new Date().getFullYear() + 100), ); - buyoutBidAmountWei = toWei( - formData.buyoutPricePerToken.toString(), - ); - } else { - const tokenContract = getContract({ - address: formData.currencyContractAddress, - chain: contract.chain, - client: contract.client, + const transaction = createListing({ + assetContractAddress: _selectedContract.address, + contract, + currencyContractAddress: formData.currencyContractAddress, + endTimestamp, + pricePerToken: String(formData.pricePerToken), + quantity: selectedQuantity, + startTimestamp: formData.startTimestamp, + tokenId: selectedTokenId, }); - const _decimals = await decimals({ contract: tokenContract }); - minimumBidAmountWei = toUnits( - formData.reservePricePerToken.toString(), - _decimals, - ); - buyoutBidAmountWei = toUnits( - formData.buyoutPricePerToken.toString(), - _decimals, - ); - } - const transaction = createAuction({ - assetContractAddress: _selectedContract.address, - buyoutBidAmountWei: buyoutBidAmountWei * selectedQuantity, - contract, - currencyContractAddress: formData.currencyContractAddress, - endTimestamp: new Date( - Date.now() + - Number.parseInt(formData.listingDurationInSeconds) * 1000, - ), - minimumBidAmountWei: minimumBidAmountWei * selectedQuantity, - startTimestamp: formData.startTimestamp, - tokenId: selectedTokenId, - }); + await sendAndConfirmTx.mutateAsync(transaction, { + onSuccess: () => setOpen(false), + }); - await sendAndConfirmTx.mutateAsync(transaction, { - onSuccess: () => { - setOpen(false); - }, - }); - auctionNotifications.onSuccess(); - } - } catch (err) { - console.error(err); - if (formData.listingType === "auction") { - auctionNotifications.onError(err); - } else { - listingNotifications.onError(err); + listingNotifications.onSuccess(); + } else if (formData.listingType === "auction") { + let minimumBidAmountWei: bigint; + let buyoutBidAmountWei: bigint; + if ( + formData.currencyContractAddress.toLowerCase() === + NATIVE_TOKEN_ADDRESS.toLocaleLowerCase() + ) { + minimumBidAmountWei = toWei( + formData.reservePricePerToken.toString(), + ); + buyoutBidAmountWei = toWei( + formData.buyoutPricePerToken.toString(), + ); + } else { + const tokenContract = getContract({ + address: formData.currencyContractAddress, + chain: contract.chain, + client: contract.client, + }); + const _decimals = await decimals({ contract: tokenContract }); + minimumBidAmountWei = toUnits( + formData.reservePricePerToken.toString(), + _decimals, + ); + buyoutBidAmountWei = toUnits( + formData.buyoutPricePerToken.toString(), + _decimals, + ); + } + + const transaction = createAuction({ + assetContractAddress: _selectedContract.address, + buyoutBidAmountWei: buyoutBidAmountWei * selectedQuantity, + contract, + currencyContractAddress: formData.currencyContractAddress, + endTimestamp: new Date( + Date.now() + + Number.parseInt(formData.listingDurationInSeconds) * 1000, + ), + minimumBidAmountWei: minimumBidAmountWei * selectedQuantity, + startTimestamp: formData.startTimestamp, + tokenId: selectedTokenId, + }); + + await sendAndConfirmTx.mutateAsync(transaction, { + onSuccess: () => { + setOpen(false); + }, + }); + auctionNotifications.onSuccess(); + } + } catch (err) { + console.error(err); + if (formData.listingType === "auction") { + auctionNotifications.onError(err); + } else { + listingNotifications.onError(err); + } } - } - - setIsFormLoading(false); - })} - > - {mode === "manual" ? ( - <> - - - Manually enter the contract address and token ID of the NFT you - want to list for sale - - NFT Contract Address - - - - Token ID - - - - ) : ( - - - Select the NFT you want to list for sale - - {!isSupportedChain ? ( - -
- -

- This chain is not supported by our NFT API yet, please enter - the contract address of the NFT you want to list. -

-
- - Contract address - + {mode === "manual" && ( + <> + {/* contract address */} + + + Manually enter the contract address and token ID of the NFT you + want to list for sale + + NFT Contract Address + + - - {form.formState.errors.selected?.contractAddress?.message} - - - This will display all the NFTs you own from this contract. - -
- ) : null} - {isWalletNFTsLoading || - (isOwnedNFTsLoading && - !isSupportedChain && - form.watch("selected.contractAddress")) ? ( -
- -
- ) : nfts && nfts.length !== 0 ? ( - - {nfts?.map((nft) => { - return ( - + + + + {/* token id */} + + Token ID + + + + + + + )} + + {/* select owned nft */} + {mode === "automatic" && ( + + + Select the NFT you want to list for sale + + + {!isSupportedChain ? ( +
+
+ +

+ This chain is not supported by our NFT API yet, please enter + the contract address of the NFT you want to list. +

+
+ + Contract address + + + + + {form.formState.errors.selected?.contractAddress?.message} + + + This will display all the NFTs you own from this contract. + + +
+ ) : null} + + {isWalletNFTsLoading || + (isOwnedNFTsLoading && + !isSupportedChain && + form.watch("selected.contractAddress")) ? ( +
+ {new Array(8).fill(0).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} +
+ ) : nfts && nfts.length !== 0 ? ( +
+ {nfts?.map((nft) => { + function handleClick() { + if (isSelected(nft)) { + form.setValue("selected", undefined); + } else { + form.setValue("selected", nft); + } + } + + return ( +
  • Name: {nft.metadata?.name || "N/A"} @@ -514,144 +544,173 @@ export const CreateListingsForm: React.FC = ({ Token Standard: {nft.type}
  • - - } - placement="left-end" - shouldWrapChildren - > - - isSelected(nft) - ? form.setValue("selected", undefined) - : form.setValue("selected", nft) } - outline={isSelected(nft) ? "3px solid" : undefined} - outlineColor={isSelected(nft) ? "purple.500" : undefined} - overflow="hidden" > - - - - ); - })} - - ) : nfts && nfts.length === 0 ? ( -
    - -

    - There are no NFTs owned by this wallet. You need NFTs to create - a listing. You can create NFTs with thirdweb.{" "} - - Explore NFT contracts - - . -

    -
    - ) : null} - - )} - - - Listing Currency - - form.setValue("currencyContractAddress", e.target.value) - } - value={form.watch("currencyContractAddress")} - /> - - 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 - + {/** biome-ignore lint/a11y/useSemanticElements: ok */} +
    { + if (e.key === "Enter" || e.key === " ") { + handleClick(); + } + }} + className={cn( + "rounded-lg cursor-pointer overflow-hidden", + isSelected(nft) && + "ring-2 ring-primary ring-offset-2 ring-offset-background", + )} + onClick={handleClick} + > + +
    +
    + ); + })} +
    + ) : nfts && nfts.length === 0 ? ( +
    + +

    + There are no NFTs owned by this wallet. You need NFTs to + create a listing. You can create NFTs with thirdweb.{" "} + + Explore NFT contracts + + . +

    +
    + ) : null} +
    + )} + + {/* listing currency */} + + Listing Currency + + + form.setValue("currencyContractAddress", e.target.value) + } + value={form.watch("currencyContractAddress")} + /> - - Auction Duration - - The duration of this auction. + + The currency you want to sell your tokens for. + + + + {/* listing price */} + + + {form.watch("listingType") === "auction" + ? "Buyout Price Per Token" + : "Listing Price"} + + + - - )} - - {mode === "automatic" && !form.watch("selected.id") && ( - - - No NFT selected - - )} - - {/* Need to pin these at the bottom because this is a very long form */} -
    - - - {actionText} - -
    - + + {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."} + +
    + + {/* quantity */} + {form.watch("selected")?.type?.toLowerCase() !== "erc721" && ( + +
    + Quantity +
    + + + + + The number of tokens to list for sale. + +
    + )} + + {/* auction */} + {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. + + + )} + + {/* Need to pin these at the bottom because this is a very long form */} +
    + + + {actionText} + +
    + + ); }; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/listing-drawer.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/listing-drawer.tsx index b660f175a73..8d69f8a122d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/listing-drawer.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/listing-drawer.tsx @@ -1,4 +1,3 @@ -import { Flex, GridItem, SimpleGrid, usePrevious } from "@chakra-ui/react"; import { toast } from "sonner"; import type { ThirdwebContract } from "thirdweb"; import type { @@ -11,169 +10,177 @@ import { WalletAddress } from "@/components/blocks/wallet-address"; import { Badge } from "@/components/ui/badge"; import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; -import { Card } from "@/components/ui/card"; import { CodeClient } from "@/components/ui/code/code.client"; import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@/components/ui/table"; import { CancelTab } from "./cancel-tab"; import { LISTING_STATUS } from "./types"; -interface NFTDrawerProps { +export function ListingDrawer(props: { contract: ThirdwebContract; isOpen: boolean; onClose: () => void; - data: DirectListing | EnglishAuction | null; + data: DirectListing | EnglishAuction; isLoggedIn: boolean; -} - -export const ListingDrawer: React.FC = ({ - contract, - isOpen, - onClose, - data, - isLoggedIn, -}) => { +}) { + const { contract, isOpen, onClose, data, isLoggedIn } = props; const address = useActiveAccount()?.address; - const prevData = usePrevious(data); - - const renderData = data || prevData; - const isOwner = - address?.toLowerCase() === renderData?.creatorAddress.toLowerCase(); - - const tokenId = renderData?.asset.id.toString() || ""; - - if (!renderData) { - return null; - } + const isOwner = address?.toLowerCase() === data.creatorAddress.toLowerCase(); + const tokenId = data.asset.id.toString(); return ( - -
    + +
    - -

    - {renderData.asset.metadata.name} +

    +

    + {data.asset.metadata.name}

    -

    - {renderData.asset.metadata.name} -

    - + {data.asset.metadata.description && ( +

    + {data.asset.metadata.description} +

    + )} +
    - - - - -

    Asset contract address

    -
    - + + + {/* NFT contract address */} + + NFT contract address + - - -

    Token ID

    -
    - +
    +
    + + {/* Token ID */} + + Token ID + - - -

    Seller

    -
    - +
    +
    + + {/* Seller */} + + Seller + - - -

    Listing ID

    -
    - +
    +
    + + {/* Listing ID */} + + Listing ID + - - -

    Type

    -
    - {renderData.asset.type} - -

    Status

    -
    - +
    +
    + + {/* Type */} + + Type + {data.asset.type} + + + {/* Status */} + + Status + - {LISTING_STATUS[renderData.status]} + {LISTING_STATUS[data.status]} - - -

    Quantity

    -
    - - {(renderData.quantity || 0n).toString()}{" "} +
    +
    + + {/* Quantity */} + + Quantity + + {(data.quantity || 0n).toString()}{" "} {/* For listings that are completed, the `quantity` would be `0` - So we show this text to make it clear */} - {LISTING_STATUS[renderData.status] === "Completed" + So we show this text to make it clear */} + {LISTING_STATUS[data.status] === "Completed" ? "(Sold out)" : ""} - + + + + {/* Price */} + {data.type === "direct-listing" && ( + + Price + + {data.currencyValuePerToken.displayValue}{" "} + {data.currencyValuePerToken.symbol} + + + )} +
    +
    + + {data?.asset.metadata.properties ? ( +
    +

    Attributes

    + +
    + ) : null} - {renderData.type === "direct-listing" && ( - <> - -

    Price

    -
    - - {renderData.currencyValuePerToken.displayValue}{" "} - {renderData.currencyValuePerToken.symbol} - - - )} -
    -
    - {data?.asset.metadata.properties ? ( - -

    Attributes

    - -
    - ) : null} -
    {isOwner && ( )} - {!isOwner && renderData.status === "ACTIVE" && ( + {!isOwner && data.status === "ACTIVE" && ( { toast.error("Failed to buy listing", { description: error.message, @@ -184,10 +191,18 @@ export const ListingDrawer: React.FC = ({ }} quantity={1n} > - Buy Listing + Buy NFT )}
    ); -}; +} + +function StyledTableHead({ children }: { children: React.ReactNode }) { + return {children}; +} + +function StyledTableCell({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/marketplace-table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/marketplace-table.tsx index 800aee0badd..8efac373528 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/marketplace-table.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/marketplace-table.tsx @@ -1,49 +1,29 @@ -// biome-ignore-all lint/nursery/noNestedComponentDefinitions: TODO -/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */ - -import { - IconButton, - Select, - Skeleton, - Spinner, - Table, - TableContainer, - Tbody, - Td, - Th, - Thead, - Tr, - usePrevious, -} from "@chakra-ui/react"; import type { UseQueryResult } from "@tanstack/react-query"; -import { - ChevronFirstIcon, - ChevronLastIcon, - ChevronLeftIcon, - ChevronRightIcon, - MoveRightIcon, -} from "lucide-react"; -import { - type Dispatch, - type SetStateAction, - useEffect, - useMemo, - useState, -} from "react"; -import { type Cell, type Column, usePagination, useTable } from "react-table"; +import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; import type { ThirdwebContract } from "thirdweb"; import type { DirectListing, EnglishAuction, } from "thirdweb/extensions/marketplace"; import { min } from "thirdweb/utils"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; import { WalletAddress } from "@/components/blocks/wallet-address"; import { MediaCell } from "@/components/contracts/media-cell"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { ListingDrawer } from "./listing-drawer"; import { LISTING_STATUS } from "./types"; -interface MarketplaceTableProps { +export function MarketplaceTable(props: { contract: ThirdwebContract; getAllQueryResult: UseQueryResult; getValidQueryResult: UseQueryResult; @@ -59,279 +39,217 @@ interface MarketplaceTableProps { }> >; isLoggedIn: boolean; -} - -const DEFAULT_QUERY_STATE = { count: 50, start: 0 }; + title: string; + cta: React.ReactNode; +}) { + const { + contract, + getAllQueryResult, + getValidQueryResult, + totalCountQuery, + queryParams, + setQueryParams, + isLoggedIn, + cta, + title, + } = props; -export const MarketplaceTable: React.FC = ({ - contract, - getAllQueryResult, - getValidQueryResult, - totalCountQuery, - queryParams, - setQueryParams, - isLoggedIn, -}) => { const [listingsToShow, setListingsToShow_] = useState<"all" | "valid">("all"); const setListingsToShow = (value: "all" | "valid") => { - setQueryParams(DEFAULT_QUERY_STATE); + setQueryParams({ count: 50, start: 0 }); setListingsToShow_(value); }; - const prevData = usePrevious( - listingsToShow === "all" - ? getAllQueryResult?.data - : getValidQueryResult?.data, - ); - const renderData = useMemo(() => { if (listingsToShow === "all") { - return getAllQueryResult?.data || prevData; + return getAllQueryResult?.data; } - return getValidQueryResult?.data || prevData; - }, [getAllQueryResult, getValidQueryResult, listingsToShow, prevData]); + return getValidQueryResult?.data; + }, [getAllQueryResult, getValidQueryResult, listingsToShow]); - const tableColumns: Column[] = useMemo(() => { - return [ - { - accessor: (row) => row.id.toString(), - Header: "Listing Id", - }, - { - accessor: (row) => row.asset.metadata, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: (cell: any) => , - Header: "Media", - }, - { - accessor: (row) => row.asset.metadata.name ?? "N/A", - Header: "Name", - }, - { - accessor: (row) => row.creatorAddress, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: ({ cell }: { cell: Cell }) => ( - - ), - Header: "Creator", - }, - { - accessor: (row) => - (row as DirectListing)?.currencyValuePerToken || - (row as EnglishAuction)?.buyoutCurrencyValue, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: ({ cell }: { cell: Cell }) => { - return ( -

    - {cell.value.displayValue} {cell.value.symbol} -

    - ); - }, - Header: "Price", - }, - { - accessor: (row) => LISTING_STATUS[row.status], - Header: "Status", - }, - ]; - }, [contract.client]); - - const { - getTableProps, - getTableBodyProps, - headerGroups, - prepareRow, - page, - canPreviousPage, - canNextPage, - pageCount, - gotoPage, - nextPage, - previousPage, - setPageSize, - state: { pageIndex, pageSize }, - } = useTable( - { - columns: tableColumns, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - data: (renderData as any) || [], - initialState: { - pageIndex: 0, - pageSize: queryParams.count, - }, - manualPagination: true, - pageCount: Math.max( - Math.ceil( - Number( - // To avoid overflow issue - min(totalCountQuery.data || 0n, BigInt(Number.MAX_SAFE_INTEGER)), - ) / queryParams.count, - ), - 1, - ), - }, - // FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore - // eslint-disable-next-line react-compiler/react-compiler - usePagination, + const pageSize = queryParams.count; + const currentPage = Math.floor(queryParams.start / pageSize) + 1; // PaginationButtons uses 1-based indexing + const totalCount = Number( + min(totalCountQuery.data || 0n, BigInt(Number.MAX_SAFE_INTEGER)), ); + const totalPages = Math.max(Math.ceil(totalCount / pageSize), 1); + const showPagination = totalPages > 1; - // FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - setQueryParams({ count: pageSize, start: pageIndex * pageSize }); - }, [pageIndex, pageSize, setQueryParams]); + // Pagination handler for PaginationButtons + const handlePageChange = (page: number) => { + setQueryParams({ count: pageSize, start: (page - 1) * pageSize }); + }; - const [tokenRow, setTokenRow] = useState< + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const [selectedToken, setSelectedToken] = useState< DirectListing | EnglishAuction | null >(null); + const isFetching = + (listingsToShow === "all" && getAllQueryResult.isFetching) || + (listingsToShow === "valid" && getValidQueryResult.isFetching); + return ( -
    -
    - - +
    +
    +

    {title}

    +
    +
    + + +
    + {cta} +
    - - {((listingsToShow === "all" && getAllQueryResult.isFetching) || - (listingsToShow === "valid" && getValidQueryResult.isFetching)) && ( - + +
    + + + {renderData?.length === 0 ? ( +
    +

    No listings found

    +
    + ) : ( + + + + +

    Listing Id

    +
    + +

    Media

    +
    + +

    Name

    +
    + +

    Creator

    +
    + +

    Price

    +
    + +

    Status

    +
    +
    +
    + + {isFetching && + Array.from({ length: 5 }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} + + {!isFetching && + renderData?.map((row, _rowIndex) => ( + { + setSelectedToken(row); + setIsDrawerOpen(true); + }} + > + {row.id.toString()} + + + + {row.asset.metadata.name ?? "N/A"} + + + + +

    + {(row as DirectListing)?.currencyValuePerToken + ?.displayValue || + (row as EnglishAuction)?.buyoutCurrencyValue + ?.displayValue || + "N/A"}{" "} + {(row as DirectListing)?.currencyValuePerToken + ?.symbol || + (row as EnglishAuction)?.buyoutCurrencyValue + ?.symbol || + ""} +

    +
    + {LISTING_STATUS[row.status]} +
    + ))} +
    +
    )} +
    + + {showPagination && ( + + )} + + {selectedToken && ( setTokenRow(null)} + isOpen={isDrawerOpen} + onClose={() => setIsDrawerOpen(false)} /> - - - {headerGroups.map((headerGroup, headerGroupIndex) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: FIXME - - {headerGroup.headers.map((column, columnIndex) => ( - - ))} - {/* // Need to add an empty header for the drawer button */} - - ))} - - - {page.map((row, rowIndex) => { - prepareRow(row); - return ( - setTokenRow(row.original)} - role="group" - style={{ cursor: "pointer" }} - > - {row.cells.map((cell, cellIndex) => ( - - ))} - - - ); - })} - -
    -

    - {column.render("Header")} -

    -
    -
    - {cell.render("Cell")} - - -
    - -
    -
    - } - isDisabled={!canPreviousPage || totalCountQuery.isPending} - onClick={() => gotoPage(0)} - /> - } - isDisabled={!canPreviousPage || totalCountQuery.isPending} - onClick={() => previousPage()} - /> -

    - Page {pageIndex + 1} of{" "} - - {pageCount} - -

    - } - isDisabled={!canNextPage || totalCountQuery.isPending} - onClick={() => nextPage()} - /> - } - isDisabled={!canNextPage || totalCountQuery.isPending} - onClick={() => gotoPage(pageCount - 1)} - /> - - -
    -
    + )}
    ); -}; +} + +function SkeletonRow() { + return ( + + {/* listing id */} + + + + {/* media */} + + + + {/* name */} + + + + {/* creator */} + + + + {/* price */} + + + + {/* status */} + + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/ContractDirectListingsPage.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/ContractDirectListingsPage.tsx index db5f9e1d015..9bcad03b1cb 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/ContractDirectListingsPage.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/ContractDirectListingsPage.tsx @@ -4,33 +4,24 @@ import type { ThirdwebContract } from "thirdweb"; import { CreateListingButton } from "../components/list-button"; import { DirectListingsTable } from "./components/table"; -interface ContractDirectListingsPageProps { +export function ContractDirectListingsPage(props: { contract: ThirdwebContract; isLoggedIn: boolean; isInsightSupported: boolean; -} - -export const ContractDirectListingsPage: React.FC< - ContractDirectListingsPageProps -> = ({ contract, isLoggedIn, isInsightSupported }) => { +}) { return ( -
    -
    -

    - Direct Listings -

    -
    - -
    -
    - - -
    + + } + /> ); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/components/table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/components/table.tsx index 4ccbaadbd0d..3df767cb9c5 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/components/table.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/direct-listings/components/table.tsx @@ -10,39 +10,38 @@ import { import { useReadContract } from "thirdweb/react"; import { MarketplaceTable } from "../../components/marketplace-table"; -interface DirectListingsTableProps { +export function DirectListingsTable(props: { contract: ThirdwebContract; isLoggedIn: boolean; -} - -const DEFAULT_QUERY_STATE = { count: 50, start: 0 }; - -export const DirectListingsTable: React.FC = ({ - contract, - isLoggedIn, -}) => { - const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_STATE); + cta: React.ReactNode; +}) { + const [queryParams, setQueryParams] = useState({ count: 50, start: 0 }); const getAllQueryResult = useReadContract(getAllListings, { - contract, + contract: props.contract, count: BigInt(queryParams.count), start: queryParams.start, }); + const getValidQueryResult = useReadContract(getAllValidListings, { - contract, + contract: props.contract, count: BigInt(queryParams.count), start: queryParams.start, }); - const totalCountQuery = useReadContract(totalListings, { contract }); + const totalCountQuery = useReadContract(totalListings, { + contract: props.contract, + }); return ( ); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/ContractEnglishAuctionsPage.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/ContractEnglishAuctionsPage.tsx index 805ab5c34c3..a98fe99b053 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/ContractEnglishAuctionsPage.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/ContractEnglishAuctionsPage.tsx @@ -4,33 +4,24 @@ import type { ThirdwebContract } from "thirdweb"; import { CreateListingButton } from "../components/list-button"; import { EnglishAuctionsTable } from "./components/table"; -interface ContractEnglishAuctionsProps { +export function ContractEnglishAuctionsPage(props: { contract: ThirdwebContract; isLoggedIn: boolean; isInsightSupported: boolean; -} - -export const ContractEnglishAuctionsPage: React.FC< - ContractEnglishAuctionsProps -> = ({ contract, isLoggedIn, isInsightSupported }) => { +}) { return ( -
    -
    -

    - English Auctions -

    -
    - -
    -
    - - -
    + + } + /> ); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/components/table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/components/table.tsx index 2d20b7360fd..c3e91ba0a8b 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/components/table.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/english-auctions/components/table.tsx @@ -10,39 +10,37 @@ import { import { useReadContract } from "thirdweb/react"; import { MarketplaceTable } from "../../components/marketplace-table"; -interface EnglishAuctionsTableProps { +export function EnglishAuctionsTable(props: { contract: ThirdwebContract; isLoggedIn: boolean; -} - -const DEFAULT_QUERY_STATE = { count: 50, start: 0 }; - -export const EnglishAuctionsTable: React.FC = ({ - contract, - isLoggedIn, -}) => { - const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_STATE); + cta: React.ReactNode; +}) { + const [queryParams, setQueryParams] = useState({ count: 50, start: 0 }); const getAllQueryResult = useReadContract(getAllAuctions, { - contract, + contract: props.contract, count: BigInt(queryParams.count), start: queryParams.start, }); const getValidQueryResult = useReadContract(getAllValidAuctions, { - contract, + contract: props.contract, count: BigInt(queryParams.count), start: queryParams.start, }); - const totalCountQuery = useReadContract(totalAuctions, { contract }); + const totalCountQuery = useReadContract(totalAuctions, { + contract: props.contract, + }); return ( ); -}; +}