diff --git a/package.json b/package.json index 995b7c8ce..6b2a72350 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,15 @@ "@ar.io/wayfinder-core": "^1.0.4", "@ar.io/wayfinder-react": "^1.0.11", "@ardrive/turbo-sdk": "^1.23.1", + "@blockydevs/arns-marketplace-data": "^0.2.0", + "@blockydevs/arns-marketplace-ui": "^0.1.0", "@permaweb/aoconnect": "0.0.69", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.13", "@sentry/react": "^7.45.0", "@stripe/react-stripe-js": "^3.6.0", "@stripe/stripe-js": "^7.0.0", @@ -48,6 +52,7 @@ "arweave-graphql": "^0.0.5", "arweave-wallet-connector": "^1.0.2", "axios": "^1.1.3", + "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", "emoji-regex": "^10.3.0", "eventemitter3": "^5.0.0", @@ -60,6 +65,7 @@ "puny-coder": "^1.0.1", "radix-ui": "^1.1.3", "react": "^18.2.0", + "react-day-picker": "^9.9.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", "react-markdown": "6", diff --git a/src/App.tsx b/src/App.tsx index 724d55876..4a9f44e2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Logger } from '@ar.io/sdk/web'; import * as Sentry from '@sentry/react'; +import { usePrefetchMarketplaceData } from '@src/hooks/listings/usePrefetchMarketplaceData'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import React, { Suspense, useMemo } from 'react'; @@ -19,14 +20,26 @@ import NetworkSettings from './components/pages/Settings/NetworkSettings'; import DevTools from './components/pages/Settings/devtools/DevTools'; import useSyncSettings from './hooks/useSyncSettings/useSyncSettings'; import useWanderEvents from './hooks/useWanderEvents/useWanderEvents'; -import './index.css'; import { useGlobalState } from './state'; // set the log level of ar-io-sdk Logger.default.setLogLevel('none'); +const MyANTs = React.lazy(() => import('./components/pages/MyANTs/MyANTs')); +const MyANTsNewListing = React.lazy( + () => import('./components/pages/MyANTs/NewListing'), +); const Manage = React.lazy(() => import('./components/pages/Manage/Manage')); const Home = React.lazy(() => import('./components/pages/Home/Home')); + +const Listings = React.lazy( + () => import('./components/pages/Listings/Listings'), +); +const Details = React.lazy( + () => import('./components/pages/Listings/Details/Details'), +); +const Confirm = React.lazy(() => import('./components/pages/Listings/Confirm')); + const ManageANT = React.lazy( () => import('./components/pages/ManageANT/ManageANT'), ); @@ -72,6 +85,7 @@ const sentryCreateBrowserRouter = function App() { useWanderEvents(); useSyncSettings(); + usePrefetchMarketplaceData(); const [{ turboNetwork }] = useGlobalState(); const stripePromise = useMemo(() => { @@ -336,6 +350,66 @@ function App() { } /> + + } + > + + + } + /> + + } + > +
+ + } + /> + + } + > + + + } + /> + + } + > + + + } + /> + + } + > + + + } + /> What are ARIO tokens? - {' '} + - + {row.getValue('role') === 'owner' ? ( )} + {row.getValue('role') === 'owner' && ( + + navigate( + `/my-ants/new-listing/${ + row.original.processId + }?name=${lowerCaseDomain(row.original.name)}`, + ) + } + > + + + } + /> + )} {!isMobile ? ( <> - {' '} {links} {!wallet || !walletAddress ? : } diff --git a/src/components/pages/Listings/ActiveListingsTab.tsx b/src/components/pages/Listings/ActiveListingsTab.tsx new file mode 100644 index 000000000..b746bead0 --- /dev/null +++ b/src/components/pages/Listings/ActiveListingsTab.tsx @@ -0,0 +1,73 @@ +import { fetchActiveListings } from '@blockydevs/arns-marketplace-data'; +import { + ActiveListingTable, + Card, + Pagination, + useCursorPagination, +} from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { usePrepareListings } from '@src/hooks/listings/usePrepareListings'; +import { useGlobalState } from '@src/state'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +const PAGE_SIZE = 20; + +const ActiveListingsTab = () => { + const [{ aoClient }] = useGlobalState(); + const pagination = useCursorPagination(PAGE_SIZE); + const queryAntsMetadata = useAntsMetadata(); + const queryActiveListings = useQuery({ + refetchInterval: 15 * 1000, + structuralSharing: false, + queryKey: marketplaceQueryKeys.listings.list('active', { + page: pagination.page, + pageSize: pagination.pageSize, + cursor: pagination.cursor, + }), + queryFn: () => { + return fetchActiveListings({ + ao: aoClient, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + limit: pagination.pageSize, + cursor: pagination.cursor, + }); + }, + }); + + const preparedItems = usePrepareListings(queryActiveListings.data); + const { totalItems } = queryActiveListings.data ?? {}; + const totalPages = pagination.getTotalPages(totalItems); + const isPending = + queryActiveListings.isPending || queryAntsMetadata.isPending; + + useEffect(() => { + if (!queryActiveListings.data) return; + + const { nextCursor, hasMore } = queryActiveListings.data; + pagination.storeNextCursor(nextCursor, !!hasMore); + }, [queryActiveListings.data, pagination.storeNextCursor]); + + return ( + + + {!isPending && ( + + )} + + ); +}; + +export default ActiveListingsTab; diff --git a/src/components/pages/Listings/CompletedListingsTab.tsx b/src/components/pages/Listings/CompletedListingsTab.tsx new file mode 100644 index 000000000..89e36957f --- /dev/null +++ b/src/components/pages/Listings/CompletedListingsTab.tsx @@ -0,0 +1,73 @@ +import { fetchCompletedListings } from '@blockydevs/arns-marketplace-data'; +import { + Card, + CompletedListingTable, + Pagination, + useCursorPagination, +} from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { usePrepareListings } from '@src/hooks/listings/usePrepareListings'; +import { useGlobalState } from '@src/state'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +const PAGE_SIZE = 20; + +const CompletedListingsTab = () => { + const [{ aoClient }] = useGlobalState(); + const pagination = useCursorPagination(PAGE_SIZE); + const queryAntsMetadata = useAntsMetadata(); + const queryCompletedListings = useQuery({ + refetchInterval: 15 * 1000, + enabled: Boolean(aoClient), + queryKey: marketplaceQueryKeys.listings.list('completed', { + page: pagination.page, + pageSize: pagination.pageSize, + cursor: pagination.cursor, + }), + queryFn: () => { + return fetchCompletedListings({ + ao: aoClient, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + limit: pagination.pageSize, + cursor: pagination.cursor, + }); + }, + }); + + const preparedItems = usePrepareListings(queryCompletedListings.data); + const { totalItems } = queryCompletedListings.data ?? {}; + const totalPages = pagination.getTotalPages(totalItems); + const isPending = + queryCompletedListings.isPending || queryAntsMetadata.isPending; + + useEffect(() => { + if (!queryCompletedListings.data) return; + + const { nextCursor, hasMore } = queryCompletedListings.data; + pagination.storeNextCursor(nextCursor, !!hasMore); + }, [queryCompletedListings.data, pagination.storeNextCursor]); + + return ( + + + {!isPending && ( + + )} + + ); +}; + +export default CompletedListingsTab; diff --git a/src/components/pages/Listings/Confirm.tsx b/src/components/pages/Listings/Confirm.tsx new file mode 100644 index 000000000..3479f0c41 --- /dev/null +++ b/src/components/pages/Listings/Confirm.tsx @@ -0,0 +1,194 @@ +import { + Button, + Card, + GoBackHeader, + Paragraph, + Row, + Spinner, +} from '@blockydevs/arns-marketplace-ui'; +import { useBidListing } from '@src/hooks/listings/useBidListing'; +import { useBuyListing } from '@src/hooks/listings/useBuyListing'; +import { useWalletState } from '@src/state'; +import eventEmitter from '@src/utils/events'; +import { marketplaceQueryKeys } from '@src/utils/marketplace'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; + +const Confirm = () => { + const { id: listingId } = useParams(); + const searchParams = useSearchParams(); + const navigate = useNavigate(); + + const queryClient = useQueryClient(); + const [{ walletAddress }] = useWalletState(); + + const name = searchParams[0].get('name') ?? '-'; + const antProcessId = searchParams[0].get('antProcessId'); + const price = searchParams[0].get('price'); + const type = searchParams[0].get('type'); + + const mutationBuyListing = useBuyListing(antProcessId, listingId, type); + const mutationBidListing = useBidListing(antProcessId, listingId); + + const isMutationPending = + mutationBidListing.isPending || mutationBuyListing.isPending; + const isMutationSuccess = + mutationBidListing.isSuccess || mutationBuyListing.isSuccess; + + if (isMutationSuccess) { + return ( + <> + +
+ + + {type === 'english' + ? 'You can increase your bid anytime before the auction ends.' + : 'Transaction confirmed – ANT is in your wallet.'} + +
+ + Domain name + + + {name} + +
+ + {type === 'english' ? ( + + ) : ( + + )} +
+
+ {type !== 'english' && ( + + )} + +
+
+ + ); + } + + return ( + <> + { + navigate(listingId ? `/listings/${listingId}` : '/listings'); + }} + /> +
+ + + +
+ + +
+
+ {isMutationPending && ( +
+ + + Waiting for wallet confirmation... + +
+ )} +
+ + ); +}; + +export default Confirm; diff --git a/src/components/pages/Listings/Details/Details.tsx b/src/components/pages/Listings/Details/Details.tsx new file mode 100644 index 000000000..fec80dca0 --- /dev/null +++ b/src/components/pages/Listings/Details/Details.tsx @@ -0,0 +1,91 @@ +import { fetchListingDetails } from '@blockydevs/arns-marketplace-data'; +import { DetailsCard, Spinner } from '@blockydevs/arns-marketplace-ui'; +import EnglishListingBidsSection from '@src/components/pages/Listings/Details/EnglishListingBidsSection'; +import ListingBuyerSection from '@src/components/pages/Listings/Details/ListingBuyerSection'; +import ListingExpiredSection from '@src/components/pages/Listings/Details/ListingExpiredSection'; +import ListingMetadata from '@src/components/pages/Listings/Details/ListingMetadata'; +import ListingPriceSection from '@src/components/pages/Listings/Details/ListingPriceSection'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { useGlobalState, useWalletState } from '@src/state'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + getCurrentListingArioPrice, + getStatusVariantFromListing, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'react-router-dom'; + +const Details = () => { + const { id } = useParams(); + const [{ aoClient }] = useGlobalState(); + const [{ walletAddress }] = useWalletState(); + const queryAntsMetadata = useAntsMetadata(); + const queryDetails = useQuery({ + enabled: !!id, + refetchInterval: 15 * 1000, + queryKey: marketplaceQueryKeys.listings.item(id), + queryFn: () => { + if (!id) throw new Error('guard: no id provided'); + + return fetchListingDetails({ + ao: aoClient, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + orderId: id, + }); + }, + }); + + if (queryDetails.isPending || queryAntsMetadata.isPending) { + return ( +
+ +
+ ); + } + + if (queryDetails.error) { + return ( +

+ Failed to load listing details: {queryDetails.error.message} +

+ ); + } + + const listing = queryDetails.data; + const currentPrice = getCurrentListingArioPrice(listing); + const status = getStatusVariantFromListing(listing); + + return ( +
+ +
+ + + + {listing.type === 'english' && + listing.status === 'ready-for-settlement' && + listing.highestBidder !== walletAddress?.toString() && ( + + )} + {listing.status === 'settled' && ( + + )} + {listing.status === 'expired' && ( + + )} + {listing.type === 'english' && ( + + )} +
+
+ ); +}; + +export default Details; diff --git a/src/components/pages/Listings/Details/DutchListingPriceSection.tsx b/src/components/pages/Listings/Details/DutchListingPriceSection.tsx new file mode 100644 index 000000000..91cac4dc4 --- /dev/null +++ b/src/components/pages/Listings/Details/DutchListingPriceSection.tsx @@ -0,0 +1,67 @@ +import { ListingDutchDetails } from '@blockydevs/arns-marketplace-data'; +import { Button } from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { useWalletState } from '@src/state'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface Props { + listing: ListingDutchDetails; +} + +const DutchListingPriceSection = ({ listing }: Props) => { + const navigate = useNavigate(); + const [{ walletAddress }] = useWalletState(); + const queryAntsMetadata = useAntsMetadata(); + + const antMeta = queryAntsMetadata.data?.[listing.antProcessId]; + + const getLabel = () => { + if (!walletAddress) return 'No wallet'; + if (!antMeta) return 'No metadata'; + return antMeta.ownershipType === 'lease' ? 'Lease now' : 'Buy now'; + }; + + const navigateToConfirmPurchase = () => { + if (!antMeta) return; + + const orderId = listing.orderId; + const name = antMeta.name; + const antProcessId = listing.antProcessId; + const currentPrice = getCurrentListingArioPrice(listing); + + const params = new URLSearchParams({ + price: String(currentPrice), + type: 'dutch', + name, + antProcessId, + }).toString(); + navigate(`/listings/${orderId}/confirm-purchase?${params}`); + }; + + useEffect(() => { + if (antMeta) return; + + queryAntsMetadata.refetch(); + }, [antMeta, queryAntsMetadata]); + + return ( + <> + {listing.status === 'active' && ( + + )} + + ); +}; + +export default DutchListingPriceSection; diff --git a/src/components/pages/Listings/Details/EnglishListingBidsSection.tsx b/src/components/pages/Listings/Details/EnglishListingBidsSection.tsx new file mode 100644 index 000000000..87a77cd35 --- /dev/null +++ b/src/components/pages/Listings/Details/EnglishListingBidsSection.tsx @@ -0,0 +1,52 @@ +import { + ListingEnglishDetails, + marioToArio, +} from '@blockydevs/arns-marketplace-data'; +import { + BidsTable, + Card, + Pagination, + Paragraph, + formatDate, +} from '@blockydevs/arns-marketplace-ui'; +import { AO_LINK_EXPLORER_URL } from '@src/utils/marketplace'; +import { useState } from 'react'; + +interface Props { + listing: ListingEnglishDetails; + pageSize?: number; +} + +const EnglishListingBidsSection = ({ listing, pageSize = 5 }: Props) => { + const [bidPage, setBidPage] = useState(1); + + const allBids = listing.bids.map((bid) => ({ + bidder: bid.bidder, + href: `${AO_LINK_EXPLORER_URL}/${bid.bidder}`, + date: formatDate(bid.timestamp, 'dd-MM-yyyy HH:mm:ss'), + price: `${marioToArio(bid.amount)} ARIO`, + })); + + const totalBidPages = Math.max(1, Math.ceil(allBids.length / pageSize)); + const startIndex = (bidPage - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, allBids.length); + const paginatedBids = allBids.slice(startIndex, endIndex); + + return ( + + + Bids ({listing.bids.length}) + +
+ +
+ +
+ ); +}; + +export default EnglishListingBidsSection; diff --git a/src/components/pages/Listings/Details/EnglishListingPriceSection.tsx b/src/components/pages/Listings/Details/EnglishListingPriceSection.tsx new file mode 100644 index 000000000..6d66d4330 --- /dev/null +++ b/src/components/pages/Listings/Details/EnglishListingPriceSection.tsx @@ -0,0 +1,79 @@ +import { ListingEnglishDetails } from '@blockydevs/arns-marketplace-data'; +import { Button, Input } from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { useWalletState } from '@src/state'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface Props { + listing: ListingEnglishDetails; +} + +const EnglishListingPriceSection = ({ listing }: Props) => { + const [bidPrice, setBidPrice] = useState(''); + const navigate = useNavigate(); + const [{ walletAddress }] = useWalletState(); + const queryAntsMetadata = useAntsMetadata(); + + const antMeta = queryAntsMetadata.data?.[listing.antProcessId]; + const currentPrice = getCurrentListingArioPrice(listing); + const minBid = listing.highestBid + ? Number(currentPrice) + 1 + : Number(currentPrice); + const isBidPriceValid = Number(bidPrice) >= minBid; + + const navigateToConfirmPurchase = () => { + const orderId = listing.orderId; + const name = antMeta.name; + const antProcessId = listing.antProcessId; + + navigate( + `/listings/${orderId}/confirm-purchase?price=${bidPrice}&type=english&name=${name}&antProcessId=${antProcessId}`, + ); + }; + + useEffect(() => { + if (antMeta) return; + + queryAntsMetadata.refetch(); + }, [antMeta, queryAntsMetadata]); + + return ( + <> + { + setBidPrice(e.target.value); + }} + placeholder={`${minBid} and up`} + label="Name your price" + suffix="ARIO" + /> + + + ); +}; + +export default EnglishListingPriceSection; diff --git a/src/components/pages/Listings/Details/EnglishListingSettlementSection.tsx b/src/components/pages/Listings/Details/EnglishListingSettlementSection.tsx new file mode 100644 index 000000000..5c0db903e --- /dev/null +++ b/src/components/pages/Listings/Details/EnglishListingSettlementSection.tsx @@ -0,0 +1,90 @@ +import { createAoSigner } from '@ar.io/sdk'; +import { + ListingEnglishDetails, + settleListing, +} from '@blockydevs/arns-marketplace-data'; +import { Button } from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState, useWalletState } from '@src/state'; +import eventEmitter from '@src/utils/events'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +interface Props { + listing: ListingEnglishDetails; +} + +const EnglishListingSettlementSection = ({ listing }: Props) => { + const queryClient = useQueryClient(); + const [{ aoClient }] = useGlobalState(); + const [{ wallet, walletAddress }] = useWalletState(); + + const mutationSettleListing = useMutation({ + mutationFn: async ({ listingId }: { listingId: string }) => { + if (!wallet || !walletAddress) { + throw new Error('No wallet connected'); + } + + if (!wallet.contractSigner) { + throw new Error('No wallet signer available'); + } + + return await settleListing({ + ao: aoClient, + orderId: listingId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + signer: createAoSigner(wallet.contractSigner), + }); + }, + }); + + if (!walletAddress || listing.highestBidder !== walletAddress.toString()) { + return null; + } + + return ( + + ); +}; + +export default EnglishListingSettlementSection; diff --git a/src/components/pages/Listings/Details/FixedListingPriceSection.tsx b/src/components/pages/Listings/Details/FixedListingPriceSection.tsx new file mode 100644 index 000000000..a37587acf --- /dev/null +++ b/src/components/pages/Listings/Details/FixedListingPriceSection.tsx @@ -0,0 +1,63 @@ +import { ListingFixedDetails } from '@blockydevs/arns-marketplace-data'; +import { Button } from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { useWalletState } from '@src/state'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface Props { + listing: ListingFixedDetails; +} + +const FixedListingPriceSection = ({ listing }: Props) => { + const navigate = useNavigate(); + const [{ walletAddress }] = useWalletState(); + const queryAntsMetadata = useAntsMetadata(); + + const antMeta = queryAntsMetadata.data?.[listing.antProcessId]; + + const getLabel = () => { + if (!walletAddress) return 'No wallet'; + if (!antMeta) return 'No metadata'; + return antMeta.ownershipType === 'lease' ? 'Lease now' : 'Buy now'; + }; + + const navigateToConfirmPurchase = () => { + if (!antMeta) return; + + const orderId = listing.orderId; + const name = antMeta.name; + const antProcessId = listing.antProcessId; + const currentPrice = getCurrentListingArioPrice(listing); + + const params = new URLSearchParams({ + price: String(currentPrice), + type: 'fixed', + name, + antProcessId, + }).toString(); + navigate(`/listings/${orderId}/confirm-purchase?${params}`); + }; + + useEffect(() => { + if (antMeta) return; + + queryAntsMetadata.refetch(); + }, [antMeta, queryAntsMetadata]); + + return ( + + ); +}; + +export default FixedListingPriceSection; diff --git a/src/components/pages/Listings/Details/ListingBuyerSection.tsx b/src/components/pages/Listings/Details/ListingBuyerSection.tsx new file mode 100644 index 000000000..a7799da26 --- /dev/null +++ b/src/components/pages/Listings/Details/ListingBuyerSection.tsx @@ -0,0 +1,43 @@ +import { + Button, + Card, + Paragraph, + shortenAddress, +} from '@blockydevs/arns-marketplace-ui'; +import { useWalletState } from '@src/state'; +import { openAoLinkExplorer } from '@src/utils/marketplace'; +import { ExternalLink } from 'lucide-react'; + +interface Props { + buyerAddress: string; +} + +const ListingBuyerSection = ({ buyerAddress }: Props) => { + const [{ walletAddress }] = useWalletState(); + + return ( + + + Buyer + +
+ + + {buyerAddress === walletAddress?.toString() && '(Your wallet)'} + +
+
+ ); +}; + +export default ListingBuyerSection; diff --git a/src/components/pages/Listings/Details/ListingExpiredSection.tsx b/src/components/pages/Listings/Details/ListingExpiredSection.tsx new file mode 100644 index 000000000..a31015856 --- /dev/null +++ b/src/components/pages/Listings/Details/ListingExpiredSection.tsx @@ -0,0 +1,91 @@ +import { createAoSigner } from '@ar.io/sdk'; +import { + ListingDetails, + cancelListing, +} from '@blockydevs/arns-marketplace-data'; +import { Button } from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState, useWalletState } from '@src/state'; +import eventEmitter from '@src/utils/events'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +interface Props { + listing: Extract; +} + +const ListingExpiredSection = ({ listing }: Props) => { + const [{ aoClient }] = useGlobalState(); + const [{ wallet, walletAddress }] = useWalletState(); + const queryClient = useQueryClient(); + + const mutationCancelListing = useMutation({ + mutationFn: async ({ listingId }: { listingId: string }) => { + if (!wallet || !walletAddress) { + throw new Error('No wallet connected'); + } + + if (!wallet.contractSigner) { + throw new Error('No wallet signer available'); + } + + return await cancelListing({ + ao: aoClient, + orderId: listingId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + signer: createAoSigner(wallet.contractSigner), + }); + }, + }); + + // FIXME: order may be already cancelled + if (listing.sender !== walletAddress?.toString()) { + return null; + } + + return ( + + ); +}; + +export default ListingExpiredSection; diff --git a/src/components/pages/Listings/Details/ListingMetadata.tsx b/src/components/pages/Listings/Details/ListingMetadata.tsx new file mode 100644 index 000000000..a5a11cabd --- /dev/null +++ b/src/components/pages/Listings/Details/ListingMetadata.tsx @@ -0,0 +1,114 @@ +import { ListingDetails, marioToArio } from '@blockydevs/arns-marketplace-data'; +import { + Button, + Card, + DecreaseScheduleTable, + Header, + Paragraph, + Row, + Schedule, + formatDate, + getDutchListingSchedule, + shortenAddress, +} from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { openAoLinkExplorer } from '@src/utils/marketplace'; +import { ExternalLink } from 'lucide-react'; +import { useEffect } from 'react'; + +interface Props { + listing: ListingDetails; +} + +const ListingMetadata = ({ listing }: Props) => { + const queryAntsMetadata = useAntsMetadata(); + + const antMeta = queryAntsMetadata.data?.[listing.antProcessId]; + + const dutchPriceSchedule: Schedule[] = + listing.type === 'dutch' + ? getDutchListingSchedule({ + startingPrice: listing.startingPrice, + minimumPrice: listing.minimumPrice, + decreaseInterval: listing.decreaseInterval, + decreaseStep: listing.decreaseStep, + createdAt: new Date(listing.createdAt).getTime(), + endedAt: new Date( + 'endedAt' in listing ? listing.endedAt : listing.expiresAt, + ).getTime(), + }).map((item) => ({ + date: formatDate(item.date), + price: Number(marioToArio(item.price)), + })) + : []; + + useEffect(() => { + if (!antMeta) { + queryAntsMetadata.refetch(); + } + }, [antMeta, queryAntsMetadata]); + + if (!antMeta) { + return null; + } + + return ( +
+ +
+ {antMeta.name} +
+
+ {antMeta.ownershipType === 'lease' && !!antMeta.leaseEndsAt && ( + + + {formatDate(antMeta.leaseEndsAt)} + + + )} + + Metadata +
+ + + + + + +
+
+ {listing.type === 'dutch' && ( + + + Price decrease schedule + +
+ +
+
+ )} +
+ ); +}; + +export default ListingMetadata; diff --git a/src/components/pages/Listings/Details/ListingPriceSection.tsx b/src/components/pages/Listings/Details/ListingPriceSection.tsx new file mode 100644 index 000000000..730018283 --- /dev/null +++ b/src/components/pages/Listings/Details/ListingPriceSection.tsx @@ -0,0 +1,63 @@ +import { ListingDetails, marioToArio } from '@blockydevs/arns-marketplace-data'; +import { + Paragraph, + formatMillisecondsToDate, +} from '@blockydevs/arns-marketplace-ui'; + +import DutchListingPriceSection from './DutchListingPriceSection'; +import EnglishListingPriceSection from './EnglishListingPriceSection'; +import EnglishListingSettlementSection from './EnglishListingSettlementSection'; +import FixedListingPriceSection from './FixedListingPriceSection'; + +interface Props { + listing: ListingDetails; +} + +const ListingPriceSection = ({ listing }: Props) => { + switch (listing.type) { + case 'english': + return ( + <> + + Starting price: {marioToArio(listing.startingPrice)} ARIO + + {listing.status === 'active' && ( + + )} + {listing.status === 'ready-for-settlement' && ( + + )} + + ); + case 'dutch': + return ( + <> + + Starting price: {marioToArio(listing.startingPrice)} ARIO + + + Floor price: {marioToArio(listing.minimumPrice)} ARIO + + + Price decrease: every{' '} + {formatMillisecondsToDate(Number(listing.decreaseInterval))} + + {listing.status === 'active' && ( + + )} + + ); + case 'fixed': + return ( + <> + {listing.status === 'active' && ( + + )} + + ); + default: + return null; + } +}; + +export default ListingPriceSection; diff --git a/src/components/pages/Listings/Listings.tsx b/src/components/pages/Listings/Listings.tsx new file mode 100644 index 000000000..1b40dc5dc --- /dev/null +++ b/src/components/pages/Listings/Listings.tsx @@ -0,0 +1,37 @@ +import { + Header, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@blockydevs/arns-marketplace-ui'; +import ActiveListingsTab from '@src/components/pages/Listings/ActiveListingsTab'; +import CompletedListingsTab from '@src/components/pages/Listings/CompletedListingsTab'; +import SearchListingByName from '@src/components/pages/Listings/SearchListingByName'; + +const Listings = () => { + return ( +
+
+
+ ArNS Marketplace +
+ +
+ + + Active Listings + Completed Listings + + + + + + + + +
+ ); +}; + +export default Listings; diff --git a/src/components/pages/Listings/SearchListingByName.tsx b/src/components/pages/Listings/SearchListingByName.tsx new file mode 100644 index 000000000..5d7cff320 --- /dev/null +++ b/src/components/pages/Listings/SearchListingByName.tsx @@ -0,0 +1,97 @@ +import { searchANT } from '@blockydevs/arns-marketplace-data'; +import { + Button, + Paragraph, + SearchInput, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState } from '@src/state'; +import { lowerCaseDomain } from '@src/utils'; +import eventEmitter from '@src/utils/events'; +import { BLOCKYDEVS_MARKETPLACE_PROCESS_ID } from '@src/utils/marketplace'; +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const SearchListingByName = () => { + const [searchValue, setSearchValue] = useState(''); + const [{ aoClient, arioProcessId }] = useGlobalState(); + const navigate = useNavigate(); + const mutationSearch = useMutation({ + mutationFn: async (name: string) => { + return searchANT({ + name: lowerCaseDomain(name), + ao: aoClient, + arioProcessId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + }); + }, + onError: (error) => { + eventEmitter.emit('error', { + name: `Failed search for "${searchValue}"`, + message: error.message, + }); + }, + onSuccess: (data) => { + if (data.ant && data.listing) { + navigate(`/listings/${data.listing.orderId}`); + } + }, + }); + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!searchValue) return; + mutationSearch.mutate(searchValue); + }; + + const shouldRenderResult = + mutationSearch.isSuccess && !mutationSearch.data.listing; + + return ( +
+ { + setSearchValue(value); + mutationSearch.reset(); + }} + /> + {shouldRenderResult && ( +
+ {!!mutationSearch.data.ant && !mutationSearch.data.listing && ( + + Domain{' '} + {mutationSearch.variables} is + taken{' '} + + )} + {!mutationSearch.data.ant && ( + <> + + Domain{' '} + {mutationSearch.variables}{' '} + is available + + + + )} +
+ )} + + ); +}; + +export default SearchListingByName; diff --git a/src/components/pages/MyANTs/MyANTs.tsx b/src/components/pages/MyANTs/MyANTs.tsx new file mode 100644 index 000000000..fc5f28797 --- /dev/null +++ b/src/components/pages/MyANTs/MyANTs.tsx @@ -0,0 +1,108 @@ +import { fetchMyANTs, marioToArio } from '@blockydevs/arns-marketplace-data'; +import { + Card, + Header, + MyANTsTable, + OwnedDomain, + Spinner, + calculateCurrentDutchListingPrice, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState, useWalletState } from '@src/state'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +const MyANTs = () => { + const navigate = useNavigate(); + const [{ aoClient, arioProcessId }] = useGlobalState(); + const [{ walletAddress }] = useWalletState(); + const queryMyANTs = useQuery({ + enabled: !!walletAddress, + refetchInterval: 15 * 1000, + queryKey: marketplaceQueryKeys.myANTs.list(walletAddress?.toString()), + queryFn: () => { + if (!walletAddress) throw new Error('No wallet address'); + + return fetchMyANTs({ + walletAddress: walletAddress.toString(), + ao: aoClient, + arioProcessId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + }); + }, + select: (data) => { + return Object.values(data).map((domain): OwnedDomain => { + return { + name: domain.name, + action: () => { + if (domain.listing) { + navigate(`/listings/${domain.listing.orderId}`); + } else { + navigate( + `/my-ants/new-listing/${domain.processId}?name=${domain.name}`, + ); + } + }, + endDate: domain.listing + ? domain.listing?.expiresAt ?? undefined + : undefined, + price: domain.listing + ? { + type: domain.listing.type === 'english' ? 'bid' : 'buyout', + symbol: 'ARIO', + value: (() => { + const item = domain.listing; + const marioPrice = + item.type === 'english' + ? item.highestBid ?? item.startingPrice + : item.type === 'dutch' + ? calculateCurrentDutchListingPrice({ + startingPrice: item.startingPrice, + minimumPrice: item.minimumPrice, + decreaseInterval: item.decreaseInterval, + decreaseStep: item.decreaseStep, + createdAt: new Date(item.createdAt).getTime(), + endedAt: item.expiresAt + ? new Date(item.expiresAt).getTime() + : undefined, + }) + : item.price; + return Number(marioToArio(marioPrice)); + })(), + } + : undefined, + type: domain.listing + ? { + value: domain.listing?.type, + } + : undefined, + ownershipType: domain.type, + status: domain.listing ? 'listed' : 'idle', + }; + }); + }, + }); + + return ( +
+
+
+ My ANTs +
+ {queryMyANTs.isRefetching && } +
+ + + +
+ ); +}; + +export default MyANTs; diff --git a/src/components/pages/MyANTs/NewListing.tsx b/src/components/pages/MyANTs/NewListing.tsx new file mode 100644 index 000000000..ce40bf2ca --- /dev/null +++ b/src/components/pages/MyANTs/NewListing.tsx @@ -0,0 +1,519 @@ +import { createAoSigner } from '@ar.io/sdk'; +import { createListing } from '@blockydevs/arns-marketplace-data'; +import { + Button, + Card, + CheckboxWithLabel, + DatePicker, + GoBackHeader, + Input, + Label, + Row, + Select, + formatDate, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState, useWalletState } from '@src/state'; +import eventEmitter from '@src/utils/events'; +import '@src/utils/marketplace'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + DecreaseInterval, + Duration, + dutchDecreaseIntervalOptions, + dutchDurationOptions, + englishDurationOptions, + getMsFromDuration, + getMsFromInterval, + marketplaceQueryKeys, + mergeDateAndTime, +} from '@src/utils/marketplace'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addMilliseconds } from 'date-fns'; +import { useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; + +import { PriceScheduleModal } from './PriceScheduleModal'; + +type Step = 1 | 2 | 3; + +interface FormState { + type: string; + price: string; + minimumPrice: string; + duration: Duration | undefined; + decrease: DecreaseInterval | undefined; + hasExpirationTime: boolean; + date: Date | undefined; + time: string; +} + +const typeOptions = [ + { label: 'Fixed price', value: 'fixed' }, + { label: 'English auction', value: 'english' }, + { label: 'Dutch auction', value: 'dutch' }, +] as const; + +function MyANTsNewListing() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<1 | 2 | 3>(1); + const { antProcessId } = useParams(); + const [searchParams] = useSearchParams(); + const [{ antAoClient, arioProcessId }] = useGlobalState(); + const [{ wallet, walletAddress }] = useWalletState(); + const now = new Date(); + const [form, setForm] = useState({ + type: '', + price: '', + minimumPrice: '', + duration: undefined, + decrease: undefined, + hasExpirationTime: false, + date: now, + time: '12:00:00', + }); + + const mutation = useMutation({ + mutationFn: async () => { + if (!wallet || !walletAddress) { + throw new Error('No wallet connected'); + } + + if (!wallet.contractSigner) { + throw new Error('No wallet signer available'); + } + + if (!antProcessId) { + throw new Error('antProcessId is missing'); + } + + if (!form.price) { + throw new Error('No price specified'); + } + + if (!form.type) { + throw new Error('No type specified'); + } + + const selectedDateTime = + form.hasExpirationTime || form.duration === 'custom' + ? mergeDateAndTime(form.date, form.time) + : null; + + if (selectedDateTime && selectedDateTime.getTime() < Date.now()) { + throw new Error('Invalid date: cannot be in the past'); + } + + return await createListing({ + ao: antAoClient, + antProcessId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + arioProcessId, + waitForConfirmation: false, + config: (() => { + switch (form.type) { + case 'fixed': { + const expiresAt = form.hasExpirationTime + ? mergeDateAndTime(form.date, form.time)?.getTime() + : undefined; + + return { + type: form.type, + price: form.price.toString(), + expiresAt, + }; + } + case 'dutch': { + if (!form.minimumPrice) { + throw new Error('minimum price is missing'); + } + + if (!form.decrease) { + throw new Error('decrease interval is missing'); + } + + if (!form.duration) { + throw new Error('duration is missing'); + } + + const decreaseIntervalMs = getMsFromInterval(form.decrease); + const durationMs = getMsFromDuration( + form.duration, + form.date, + form.time, + ); + + return { + type: form.type, + price: form.price.toString(), + minimumPrice: form.minimumPrice.toString(), + decreaseInterval: decreaseIntervalMs.toString(), + ...(durationMs && { expiresAt: Date.now() + durationMs }), + }; + } + case 'english': { + if (!form.duration) { + throw new Error('duration is missing'); + } + + const durationMs = getMsFromDuration( + form.duration, + form.date, + form.time, + ); + + const expiresAt = durationMs + ? Date.now() + durationMs + : undefined; + + return { + type: form.type, + price: form.price.toString(), + expiresAt, + }; + } + default: { + throw new Error(`Unsupported listing type ${form.type}`); + } + } + })(), + walletAddress: walletAddress.toString(), + signer: createAoSigner(wallet.contractSigner), + }); + }, + }); + + const name = searchParams.get('name') ?? '-'; + const endDate = + form.duration === 'custom' + ? form.date + ? `${formatDate(form.date.toString(), 'yyyy-MM-dd')}T${form.time}` + : now.toISOString() + : addMilliseconds( + now, + form.duration ? getMsFromDuration(form.duration) : 0, + ).toISOString(); + + const renderProperGoBackHeader = (step: Step) => { + switch (step) { + case 1: + return ( + { + navigate('/my-ants'); + }} + /> + ); + case 2: + return ( + { + setStep(1); + }} + /> + ); + case 3: + return ( + + ); + } + }; + + const updateForm = ( + field: T, + value: FormState[T], + ) => { + setForm((state) => ({ + ...state, + [field]: value, + })); + }; + + return ( + <> + {renderProperGoBackHeader(step)} +
+ + + {step === 1 ? ( + <> +
+ + updateForm('price', e.target.value)} + value={form.price} + min={0} + label="Price" + suffix="ARIO" + type="number" + /> + {form.type === 'dutch' ? ( + <> + updateForm('minimumPrice', e.target.value)} + value={form.minimumPrice} + label="Minimum price (floor)" + suffix="ARIO" + type="number" + /> +
+ + + updateForm('decrease', value as DecreaseInterval) + } + /> + +
+ + ) : form.type === 'english' ? ( + <> +
+ +