diff --git a/components/Auction.tsx b/components/Auction.tsx new file mode 100644 index 0000000..f861d2f --- /dev/null +++ b/components/Auction.tsx @@ -0,0 +1,734 @@ +import { ExternalLinkIcon } from '@chakra-ui/icons'; +import { + Box, + Button, + Flex, + Grid, + Heading, + Image, + Link, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + Spinner, + Stack, + Text, + useDisclosure, + useToast, +} from '@chakra-ui/react'; +import { AuctionHouse } from '@zoralabs/zdk'; +import makeBlockie from 'ethereum-blockies-base64'; +import { BigNumberish, ethers } from 'ethers'; +import { DateTime } from 'luxon'; +import NextLink from 'next/link'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { convertSecondsToDay } from '../lib/helpers'; +import { SongMetadata } from '../lib/types'; +import { SongADay, SongADay__factory } from '../types'; +import { RESERVE_PRICE, SONGADAY_CONTRACT_ADDRESS, SONG_CHAIN_ID } from '../utils/constants'; +import { getSongAttributeValue } from '../utils/helpers'; +import { SUPPORTED_NETWORKS } from '../web3/constants'; +import fetchGraph from '../web3/fetchGraph'; +import { formatAddress, formatToken, parseTokenURI } from '../web3/helpers'; +import { useContract } from '../web3/hooks'; +import { useWallet } from '../web3/WalletContext'; +import AuctionSongCard from './AuctionSongCard'; +import Footer from './Footer'; +import PlaceBidModal from './modals/bids/PlaceBidModal'; + +const SONG_BY_NUMBER = ` +query SongByNumber($token: String) { + reserveAuctions( + first: 1, + where: {token: $token} + ) { + id + tokenId + transactionHash + approved + duration + expectedEndTimestamp + tokenOwner { + id + } + status + currentBid { + id + transactionHash + amount + bidder { + id + } + bidType + createdAtTimestamp + } + previousBids { + id + transactionHash + amount + bidder { + id + } + bidType + createdAtTimestamp + } + approvedTimestamp + createdAtTimestamp + finalizedAtTimestamp + } +} +`; + +const LATEST_SONG = ` +query Song($tokenContract: String) { + reserveAuctions( + first: 1, + where: {tokenContract: $tokenContract, approved: true}, + orderBy: approvedTimestamp, + orderDirection: desc + ) { + id + tokenId + transactionHash + approved + duration + expectedEndTimestamp + tokenOwner { + id + } + status + currentBid { + id + transactionHash + amount + bidder { + id + } + bidType + createdAtTimestamp + } + previousBids { + id + transactionHash + amount + bidder { + id + } + bidType + createdAtTimestamp + } + approvedTimestamp + createdAtTimestamp + finalizedAtTimestamp + } +} +`; + +type Bid = { + id: string; + transactionHash: string; + amount: string; + bidder: User; + bidType: string; + createdAtTimestamp: string; +}; + +type User = { + id: string; +}; + +type Song = { + id: string; + tokenId: string; + transactionHash: string; + approved: boolean; + duration: string; + expectedEndTimestamp: string; + tokenOwner: User; + status: string; + currentBid: Bid; + previousBids: Bid[]; + approvedTimestamp: string; + createdAtTimestamp: string; + finalizedAtTimestamp: string; +}; + +type SongData = { + reserveAuctions: Song[]; +}; + +const fetchSongFromSubgraph = async (songNbr?: string, latest?: boolean) => { + if (!latest && !songNbr) { + return undefined; + } + if (latest) { + const { data } = await fetchGraph( + SONG_CHAIN_ID, + LATEST_SONG, + { + tokenContract: SONGADAY_CONTRACT_ADDRESS, + }, + ); + return data.reserveAuctions[0]; + } else { + const { data } = await fetchGraph(SONG_CHAIN_ID, SONG_BY_NUMBER, { + token: `${SONGADAY_CONTRACT_ADDRESS}-${songNbr}`, + }); + return data.reserveAuctions[0]; + } +}; + +const Auction: React.FC<{ latest?: boolean }> = ({ latest }) => { + const toast = useToast(); + const { isOpen: isTxOpen, onOpen: onTxOpen, onClose: onTxClose } = useDisclosure(); + const { isOpen: isHistoryOpen, onOpen: onHistoryOpen, onClose: onHistoryClose } = useDisclosure(); + const [pendingTxHash, setPendingTxHash] = useState(undefined); + const format = (val) => `Ξ` + val; + const parse = (val) => val.replace(/^Ξ/, ''); + const [bidValue, setBidValue] = useState(undefined); + const router = useRouter(); + const { songNbr } = router.query; + + const [song, setSong] = useState(); + const [previousSong, setPreviousSong] = useState(); + const [nextSong, setNextSong] = useState(); + const [songMetadata, setSongMetadata] = useState(); + const [expired, setExpired] = useState(false); + const [finalised, setFinalised] = useState(false); + const [duration, setDuration] = useState<{ + days?: string; + hours?: string; + minutes?: string; + years?: string; + seconds?: string; + }>({}); + + const { contract: songContract } = useContract(SONGADAY_CONTRACT_ADDRESS, SongADay__factory, { + useStaticProvider: true, + }); + + const { provider, chainId, address } = useWallet(); + + const [auctionHouseContract, setAuctionHouseContract] = useState(); + useEffect(() => { + if (provider) { + const auctionHouseContract = new AuctionHouse(provider?.getSigner(), SONG_CHAIN_ID); + setAuctionHouseContract(auctionHouseContract); + } + }, [provider]); + + const createBid = async (auctionId: string, amount: BigNumberish) => { + if (!address) { + toast({ + status: 'error', + title: 'An error occurred', + description: 'Please connect to the wallet before placing the bid', + }); + return; + } + + if (chainId !== SONG_CHAIN_ID) { + toast({ + status: 'error', + title: 'An error occurred', + description: `You are not connected to the ${SUPPORTED_NETWORKS[SONG_CHAIN_ID].name}`, + }); + + return; + } + + try { + onTxOpen(); + const bidTx = await auctionHouseContract?.createBid(auctionId, amount); + setPendingTxHash(bidTx?.hash); + await bidTx?.wait(2); + toast({ + status: 'success', + title: 'Bid placed', + description: + 'Your bid will show up in the next few minutes. Please refresh the page after some time.', + }); + } catch (error) { + toast({ + status: 'error', + title: 'An error occurred', + description: error.error?.message ?? error.message ?? 'Could not place the bid', + }); + } finally { + onTxClose(); + setPendingTxHash(undefined); + } + }; + + const settleAuction = async (auctionId: string) => { + if (!address) { + toast({ + status: 'error', + title: 'An error occurred', + description: 'Please connect to the wallet before settling the bid', + }); + return; + } + + if (chainId !== SONG_CHAIN_ID) { + toast({ + status: 'error', + title: 'An error occurred', + description: `You are not connected to the ${SUPPORTED_NETWORKS[SONG_CHAIN_ID].name}`, + }); + + return; + } + + try { + onTxOpen(); + const bidTx = await auctionHouseContract?.endAuction(auctionId); + setPendingTxHash(bidTx?.hash); + await bidTx?.wait(2); + toast({ + status: 'success', + title: 'Auction Settled', + description: + 'The winner will be transfered the song, and the auction will be closed. Please refresh the page after some time.', + }); + } catch (error) { + toast({ + status: 'error', + title: 'An error occurred', + description: error.error?.message ?? error.message ?? 'Could not settle the auction', + }); + } finally { + onTxClose(); + setPendingTxHash(undefined); + } + }; + + const fetchSong = async (songNbr?: string, latest?: boolean) => { + try { + const _song = await fetchSongFromSubgraph(songNbr as string, latest); + setSong(_song); + console.log('song', _song); + const currentBid = formatToken(_song?.currentBid?.amount) ?? '0'; + setBidValue((Number(currentBid) * 1.05).toFixed(2) ?? RESERVE_PRICE); + + const tokenURI = await (songContract as SongADay)?.tokenURI(_song.tokenId); + const songMetadata = await fetchMetadata(tokenURI); + setSongMetadata(songMetadata); + } catch (error) { + console.log(error); + return null; + } + }; + + const fetchPreviousSong = async (songNbr) => { + // TODO: Get the previous most song instead of just decrementing + try { + const _song = await fetchSongFromSubgraph(songNbr as string, false); + if (_song.tokenId) { + setPreviousSong(_song); + } else { + setPreviousSong(undefined); + } + } catch (error) { + setPreviousSong(undefined); + return null; + } + }; + + const fetchNextSong = async (songNbr) => { + // TODO: Get the next most song instead of just incrementing + try { + const _song = await fetchSongFromSubgraph(songNbr as string, false); + if (_song.tokenId) { + setNextSong(_song); + } else { + setNextSong(undefined); + } + } catch (error) { + setNextSong(undefined); + return null; + } + }; + + useEffect(() => { + fetchSong((songNbr as string) ?? '', latest); + }, [songNbr ?? '', songContract?.address ?? '']); + + useEffect(() => { + const calculateDuration = () => { + const now = new Date().getTime(); // current datetime as milliseconds + // The duration of the auction comes from the first bid + const msDiff = song?.expectedEndTimestamp + ? Number(song?.expectedEndTimestamp) * 1000 - now + : (Number(song?.approvedTimestamp) + Number(song?.duration)) * 1000 - now; + + if (msDiff < 0 && !expired) { + setExpired(true); + } + + if (song?.finalizedAtTimestamp && !finalised) { + setFinalised(true); + } + const duration = convertSecondsToDay(msDiff / 1000); + setDuration(duration); + }; + + // Calculate first time + calculateDuration(); + + const interval = setInterval(() => { + calculateDuration(); + }, 1000); + return () => { + clearInterval(interval); + }; + }, [song]); + + useEffect(() => { + if (song?.tokenId) { + fetchPreviousSong((Number(song.tokenId) - 1).toString()); + fetchNextSong((Number(song.tokenId) + 1).toString()); + } + }, [song?.tokenId]); + + const fetchMetadata = async (tokenURI: string) => { + try { + const URI = parseTokenURI(tokenURI); + const response = await fetch(URI); + const songmeta = await response.json(); + return songmeta; + } catch (e) { + console.log('metaData fatch error', e); + } + }; + + const date = DateTime.fromFormat( + getSongAttributeValue(songMetadata?.attributes, 'Date') ?? '', + 'yyyy-MM-dd', + ); + + // TODO: handle previous bids when its the first bid + // TODO: handle when the auction has no bids + + const subtitleDateString = date.toLocaleString(DateTime.DATE_FULL); + + const sortedPreviousBids = song?.previousBids + ?.slice() + .sort((a, b) => { + return Number(b.createdAtTimestamp) - Number(a.createdAtTimestamp); + }) + .filter((bid) => bid.bidType !== 'Final'); // dont want final bids to be in previous bids as we already use final bid as current bid + + const currentBid = finalised + ? song?.previousBids?.find((bid) => bid.bidType === 'Final') + : song?.currentBid; + + return ( + <> + + + {songMetadata ? ( + + ) : ( + + )} + + + {songMetadata ? ( + + + {subtitleDateString} + + + + Song {Number(song.tokenId).toLocaleString()} + + + {previousSong && ( + + + + + + )} + {nextSong && ( + + + + → + + + + )} + + + + + + {finalised ? 'Winning Bid' : 'Current Bid'} + + Ξ {formatToken(currentBid?.amount) ?? '0'} + + + + {expired ? ( + <> + {finalised ? 'Winning Bidder' : 'Current Bidder'} + + + + {formatAddress(currentBid?.bidder.id)} + + + + ) : ( + <> + Ends In + + {duration.hours}h {duration.minutes}m {parseInt(duration.seconds)}s + + + )} + + + + {finalised ? null : expired ? ( + + + + ) : ( + + setBidValue(parse(valueString))} + value={format(bidValue)} + > + + + + + + + + + )} + + + + {currentBid && ( + + + + {formatAddress(currentBid?.bidder.id)} + + + + Ξ {formatToken(currentBid?.amount)} + + + + + + + + + )} + <> + {sortedPreviousBids.slice(0, 2).map((bid) => { + return ( + + + + {formatAddress(bid.bidder.id)} + + + + Ξ {formatToken(bid.amount)} + + + + + + + + + ); + })} + + + {currentBid && ( + + + + + + Bid History + + + + Song {Number(song.tokenId).toLocaleString()} + + + + {currentBid && ( + + + + {formatAddress(currentBid?.bidder.id)} + + + + Ξ {formatToken(currentBid?.amount)} + + + + + + + + + )} + <> + {sortedPreviousBids.map((bid) => { + return ( + + + + {formatAddress(bid.bidder.id)} + + + + Ξ {formatToken(bid.amount)} + + + + + + + + + ); + })} + + + + + + + + + + )} + + ) : ( + + )} + +