parent
53a6acc199
commit
4b1b6098f3
@ -93,7 +93,7 @@ export const CollectionRow = ({
|
|||||||
<Box className={styles.primaryText}>{collection.name}</Box>
|
<Box className={styles.primaryText}>{collection.name}</Box>
|
||||||
{collection.isVerified && <VerifiedIcon className={styles.suggestionIcon} />}
|
{collection.isVerified && <VerifiedIcon className={styles.suggestionIcon} />}
|
||||||
</Row>
|
</Row>
|
||||||
<Box className={styles.secondaryText}>{putCommas(collection.stats?.total_supply)} items</Box>
|
<Box className={styles.secondaryText}>{putCommas(collection?.stats?.total_supply ?? 0)} items</Box>
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
{collection.stats?.floor_price ? (
|
{collection.stats?.floor_price ? (
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import graphql from 'babel-plugin-relay/macro'
|
import graphql from 'babel-plugin-relay/macro'
|
||||||
import { Trait } from 'nft/hooks/useCollectionFilters'
|
import { GenieCollection, Trait } from 'nft/types'
|
||||||
import { GenieCollection } from 'nft/types'
|
|
||||||
import { useLazyLoadQuery } from 'react-relay'
|
import { useLazyLoadQuery } from 'react-relay'
|
||||||
|
|
||||||
import { CollectionQuery } from './__generated__/CollectionQuery.graphql'
|
import { CollectionQuery } from './__generated__/CollectionQuery.graphql'
|
||||||
@ -122,8 +121,8 @@ export function useCollectionQuery(address: string): GenieCollection | undefined
|
|||||||
: {},
|
: {},
|
||||||
traits,
|
traits,
|
||||||
// marketplaceCount: { marketplace: string; count: number }[], // TODO add when backend supports
|
// marketplaceCount: { marketplace: string; count: number }[], // TODO add when backend supports
|
||||||
imageUrl: queryCollection?.image?.url,
|
imageUrl: queryCollection?.image?.url ?? '',
|
||||||
twitter: queryCollection?.twitterName ?? undefined,
|
twitterUrl: queryCollection?.twitterName ?? '',
|
||||||
instagram: queryCollection?.instagramName ?? undefined,
|
instagram: queryCollection?.instagramName ?? undefined,
|
||||||
discordUrl: queryCollection?.discordUrl ?? undefined,
|
discordUrl: queryCollection?.discordUrl ?? undefined,
|
||||||
externalUrl: queryCollection?.homepageUrl ?? undefined,
|
externalUrl: queryCollection?.homepageUrl ?? undefined,
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { parseEther } from '@ethersproject/units'
|
import { parseEther } from '@ethersproject/units'
|
||||||
import graphql from 'babel-plugin-relay/macro'
|
import graphql from 'babel-plugin-relay/macro'
|
||||||
import { Trait } from 'nft/hooks'
|
|
||||||
import { CollectionInfoForAsset, GenieAsset, SellOrder, TokenType } from 'nft/types'
|
import { CollectionInfoForAsset, GenieAsset, SellOrder, TokenType } from 'nft/types'
|
||||||
import { useLazyLoadQuery } from 'react-relay'
|
import { useLazyLoadQuery } from 'react-relay'
|
||||||
|
|
||||||
@ -141,14 +140,14 @@ export function useDetailsQuery(address: string, tokenId: string): [GenieAsset,
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
owner: asset?.ownerAddress ?? undefined,
|
owner: { address: asset?.ownerAddress ?? '' },
|
||||||
creator: {
|
creator: {
|
||||||
profile_img_url: asset?.creator?.profileImage?.url,
|
profile_img_url: asset?.creator?.profileImage?.url ?? '',
|
||||||
address: asset?.creator?.address,
|
address: asset?.creator?.address ?? '',
|
||||||
},
|
},
|
||||||
metadataUrl: asset?.metadataUrl ?? undefined,
|
metadataUrl: asset?.metadataUrl ?? '',
|
||||||
traits: asset?.traits?.map((trait) => {
|
traits: asset?.traits?.map((trait) => {
|
||||||
return { trait_type: trait.name ?? undefined, trait_value: trait.value ?? undefined } as Trait
|
return { trait_type: trait.name ?? '', trait_value: trait.value ?? '' }
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -29,6 +29,7 @@ import { combineBuyItemsWithTxRoute } from 'nft/utils/txRoute/combineItemsWithTx
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from 'react-query'
|
import { useQuery, useQueryClient } from 'react-query'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import styled from 'styled-components/macro'
|
||||||
|
|
||||||
import * as styles from './Bag.css'
|
import * as styles from './Bag.css'
|
||||||
import { BagContent } from './BagContent'
|
import { BagContent } from './BagContent'
|
||||||
@ -41,6 +42,15 @@ interface SeparatorProps {
|
|||||||
show?: boolean
|
show?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DetailsPageBackground = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
top: 72px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
const ScrollingIndicator = ({ top, show }: SeparatorProps) => (
|
const ScrollingIndicator = ({ top, show }: SeparatorProps) => (
|
||||||
<Box
|
<Box
|
||||||
marginX="16"
|
marginX="16"
|
||||||
@ -82,6 +92,8 @@ const Bag = () => {
|
|||||||
const shouldShowBag = isNFTPage || isProfilePage
|
const shouldShowBag = isNFTPage || isProfilePage
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
const isDetailsPage = pathname.includes('/nfts/asset/')
|
||||||
|
|
||||||
const sendTransaction = useSendTransaction((state) => state.sendTransaction)
|
const sendTransaction = useSendTransaction((state) => state.sendTransaction)
|
||||||
const transactionState = useSendTransaction((state) => state.state)
|
const transactionState = useSendTransaction((state) => state.state)
|
||||||
const setTransactionState = useSendTransaction((state) => state.setState)
|
const setTransactionState = useSendTransaction((state) => state.setState)
|
||||||
@ -304,7 +316,11 @@ const Bag = () => {
|
|||||||
<ListingModal />
|
<ListingModal />
|
||||||
)}
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
{isOpen && <Overlay onClick={() => (!bagIsLocked ? setModalIsOpen(false) : undefined)} />}
|
{isDetailsPage ? (
|
||||||
|
<DetailsPageBackground onClick={toggleBag} />
|
||||||
|
) : (
|
||||||
|
isOpen && <Overlay onClick={() => (!bagIsLocked ? setModalIsOpen(false) : undefined)} />
|
||||||
|
)}
|
||||||
</Portal>
|
</Portal>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
@ -79,7 +79,6 @@ export const detailsName = style([
|
|||||||
export const eventDetail = style([
|
export const eventDetail = style([
|
||||||
subhead,
|
subhead,
|
||||||
sprinkles({
|
sprinkles({
|
||||||
marginBottom: '4',
|
|
||||||
gap: '8',
|
gap: '8',
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
@ -48,7 +48,7 @@ const initialFilterState = {
|
|||||||
[ActivityEventType.CancelListing]: false,
|
[ActivityEventType.CancelListing]: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const reduceFilters = (state: typeof initialFilterState, action: { eventType: ActivityEventType }) => {
|
export const reduceFilters = (state: typeof initialFilterState, action: { eventType: ActivityEventType }) => {
|
||||||
return { ...state, [action.eventType]: !state[action.eventType] }
|
return { ...state, [action.eventType]: !state[action.eventType] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
ActivityListingIcon,
|
ActivityListingIcon,
|
||||||
ActivitySaleIcon,
|
ActivitySaleIcon,
|
||||||
ActivityTransferIcon,
|
ActivityTransferIcon,
|
||||||
|
CancelListingIcon,
|
||||||
RarityVerifiedIcon,
|
RarityVerifiedIcon,
|
||||||
} from 'nft/components/icons'
|
} from 'nft/components/icons'
|
||||||
import {
|
import {
|
||||||
@ -157,7 +158,7 @@ export const AddressCell = ({ address, desktopLBreakpoint, chainId }: AddressCel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarketplaceIcon = ({ marketplace }: { marketplace: Markets }) => {
|
export const MarketplaceIcon = ({ marketplace }: { marketplace: Markets }) => {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="img"
|
as="img"
|
||||||
@ -204,8 +205,9 @@ interface EventCellProps {
|
|||||||
eventType: ActivityEventType
|
eventType: ActivityEventType
|
||||||
eventTimestamp?: number
|
eventTimestamp?: number
|
||||||
eventTransactionHash?: string
|
eventTransactionHash?: string
|
||||||
|
eventOnly?: boolean
|
||||||
price?: string
|
price?: string
|
||||||
isMobile: boolean
|
isMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderEventIcon = (eventType: ActivityEventType) => {
|
const renderEventIcon = (eventType: ActivityEventType) => {
|
||||||
@ -216,6 +218,8 @@ const renderEventIcon = (eventType: ActivityEventType) => {
|
|||||||
return <ActivitySaleIcon width={16} height={16} />
|
return <ActivitySaleIcon width={16} height={16} />
|
||||||
case ActivityEventType.Transfer:
|
case ActivityEventType.Transfer:
|
||||||
return <ActivityTransferIcon width={16} height={16} />
|
return <ActivityTransferIcon width={16} height={16} />
|
||||||
|
case ActivityEventType.CancelListing:
|
||||||
|
return <CancelListingIcon width={16} height={16} />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -237,13 +241,20 @@ const eventColors = (eventType: ActivityEventType) => {
|
|||||||
[ActivityEventType.Listing]: 'gold',
|
[ActivityEventType.Listing]: 'gold',
|
||||||
[ActivityEventType.Sale]: 'green',
|
[ActivityEventType.Sale]: 'green',
|
||||||
[ActivityEventType.Transfer]: 'violet',
|
[ActivityEventType.Transfer]: 'violet',
|
||||||
[ActivityEventType.CancelListing]: 'error',
|
[ActivityEventType.CancelListing]: 'accentFailure',
|
||||||
}
|
}
|
||||||
|
|
||||||
return activityEvents[eventType] as 'gold' | 'green' | 'violet' | 'accentFailure'
|
return activityEvents[eventType] as 'gold' | 'green' | 'violet' | 'accentFailure'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventCell = ({ eventType, eventTimestamp, eventTransactionHash, price, isMobile }: EventCellProps) => {
|
export const EventCell = ({
|
||||||
|
eventType,
|
||||||
|
eventTimestamp,
|
||||||
|
eventTransactionHash,
|
||||||
|
eventOnly,
|
||||||
|
price,
|
||||||
|
isMobile,
|
||||||
|
}: EventCellProps) => {
|
||||||
const formattedPrice = useMemo(() => (price ? putCommas(formatEthPrice(price))?.toString() : null), [price])
|
const formattedPrice = useMemo(() => (price ? putCommas(formatEthPrice(price))?.toString() : null), [price])
|
||||||
return (
|
return (
|
||||||
<Column height="full" justifyContent="center" gap="4">
|
<Column height="full" justifyContent="center" gap="4">
|
||||||
@ -251,7 +262,7 @@ export const EventCell = ({ eventType, eventTimestamp, eventTransactionHash, pri
|
|||||||
{renderEventIcon(eventType)}
|
{renderEventIcon(eventType)}
|
||||||
{ActivityEventTypeDisplay[eventType]}
|
{ActivityEventTypeDisplay[eventType]}
|
||||||
</Row>
|
</Row>
|
||||||
{eventTimestamp && isValidDate(eventTimestamp) && !isMobile && (
|
{eventTimestamp && isValidDate(eventTimestamp) && !isMobile && !eventOnly && (
|
||||||
<Row className={styles.eventTime}>
|
<Row className={styles.eventTime}>
|
||||||
{getTimeDifference(eventTimestamp.toString())}
|
{getTimeDifference(eventTimestamp.toString())}
|
||||||
{eventTransactionHash && <ExternalLinkIcon transactionHash={eventTransactionHash} />}
|
{eventTransactionHash && <ExternalLinkIcon transactionHash={eventTransactionHash} />}
|
||||||
@ -301,9 +312,10 @@ interface RankingProps {
|
|||||||
rarity: TokenRarity
|
rarity: TokenRarity
|
||||||
collectionName: string
|
collectionName: string
|
||||||
rarityVerified: boolean
|
rarityVerified: boolean
|
||||||
|
details?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ranking = ({ rarity, collectionName, rarityVerified }: RankingProps) => {
|
const Ranking = ({ details, rarity, collectionName, rarityVerified }: RankingProps) => {
|
||||||
const rarityProviderLogo = getRarityProviderLogo(rarity.source)
|
const rarityProviderLogo = getRarityProviderLogo(rarity.source)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -76,8 +76,8 @@ const MobileSocialsPopover = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</MobileSocialsIcon>
|
</MobileSocialsIcon>
|
||||||
) : null}
|
) : null}
|
||||||
{collectionStats.twitter ? (
|
{collectionStats.twitterUrl ? (
|
||||||
<MobileSocialsIcon href={'https://twitter.com/' + collectionStats.twitter}>
|
<MobileSocialsIcon href={'https://twitter.com/' + collectionStats.twitterUrl}>
|
||||||
<Box margin="auto" paddingTop="6">
|
<Box margin="auto" paddingTop="6">
|
||||||
<TwitterIcon
|
<TwitterIcon
|
||||||
fill={themeVars.colors.textSecondary}
|
fill={themeVars.colors.textSecondary}
|
||||||
@ -161,8 +161,8 @@ const CollectionName = ({
|
|||||||
/>
|
/>
|
||||||
</SocialsIcon>
|
</SocialsIcon>
|
||||||
) : null}
|
) : null}
|
||||||
{collectionStats.twitter ? (
|
{collectionStats.twitterUrl ? (
|
||||||
<SocialsIcon href={'https://twitter.com/' + collectionStats.twitter}>
|
<SocialsIcon href={'https://twitter.com/' + collectionStats.twitterUrl}>
|
||||||
<TwitterIcon
|
<TwitterIcon
|
||||||
fill={themeVars.colors.textSecondary}
|
fill={themeVars.colors.textSecondary}
|
||||||
color={themeVars.colors.textSecondary}
|
color={themeVars.colors.textSecondary}
|
||||||
@ -186,7 +186,7 @@ const CollectionName = ({
|
|||||||
</Row>
|
</Row>
|
||||||
{isMobile &&
|
{isMobile &&
|
||||||
(collectionStats.discordUrl ||
|
(collectionStats.discordUrl ||
|
||||||
collectionStats.twitter ||
|
collectionStats.twitterUrl ||
|
||||||
collectionStats.instagram ||
|
collectionStats.instagram ||
|
||||||
collectionStats.externalUrl) && (
|
collectionStats.externalUrl) && (
|
||||||
<MobileSocialsPopover
|
<MobileSocialsPopover
|
||||||
|
182
src/nft/components/details/AssetActivity.tsx
Normal file
182
src/nft/components/details/AssetActivity.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { ActivityEventResponse } from 'nft/types'
|
||||||
|
import { shortenAddress } from 'nft/utils/address'
|
||||||
|
import { formatEthPrice } from 'nft/utils/currency'
|
||||||
|
import { getTimeDifference } from 'nft/utils/date'
|
||||||
|
import { putCommas } from 'nft/utils/putCommas'
|
||||||
|
import styled from 'styled-components/macro'
|
||||||
|
|
||||||
|
import { EventCell } from '../collection/ActivityCells'
|
||||||
|
import { MarketplaceIcon } from '../collection/ActivityCells'
|
||||||
|
|
||||||
|
const TR = styled.tr`
|
||||||
|
border-bottom: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TH = styled.th`
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
width: 20%;
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
&:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
&:nth-child(2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Table = styled.table`
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TD = styled.td`
|
||||||
|
height: 56px;
|
||||||
|
padding: 8px 0px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 20%;
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
&:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
&:nth-child(2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const PriceContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Link = styled.a`
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
|
const ActivityContainer = styled.div`
|
||||||
|
max-height: 310px;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
// Firefox scrollbar styling
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: ${({ theme }) => `${theme.backgroundOutline} transparent`};
|
||||||
|
|
||||||
|
// safari and chrome scrollbar styling
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
background: transparent;
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: ${({ theme }) => theme.backgroundOutline};
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const AssetActivity = ({ eventsData }: { eventsData: ActivityEventResponse | undefined }) => {
|
||||||
|
return (
|
||||||
|
<ActivityContainer id="activityContainer">
|
||||||
|
<Table>
|
||||||
|
<thead>
|
||||||
|
<TR>
|
||||||
|
<TH>Event</TH>
|
||||||
|
<TH>Price</TH>
|
||||||
|
<TH>By</TH>
|
||||||
|
<TH>To</TH>
|
||||||
|
<TH>Time</TH>
|
||||||
|
</TR>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{eventsData?.events &&
|
||||||
|
eventsData.events.map((event, index) => {
|
||||||
|
const { eventTimestamp, eventType, fromAddress, marketplace, price, toAddress, transactionHash } = event
|
||||||
|
const formattedPrice = price ? putCommas(formatEthPrice(price)).toString() : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TR key={index}>
|
||||||
|
<TD>
|
||||||
|
<EventCell
|
||||||
|
eventType={eventType}
|
||||||
|
eventTimestamp={eventTimestamp}
|
||||||
|
eventTransactionHash={transactionHash}
|
||||||
|
eventOnly
|
||||||
|
/>
|
||||||
|
</TD>
|
||||||
|
<TD>
|
||||||
|
{formattedPrice && (
|
||||||
|
<PriceContainer>
|
||||||
|
{marketplace && <MarketplaceIcon marketplace={marketplace} />}
|
||||||
|
{formattedPrice} ETH
|
||||||
|
</PriceContainer>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
|
||||||
|
<TD>
|
||||||
|
{fromAddress && (
|
||||||
|
<Link
|
||||||
|
href={`https://etherscan.io/address/${fromAddress}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{shortenAddress(fromAddress, 2, 4)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
|
||||||
|
<TD>
|
||||||
|
{toAddress && (
|
||||||
|
<Link
|
||||||
|
href={`https://etherscan.io/address/${toAddress}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{shortenAddress(toAddress, 2, 4)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</TD>
|
||||||
|
<TD>{eventTimestamp && getTimeDifference(eventTimestamp.toString())}</TD>
|
||||||
|
</TR>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</ActivityContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetActivity
|
@ -6,19 +6,18 @@ import { sprinkles, vars } from '../../css/sprinkles.css'
|
|||||||
export const image = style([
|
export const image = style([
|
||||||
sprinkles({ borderRadius: '20', height: 'full', alignSelf: 'center' }),
|
sprinkles({ borderRadius: '20', height: 'full', alignSelf: 'center' }),
|
||||||
{
|
{
|
||||||
width: 'calc(90vh - 165px)',
|
maxHeight: 'calc(90vh - 165px)',
|
||||||
height: 'calc(90vh - 165px)',
|
minHeight: 400,
|
||||||
maxHeight: '678px',
|
maxWidth: 780,
|
||||||
maxWidth: '678px',
|
|
||||||
boxShadow: `0px 20px 50px var(--shadow), 0px 10px 50px rgba(70, 115, 250, 0.2)`,
|
boxShadow: `0px 20px 50px var(--shadow), 0px 10px 50px rgba(70, 115, 250, 0.2)`,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 1024px)': {
|
'(max-width: 1024px)': {
|
||||||
maxHeight: '64vh',
|
maxHeight: '64vh',
|
||||||
maxWidth: '64vh',
|
|
||||||
},
|
},
|
||||||
'(max-width: 640px)': {
|
'(max-width: 640px)': {
|
||||||
|
minHeight: 280,
|
||||||
maxHeight: '56vh',
|
maxHeight: '56vh',
|
||||||
maxWidth: '56vh',
|
maxWidth: '100%',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -81,8 +80,6 @@ export const columns = style([
|
|||||||
])
|
])
|
||||||
|
|
||||||
export const column = style({
|
export const column = style({
|
||||||
maxWidth: '50%',
|
|
||||||
width: '50%',
|
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 1024px)': {
|
'(max-width: 1024px)': {
|
||||||
|
@ -1,38 +1,217 @@
|
|||||||
import { useWeb3React } from '@web3-react/core'
|
import Resource from 'components/Tokens/TokenDetails/Resource'
|
||||||
import { sendAnalyticsEvent } from 'analytics'
|
|
||||||
import { EventName, PageName } from 'analytics/constants'
|
|
||||||
import { useTrace } from 'analytics/Trace'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { MouseoverTooltip } from 'components/Tooltip/index'
|
import { MouseoverTooltip } from 'components/Tooltip/index'
|
||||||
import useENSName from 'hooks/useENSName'
|
import { Box } from 'nft/components/Box'
|
||||||
import { AnimatedBox, Box } from 'nft/components/Box'
|
import { reduceFilters } from 'nft/components/collection/Activity'
|
||||||
import { CollectionProfile } from 'nft/components/details/CollectionProfile'
|
import { LoadingSparkle } from 'nft/components/common/Loading/LoadingSparkle'
|
||||||
import { Details } from 'nft/components/details/Details'
|
import { AssetPriceDetails } from 'nft/components/details/AssetPriceDetails'
|
||||||
import { Traits } from 'nft/components/details/Traits'
|
import { Center } from 'nft/components/Flex'
|
||||||
import { Center, Column, Row } from 'nft/components/Flex'
|
import { VerifiedIcon } from 'nft/components/icons'
|
||||||
import { CloseDropDownIcon, CornerDownLeftIcon, Eth2Icon, ShareIcon, SuspiciousIcon } from 'nft/components/icons'
|
import { ActivityFetcher } from 'nft/queries/genie/ActivityFetcher'
|
||||||
import { ExpandableText } from 'nft/components/layout/ExpandableText'
|
import { ActivityEventResponse, ActivityEventType } from 'nft/types'
|
||||||
import { badge, bodySmall, caption, headlineMedium, subhead } from 'nft/css/common.css'
|
import { CollectionInfoForAsset, GenieAsset, GenieCollection } from 'nft/types'
|
||||||
import { themeVars } from 'nft/css/sprinkles.css'
|
|
||||||
import { useBag } from 'nft/hooks'
|
|
||||||
import { useTimeout } from 'nft/hooks/useTimeout'
|
|
||||||
import { CollectionInfoForAsset, Deprecated_SellOrder, GenieAsset, SellOrder } from 'nft/types'
|
|
||||||
import { useUsdPrice } from 'nft/utils'
|
|
||||||
import { shortenAddress } from 'nft/utils/address'
|
import { shortenAddress } from 'nft/utils/address'
|
||||||
import { formatEthPrice } from 'nft/utils/currency'
|
import { formatEthPrice } from 'nft/utils/currency'
|
||||||
import { isAssetOwnedByUser } from 'nft/utils/isAssetOwnedByUser'
|
|
||||||
import { isAudio } from 'nft/utils/isAudio'
|
import { isAudio } from 'nft/utils/isAudio'
|
||||||
import { isVideo } from 'nft/utils/isVideo'
|
import { isVideo } from 'nft/utils/isVideo'
|
||||||
import { fallbackProvider, rarityProviderLogo } from 'nft/utils/rarity'
|
import { putCommas } from 'nft/utils/putCommas'
|
||||||
import { toSignificant } from 'nft/utils/toSignificant'
|
import { fallbackProvider, getRarityProviderLogo } from 'nft/utils/rarity'
|
||||||
import qs from 'query-string'
|
import { useCallback, useMemo, useReducer, useState } from 'react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import { useInfiniteQuery, useQuery } from 'react-query'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { useSpring } from 'react-spring'
|
import styled, { css } from 'styled-components/macro'
|
||||||
|
|
||||||
import { SUSPICIOUS_TEXT } from '../collection/Card'
|
import AssetActivity from './AssetActivity'
|
||||||
import * as styles from './AssetDetails.css'
|
import * as styles from './AssetDetails.css'
|
||||||
|
import DetailsContainer from './DetailsContainer'
|
||||||
|
import InfoContainer from './InfoContainer'
|
||||||
|
import TraitsContainer from './TraitsContainer'
|
||||||
|
|
||||||
|
const OpacityTransition = css`
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
|
const CollectionHeader = styled.span`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
margin-top: 28px;
|
||||||
|
text-decoration: none;
|
||||||
|
${OpacityTransition};
|
||||||
|
`
|
||||||
|
|
||||||
|
const AssetPriceDetailsContainer = styled.div`
|
||||||
|
margin-top: 20px;
|
||||||
|
display: none;
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const AssetHeader = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
margin-top: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const MediaContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Column = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 780px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AddressTextLink = styled.a`
|
||||||
|
display: inline-block;
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
text-decoration: none;
|
||||||
|
max-width: 100%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
${OpacityTransition};
|
||||||
|
`
|
||||||
|
|
||||||
|
const SocialsContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const DescriptionText = styled.div`
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const RarityWrap = styled.span`
|
||||||
|
display: flex;
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const EmptyActivitiesContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 36px;
|
||||||
|
padding: 56px 0px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Link = styled(RouterLink)`
|
||||||
|
color: ${({ theme }) => theme.accentAction};
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
${OpacityTransition};
|
||||||
|
`
|
||||||
|
|
||||||
|
const DefaultLink = styled(RouterLink)`
|
||||||
|
text-decoration: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ActivitySelectContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 34px;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
// Firefox scrollbar styling
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: ${({ theme }) => `${theme.backgroundOutline} transparent`};
|
||||||
|
|
||||||
|
// safari and chrome scrollbar styling
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
background: transparent;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: ${({ theme }) => theme.backgroundOutline};
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ContentNotAvailable = styled.div`
|
||||||
|
display: flex;
|
||||||
|
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 450px;
|
||||||
|
height: 450px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FilterBox = styled.div<{ isActive?: boolean }>`
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: ${({ theme }) => theme.backgroundInteractive};
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: ${({ isActive, theme }) => (isActive ? `1px solid ${theme.accentActive}` : undefined)};
|
||||||
|
${OpacityTransition};
|
||||||
|
`
|
||||||
|
|
||||||
|
const ByText = styled.span`
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Img = styled.img`
|
||||||
|
background-color: white;
|
||||||
|
`
|
||||||
|
|
||||||
|
const HoverImageContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
margin-right: 4px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const HoverContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ContainerText = styled.span`
|
||||||
|
font-size: 14px;
|
||||||
|
`
|
||||||
|
|
||||||
const AudioPlayer = ({
|
const AudioPlayer = ({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@ -58,26 +237,11 @@ const AudioPlayer = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatter = Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short' })
|
const initialFilterState = {
|
||||||
|
[ActivityEventType.Listing]: true,
|
||||||
const CountdownTimer = ({ sellOrder }: { sellOrder: Deprecated_SellOrder | SellOrder }) => {
|
[ActivityEventType.Sale]: true,
|
||||||
const { date, expires } = useMemo(() => {
|
[ActivityEventType.Transfer]: false,
|
||||||
const date = new Date((sellOrder as Deprecated_SellOrder).orderClosingDate ?? (sellOrder as SellOrder).endAt)
|
[ActivityEventType.CancelListing]: false,
|
||||||
return {
|
|
||||||
date,
|
|
||||||
expires: formatter.format(date),
|
|
||||||
}
|
|
||||||
}, [sellOrder])
|
|
||||||
const [days, hours, minutes, seconds] = useTimeout(date)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MouseoverTooltip text={<Box fontSize="12">Expires {expires}</Box>}>
|
|
||||||
<Box as="span" fontWeight="normal" className={caption} color="textSecondary">
|
|
||||||
Expires: {days !== 0 ? `${days} days` : ''} {hours !== 0 ? `${hours} hours` : ''} {minutes} minutes {seconds}{' '}
|
|
||||||
seconds
|
|
||||||
</Box>
|
|
||||||
</MouseoverTooltip>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssetView = ({
|
const AssetView = ({
|
||||||
@ -112,46 +276,19 @@ enum MediaType {
|
|||||||
interface AssetDetailsProps {
|
interface AssetDetailsProps {
|
||||||
asset: GenieAsset
|
asset: GenieAsset
|
||||||
collection: CollectionInfoForAsset
|
collection: CollectionInfoForAsset
|
||||||
|
collectionStats: GenieCollection | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
|
export const AssetDetails = ({ asset, collection, collectionStats }: AssetDetailsProps) => {
|
||||||
const { pathname, search } = useLocation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const addAssetsToBag = useBag((state) => state.addAssetsToBag)
|
|
||||||
const removeAssetsFromBag = useBag((state) => state.removeAssetsFromBag)
|
|
||||||
const itemsInBag = useBag((state) => state.itemsInBag)
|
|
||||||
const bagExpanded = useBag((state) => state.bagExpanded)
|
|
||||||
const [creatorAddress, setCreatorAddress] = useState('')
|
|
||||||
const [ownerAddress, setOwnerAddress] = useState('')
|
|
||||||
const [dominantColor] = useState<[number, number, number]>([0, 0, 0])
|
const [dominantColor] = useState<[number, number, number]>([0, 0, 0])
|
||||||
const creatorEnsName = useENSName(creatorAddress)
|
|
||||||
const ownerEnsName = useENSName(ownerAddress)
|
|
||||||
const parsed = qs.parse(search)
|
|
||||||
const { gridWidthOffset } = useSpring({
|
|
||||||
gridWidthOffset: bagExpanded ? 324 : 0,
|
|
||||||
})
|
|
||||||
const [showTraits, setShowTraits] = useState(true)
|
|
||||||
const [isSelected, setSelected] = useState(false)
|
|
||||||
const [isOwned, setIsOwned] = useState(false)
|
|
||||||
const { account: address, provider } = useWeb3React()
|
|
||||||
|
|
||||||
const trace = useTrace({ page: PageName.NFT_DETAILS_PAGE })
|
const { rarityProvider } = useMemo(
|
||||||
|
|
||||||
const eventProperties = {
|
|
||||||
collection_address: asset.address,
|
|
||||||
token_id: asset.tokenId,
|
|
||||||
token_type: asset.tokenType,
|
|
||||||
...trace,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rarityProvider, rarityLogo } = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
asset.rarity
|
asset.rarity
|
||||||
? {
|
? {
|
||||||
rarityProvider: asset?.rarity?.providers?.find(
|
rarityProvider: asset?.rarity?.providers?.find(
|
||||||
({ provider: _provider }) => _provider === asset.rarity?.primaryProvider
|
({ provider: _provider }) => _provider === asset.rarity?.primaryProvider
|
||||||
),
|
),
|
||||||
rarityLogo: rarityProviderLogo[asset.rarity.primaryProvider] || '',
|
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
[asset.rarity]
|
[asset.rarity]
|
||||||
@ -166,282 +303,217 @@ export const AssetDetails = ({ asset, collection }: AssetDetailsProps) => {
|
|||||||
return MediaType.Image
|
return MediaType.Image
|
||||||
}, [asset])
|
}, [asset])
|
||||||
|
|
||||||
useEffect(() => {
|
const { address: contractAddress, tokenId: token_id } = asset
|
||||||
if (asset.creator) setCreatorAddress(asset.creator.address ?? '')
|
|
||||||
if (asset.owner) setOwnerAddress(asset.owner)
|
|
||||||
}, [asset])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: priceData } = useQuery<ActivityEventResponse>(
|
||||||
setSelected(
|
[
|
||||||
!!itemsInBag.find((item) => item.asset.tokenId === asset.tokenId && item.asset.address === asset.address)
|
'collectionActivity',
|
||||||
|
{
|
||||||
|
contractAddress,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async ({ pageParam = '' }) => {
|
||||||
|
return await ActivityFetcher(
|
||||||
|
contractAddress,
|
||||||
|
{
|
||||||
|
token_id,
|
||||||
|
eventTypes: [ActivityEventType.Sale],
|
||||||
|
},
|
||||||
|
pageParam,
|
||||||
|
'1'
|
||||||
)
|
)
|
||||||
}, [asset, itemsInBag])
|
},
|
||||||
|
{
|
||||||
useEffect(() => {
|
getNextPageParam: (lastPage) => {
|
||||||
if (provider) {
|
return lastPage.events?.length === 25 ? lastPage.cursor : undefined
|
||||||
isAssetOwnedByUser({
|
},
|
||||||
tokenId: asset.tokenId,
|
refetchInterval: 15000,
|
||||||
userAddress: address || '',
|
refetchIntervalInBackground: false,
|
||||||
assetAddress: asset.address,
|
refetchOnWindowFocus: false,
|
||||||
tokenType: asset.tokenType,
|
refetchOnMount: false,
|
||||||
provider,
|
|
||||||
}).then(setIsOwned)
|
|
||||||
}
|
}
|
||||||
}, [asset, address, provider])
|
)
|
||||||
|
|
||||||
const USDPrice = useUsdPrice(asset)
|
const lastSalePrice = priceData?.events[0]?.price ?? null
|
||||||
|
const formattedEthprice = formatEthPrice(lastSalePrice ?? '') || 0
|
||||||
|
const formattedPrice = lastSalePrice ? putCommas(formattedEthprice).toString() : null
|
||||||
|
const [activeFilters, filtersDispatch] = useReducer(reduceFilters, initialFilterState)
|
||||||
|
|
||||||
|
const Filter = useCallback(
|
||||||
|
function ActivityFilter({ eventType }: { eventType: ActivityEventType }) {
|
||||||
|
const isActive = activeFilters[eventType]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBox
|
<FilterBox isActive={isActive} onClick={() => filtersDispatch({ eventType })}>
|
||||||
style={{
|
{eventType === ActivityEventType.CancelListing
|
||||||
// @ts-ignore
|
? 'Cancellations'
|
||||||
width: gridWidthOffset.to((x) => `calc(100% - ${x}px)`),
|
: eventType.charAt(0) + eventType.slice(1).toLowerCase() + 's'}
|
||||||
}}
|
</FilterBox>
|
||||||
className={styles.container}
|
)
|
||||||
>
|
},
|
||||||
<div className={styles.columns}>
|
[activeFilters]
|
||||||
<Column className={styles.column}>
|
)
|
||||||
{assetMediaType === MediaType.Image ? (
|
|
||||||
<img
|
const {
|
||||||
|
data: eventsData,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isSuccess,
|
||||||
|
} = useInfiniteQuery<ActivityEventResponse>(
|
||||||
|
[
|
||||||
|
'collectionActivity',
|
||||||
|
{
|
||||||
|
contractAddress,
|
||||||
|
activeFilters,
|
||||||
|
token_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async ({ pageParam = '' }) => {
|
||||||
|
return await ActivityFetcher(
|
||||||
|
contractAddress,
|
||||||
|
{
|
||||||
|
token_id,
|
||||||
|
eventTypes: Object.keys(activeFilters)
|
||||||
|
.map((key) => key as ActivityEventType)
|
||||||
|
.filter((key) => activeFilters[key]),
|
||||||
|
},
|
||||||
|
pageParam
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
return lastPage.events?.length === 25 ? lastPage.cursor : undefined
|
||||||
|
},
|
||||||
|
refetchInterval: 15000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const rarity = asset?.rarity?.providers?.length ? asset?.rarity?.providers?.[0] : undefined
|
||||||
|
const [showHolder, setShowHolder] = useState(false)
|
||||||
|
const rarityProviderLogo = getRarityProviderLogo(rarity?.provider)
|
||||||
|
const events = useMemo(
|
||||||
|
() => (isSuccess ? eventsData?.pages.map((page) => page.events).flat() : null),
|
||||||
|
[isSuccess, eventsData]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column>
|
||||||
|
<MediaContainer>
|
||||||
|
{asset.imageUrl === undefined || showHolder ? (
|
||||||
|
<ContentNotAvailable>Content not available yet</ContentNotAvailable>
|
||||||
|
) : assetMediaType === MediaType.Image ? (
|
||||||
|
<Img
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
src={asset.imageUrl}
|
src={asset.imageUrl}
|
||||||
alt={asset.name || collection.collectionName}
|
alt={asset.name || collection.collectionName}
|
||||||
style={{ ['--shadow' as string]: `rgba(${dominantColor.join(', ')}, 0.5)` }}
|
onError={() => setShowHolder(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AssetView asset={asset} mediaType={assetMediaType} dominantColor={dominantColor} />
|
<AssetView asset={asset} mediaType={assetMediaType} dominantColor={dominantColor} />
|
||||||
)}
|
)}
|
||||||
</Column>
|
</MediaContainer>
|
||||||
<Column className={clsx(styles.column, styles.columnRight)} width="full">
|
<DefaultLink to={`/nfts/collection/${asset.address}`}>
|
||||||
<Column>
|
<CollectionHeader>
|
||||||
<Row
|
{collection.collectionName} {collectionStats?.isVerified && <VerifiedIcon />}
|
||||||
marginBottom="8"
|
</CollectionHeader>
|
||||||
alignItems="center"
|
</DefaultLink>
|
||||||
textAlign="center"
|
|
||||||
justifyContent={rarityProvider ? 'space-between' : 'flex-end'}
|
<AssetHeader>{asset.name ?? `${asset.collectionName} #${asset.tokenId}`}</AssetHeader>
|
||||||
>
|
<AssetPriceDetailsContainer>
|
||||||
{rarityProvider && (
|
<AssetPriceDetails asset={asset} collection={collection} />
|
||||||
|
</AssetPriceDetailsContainer>
|
||||||
|
<InfoContainer
|
||||||
|
primaryHeader="Traits"
|
||||||
|
defaultOpen
|
||||||
|
secondaryHeader={
|
||||||
|
rarityProvider && rarity && rarity.score ? (
|
||||||
<MouseoverTooltip
|
<MouseoverTooltip
|
||||||
text={
|
text={
|
||||||
<Row gap="4">
|
<HoverContainer>
|
||||||
<img src={rarityLogo} width={16} alt={rarityProvider.provider} />
|
<HoverImageContainer>
|
||||||
Ranking by{' '}
|
<img src={rarityProviderLogo} alt="cardLogo" width={16} />
|
||||||
{asset.rarity?.primaryProvider === 'Genie' ? fallbackProvider : asset.rarity?.primaryProvider}
|
</HoverImageContainer>
|
||||||
</Row>
|
<ContainerText>
|
||||||
|
{collectionStats?.rarityVerified
|
||||||
|
? `Verified by ${collectionStats?.name}`
|
||||||
|
: `Ranking by ${rarity.provider === 'Genie' ? fallbackProvider : rarity.provider}`}
|
||||||
|
</ContainerText>
|
||||||
|
</HoverContainer>
|
||||||
}
|
}
|
||||||
|
placement="top"
|
||||||
>
|
>
|
||||||
<Center
|
<RarityWrap>Rarity: {putCommas(rarity.score)}</RarityWrap>
|
||||||
paddingLeft="6"
|
|
||||||
paddingRight="4"
|
|
||||||
className={badge}
|
|
||||||
backgroundColor="backgroundSurface"
|
|
||||||
color="textPrimary"
|
|
||||||
borderRadius="4"
|
|
||||||
>
|
|
||||||
#{rarityProvider.rank} <img src="/nft/svgs/rarity.svg" height={15} width={15} alt="Rarity rank" />
|
|
||||||
</Center>
|
|
||||||
</MouseoverTooltip>
|
</MouseoverTooltip>
|
||||||
)}
|
) : null
|
||||||
<Row gap="12">
|
|
||||||
<Center
|
|
||||||
as="button"
|
|
||||||
padding="0"
|
|
||||||
border="none"
|
|
||||||
background="transparent"
|
|
||||||
cursor="pointer"
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.clipboard.writeText(`${window.location.hostname}/#${pathname}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShareIcon />
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Center
|
|
||||||
as="button"
|
|
||||||
border="none"
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
padding="0"
|
|
||||||
background="transparent"
|
|
||||||
cursor="pointer"
|
|
||||||
onClick={() => {
|
|
||||||
if (!parsed.origin || parsed.origin === 'collection') {
|
|
||||||
navigate(`/nfts/collection/${asset.address}`)
|
|
||||||
} else if (parsed.origin === 'profile') {
|
|
||||||
navigate('/profile', undefined)
|
|
||||||
} else if (parsed.origin === 'explore') {
|
|
||||||
navigate(`/nfts`, undefined)
|
|
||||||
} else if (parsed.origin === 'activity') {
|
|
||||||
navigate(`/nfts/collection/${asset.address}/activity`, undefined)
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{parsed.origin ? (
|
<TraitsContainer asset={asset} />
|
||||||
<CornerDownLeftIcon width="28" height="28" />
|
</InfoContainer>
|
||||||
) : (
|
<InfoContainer
|
||||||
<CloseDropDownIcon color={themeVars.colors.textSecondary} />
|
primaryHeader="Activity"
|
||||||
)}
|
secondaryHeader={formattedPrice ? `Last Sale: ${formattedPrice} ETH` : undefined}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<ActivitySelectContainer>
|
||||||
|
<Filter eventType={ActivityEventType.Listing} />
|
||||||
|
<Filter eventType={ActivityEventType.Sale} />
|
||||||
|
<Filter eventType={ActivityEventType.Transfer} />
|
||||||
|
<Filter eventType={ActivityEventType.CancelListing} />
|
||||||
|
</ActivitySelectContainer>
|
||||||
|
{events && events.length > 0 ? (
|
||||||
|
<InfiniteScroll
|
||||||
|
next={fetchNextPage}
|
||||||
|
hasMore={!!hasNextPage}
|
||||||
|
loader={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<Center>
|
||||||
|
<LoadingSparkle />
|
||||||
</Center>
|
</Center>
|
||||||
</Row>
|
) : null
|
||||||
</Row>
|
|
||||||
<Row as="h1" marginTop="0" marginBottom="12" gap="2" className={headlineMedium}>
|
|
||||||
{asset.susFlag && (
|
|
||||||
<Box marginTop="8">
|
|
||||||
<MouseoverTooltip text={<Box fontWeight="normal">{SUSPICIOUS_TEXT}</Box>}>
|
|
||||||
<SuspiciousIcon height="30" width="30" viewBox="0 0 16 17" />
|
|
||||||
</MouseoverTooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{asset.name || `${collection.collectionName} #${asset.tokenId}`}
|
|
||||||
</Row>
|
|
||||||
{collection.collectionDescription ? (
|
|
||||||
<ExpandableText>
|
|
||||||
<ReactMarkdown
|
|
||||||
allowedTypes={['link', 'paragraph', 'strong', 'code', 'emphasis', 'text']}
|
|
||||||
source={collection.collectionDescription}
|
|
||||||
/>
|
|
||||||
</ExpandableText>
|
|
||||||
) : null}
|
|
||||||
<Row
|
|
||||||
justifyContent={{
|
|
||||||
sm: 'space-between',
|
|
||||||
}}
|
|
||||||
gap={{
|
|
||||||
sm: 'unset',
|
|
||||||
}}
|
|
||||||
marginBottom="36"
|
|
||||||
>
|
|
||||||
{ownerAddress.length > 0 && (
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
href={`https://etherscan.io/address/${asset.owner}`}
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
<CollectionProfile
|
|
||||||
label="Owner"
|
|
||||||
avatarUrl=""
|
|
||||||
name={ownerEnsName.ENSName ?? shortenAddress(ownerAddress, 0, 4)}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link to={`/nfts/collection/${asset.address}`} style={{ textDecoration: 'none' }}>
|
|
||||||
<CollectionProfile
|
|
||||||
label="Collection"
|
|
||||||
avatarUrl={collection.collectionImageUrl}
|
|
||||||
name={collection.collectionName}
|
|
||||||
isVerified={collection.isVerified}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{creatorAddress ? (
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
href={`https://etherscan.io/address/${creatorAddress}`}
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
<CollectionProfile
|
|
||||||
label="Creator"
|
|
||||||
avatarUrl={asset.creator.profile_img_url}
|
|
||||||
name={creatorEnsName.ENSName ?? shortenAddress(creatorAddress, 0, 4)}
|
|
||||||
isVerified
|
|
||||||
className={styles.creator}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</Row>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
{asset.priceInfo && asset.sellorders && !isOwned ? (
|
|
||||||
<Row
|
|
||||||
marginTop="8"
|
|
||||||
marginBottom="40"
|
|
||||||
justifyContent="space-between"
|
|
||||||
borderRadius="12"
|
|
||||||
paddingTop="16"
|
|
||||||
paddingBottom="16"
|
|
||||||
paddingLeft="16"
|
|
||||||
paddingRight="24"
|
|
||||||
background="accentActiveSoft"
|
|
||||||
>
|
|
||||||
<Column justifyContent="flex-start" gap="8">
|
|
||||||
<Row gap="12" as="a" target="_blank" rel="norefferer">
|
|
||||||
<a href={asset.sellorders[0].marketplaceUrl} rel="noreferrer" target="_blank">
|
|
||||||
<img
|
|
||||||
className={styles.marketplace}
|
|
||||||
src={`/nft/svgs/marketplaces/${asset.sellorders[0].marketplace.toLowerCase()}.svg`}
|
|
||||||
height={16}
|
|
||||||
width={16}
|
|
||||||
alt="Markeplace"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<Row as="span" className={subhead} color="textPrimary">
|
|
||||||
{formatEthPrice(asset.priceInfo.ETHPrice)} <Eth2Icon />
|
|
||||||
</Row>
|
|
||||||
{USDPrice && (
|
|
||||||
<Box as="span" color="textSecondary" className={bodySmall}>
|
|
||||||
${toSignificant(USDPrice)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
{(asset.sellorders?.[0] as Deprecated_SellOrder).orderClosingDate ||
|
|
||||||
(asset.sellorders?.[0] as SellOrder).endAt ? (
|
|
||||||
<CountdownTimer sellOrder={asset.sellorders[0]} />
|
|
||||||
) : null}
|
|
||||||
</Column>
|
|
||||||
<Box
|
|
||||||
as="button"
|
|
||||||
paddingTop="14"
|
|
||||||
paddingBottom="14"
|
|
||||||
fontWeight="medium"
|
|
||||||
textAlign="center"
|
|
||||||
fontSize="14"
|
|
||||||
style={{ width: '244px' }}
|
|
||||||
color={isSelected ? 'genieBlue' : 'explicitWhite'}
|
|
||||||
border="none"
|
|
||||||
borderRadius="12"
|
|
||||||
background={isSelected ? 'explicitWhite' : 'genieBlue'}
|
|
||||||
transition="250"
|
|
||||||
boxShadow={{ hover: 'elevation' }}
|
|
||||||
onClick={() => {
|
|
||||||
if (isSelected) {
|
|
||||||
removeAssetsFromBag([asset])
|
|
||||||
} else {
|
|
||||||
addAssetsToBag([asset])
|
|
||||||
sendAnalyticsEvent(EventName.NFT_BUY_ADDED, { ...eventProperties })
|
|
||||||
}
|
}
|
||||||
setSelected((x) => !x)
|
dataLength={events?.length ?? 0}
|
||||||
}}
|
scrollableTarget="activityContainer"
|
||||||
>
|
>
|
||||||
{isSelected ? 'Added to Bag' : 'Buy Now'}
|
<AssetActivity eventsData={{ events }} />
|
||||||
</Box>
|
</InfiniteScroll>
|
||||||
</Row>
|
|
||||||
) : null}
|
|
||||||
<Row gap="32" marginBottom="20">
|
|
||||||
<button data-active={showTraits} onClick={() => setShowTraits(true)} className={styles.tab}>
|
|
||||||
Traits
|
|
||||||
</button>
|
|
||||||
<button data-active={!showTraits} onClick={() => setShowTraits(false)} className={styles.tab}>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</Row>
|
|
||||||
{showTraits ? (
|
|
||||||
<Traits collectionAddress={asset.address} traits={asset.traits ?? []} />
|
|
||||||
) : (
|
) : (
|
||||||
<Details
|
<EmptyActivitiesContainer>
|
||||||
contractAddress={asset.address}
|
<div>No activities yet</div>
|
||||||
tokenId={asset.tokenId}
|
<Link to={`/nfts/collection/${asset.address}`}>View collection items</Link>{' '}
|
||||||
tokenType={asset.tokenType}
|
</EmptyActivitiesContainer>
|
||||||
blockchain="Ethereum"
|
|
||||||
metadataUrl={asset.metadataUrl}
|
|
||||||
totalSupply={collection.totalSupply}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
</InfoContainer>
|
||||||
|
<InfoContainer primaryHeader="Description" secondaryHeader={null}>
|
||||||
|
<>
|
||||||
|
<ByText>By </ByText>
|
||||||
|
{asset?.creator && asset.creator?.address && (
|
||||||
|
<AddressTextLink
|
||||||
|
href={`https://etherscan.io/address/${asset.creator.address}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{shortenAddress(asset.creator.address, 2, 4)}
|
||||||
|
</AddressTextLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DescriptionText>{collection.collectionDescription}</DescriptionText>
|
||||||
|
<SocialsContainer>
|
||||||
|
{collectionStats?.externalUrl && <Resource name="Website" link={`${collectionStats?.externalUrl}`} />}
|
||||||
|
{collectionStats?.twitterUrl && (
|
||||||
|
<Resource name="Twitter" link={`https://twitter.com/${collectionStats?.twitterUrl}`} />
|
||||||
|
)}
|
||||||
|
{collectionStats?.discordUrl && <Resource name="Discord" link={collectionStats?.discordUrl} />}
|
||||||
|
</SocialsContainer>
|
||||||
|
</>
|
||||||
|
</InfoContainer>
|
||||||
|
<InfoContainer primaryHeader="Details" secondaryHeader={null}>
|
||||||
|
<DetailsContainer asset={asset} collection={collection} />
|
||||||
|
</InfoContainer>
|
||||||
</Column>
|
</Column>
|
||||||
</div>
|
|
||||||
</AnimatedBox>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { useWeb3React } from '@web3-react/core'
|
import { useWeb3React } from '@web3-react/core'
|
||||||
import { sendAnalyticsEvent } from 'analytics'
|
import useCopyClipboard from 'hooks/useCopyClipboard'
|
||||||
import { EventName, PageName } from 'analytics/constants'
|
|
||||||
import { useTrace } from 'analytics/Trace'
|
|
||||||
import { CancelListingIcon, MinusIcon, PlusIcon } from 'nft/components/icons'
|
import { CancelListingIcon, MinusIcon, PlusIcon } from 'nft/components/icons'
|
||||||
import { useBag } from 'nft/hooks'
|
import { useBag } from 'nft/hooks'
|
||||||
import { CollectionInfoForAsset, Deprecated_SellOrder, GenieAsset, SellOrder, TokenType } from 'nft/types'
|
import { CollectionInfoForAsset, Deprecated_SellOrder, GenieAsset, SellOrder, TokenType } from 'nft/types'
|
||||||
import { ethNumberStandardFormatter, formatEthPrice, getMarketplaceIcon, timeLeft, useUsdPrice } from 'nft/utils'
|
import { ethNumberStandardFormatter, formatEthPrice, getMarketplaceIcon, timeLeft, useUsdPrice } from 'nft/utils'
|
||||||
|
import { shortenAddress } from 'nft/utils/address'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { Upload } from 'react-feather'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import styled, { useTheme } from 'styled-components/macro'
|
import styled, { css, useTheme } from 'styled-components/macro'
|
||||||
import { ThemedText } from 'theme'
|
import { ThemedText } from 'theme'
|
||||||
|
|
||||||
interface AssetPriceDetailsProps {
|
interface AssetPriceDetailsProps {
|
||||||
@ -16,8 +16,40 @@ interface AssetPriceDetailsProps {
|
|||||||
collection: CollectionInfoForAsset
|
collection: CollectionInfoForAsset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hoverState = css`
|
||||||
|
:hover::after {
|
||||||
|
border-radius: 12px;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: ${({ theme }) => theme.stateOverlayHover};
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:active::after {
|
||||||
|
border-radius: 12px;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: ${({ theme }) => theme.stateOverlayPressed};
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
margin-left: 86px;
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
position: fixed;
|
||||||
|
width: 360px;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const BestPriceContainer = styled.div`
|
const BestPriceContainer = styled.div`
|
||||||
@ -28,7 +60,6 @@ const BestPriceContainer = styled.div`
|
|||||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 320px;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const HeaderRow = styled.div`
|
const HeaderRow = styled.div`
|
||||||
@ -59,11 +90,17 @@ const BuyNowButton = styled.div<{ assetInBag: boolean; margin: boolean; useAccen
|
|||||||
margin-top: ${({ margin }) => (margin ? '12px' : '0px')};
|
margin-top: ${({ margin }) => (margin ? '12px' : '0px')};
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
${hoverState}
|
||||||
|
`
|
||||||
|
|
||||||
|
const BuyNowButtonContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
`
|
`
|
||||||
|
|
||||||
const Erc1155BuyNowButton = styled.div`
|
const Erc1155BuyNowButton = styled.div`
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||||
border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
|
border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
|
||||||
@ -74,6 +111,9 @@ const Erc1155BuyNowButton = styled.div`
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
`
|
`
|
||||||
|
const Tertiary = styled(ThemedText.BodySecondary)`
|
||||||
|
color: ${({ theme }) => theme.textTertiary};
|
||||||
|
`
|
||||||
|
|
||||||
const Erc1155BuyNowText = styled.div`
|
const Erc1155BuyNowText = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -88,12 +128,32 @@ const Erc1155ChangeButton = styled(Erc1155BuyNowText)<{ remove: boolean }>`
|
|||||||
color: ${({ theme, remove }) => (remove ? theme.accentFailure : theme.accentAction)};
|
color: ${({ theme, remove }) => (remove ? theme.accentFailure : theme.accentAction)};
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
:hover {
|
${hoverState}
|
||||||
background-color: ${({ theme, remove }) => (remove ? theme.accentFailure : theme.accentAction)};
|
|
||||||
color: ${({ theme }) => theme.textPrimary};
|
&:hover::after {
|
||||||
|
border-radius: 0px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const UploadLink = styled.a`
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
const NotForSaleContainer = styled.div`
|
const NotForSaleContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -111,10 +171,40 @@ const DiscoveryContainer = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const OwnerText = styled.a`
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
|
const OwnerInformationContainer = styled.div`
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
export const OwnerContainer = ({ asset }: { asset: GenieAsset }) => {
|
export const OwnerContainer = ({ asset }: { asset: GenieAsset }) => {
|
||||||
const listing = asset.sellorders && asset.sellorders.length > 0 ? asset.sellorders[0] : undefined
|
const listing = asset.sellorders && asset.sellorders.length > 0 ? asset.sellorders[0] : undefined
|
||||||
const expirationDate = listing
|
const cheapestOrder = asset.sellorders && asset.sellorders.length > 0 ? asset.sellorders[0] : undefined
|
||||||
? new Date((listing as Deprecated_SellOrder).orderClosingDate ?? (listing as SellOrder).endAt)
|
const expirationDate = cheapestOrder
|
||||||
|
? new Date((cheapestOrder as Deprecated_SellOrder).orderClosingDate ?? (cheapestOrder as SellOrder).endAt)
|
||||||
: undefined
|
: undefined
|
||||||
const USDPrice = useUsdPrice(asset)
|
const USDPrice = useUsdPrice(asset)
|
||||||
|
|
||||||
@ -190,26 +280,27 @@ export const NotForSale = ({ collection }: { collection: CollectionInfoForAsset
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SubHeader = styled(ThemedText.SubHeader)`
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
`
|
||||||
|
|
||||||
export const AssetPriceDetails = ({ asset, collection }: AssetPriceDetailsProps) => {
|
export const AssetPriceDetails = ({ asset, collection }: AssetPriceDetailsProps) => {
|
||||||
const { account } = useWeb3React()
|
const { account } = useWeb3React()
|
||||||
|
|
||||||
const cheapestOrder = asset.sellorders && asset.sellorders.length > 0 ? asset.sellorders[0] : undefined
|
const cheapestOrder = asset.sellorders && asset.sellorders.length > 0 ? asset.sellorders[0] : undefined
|
||||||
const expirationDate = cheapestOrder
|
const expirationDate = cheapestOrder
|
||||||
? new Date((cheapestOrder as Deprecated_SellOrder).orderClosingDate ?? (cheapestOrder as SellOrder).endAt)
|
? new Date((cheapestOrder as Deprecated_SellOrder).orderClosingDate ?? (cheapestOrder as SellOrder).endAt)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const itemsInBag = useBag((s) => s.itemsInBag)
|
const itemsInBag = useBag((s) => s.itemsInBag)
|
||||||
const addAssetsToBag = useBag((s) => s.addAssetsToBag)
|
const addAssetsToBag = useBag((s) => s.addAssetsToBag)
|
||||||
const removeAssetsFromBag = useBag((s) => s.removeAssetsFromBag)
|
const removeAssetsFromBag = useBag((s) => s.removeAssetsFromBag)
|
||||||
|
const toggleBag = useBag((s) => s.toggleBag)
|
||||||
|
const bagExpanded = useBag((s) => s.bagExpanded)
|
||||||
|
|
||||||
const USDPrice = useUsdPrice(asset)
|
const USDPrice = useUsdPrice(asset)
|
||||||
const isErc1555 = asset.tokenType === TokenType.ERC1155
|
const isErc1555 = asset.tokenType === TokenType.ERC1155
|
||||||
|
const [, setCopied] = useCopyClipboard()
|
||||||
const trace = useTrace({ page: PageName.NFT_DETAILS_PAGE })
|
|
||||||
const eventProperties = {
|
|
||||||
collection_address: asset.address,
|
|
||||||
token_id: asset.tokenId,
|
|
||||||
token_type: asset.tokenType,
|
|
||||||
...trace,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { quantity, assetInBag } = useMemo(() => {
|
const { quantity, assetInBag } = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@ -222,8 +313,7 @@ export const AssetPriceDetails = ({ asset, collection }: AssetPriceDetailsProps)
|
|||||||
}
|
}
|
||||||
}, [asset, itemsInBag])
|
}, [asset, itemsInBag])
|
||||||
|
|
||||||
const isOwner =
|
const isOwner = asset.owner ? account?.toLowerCase() === asset.owner?.address?.toLowerCase() : false
|
||||||
asset.owner && typeof asset.owner === 'string' ? account?.toLowerCase() === asset.owner.toLowerCase() : false
|
|
||||||
|
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
return <OwnerContainer asset={asset} />
|
return <OwnerContainer asset={asset} />
|
||||||
@ -231,6 +321,28 @@ export const AssetPriceDetails = ({ asset, collection }: AssetPriceDetailsProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
<OwnerInformationContainer>
|
||||||
|
<OwnerText
|
||||||
|
target="_blank"
|
||||||
|
href={`https://etherscan.io/address/${asset.owner.address}`}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{asset.tokenType === 'ERC1155' ? (
|
||||||
|
''
|
||||||
|
) : (
|
||||||
|
<span> Seller: {isOwner ? 'you' : asset.owner.address && shortenAddress(asset.owner.address, 2, 4)}</span>
|
||||||
|
)}
|
||||||
|
</OwnerText>
|
||||||
|
<UploadLink
|
||||||
|
onClick={() => {
|
||||||
|
setCopied(window.location.href)
|
||||||
|
}}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Upload size={20} strokeWidth={2} />
|
||||||
|
</UploadLink>
|
||||||
|
</OwnerInformationContainer>
|
||||||
|
|
||||||
{cheapestOrder && asset.priceInfo ? (
|
{cheapestOrder && asset.priceInfo ? (
|
||||||
<BestPriceContainer>
|
<BestPriceContainer>
|
||||||
<HeaderRow>
|
<HeaderRow>
|
||||||
@ -241,7 +353,7 @@ export const AssetPriceDetails = ({ asset, collection }: AssetPriceDetailsProps)
|
|||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
<PriceRow>
|
<PriceRow>
|
||||||
<ThemedText.MediumHeader fontSize={'28px'} lineHeight={'36px'}>
|
<ThemedText.MediumHeader fontSize={'28px'} lineHeight={'36px'}>
|
||||||
{formatEthPrice(asset.priceInfo.ETHPrice)}
|
{formatEthPrice(asset.priceInfo.ETHPrice)} ETH
|
||||||
</ThemedText.MediumHeader>
|
</ThemedText.MediumHeader>
|
||||||
{USDPrice && (
|
{USDPrice && (
|
||||||
<ThemedText.BodySecondary lineHeight={'24px'}>
|
<ThemedText.BodySecondary lineHeight={'24px'}>
|
||||||
@ -249,34 +361,48 @@ export const AssetPriceDetails = ({ asset, collection }: AssetPriceDetailsProps)
|
|||||||
</ThemedText.BodySecondary>
|
</ThemedText.BodySecondary>
|
||||||
)}
|
)}
|
||||||
</PriceRow>
|
</PriceRow>
|
||||||
{expirationDate && (
|
{expirationDate && <Tertiary fontSize={'14px'}>Sale ends: {timeLeft(expirationDate)}</Tertiary>}
|
||||||
<ThemedText.BodySecondary fontSize={'14px'}>Sale ends: {timeLeft(expirationDate)}</ThemedText.BodySecondary>
|
<div>
|
||||||
)}
|
|
||||||
{!isErc1555 || !assetInBag ? (
|
{!isErc1555 || !assetInBag ? (
|
||||||
|
<BuyNowButtonContainer>
|
||||||
<BuyNowButton
|
<BuyNowButton
|
||||||
assetInBag={assetInBag}
|
assetInBag={assetInBag}
|
||||||
margin={true}
|
margin={true}
|
||||||
useAccentColor={true}
|
useAccentColor={true}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
assetInBag ? removeAssetsFromBag([asset]) : addAssetsToBag([asset])
|
assetInBag ? removeAssetsFromBag([asset]) : addAssetsToBag([asset])
|
||||||
!assetInBag && sendAnalyticsEvent(EventName.NFT_BUY_ADDED, { ...eventProperties })
|
if (!assetInBag && !isErc1555 && !bagExpanded) {
|
||||||
|
toggleBag()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThemedText.SubHeader lineHeight={'20px'}>{assetInBag ? 'Remove' : 'Buy Now'}</ThemedText.SubHeader>
|
<SubHeader lineHeight={'20px'}>
|
||||||
|
<span>{assetInBag ? 'Remove' : 'Buy Now'}</span>
|
||||||
|
</SubHeader>
|
||||||
</BuyNowButton>
|
</BuyNowButton>
|
||||||
|
</BuyNowButtonContainer>
|
||||||
) : (
|
) : (
|
||||||
<Erc1155BuyNowButton>
|
<Erc1155BuyNowButton>
|
||||||
|
<BuyNowButtonContainer>
|
||||||
<Erc1155ChangeButton remove={true} onClick={() => removeAssetsFromBag([asset])}>
|
<Erc1155ChangeButton remove={true} onClick={() => removeAssetsFromBag([asset])}>
|
||||||
<MinusIcon width="20px" height="20px" />
|
<MinusIcon width="20px" height="20px" />
|
||||||
</Erc1155ChangeButton>
|
</Erc1155ChangeButton>
|
||||||
|
</BuyNowButtonContainer>
|
||||||
|
|
||||||
|
<BuyNowButtonContainer>
|
||||||
<Erc1155BuyNowText>
|
<Erc1155BuyNowText>
|
||||||
<ThemedText.SubHeader lineHeight={'20px'}>{quantity}</ThemedText.SubHeader>
|
<ThemedText.SubHeader lineHeight={'20px'}>{quantity}</ThemedText.SubHeader>
|
||||||
</Erc1155BuyNowText>
|
</Erc1155BuyNowText>
|
||||||
|
</BuyNowButtonContainer>
|
||||||
|
|
||||||
|
<BuyNowButtonContainer>
|
||||||
<Erc1155ChangeButton remove={false} onClick={() => addAssetsToBag([asset])}>
|
<Erc1155ChangeButton remove={false} onClick={() => addAssetsToBag([asset])}>
|
||||||
<PlusIcon width="20px" height="20px" />
|
<PlusIcon width="20px" height="20px" />
|
||||||
</Erc1155ChangeButton>
|
</Erc1155ChangeButton>
|
||||||
|
</BuyNowButtonContainer>
|
||||||
</Erc1155BuyNowButton>
|
</Erc1155BuyNowButton>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</BestPriceContainer>
|
</BestPriceContainer>
|
||||||
) : (
|
) : (
|
||||||
<NotForSale collection={collection} />
|
<NotForSale collection={collection} />
|
||||||
|
132
src/nft/components/details/DetailsContainer.tsx
Normal file
132
src/nft/components/details/DetailsContainer.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import useCopyClipboard from 'hooks/useCopyClipboard'
|
||||||
|
import { CollectionInfoForAsset, GenieAsset } from 'nft/types'
|
||||||
|
import { putCommas } from 'nft/utils'
|
||||||
|
import { shortenAddress } from 'nft/utils/address'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { Copy } from 'react-feather'
|
||||||
|
import styled from 'styled-components/macro'
|
||||||
|
|
||||||
|
const Details = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
grid-gap: 40px;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 450px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Body = styled.div`
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Center = styled.span`
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreatorLink = styled.a`
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
|
const CopyIcon = styled(Copy)`
|
||||||
|
cursor: pointer;
|
||||||
|
`
|
||||||
|
|
||||||
|
const GridItem = ({ header, body }: { header: string; body: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header>{header}</Header>
|
||||||
|
<Body>{body}</Body>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringShortener = (text: string) => `${text.substring(0, 4)}...${text.substring(text.length - 4, text.length)}`
|
||||||
|
|
||||||
|
const DetailsContainer = ({ asset, collection }: { asset: GenieAsset; collection: CollectionInfoForAsset }) => {
|
||||||
|
const { address, tokenId, tokenType, creator } = asset
|
||||||
|
const { totalSupply } = collection
|
||||||
|
|
||||||
|
const [, setCopied] = useCopyClipboard()
|
||||||
|
const copy = useCallback(() => {
|
||||||
|
setCopied(address ?? '')
|
||||||
|
}, [address, setCopied])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Details>
|
||||||
|
<GridItem
|
||||||
|
header="Contract address"
|
||||||
|
body={
|
||||||
|
<Center onClick={copy}>
|
||||||
|
{shortenAddress(address, 2, 4)} <CopyIcon size={13} />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<GridItem header="Token ID" body={tokenId.length > 9 ? stringShortener(tokenId) : tokenId} />
|
||||||
|
<GridItem header="Token standard" body={tokenType} />
|
||||||
|
<GridItem header="Blockchain" body="Ethereum" />
|
||||||
|
<GridItem header="Total supply" body={`${putCommas(totalSupply ?? 0)}`} />
|
||||||
|
<GridItem
|
||||||
|
header="Creator"
|
||||||
|
body={
|
||||||
|
creator?.address && (
|
||||||
|
<CreatorLink
|
||||||
|
href={`https://etherscan.io/address/${creator.address}`}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{shortenAddress(creator.address, 2, 4)}
|
||||||
|
</CreatorLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetailsContainer
|
89
src/nft/components/details/InfoContainer.tsx
Normal file
89
src/nft/components/details/InfoContainer.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp } from 'react-feather'
|
||||||
|
import styled, { css } from 'styled-components/macro'
|
||||||
|
|
||||||
|
const Header = styled.div<{ isOpen: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
border-radius: ${({ isOpen }) => (isOpen ? '16px 16px 0px 0px' : '16px')};
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||||
|
padding: 14px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||||
|
margin-top: 28px;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${({ theme }) => theme.stateOverlayHover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: ${({ theme }) => theme.stateOverlayPressed};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => css`background-color ${duration.medium} ${timing.ease}`};
|
||||||
|
`
|
||||||
|
|
||||||
|
const PrimaryHeader = styled.span`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 28px;
|
||||||
|
font-size: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SecondaryHeader = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
`
|
||||||
|
|
||||||
|
const SecondaryHeaderContainer = styled.span`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
`
|
||||||
|
|
||||||
|
const ContentContainer = styled.div`
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0px 0px 16px 16px;
|
||||||
|
background-color: ${({ theme }) => theme.backgroundSurface}; ;
|
||||||
|
`
|
||||||
|
|
||||||
|
const InfoContainer = ({
|
||||||
|
children,
|
||||||
|
primaryHeader,
|
||||||
|
secondaryHeader,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
children: JSX.Element
|
||||||
|
primaryHeader: string
|
||||||
|
secondaryHeader: React.ReactNode
|
||||||
|
defaultOpen?: boolean
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(!!defaultOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header isOpen={isOpen} onClick={() => setIsOpen(!isOpen)}>
|
||||||
|
<PrimaryHeader>
|
||||||
|
{primaryHeader} <SecondaryHeader>{secondaryHeader}</SecondaryHeader>
|
||||||
|
</PrimaryHeader>
|
||||||
|
<SecondaryHeaderContainer>{isOpen ? <ChevronUp /> : <ChevronDown />}</SecondaryHeaderContainer>
|
||||||
|
</Header>
|
||||||
|
{isOpen && <ContentContainer>{children}</ContentContainer>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoContainer
|
@ -1,18 +0,0 @@
|
|||||||
import { style } from '@vanilla-extract/css'
|
|
||||||
|
|
||||||
import { sprinkles } from '../../css/sprinkles.css'
|
|
||||||
|
|
||||||
export const grid = style([
|
|
||||||
sprinkles({ gap: '16', display: 'grid' }),
|
|
||||||
{
|
|
||||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
||||||
'@media': {
|
|
||||||
'(max-width: 1536px)': {
|
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
|
||||||
},
|
|
||||||
'(max-width: 640px)': {
|
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
@ -1,62 +0,0 @@
|
|||||||
import { Trait } from 'nft/hooks'
|
|
||||||
import qs from 'query-string'
|
|
||||||
|
|
||||||
import { badge } from '../../css/common.css'
|
|
||||||
import { Box } from '../Box'
|
|
||||||
import { Column } from '../Flex'
|
|
||||||
import * as styles from './Traits.css'
|
|
||||||
|
|
||||||
const TraitRow: React.FC<Trait> = ({ trait_type, trait_value }: Trait) => (
|
|
||||||
<Column backgroundColor="backgroundSurface" padding="16" gap="4" borderRadius="12">
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
className={badge}
|
|
||||||
color="textSecondary"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
overflow="hidden"
|
|
||||||
textOverflow="ellipsis"
|
|
||||||
style={{ textTransform: 'uppercase' }}
|
|
||||||
maxWidth={{ sm: '120', md: '160' }}
|
|
||||||
>
|
|
||||||
{trait_type}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
color="textPrimary"
|
|
||||||
fontSize="16"
|
|
||||||
fontWeight="normal"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
overflow="hidden"
|
|
||||||
textOverflow="ellipsis"
|
|
||||||
maxWidth={{ sm: '120', md: '160' }}
|
|
||||||
>
|
|
||||||
{trait_value}
|
|
||||||
</Box>
|
|
||||||
</Column>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Traits = ({ traits, collectionAddress }: { traits: Trait[]; collectionAddress: string }) => (
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{traits.length === 0
|
|
||||||
? 'No traits'
|
|
||||||
: traits.map((item) => {
|
|
||||||
const params = qs.stringify(
|
|
||||||
{ traits: [`("${item.trait_type}","${item.trait_value}")`] },
|
|
||||||
{
|
|
||||||
arrayFormat: 'comma',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={`${item.trait_type}-${item.trait_value}`}
|
|
||||||
href={`#/nfts/collection/${collectionAddress}?${params}`}
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
<TraitRow trait_type={item.trait_type} trait_value={item.trait_value} />
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
97
src/nft/components/details/TraitsContainer.tsx
Normal file
97
src/nft/components/details/TraitsContainer.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { GenieAsset, Trait } from 'nft/types'
|
||||||
|
import qs from 'query-string'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import styled from 'styled-components/macro'
|
||||||
|
|
||||||
|
const Grid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 780px;
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const GridItemContainer = styled(Link)`
|
||||||
|
background-color: ${({ theme }) => theme.backgroundInteractive};
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.hover};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: ${({ theme }) => theme.opacity.click};
|
||||||
|
}
|
||||||
|
|
||||||
|
transition: ${({
|
||||||
|
theme: {
|
||||||
|
transition: { duration, timing },
|
||||||
|
},
|
||||||
|
}) => `opacity ${duration.medium} ${timing.ease}`};
|
||||||
|
min-width: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TraitType = styled.div`
|
||||||
|
color: ${({ theme }) => theme.textSecondary};
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TraitValue = styled.div`
|
||||||
|
color: ${({ theme }) => theme.textPrimary};
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const GridItem = ({ trait, collectionAddress }: { trait: Trait; collectionAddress: string }) => {
|
||||||
|
const { trait_type, trait_value } = trait
|
||||||
|
const params = qs.stringify(
|
||||||
|
{ traits: [`("${trait_type}","${trait_value}")`] },
|
||||||
|
{
|
||||||
|
arrayFormat: 'comma',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridItemContainer to={`/nfts/collection/${collectionAddress}?${params}`}>
|
||||||
|
<TraitType>{trait_type}</TraitType>
|
||||||
|
<TraitValue>{trait_value}</TraitValue>
|
||||||
|
</GridItemContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TraitsContainer = ({ asset }: { asset: GenieAsset }) => {
|
||||||
|
const traits = useMemo(() => asset.traits?.sort((a, b) => a.trait_type.localeCompare(b.trait_type)), [asset])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid>
|
||||||
|
{traits?.map((trait) => {
|
||||||
|
return <GridItem key={trait.trait_type} trait={trait} collectionAddress={asset.address} />
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TraitsContainer
|
@ -1495,11 +1495,10 @@ export const EmptyNFTWalletIcon = (props: SVGProps) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const CancelListingIcon = (props: SVGProps) => (
|
export const CancelListingIcon = (props: SVGProps) => (
|
||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path
|
<path
|
||||||
d="M71 31L75.36 35.36C76.85 36.8589 77.6863 38.8865 77.6863 41C77.6863 43.1135 76.85 45.1411 75.36 46.64L46.68 75.32C45.937 76.0638 45.0547 76.6539 44.0835 77.0565C43.1123 77.4591 42.0713 77.6663 41.02 77.6663C39.9687 77.6663 38.9277 77.4591 37.9565 77.0565C36.9853 76.6539 36.103 76.0638 35.36 75.32L31 71M47.8 7.8L41 1H1V41L7.8 47.8M77.6863 1L1.62987 77.0565M21 21H21.0333"
|
d="M12.6667 6L13.3933 6.72667C13.6417 6.97648 13.7811 7.31442 13.7811 7.66667C13.7811 8.01891 13.6417 8.35685 13.3933 8.60667L8.61333 13.3867C8.4895 13.5106 8.34245 13.609 8.18059 13.6761C8.01872 13.7432 7.84522 13.7777 7.67 13.7777C7.49478 13.7777 7.32128 13.7432 7.15941 13.6761C6.99755 13.609 6.8505 13.5106 6.72667 13.3867L6 12.6667M8.8 2.13333L7.66667 1H1V7.66667L2.13333 8.8M13.7811 1L1.10498 13.6761M4.33333 4.33333H4.33889"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
|
@ -5,6 +5,7 @@ import { useDetailsQuery } from 'graphql/data/nft/Details'
|
|||||||
import { AssetDetails } from 'nft/components/details/AssetDetails'
|
import { AssetDetails } from 'nft/components/details/AssetDetails'
|
||||||
import { AssetPriceDetails } from 'nft/components/details/AssetPriceDetails'
|
import { AssetPriceDetails } from 'nft/components/details/AssetPriceDetails'
|
||||||
import { fetchSingleAsset } from 'nft/queries'
|
import { fetchSingleAsset } from 'nft/queries'
|
||||||
|
import { CollectionStatsFetcher } from 'nft/queries'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from 'react-query'
|
import { useQuery } from 'react-query'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
@ -12,8 +13,20 @@ import styled from 'styled-components/macro'
|
|||||||
|
|
||||||
const AssetContainer = styled.div`
|
const AssetContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-right: 116px;
|
width: 100%;
|
||||||
padding-left: 116px;
|
justify-content: center;
|
||||||
|
gap: 60px;
|
||||||
|
padding: 48px 40px 0 40px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AssetPriceDetailsContainer = styled.div`
|
||||||
|
min-width: 360px;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 100px;
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const Asset = () => {
|
const Asset = () => {
|
||||||
@ -37,6 +50,10 @@ const Asset = () => {
|
|||||||
[data, gqlData, isNftGraphQl]
|
[data, gqlData, isNftGraphQl]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
|
||||||
|
CollectionStatsFetcher(contractAddress)
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Trace
|
<Trace
|
||||||
@ -46,8 +63,10 @@ const Asset = () => {
|
|||||||
>
|
>
|
||||||
{asset && collection ? (
|
{asset && collection ? (
|
||||||
<AssetContainer>
|
<AssetContainer>
|
||||||
<AssetDetails collection={collection} asset={asset} />
|
<AssetDetails collection={collection} asset={asset} collectionStats={collectionStats} />
|
||||||
|
<AssetPriceDetailsContainer>
|
||||||
<AssetPriceDetails collection={collection} asset={asset} />
|
<AssetPriceDetails collection={collection} asset={asset} />
|
||||||
|
</AssetPriceDetailsContainer>
|
||||||
</AssetContainer>
|
</AssetContainer>
|
||||||
) : (
|
) : (
|
||||||
<div>Holder for loading ...</div>
|
<div>Holder for loading ...</div>
|
||||||
|
@ -3,15 +3,19 @@ import { ActivityEventResponse, ActivityFilter } from '../../types'
|
|||||||
export const ActivityFetcher = async (
|
export const ActivityFetcher = async (
|
||||||
contractAddress: string,
|
contractAddress: string,
|
||||||
filters?: ActivityFilter,
|
filters?: ActivityFilter,
|
||||||
cursor?: string
|
cursor?: string,
|
||||||
|
limit?: string
|
||||||
): Promise<ActivityEventResponse> => {
|
): Promise<ActivityEventResponse> => {
|
||||||
const filterParam =
|
const filterParam =
|
||||||
filters && filters.eventTypes
|
filters && filters.eventTypes
|
||||||
? `&${filters.eventTypes?.map((eventType) => `event_types[]=${eventType}`).join('&')}`
|
? `&${filters.eventTypes?.map((eventType) => `event_types[]=${eventType}`).join('&')}`
|
||||||
: ''
|
: ''
|
||||||
const url = `${
|
|
||||||
process.env.REACT_APP_GENIE_V3_API_URL
|
const tokenId = filters?.token_id ? `&token_id=${filters?.token_id}` : ''
|
||||||
}/collections/${contractAddress}/activity?limit=25${filterParam}${cursor ? `&cursor=${cursor}` : ''}`
|
|
||||||
|
const url = `${process.env.REACT_APP_GENIE_V3_API_URL}/collections/${contractAddress}/activity?limit=${
|
||||||
|
limit ? limit : '25'
|
||||||
|
}${filterParam}${cursor ? `&cursor=${cursor}` : ''}${tokenId}`
|
||||||
|
|
||||||
const r = await fetch(url, {
|
const r = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { CollectionInfoForAsset, GenieAsset } from '../../types'
|
import { CollectionInfoForAsset, GenieAsset } from '../../types'
|
||||||
|
|
||||||
|
interface ReponseTrait {
|
||||||
|
trait_type: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchSingleAsset = async ({
|
export const fetchSingleAsset = async ({
|
||||||
contractAddress,
|
contractAddress,
|
||||||
tokenId,
|
tokenId,
|
||||||
@ -10,5 +15,9 @@ export const fetchSingleAsset = async ({
|
|||||||
const url = `${process.env.REACT_APP_GENIE_V3_API_URL}/assetDetails?address=${contractAddress}&tokenId=${tokenId}`
|
const url = `${process.env.REACT_APP_GENIE_V3_API_URL}/assetDetails?address=${contractAddress}&tokenId=${tokenId}`
|
||||||
const r = await fetch(url)
|
const r = await fetch(url)
|
||||||
const data = await r.json()
|
const data = await r.json()
|
||||||
return [data.asset[0], data.collection]
|
const asset = data.asset[0]
|
||||||
|
|
||||||
|
asset.traits = asset.traits.map((trait: ReponseTrait) => ({ trait_type: trait.trait_type, trait_value: trait.value }))
|
||||||
|
|
||||||
|
return [asset, data.collection]
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ export enum ActivityEventTypeDisplay {
|
|||||||
'LISTING' = 'Listed',
|
'LISTING' = 'Listed',
|
||||||
'SALE' = 'Sold',
|
'SALE' = 'Sold',
|
||||||
'TRANSFER' = 'Transferred',
|
'TRANSFER' = 'Transferred',
|
||||||
'CANCEL_LISTING' = 'Cancelled',
|
'CANCEL_LISTING' = 'Cancellation',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum OrderStatus {
|
export enum OrderStatus {
|
||||||
@ -65,6 +65,7 @@ export interface ActivityFilter {
|
|||||||
collectionAddress?: string
|
collectionAddress?: string
|
||||||
eventTypes?: ActivityEventType[]
|
eventTypes?: ActivityEventType[]
|
||||||
marketplaces?: Markets[]
|
marketplaces?: Markets[]
|
||||||
|
token_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityEventResponse {
|
export interface ActivityEventResponse {
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Trait } from 'nft/hooks/useCollectionFilters'
|
|
||||||
|
|
||||||
import { Deprecated_SellOrder, SellOrder } from '../sell'
|
import { Deprecated_SellOrder, SellOrder } from '../sell'
|
||||||
|
|
||||||
export interface OpenSeaCollection {
|
export interface OpenSeaCollection {
|
||||||
@ -43,13 +41,6 @@ export interface OpenSeaAsset {
|
|||||||
collection?: OpenSeaCollection
|
collection?: OpenSeaCollection
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenSeaUser {
|
|
||||||
user?: null
|
|
||||||
profile_img_url?: string
|
|
||||||
address?: string
|
|
||||||
config?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
ERC20 = 'ERC20',
|
ERC20 = 'ERC20',
|
||||||
ERC721 = 'ERC721',
|
ERC721 = 'ERC721',
|
||||||
@ -77,6 +68,14 @@ export interface Rarity {
|
|||||||
providers?: { provider: string; rank?: number; url?: string; score?: number }[]
|
providers?: { provider: string; rank?: number; url?: string; score?: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Trait {
|
||||||
|
trait_type: string
|
||||||
|
trait_value: string
|
||||||
|
display_type?: any
|
||||||
|
max_value?: any
|
||||||
|
trait_count?: number
|
||||||
|
order?: any
|
||||||
|
}
|
||||||
export interface GenieAsset {
|
export interface GenieAsset {
|
||||||
id?: string // This would be a random id created and assigned by front end
|
id?: string // This would be a random id created and assigned by front end
|
||||||
address: string
|
address: string
|
||||||
@ -96,9 +95,14 @@ export interface GenieAsset {
|
|||||||
totalCount?: number // The totalCount from the query to /assets
|
totalCount?: number // The totalCount from the query to /assets
|
||||||
collectionIsVerified?: boolean
|
collectionIsVerified?: boolean
|
||||||
rarity?: Rarity
|
rarity?: Rarity
|
||||||
owner?: string
|
owner: {
|
||||||
creator: OpenSeaUser
|
address: string
|
||||||
metadataUrl?: string
|
}
|
||||||
|
metadataUrl: string
|
||||||
|
creator: {
|
||||||
|
address: string
|
||||||
|
profile_img_url: string
|
||||||
|
}
|
||||||
traits?: Trait[]
|
traits?: Trait[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,8 +126,8 @@ export interface GenieCollection {
|
|||||||
}
|
}
|
||||||
traits?: Record<string, Trait[]>
|
traits?: Record<string, Trait[]>
|
||||||
marketplaceCount?: { marketplace: string; count: number }[]
|
marketplaceCount?: { marketplace: string; count: number }[]
|
||||||
imageUrl?: string
|
imageUrl: string
|
||||||
twitter?: string
|
twitterUrl?: string
|
||||||
instagram?: string
|
instagram?: string
|
||||||
discordUrl?: string
|
discordUrl?: string
|
||||||
externalUrl?: string
|
externalUrl?: string
|
||||||
|
@ -20,7 +20,8 @@ export const fetchPrice = async (currency: Currency = Currency.ETH): Promise<num
|
|||||||
|
|
||||||
export function useUsdPrice(asset: GenieAsset): string | undefined {
|
export function useUsdPrice(asset: GenieAsset): string | undefined {
|
||||||
const { data: fetchedPriceData } = useQuery(['fetchPrice', {}], () => fetchPrice(), {})
|
const { data: fetchedPriceData } = useQuery(['fetchPrice', {}], () => fetchPrice(), {})
|
||||||
return fetchedPriceData && asset.priceInfo.ETHPrice
|
|
||||||
? (parseFloat(formatEther(asset.priceInfo.ETHPrice)) * fetchedPriceData).toString()
|
return fetchedPriceData && asset?.priceInfo?.ETHPrice
|
||||||
: undefined
|
? (parseFloat(formatEther(asset?.priceInfo?.ETHPrice)) * fetchedPriceData).toString()
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export const putCommas = (value?: number) => {
|
export const putCommas = (value: number) => {
|
||||||
try {
|
try {
|
||||||
if (!value) return value
|
if (!value) return value
|
||||||
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
Loading…
Reference in New Issue
Block a user