From 11d9f3e5135c4784a2c37013069d3a4224e92aaf Mon Sep 17 00:00:00 2001 From: MananTank Date: Sun, 27 Jul 2025 22:56:58 +0000 Subject: [PATCH] Dashboard: Migrate marketplace contract pages from chakra to tailwind (#7730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on improving the UI components and their functionality in the dashboard application, particularly around NFT listings, by enhancing type safety, optimizing rendering, and updating UI elements for better usability. ### Detailed summary - Made `image_url` optional in `getWalletNFTs.ts`. - Updated button styles and sizes in `list-button.tsx`. - Enhanced `CurrencySelector` component with better class management. - Refactored `ContractDirectListingsPage` and `ContractEnglishAuctionsPage` to use `props`. - Improved `ListingDrawer` layout and added table components for better data presentation. - Refined `MarketplaceTable` to handle pagination and data fetching more efficiently. - Updated listing forms to use new UI components and improved validation. - Enhanced error handling and loading states across various components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit * **New Features** * Enhanced currency selector with an icon-based back button and improved styling flexibility. * **Refactor** * Migrated marketplace listing forms and tables to a new custom UI component library for a more consistent and modern interface. * Simplified and streamlined marketplace table and drawer components, removing legacy dependencies and improving layout. * Updated listing and auction pages to delegate call-to-action buttons for better composability. * Improved listing details drawer with a structured table layout and clearer asset information. * **Bug Fixes** * Improved handling of missing NFT images to prevent errors when image URLs are unavailable. * **Style** * Updated button and icon styles for a more polished user experience. --- apps/dashboard/src/@/actions/getWalletNFTs.ts | 4 +- .../@/components/blocks/CurrencySelector.tsx | 7 +- .../(marketplace)/components/cancel-tab.tsx | 46 +- .../(marketplace)/components/list-button.tsx | 10 +- .../(marketplace)/components/list-form.tsx | 865 ++++++++++-------- .../components/listing-drawer.tsx | 241 ++--- .../components/marketplace-table.tsx | 484 ++++------ .../ContractDirectListingsPage.tsx | 41 +- .../direct-listings/components/table.tsx | 31 +- .../ContractEnglishAuctionsPage.tsx | 41 +- .../english-auctions/components/table.tsx | 30 +- 11 files changed, 888 insertions(+), 912 deletions(-) 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 ( ); -}; +}