From 034797ef59bae842903f751660732bd1e2094ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Doma=C5=84ski?= Date: Fri, 26 Sep 2025 15:15:34 +0200 Subject: [PATCH 1/8] feat: arns marketplace ui --- package.json | 6 + src/App.tsx | 75 ++- .../cards/NavMenuCard/NavMenuCard.tsx | 11 +- .../layout/Navbar/NavGroup/NavGroup.tsx | 1 - .../pages/Listings/ActiveListingsTab.tsx | 90 +++ .../pages/Listings/CompletedListingsTab.tsx | 91 +++ src/components/pages/Listings/Confirm.tsx | 258 +++++++++ .../pages/Listings/Details/Details.tsx | 89 +++ .../Details/DutchListingPriceSection.tsx | 44 ++ .../Details/EnglishListingBidsSection.tsx | 52 ++ .../Details/EnglishListingPriceSection.tsx | 69 +++ .../EnglishListingSettlementSection.tsx | 90 +++ .../Details/FixedListingPriceSection.tsx | 40 ++ .../Listings/Details/ListingBuyerSection.tsx | 43 ++ .../Details/ListingExpiredSection.tsx | 91 +++ .../Listings/Details/ListingMetadata.tsx | 98 ++++ .../Listings/Details/ListingPriceSection.tsx | 63 +++ src/components/pages/Listings/Listings.tsx | 37 ++ .../pages/Listings/SearchListingByName.tsx | 94 ++++ src/components/pages/MyANTs/MyANTs.tsx | 109 ++++ src/components/pages/MyANTs/NewListing.tsx | 522 ++++++++++++++++++ .../pages/MyANTs/PriceScheduleModal.tsx | 86 +++ src/components/pages/index.ts | 2 + src/main.tsx | 3 +- src/utils/marketplace.ts | 194 +++++++ src/utils/routes.tsx | 18 +- vite.config.ts | 1 + yarn.lock | 276 ++++++++- 28 files changed, 2542 insertions(+), 11 deletions(-) create mode 100644 src/components/pages/Listings/ActiveListingsTab.tsx create mode 100644 src/components/pages/Listings/CompletedListingsTab.tsx create mode 100644 src/components/pages/Listings/Confirm.tsx create mode 100644 src/components/pages/Listings/Details/Details.tsx create mode 100644 src/components/pages/Listings/Details/DutchListingPriceSection.tsx create mode 100644 src/components/pages/Listings/Details/EnglishListingBidsSection.tsx create mode 100644 src/components/pages/Listings/Details/EnglishListingPriceSection.tsx create mode 100644 src/components/pages/Listings/Details/EnglishListingSettlementSection.tsx create mode 100644 src/components/pages/Listings/Details/FixedListingPriceSection.tsx create mode 100644 src/components/pages/Listings/Details/ListingBuyerSection.tsx create mode 100644 src/components/pages/Listings/Details/ListingExpiredSection.tsx create mode 100644 src/components/pages/Listings/Details/ListingMetadata.tsx create mode 100644 src/components/pages/Listings/Details/ListingPriceSection.tsx create mode 100644 src/components/pages/Listings/Listings.tsx create mode 100644 src/components/pages/Listings/SearchListingByName.tsx create mode 100644 src/components/pages/MyANTs/MyANTs.tsx create mode 100644 src/components/pages/MyANTs/NewListing.tsx create mode 100644 src/components/pages/MyANTs/PriceScheduleModal.tsx create mode 100644 src/utils/marketplace.ts diff --git a/package.json b/package.json index 995b7c8ce..b1cc5a30f 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.1.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..838bede09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { Logger } from '@ar.io/sdk/web'; +import '@blockydevs/arns-marketplace-ui/style.css'; import * as Sentry from '@sentry/react'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; @@ -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'), ); @@ -336,6 +349,66 @@ function App() { } /> + + } + > + + + } + /> + + } + > +
+ + } + /> + + } + > + + + } + /> + + } + > + + + } + /> + + } + > + + + } + /> What are ARIO tokens? - {' '} + + + + + My ANTs + + {!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..2b5d8c0a4 --- /dev/null +++ b/src/components/pages/Listings/ActiveListingsTab.tsx @@ -0,0 +1,90 @@ +import { fetchActiveListings } from '@blockydevs/arns-marketplace-data'; +import { + ActiveListingTable, + Card, + type Domain, + Pagination, + useCursorPagination, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState } from '@src/state'; +import { + BLOCKYDEVS_ACTIVITY_PROCESS_ID, + getCurrentListingArioPrice, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +const PAGE_SIZE = 20; + +const ActiveListingsTab = () => { + const navigate = useNavigate(); + const [{ aoClient }] = useGlobalState(); + const pagination = useCursorPagination(PAGE_SIZE); + + const queryActiveListings = useQuery({ + refetchInterval: 15 * 1000, + structuralSharing: false, + queryKey: marketplaceQueryKeys.listings.list('active', { + page: pagination.page, + pageSize: pagination.pageSize, + }), + queryFn: () => { + return fetchActiveListings({ + ao: aoClient, + activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, + limit: pagination.pageSize, + cursor: pagination.cursor, + }); + }, + select: (data) => { + pagination.storeNextCursor(data.nextCursor, !!data.hasMore); + + return { + ...data, + items: data.items.map((item): Domain => { + const currentPrice = getCurrentListingArioPrice(item); + + return { + name: item.name, + endDate: item.expiresAt ?? undefined, + ownershipType: item.ownershipType, + price: { + type: item.type === 'english' ? 'bid' : 'buyout', + symbol: 'ARIO', + value: Number(currentPrice), + }, + type: { + value: item.type, + }, + action: () => { + navigate(`/listings/${item.orderId}`); + }, + }; + }), + }; + }, + }); + + const { totalItems } = queryActiveListings.data ?? {}; + const totalPages = pagination.getTotalPages(totalItems); + + return ( + + + {!queryActiveListings.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..6ff65df8e --- /dev/null +++ b/src/components/pages/Listings/CompletedListingsTab.tsx @@ -0,0 +1,91 @@ +import { fetchCompletedListings } from '@blockydevs/arns-marketplace-data'; +import { + Card, + CompletedListingTable, + type Domain, + Pagination, + useCursorPagination, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState } from '@src/state'; +import { + BLOCKYDEVS_ACTIVITY_PROCESS_ID, + getCurrentListingArioPrice, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +const PAGE_SIZE = 20; + +const CompletedListingsTab = () => { + const navigate = useNavigate(); + const [{ aoClient }] = useGlobalState(); + const pagination = useCursorPagination(PAGE_SIZE); + + const queryCompletedListings = useQuery({ + refetchInterval: 15 * 1000, + enabled: Boolean(aoClient), + queryKey: marketplaceQueryKeys.listings.list('completed', { + page: pagination.page, + pageSize: pagination.pageSize, + }), + queryFn: () => { + return fetchCompletedListings({ + ao: aoClient, + activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, + limit: pagination.pageSize, + cursor: pagination.cursor, + }); + }, + select: (data) => { + pagination.storeNextCursor(data.nextCursor, !!data.hasMore); + + return { + ...data, + items: data.items.map((item): Domain => { + const currentPrice = getCurrentListingArioPrice(item); + + return { + name: item.name, + createdAt: item.createdAt, + endDate: item.endedAt, + ownershipType: item.ownershipType, + price: { + type: item.type === 'english' ? 'bid' : 'buyout', + symbol: 'ARIO', + value: Number(currentPrice), + }, + type: { + value: item.type, + }, + action: () => { + navigate(`/listings/${item.orderId}`); + }, + }; + }), + }; + }, + }); + + const { totalItems } = queryCompletedListings.data ?? {}; + const totalPages = pagination.getTotalPages(totalItems); + + return ( + + + {!queryCompletedListings.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..d64dd812b --- /dev/null +++ b/src/components/pages/Listings/Confirm.tsx @@ -0,0 +1,258 @@ +import { createAoSigner } from '@ar.io/sdk'; +import { bidListing, buyListing } from '@blockydevs/arns-marketplace-data'; +import { + Button, + Card, + GoBackHeader, + Paragraph, + Row, + Spinner, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState, useWalletState } from '@src/state'; +import eventEmitter from '@src/utils/events'; +import { + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + BLOCKYDEVS_SWAP_TOKEN_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { useMutation, 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 [{ antAoClient }] = useGlobalState(); + const [{ wallet, 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 = useMutation({ + mutationFn: async ({ price }: { price: string }) => { + 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 (!listingId) { + throw new Error('listingId is missing'); + } + + if (type !== 'fixed' && type !== 'dutch') { + throw new Error(`invalid listing type for buy: ${type}`); + } + + return await buyListing({ + ao: antAoClient, + orderId: listingId, + price, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + antTokenId: antProcessId, + swapTokenId: BLOCKYDEVS_SWAP_TOKEN_ID, + walletAddress: walletAddress.toString(), + signer: createAoSigner(wallet.contractSigner), + orderType: type, + }); + }, + }); + + const mutationBidListing = useMutation({ + mutationFn: async ({ price }: { price: string }) => { + 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 (!listingId) { + throw new Error('listingId is missing'); + } + + return await bidListing({ + ao: antAoClient, + orderId: listingId, + bidPrice: price, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + antTokenId: antProcessId, + swapTokenId: BLOCKYDEVS_SWAP_TOKEN_ID, + walletAddress: walletAddress.toString(), + signer: createAoSigner(wallet.contractSigner), + }); + }, + }); + + 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..5807fc954 --- /dev/null +++ b/src/components/pages/Listings/Details/Details.tsx @@ -0,0 +1,89 @@ +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 { useGlobalState, useWalletState } from '@src/state'; +import { + BLOCKYDEVS_ACTIVITY_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 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, + activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, + orderId: id, + }); + }, + }); + + if (queryDetails.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..434b1145d --- /dev/null +++ b/src/components/pages/Listings/Details/DutchListingPriceSection.tsx @@ -0,0 +1,44 @@ +import { ListingDutchDetails } from '@blockydevs/arns-marketplace-data'; +import { Button } from '@blockydevs/arns-marketplace-ui'; +import { useWalletState } from '@src/state'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { useNavigate } from 'react-router-dom'; + +interface Props { + listing: ListingDutchDetails; +} + +const DutchListingPriceSection = ({ listing }: Props) => { + const navigate = useNavigate(); + const [{ walletAddress }] = useWalletState(); + + const navigateToConfirmPurchase = () => { + const orderId = listing.orderId; + const name = listing.name; + const antProcessId = listing.antProcessId; + const currentPrice = getCurrentListingArioPrice(listing); + + navigate( + `/listings/${orderId}/confirm-purchase?price=${currentPrice}&type=dutch&name=${name}&antProcessId=${antProcessId}`, + ); + }; + + 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..b7cf50f57 --- /dev/null +++ b/src/components/pages/Listings/Details/EnglishListingPriceSection.tsx @@ -0,0 +1,69 @@ +import { ListingEnglishDetails } from '@blockydevs/arns-marketplace-data'; +import { Button, Input } from '@blockydevs/arns-marketplace-ui'; +import { useWalletState } from '@src/state'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { 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 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 = listing.name; + const antProcessId = listing.antProcessId; + + navigate( + `/listings/${orderId}/confirm-purchase?price=${bidPrice}&type=english&name=${name}&antProcessId=${antProcessId}`, + ); + }; + + 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..9a23ad0c0 --- /dev/null +++ b/src/components/pages/Listings/Details/FixedListingPriceSection.tsx @@ -0,0 +1,40 @@ +import { ListingFixedDetails } from '@blockydevs/arns-marketplace-data'; +import { Button } from '@blockydevs/arns-marketplace-ui'; +import { useWalletState } from '@src/state'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { useNavigate } from 'react-router-dom'; + +interface Props { + listing: ListingFixedDetails; +} + +const FixedListingPriceSection = ({ listing }: Props) => { + const navigate = useNavigate(); + const [{ walletAddress }] = useWalletState(); + + const navigateToConfirmPurchase = () => { + const orderId = listing.orderId; + const name = listing.name; + const antProcessId = listing.antProcessId; + const currentPrice = getCurrentListingArioPrice(listing); + + navigate( + `/listings/${orderId}/confirm-purchase?price=${currentPrice}&type=fixed&name=${name}&antProcessId=${antProcessId}`, + ); + }; + + 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..1cabd7e19 --- /dev/null +++ b/src/components/pages/Listings/Details/ListingMetadata.tsx @@ -0,0 +1,98 @@ +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 { openAoLinkExplorer } from '@src/utils/marketplace'; +import { ExternalLink } from 'lucide-react'; + +interface Props { + listing: ListingDetails; +} + +const ListingMetadata = ({ listing }: Props) => { + 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)), + })) + : []; + + return ( +
+ +
+ {listing.name} +
+
+ {listing.ownershipType === 'lease' && !!listing.leaseEndsAt && ( + + + {formatDate(listing.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..796887aa1 --- /dev/null +++ b/src/components/pages/Listings/SearchListingByName.tsx @@ -0,0 +1,94 @@ +import { searchANT } from '@blockydevs/arns-marketplace-data'; +import { + Button, + Paragraph, + SearchInput, +} from '@blockydevs/arns-marketplace-ui'; +import { useGlobalState } from '@src/state'; +import eventEmitter from '@src/utils/events'; +import { BLOCKYDEVS_ACTIVITY_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, + ao: aoClient, + networkProcessId: arioProcessId, + activityProcessId: BLOCKYDEVS_ACTIVITY_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..89514d706 --- /dev/null +++ b/src/components/pages/MyANTs/MyANTs.tsx @@ -0,0 +1,109 @@ +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_ACTIVITY_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, aoNetwork, 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, + networkProcessId: arioProcessId, + activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, + graphqlUrl: aoNetwork.ANT.GRAPHQL_URL, + }); + }, + 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..85df91b4f --- /dev/null +++ b/src/components/pages/MyANTs/NewListing.tsx @@ -0,0 +1,522 @@ +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_ACTIVITY_PROCESS_ID, + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + BLOCKYDEVS_SWAP_TOKEN_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 }] = 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, + activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + swapTokenId: BLOCKYDEVS_SWAP_TOKEN_ID, + 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' ? ( + <> +
+ + { className="w-full" disabled={ !walletAddress || + !antMeta || bidPrice === undefined || // if highest bid exists, bid must be strictly greater than it // if no bids yet, bid must be at least equal to starting price diff --git a/src/components/pages/Listings/Details/FixedListingPriceSection.tsx b/src/components/pages/Listings/Details/FixedListingPriceSection.tsx index fde099c6c..a37587acf 100644 --- a/src/components/pages/Listings/Details/FixedListingPriceSection.tsx +++ b/src/components/pages/Listings/Details/FixedListingPriceSection.tsx @@ -1,7 +1,9 @@ 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 { @@ -11,10 +13,21 @@ interface Props { 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 = listing.name; + const name = antMeta.name; const antProcessId = listing.antProcessId; const currentPrice = getCurrentListingArioPrice(listing); @@ -27,16 +40,22 @@ const FixedListingPriceSection = ({ listing }: Props) => { navigate(`/listings/${orderId}/confirm-purchase?${params}`); }; + useEffect(() => { + if (antMeta) return; + + queryAntsMetadata.refetch(); + }, [antMeta, queryAntsMetadata]); + return ( ); }; diff --git a/src/components/pages/Listings/Details/ListingMetadata.tsx b/src/components/pages/Listings/Details/ListingMetadata.tsx index 1cabd7e19..a5a11cabd 100644 --- a/src/components/pages/Listings/Details/ListingMetadata.tsx +++ b/src/components/pages/Listings/Details/ListingMetadata.tsx @@ -11,14 +11,20 @@ import { 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({ @@ -36,17 +42,27 @@ const ListingMetadata = ({ listing }: Props) => { })) : []; + useEffect(() => { + if (!antMeta) { + queryAntsMetadata.refetch(); + } + }, [antMeta, queryAntsMetadata]); + + if (!antMeta) { + return null; + } + return (
- {listing.name} + {antMeta.name}
- {listing.ownershipType === 'lease' && !!listing.leaseEndsAt && ( + {antMeta.ownershipType === 'lease' && !!antMeta.leaseEndsAt && ( - {formatDate(listing.leaseEndsAt)} + {formatDate(antMeta.leaseEndsAt)} )} diff --git a/src/components/pages/Listings/SearchListingByName.tsx b/src/components/pages/Listings/SearchListingByName.tsx index 5557ebae8..5d7cff320 100644 --- a/src/components/pages/Listings/SearchListingByName.tsx +++ b/src/components/pages/Listings/SearchListingByName.tsx @@ -7,7 +7,7 @@ import { import { useGlobalState } from '@src/state'; import { lowerCaseDomain } from '@src/utils'; import eventEmitter from '@src/utils/events'; -import { BLOCKYDEVS_ACTIVITY_PROCESS_ID } from '@src/utils/marketplace'; +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'; @@ -21,8 +21,8 @@ const SearchListingByName = () => { return searchANT({ name: lowerCaseDomain(name), ao: aoClient, - networkProcessId: arioProcessId, - activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, + arioProcessId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, }); }, onError: (error) => { diff --git a/src/components/pages/MyANTs/MyANTs.tsx b/src/components/pages/MyANTs/MyANTs.tsx index 89514d706..fc5f28797 100644 --- a/src/components/pages/MyANTs/MyANTs.tsx +++ b/src/components/pages/MyANTs/MyANTs.tsx @@ -9,7 +9,7 @@ import { } from '@blockydevs/arns-marketplace-ui'; import { useGlobalState, useWalletState } from '@src/state'; import { - BLOCKYDEVS_ACTIVITY_PROCESS_ID, + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, marketplaceQueryKeys, } from '@src/utils/marketplace'; import { useQuery } from '@tanstack/react-query'; @@ -17,7 +17,7 @@ import { useNavigate } from 'react-router-dom'; const MyANTs = () => { const navigate = useNavigate(); - const [{ aoClient, aoNetwork, arioProcessId }] = useGlobalState(); + const [{ aoClient, arioProcessId }] = useGlobalState(); const [{ walletAddress }] = useWalletState(); const queryMyANTs = useQuery({ enabled: !!walletAddress, @@ -29,9 +29,8 @@ const MyANTs = () => { return fetchMyANTs({ walletAddress: walletAddress.toString(), ao: aoClient, - networkProcessId: arioProcessId, - activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, - graphqlUrl: aoNetwork.ANT.GRAPHQL_URL, + arioProcessId, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, }); }, select: (data) => { diff --git a/src/components/pages/MyANTs/NewListing.tsx b/src/components/pages/MyANTs/NewListing.tsx index 85df91b4f..ce40bf2ca 100644 --- a/src/components/pages/MyANTs/NewListing.tsx +++ b/src/components/pages/MyANTs/NewListing.tsx @@ -16,9 +16,7 @@ import { useGlobalState, useWalletState } from '@src/state'; import eventEmitter from '@src/utils/events'; import '@src/utils/marketplace'; import { - BLOCKYDEVS_ACTIVITY_PROCESS_ID, BLOCKYDEVS_MARKETPLACE_PROCESS_ID, - BLOCKYDEVS_SWAP_TOKEN_ID, DecreaseInterval, Duration, dutchDecreaseIntervalOptions, @@ -62,7 +60,7 @@ function MyANTsNewListing() { const [step, setStep] = useState<1 | 2 | 3>(1); const { antProcessId } = useParams(); const [searchParams] = useSearchParams(); - const [{ antAoClient }] = useGlobalState(); + const [{ antAoClient, arioProcessId }] = useGlobalState(); const [{ wallet, walletAddress }] = useWalletState(); const now = new Date(); const [form, setForm] = useState({ @@ -110,9 +108,8 @@ function MyANTsNewListing() { return await createListing({ ao: antAoClient, antProcessId, - activityProcessId: BLOCKYDEVS_ACTIVITY_PROCESS_ID, marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, - swapTokenId: BLOCKYDEVS_SWAP_TOKEN_ID, + arioProcessId, waitForConfirmation: false, config: (() => { switch (form.type) { diff --git a/src/hooks/listings/useAntsMetadata.ts b/src/hooks/listings/useAntsMetadata.ts new file mode 100644 index 000000000..10ebe3cd7 --- /dev/null +++ b/src/hooks/listings/useAntsMetadata.ts @@ -0,0 +1,107 @@ +import { AoClient } from '@ar.io/sdk'; +import { + FetchANTsMetadataResult, + fetchANTsMetadata, + fetchAllAntsFromActivity, +} from '@blockydevs/arns-marketplace-data'; +import { useGlobalState } from '@src/state'; +import { + BLOCKYDEVS_MARKETPLACE_METADATA_STORAGE_KEY, + BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + marketplaceQueryKeys, +} from '@src/utils/marketplace'; +import { + keepPreviousData, + queryOptions, + useQuery, +} from '@tanstack/react-query'; +import { useCallback, useRef } from 'react'; + +const readStorage = (): FetchANTsMetadataResult | null => { + try { + const stored = localStorage.getItem( + BLOCKYDEVS_MARKETPLACE_METADATA_STORAGE_KEY, + ); + if (!stored) return null; + + const parsed = JSON.parse(stored); + return parsed.data; + } catch (error) { + console.warn('Failed to parse marketplace metadata:', error); + return null; + } +}; + +const updateStorage = (data: FetchANTsMetadataResult) => { + try { + localStorage.setItem( + BLOCKYDEVS_MARKETPLACE_METADATA_STORAGE_KEY, + JSON.stringify({ timestamp: Date.now(), data }), + ); + } catch (error) { + console.warn('Failed to marketplace metadata:', error); + } +}; + +export const antsMetadataQueryOptions = ({ + aoClient, + arioProcessId, +}: { + aoClient: AoClient; + arioProcessId: string; +}) => { + return queryOptions({ + queryKey: [marketplaceQueryKeys.metadata.all], + initialData: () => readStorage() ?? undefined, + queryFn: async () => { + const antIds = await fetchAllAntsFromActivity({ + ao: aoClient, + marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + }); + + const result = await fetchANTsMetadata({ + ao: aoClient, + arioProcessId, + antIds, + }); + + updateStorage(result); + + return result; + }, + }); +}; + +export const useAntsMetadata = () => { + const [{ aoClient, arioProcessId }] = useGlobalState(); + const inFlightRef = useRef(false); + + const initialData = readStorage(); + + // refetching should happen only when there's no initial data or user has encountered ANT without metadata + const query = useQuery({ + ...antsMetadataQueryOptions({ aoClient, arioProcessId }), + refetchInterval: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + staleTime: initialData ? Infinity : 0, + placeholderData: keepPreviousData, + }); + + const deduplicatedRefetch = useCallback(async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + await query.refetch(); + } finally { + inFlightRef.current = false; + } + }, [query]); + + return { + data: query.data ?? {}, + isPending: query.isPending, + refetch: deduplicatedRefetch, + }; +}; diff --git a/src/hooks/listings/useBidListing.tsx b/src/hooks/listings/useBidListing.tsx index 1ae2e2d49..236a248cd 100644 --- a/src/hooks/listings/useBidListing.tsx +++ b/src/hooks/listings/useBidListing.tsx @@ -1,10 +1,7 @@ import { createAoSigner } from '@ar.io/sdk'; import { bidListing } from '@blockydevs/arns-marketplace-data'; import { useGlobalState, useWalletState } from '@src/state'; -import { - BLOCKYDEVS_MARKETPLACE_PROCESS_ID, - BLOCKYDEVS_SWAP_TOKEN_ID, -} from '@src/utils/marketplace'; +import { BLOCKYDEVS_MARKETPLACE_PROCESS_ID } from '@src/utils/marketplace'; import { useMutation } from '@tanstack/react-query'; export const useBidListing = ( @@ -12,7 +9,7 @@ export const useBidListing = ( listingId: string | undefined, ) => { const [{ wallet, walletAddress }] = useWalletState(); - const [{ antAoClient }] = useGlobalState(); + const [{ antAoClient, arioProcessId }] = useGlobalState(); return useMutation({ mutationFn: async ({ price }: { price: string }) => { @@ -37,8 +34,8 @@ export const useBidListing = ( orderId: listingId, bidPrice: price, marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + arioProcessId, antTokenId: antProcessId, - swapTokenId: BLOCKYDEVS_SWAP_TOKEN_ID, walletAddress: walletAddress.toString(), signer: createAoSigner(wallet.contractSigner), }); diff --git a/src/hooks/listings/useBuyListing.tsx b/src/hooks/listings/useBuyListing.tsx index 92c1a4bc7..8c310cb4d 100644 --- a/src/hooks/listings/useBuyListing.tsx +++ b/src/hooks/listings/useBuyListing.tsx @@ -1,10 +1,7 @@ import { createAoSigner } from '@ar.io/sdk'; import { buyListing } from '@blockydevs/arns-marketplace-data'; import { useGlobalState, useWalletState } from '@src/state'; -import { - BLOCKYDEVS_MARKETPLACE_PROCESS_ID, - BLOCKYDEVS_SWAP_TOKEN_ID, -} from '@src/utils/marketplace'; +import { BLOCKYDEVS_MARKETPLACE_PROCESS_ID } from '@src/utils/marketplace'; import { useMutation } from '@tanstack/react-query'; export const useBuyListing = ( @@ -13,7 +10,7 @@ export const useBuyListing = ( type: string | null, ) => { const [{ wallet, walletAddress }] = useWalletState(); - const [{ antAoClient }] = useGlobalState(); + const [{ antAoClient, arioProcessId }] = useGlobalState(); return useMutation({ mutationFn: async ({ price }: { price: string }) => { @@ -42,8 +39,8 @@ export const useBuyListing = ( orderId: listingId, price, marketplaceProcessId: BLOCKYDEVS_MARKETPLACE_PROCESS_ID, + arioProcessId, antTokenId: antProcessId, - swapTokenId: BLOCKYDEVS_SWAP_TOKEN_ID, walletAddress: walletAddress.toString(), signer: createAoSigner(wallet.contractSigner), orderType: type, diff --git a/src/hooks/listings/usePrefetchMarketplaceData.ts b/src/hooks/listings/usePrefetchMarketplaceData.ts new file mode 100644 index 000000000..9f5b62f5f --- /dev/null +++ b/src/hooks/listings/usePrefetchMarketplaceData.ts @@ -0,0 +1,26 @@ +import { antsMetadataQueryOptions } from '@src/hooks/listings/useAntsMetadata'; +import { useGlobalState } from '@src/state'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +export const usePrefetchMarketplaceData = () => { + const [{ aoClient, arioProcessId }] = useGlobalState(); + const queryClient = useQueryClient(); + + const queryOptions = useMemo( + () => + antsMetadataQueryOptions({ + aoClient, + arioProcessId, + }), + [aoClient, arioProcessId], + ); + + const hasInitialData = !!queryOptions.initialData?.(); + + // prefetch marketplace data on app load, but only if we don't have it already stored + useEffect(() => { + if (hasInitialData) return; + void queryClient.prefetchQuery(queryOptions); + }, [queryClient, queryOptions, hasInitialData]); +}; diff --git a/src/hooks/listings/usePrepareListings.ts b/src/hooks/listings/usePrepareListings.ts new file mode 100644 index 000000000..4366b7b50 --- /dev/null +++ b/src/hooks/listings/usePrepareListings.ts @@ -0,0 +1,62 @@ +import type { + FetchActiveListingsResult, + FetchCompletedListingsResult, +} from '@blockydevs/arns-marketplace-data'; +import type { Domain } from '@blockydevs/arns-marketplace-ui'; +import { useAntsMetadata } from '@src/hooks/listings/useAntsMetadata'; +import { getCurrentListingArioPrice } from '@src/utils/marketplace'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export const usePrepareListings = ( + queryData: + | FetchActiveListingsResult + | FetchCompletedListingsResult + | undefined, +) => { + const navigate = useNavigate(); + const queryAntsMetadata = useAntsMetadata(); + + const preparedItems = (queryData?.items ?? []) + .filter((item) => !!queryAntsMetadata.data[item.antProcessId]) + .map((item): Domain & { antId: string } => { + const currentPrice = getCurrentListingArioPrice(item); + const antMeta = queryAntsMetadata.data[item.antProcessId]; + const isCompleted = 'endedAt' in item; + + return { + antId: item.antProcessId, + name: antMeta.name, + ownershipType: antMeta.ownershipType, + price: { + type: item.type === 'english' ? 'bid' : 'buyout', + symbol: 'ARIO', + value: Number(currentPrice), + }, + type: { + value: item.type, + }, + action: () => { + navigate(`/listings/${item.orderId}`); + }, + ...(isCompleted && { + createdAt: item.createdAt, + endDate: item.endedAt, + }), + ...(!isCompleted && { + endDate: item.expiresAt ?? undefined, + }), + }; + }); + + const hasMissingMetadata = queryData?.items.some( + (item) => !queryAntsMetadata.data[item.antProcessId], + ); + + useEffect(() => { + if (!hasMissingMetadata) return; + queryAntsMetadata.refetch(); + }, [hasMissingMetadata, queryAntsMetadata]); + + return preparedItems; +}; diff --git a/src/utils/marketplace.ts b/src/utils/marketplace.ts index e4e05be04..29c26f930 100644 --- a/src/utils/marketplace.ts +++ b/src/utils/marketplace.ts @@ -12,7 +12,6 @@ export type DecreaseInterval = (typeof dutchDecreaseIntervalOptions)[number]['value']; export const englishDurationOptions = [ - { label: '1 hour', value: '1h' }, // FIXME: remove test code { label: '1 day', value: '1d' }, { label: '7 days', value: '7d' }, { label: '30 days', value: '30d' }, @@ -20,7 +19,6 @@ export const englishDurationOptions = [ ] as const; export const dutchDurationOptions = [ - { label: '1 hour', value: '1h' }, // FIXME: remove test code { label: '1 day', value: '1d' }, { label: '5 days', value: '5d' }, { label: '7 days', value: '7d' }, @@ -29,7 +27,6 @@ export const dutchDurationOptions = [ ] as const; export const dutchDecreaseIntervalOptions = [ - { label: '5 minutes', value: '5m' }, // FIXME: remove test code { label: '4 hours', value: '4h' }, { label: '8 hours', value: '8h' }, { label: '12 hours', value: '12h' }, @@ -54,9 +51,6 @@ export const mergeDateAndTime = ( export const getMsFromInterval = (interval: DecreaseInterval | undefined) => { switch (interval) { - case '5m': { - return 5 * 60 * 1000; - } case '4h': { return 4 * oneHourMs; } @@ -81,9 +75,6 @@ export const getMsFromDuration = ( time?: string, ) => { switch (duration) { - case '1h': { - return oneHourMs; - } case '1d': { return 1 * 24 * oneHourMs; } @@ -158,14 +149,15 @@ export const openAoLinkExplorer = (address: string) => { ); }; -export const BLOCKYDEVS_ACTIVITY_PROCESS_ID = - 'Jj8LhgFLmCE_BAMys_zoTDRx8eYXsSl3-BMBIov8n9E'; +export const BLOCKYDEVS_MARKETPLACE_METADATA_STORAGE_KEY = + 'marketplace-ants-metadata'; export const BLOCKYDEVS_MARKETPLACE_PROCESS_ID = - 'a3jqBgXGAqefY4EHqkMwXhkBSFxZfzVdLU1oMUTQ-1M'; -export const BLOCKYDEVS_SWAP_TOKEN_ID = - 'agYcCFJtrMG6cqMuZfskIkFTGvUPddICmtQSBIoPdiA'; + 'iFLRI3mfcFMrhIAjmXAwhoF65bsKy32e47hd0MGW45M'; export const AO_LINK_EXPLORER_URL = 'https://ao.link/#/entity'; export const marketplaceQueryKeys = { + metadata: { + all: 'marketplace-metadata', + }, myANTs: { all: 'my-ants', list: (walletAddress: string | undefined) => [ diff --git a/yarn.lock b/yarn.lock index 0959b2305..95347bd51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1506,10 +1506,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@blockydevs/arns-marketplace-data@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@blockydevs/arns-marketplace-data/-/arns-marketplace-data-0.1.0.tgz#42c131a5c156777c0d64bf5b3c3d2891743a15a7" - integrity sha512-/nLRCNE24/UgnoulqdHvSCXNbmZsA+yGd8knWLFLmTkTw/AMQIGbTBL3KFsaak4lKLi4oC3euhbvuCq3Qe1C8w== +"@blockydevs/arns-marketplace-data@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@blockydevs/arns-marketplace-data/-/arns-marketplace-data-0.2.0.tgz#ca925eb509ed02f61c964b3ec5edb9144a6650e7" + integrity sha512-/4slccX+bWsHCD0WTiyn3YvDVo8J86+IKcRw74Npegiu90Qst5LHsfOT903J3BOrvDPHhX6VDWMPZN7/FjqaFQ== "@blockydevs/arns-marketplace-ui@^0.1.0": version "0.1.0"