diff --git a/src/nft/hooks/useBag.ts b/src/nft/hooks/useBag.ts index 8b07d3548e..2c3d5b151e 100644 --- a/src/nft/hooks/useBag.ts +++ b/src/nft/hooks/useBag.ts @@ -1,22 +1,61 @@ -import { BagItem } from 'nft/types' +import { v4 as uuidv4 } from 'uuid' import create from 'zustand' import { devtools } from 'zustand/middleware' -interface BagState { +import { BagItem, BagItemStatus, BagStatus, UpdatedGenieAsset } from '../types' + +type BagState = { itemsInBag: BagItem[] + addAssetToBag: (asset: UpdatedGenieAsset) => void + removeAssetFromBag: (asset: UpdatedGenieAsset) => void + isLocked: boolean + setLocked: (isLocked: boolean) => void bagExpanded: boolean toggleBag: () => void } export const useBag = create()( devtools( - (set) => ({ + (set, get) => ({ bagExpanded: false, - itemsInBag: [], toggleBag: () => set(({ bagExpanded }) => ({ bagExpanded: !bagExpanded, })), + isLocked: false, + setLocked: (_isLocked) => + set(() => ({ + isLocked: _isLocked, + })), + itemsInBag: [], + addAssetToBag: (asset) => + set(({ itemsInBag }) => { + if (get().isLocked) return { itemsInBag } + const assetWithId = { asset: { id: uuidv4(), ...asset }, status: BagItemStatus.ADDED_TO_BAG } + if (itemsInBag.length === 0) + return { + itemsInBag: [assetWithId], + bagStatus: BagStatus.ADDING_TO_BAG, + } + else + return { + itemsInBag: [...itemsInBag, assetWithId], + bagStatus: BagStatus.ADDING_TO_BAG, + } + }), + removeAssetFromBag: (asset) => { + set(({ itemsInBag }) => { + if (get().isLocked) return { itemsInBag } + if (itemsInBag.length === 0) return { itemsInBag: [] } + const itemsCopy = [...itemsInBag] + const index = itemsCopy.findIndex((n) => + asset.id ? n.asset.id === asset.id : n.asset.tokenId === asset.tokenId && n.asset.address === asset.address + ) + if (index === -1) return { itemsInBag } + itemsCopy.splice(index, 1) + return { itemsInBag: itemsCopy } + }) + }, }), { name: 'useBag' } ) diff --git a/src/nft/hooks/useTimeout.ts b/src/nft/hooks/useTimeout.ts new file mode 100644 index 0000000000..5690e2d747 --- /dev/null +++ b/src/nft/hooks/useTimeout.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' + +const MINUTE = 1000 * 60 +const HOUR = MINUTE * 60 +const DAY = 24 * HOUR + +const getReturnValues = (countDown: number): [number, number, number, number] => { + // calculate time left + const days = Math.floor(countDown / DAY) + const hours = Math.floor((countDown % DAY) / HOUR) + const minutes = Math.floor((countDown % HOUR) / MINUTE) + const seconds = Math.floor((countDown % MINUTE) / 1000) + + return [days, hours, minutes, seconds] +} + +export const useTimeout = (targetDate: Date) => { + const countDownDate = new Date(targetDate).getTime() + + const [countDown, setCountDown] = useState(countDownDate - new Date().getTime()) + + useEffect(() => { + const interval = setInterval(() => { + setCountDown(countDownDate - new Date().getTime()) + }, 1000) + + return () => clearInterval(interval) + }, [countDownDate]) + + return getReturnValues(countDown) +} diff --git a/src/nft/pages/asset/Asset.css.ts b/src/nft/pages/asset/Asset.css.ts index 304b2be70e..c3bc54e809 100644 --- a/src/nft/pages/asset/Asset.css.ts +++ b/src/nft/pages/asset/Asset.css.ts @@ -35,7 +35,7 @@ export const marketplace = sprinkles({ borderRadius: '4' }) export const tab = style([ subhead, - sprinkles({ color: 'darkGray', border: 'none', padding: '0', background: 'transparent' }), + sprinkles({ color: 'darkGray', border: 'none', padding: '0', background: 'transparent', cursor: 'pointer' }), { selectors: { '&[data-active="true"]': { diff --git a/src/nft/pages/asset/Asset.tsx b/src/nft/pages/asset/Asset.tsx index 567e48e9aa..405f47adae 100644 --- a/src/nft/pages/asset/Asset.tsx +++ b/src/nft/pages/asset/Asset.tsx @@ -1,3 +1,4 @@ +import { useWeb3React } from '@web3-react/core' import clsx from 'clsx' import useENSName from 'hooks/useENSName' import qs from 'query-string' @@ -7,22 +8,27 @@ import { useQuery } from 'react-query' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { useSpring } from 'react-spring/web' +import { MouseoverTooltip } from '../../../components/Tooltip/index' import { AnimatedBox, Box } from '../../components/Box' import { CollectionProfile } from '../../components/details/CollectionProfile' import { Details } from '../../components/details/Details' import { Traits } from '../../components/details/Traits' import { Center, Column, Row } from '../../components/Flex' -import { CloseDropDownIcon, CornerDownLeftIcon, ShareIcon } from '../../components/icons' +import { CloseDropDownIcon, CornerDownLeftIcon, Eth2Icon, ShareIcon, SuspiciousIcon } from '../../components/icons' import { ExpandableText } from '../../components/layout/ExpandableText' -import { header2 } from '../../css/common.css' +import { badge, bodySmall, caption, header2, subhead } from '../../css/common.css' import { themeVars } from '../../css/sprinkles.css' import { useBag } from '../../hooks' +import { useTimeout } from '../../hooks/useTimeout' import { fetchSingleAsset } from '../../queries' -import { CollectionInfoForAsset, GenieAsset } from '../../types' +import { CollectionInfoForAsset, GenieAsset, SellOrder } from '../../types' import { shortenAddress } from '../../utils/address' +import { formatEthPrice } from '../../utils/currency' +import { isAssetOwnedByUser } from '../../utils/isAssetOwnedByUser' import { isAudio } from '../../utils/isAudio' import { isVideo } from '../../utils/isVideo' -import { rarityProviderLogo } from '../../utils/rarity' +import { fallbackProvider, rarityProviderLogo } from '../../utils/rarity' +import { toSignificant } from '../../utils/toSignificant' import * as styles from './Asset.css' const AudioPlayer = ({ @@ -49,6 +55,28 @@ const AudioPlayer = ({ ) } +const formatter = Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short' }) + +const CountdownTimer = ({ sellOrder }: { sellOrder: SellOrder }) => { + const { date, expires } = useMemo(() => { + const date = new Date(sellOrder.orderClosingDate) + return { + date, + expires: formatter.format(date), + } + }, [sellOrder]) + const [days, hours, minutes, seconds] = useTimeout(date) + + return ( + Expires {expires}}> + + Expires: {days !== 0 ? `${days} days` : ''} {hours !== 0 ? `${hours} hours` : ''} {minutes} minutes {seconds}{' '} + seconds + + + ) +} + const AssetView = ({ mediaType, asset, @@ -85,6 +113,9 @@ const Asset = () => { ) const { pathname, search } = useLocation() const navigate = useNavigate() + const addAssetToBag = useBag((state) => state.addAssetToBag) + const removeAssetFromBag = useBag((state) => state.removeAssetFromBag) + const itemsInBag = useBag((state) => state.itemsInBag) const bagExpanded = useBag((state) => state.bagExpanded) const [creatorAddress, setCreatorAddress] = useState('') const [ownerAddress, setOwnerAddress] = useState('') @@ -98,13 +129,11 @@ const Asset = () => { gridWidthOffset: bagExpanded ? 324 : 0, }) const [showTraits, setShowTraits] = useState(true) + const [isSelected, setSelected] = useState(false) + const [isOwned, setIsOwned] = useState(false) + const { account: address, provider } = useWeb3React() - useEffect(() => { - if (asset.creator) setCreatorAddress(asset.creator.address) - if (asset.owner) setOwnerAddress(asset.owner) - }, [asset]) - - const { rarityProvider } = useMemo( + const { rarityProvider, rarityLogo } = useMemo( () => asset.rarity ? { @@ -126,6 +155,29 @@ const Asset = () => { return MediaType.Image }, [asset]) + useEffect(() => { + if (asset.creator) setCreatorAddress(asset.creator.address) + if (asset.owner) setOwnerAddress(asset.owner) + }, [asset]) + + useEffect(() => { + setSelected( + !!itemsInBag.find((item) => item.asset.tokenId === asset.tokenId && item.asset.address === asset.address) + ) + }, [asset, itemsInBag]) + + useEffect(() => { + if (provider) { + isAssetOwnedByUser({ + tokenId: asset.tokenId, + userAddress: address || '', + assetAddress: asset.address, + tokenType: asset.tokenType, + provider, + }).then(setIsOwned) + } + }, [asset, address, provider]) + return ( { - + + {rarityProvider && ( + + {rarityProvider.provider} + Ranking by{' '} + {asset.rarity?.primaryProvider === 'Genie' ? fallbackProvider : asset.rarity?.primaryProvider} + + } + > +
+ #{rarityProvider.rank} Rarity rank +
+ + )}
{ - await navigator.clipboard.writeText(window.location.hostname + pathname) + await navigator.clipboard.writeText(`${window.location.hostname}/#${pathname}`) }} > @@ -173,13 +253,13 @@ const Asset = () => { cursor="pointer" onClick={() => { if (!parsed.origin || parsed.origin === 'collection') { - navigate(`/nft/collection/${asset.address}`, undefined) + navigate(`/nfts/collection/${asset.address}`) } else if (parsed.origin === 'sell') { - navigate('/nft/sell', undefined) + navigate('/nfts/sell', undefined) } else if (parsed.origin === 'explore') { - navigate(`/nft`, undefined) + navigate(`/nfts`, undefined) } else if (parsed.origin === 'activity') { - navigate(`/nft/collection/${asset.address}/activity`, undefined) + navigate(`/nfts/collection/${asset.address}/activity`, undefined) } }} > @@ -192,6 +272,14 @@ const Asset = () => { + {asset.openseaSusFlag && ( + + Reported for suspicious activity on OpenSea}> + + + + )} + {asset.name || `${collection.collectionName} #${asset.tokenId}`} {collection.collectionDescription ? ( @@ -252,28 +340,85 @@ const Asset = () => { ) : null} - - - - - - {showTraits ? ( - - ) : ( -
- )} + + {asset.priceInfo && !isOwned ? ( + + + + + Markeplace + + + {formatEthPrice(asset.priceInfo.ETHPrice)} + + + ${toSignificant(asset.priceInfo.USDPrice)} + + + {asset.sellorders?.[0].orderClosingDate ? : null} + + { + if (isSelected) { + removeAssetFromBag(asset) + } else addAssetToBag(asset) + setSelected((x) => !x) + }} + > + {isSelected ? 'Added to Bag' : 'Buy Now'} + + + ) : null} + + + + + {showTraits ? ( + + ) : ( +
+ )}