Commit 5dc0df21 authored by Jack Short's avatar Jack Short Committed by GitHub

feat: initial bag port (#4665)

* initial bag port

* adding add to bag

* adding remove

* addressing comments

* reenable bag on disconnect when reviewing price changes
parent 7f2cc9a3
...@@ -10,6 +10,7 @@ import { useModalIsOpen, useToggleModal } from 'state/application/hooks' ...@@ -10,6 +10,7 @@ import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer' import { ApplicationModal } from 'state/application/reducer'
const Cart = lazy(() => import('nft/components/sell/modal/ListingTag')) const Cart = lazy(() => import('nft/components/sell/modal/ListingTag'))
const Bag = lazy(() => import('nft/components/bag/Bag'))
export default function TopLevelModals() { export default function TopLevelModals() {
const addressClaimOpen = useModalIsOpen(ApplicationModal.ADDRESS_CLAIM) const addressClaimOpen = useModalIsOpen(ApplicationModal.ADDRESS_CLAIM)
...@@ -28,6 +29,7 @@ export default function TopLevelModals() { ...@@ -28,6 +29,7 @@ export default function TopLevelModals() {
{useTokensFlag() === TokensVariant.Enabled && {useTokensFlag() === TokensVariant.Enabled &&
(location.pathname.includes('/pool') || location.pathname.includes('/swap')) && <TokensBanner />} (location.pathname.includes('/pool') || location.pathname.includes('/swap')) && <TokensBanner />}
<Cart /> <Cart />
<Bag />
</> </>
) )
} }
import { style } from '@vanilla-extract/css'
import { subhead } from 'nft/css/common.css'
import { breakpoints, sprinkles, vars } from 'nft/css/sprinkles.css'
export const bagContainer = style([
sprinkles({
position: 'fixed',
top: { sm: '0', md: '72' },
width: 'full',
height: 'full',
right: '0',
background: 'lightGray',
color: 'blackBlue',
paddingTop: '20',
paddingBottom: '24',
zIndex: { sm: 'offcanvas', md: '3' },
}),
{
'@media': {
[`(min-width: ${breakpoints.md}px)`]: {
width: '316px',
height: 'calc(100vh - 72px)',
},
},
},
])
export const assetsContainer = style([
sprinkles({
paddingX: '32',
maxHeight: 'full',
overflowY: 'scroll',
}),
{
'::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
},
])
export const header = style([
subhead,
sprinkles({
color: 'blackBlue',
justifyContent: 'space-between',
}),
{
lineHeight: '24px',
},
])
export const clearAll = style([
sprinkles({
color: 'placeholder',
cursor: 'pointer',
fontWeight: 'semibold',
}),
{
':hover': {
color: vars.color.blue400,
},
},
])
This diff is collapsed.
import { style } from '@vanilla-extract/css'
import { body } from 'nft/css/common.css'
import { sprinkles } from 'nft/css/sprinkles.css'
export const footerContainer = sprinkles({
marginTop: '20',
paddingX: '16',
})
export const footer = style([
sprinkles({
background: 'lightGray',
color: 'blackBlue',
paddingX: '16',
paddingY: '12',
borderBottomLeftRadius: '12',
borderBottomRightRadius: '12',
}),
])
export const warningContainer = style([
sprinkles({
paddingY: '12',
borderTopLeftRadius: '12',
borderTopRightRadius: '12',
justifyContent: 'center',
fontSize: '12',
fontWeight: 'semibold',
}),
{
color: '#EEB317',
background: '#EEB3173D',
lineHeight: '12px',
},
])
export const payButton = style([
body,
sprinkles({
background: 'blue400',
color: 'blackBlue',
border: 'none',
borderRadius: '12',
paddingY: '12',
fontWeight: 'semibold',
cursor: 'pointer',
justifyContent: 'center',
gap: '16',
}),
{
':disabled': {
opacity: '0.6',
cursor: 'auto',
},
},
])
export const ethPill = style([
sprinkles({
background: 'lightGray',
gap: '8',
paddingY: '4',
paddingLeft: '4',
paddingRight: '12',
fontSize: '20',
fontWeight: 'semibold',
borderRadius: 'round',
}),
{
lineHeight: '24px',
},
])
import { BigNumber } from '@ethersproject/bignumber'
import ethereumLogoUrl from 'assets/images/ethereum-logo.png'
import Loader from 'components/Loader'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { bodySmall, header2, subheadSmall } from 'nft/css/common.css'
import { BagStatus } from 'nft/types'
import { ethNumberStandardFormatter, formatWeiToDecimal } from 'nft/utils/currency'
import { useModalIsOpen, useToggleWalletModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import * as styles from './BagFooter.css'
interface BagFooterProps {
balance: BigNumber
isConnected: boolean
sufficientBalance: boolean
totalEthPrice: BigNumber
totalUsdPrice: number | undefined
bagStatus: BagStatus
setBagStatus: (status: BagStatus) => void
fetchReview: () => void
assetsAreInReview: boolean
}
const PENDING_BAG_STATUSES = [
BagStatus.FETCHING_ROUTE,
BagStatus.CONFIRMING_IN_WALLET,
BagStatus.FETCHING_FINAL_ROUTE,
BagStatus.PROCESSING_TRANSACTION,
]
export const BagFooter = ({
balance,
isConnected,
sufficientBalance,
totalEthPrice,
totalUsdPrice,
bagStatus,
setBagStatus,
fetchReview,
assetsAreInReview,
}: BagFooterProps) => {
const toggleWalletModal = useToggleWalletModal()
const walletModalIsOpen = useModalIsOpen(ApplicationModal.WALLET)
const isPending = PENDING_BAG_STATUSES.includes(bagStatus) || walletModalIsOpen
const isDisabled = isConnected && (isPending || !sufficientBalance || assetsAreInReview)
const showWarning = isConnected && (!sufficientBalance || bagStatus === BagStatus.WARNING)
return (
<Column className={styles.footerContainer}>
{showWarning && (
<Row className={styles.warningContainer}>
{!sufficientBalance
? `Insufficient funds (${formatWeiToDecimal(balance.toString())} ETH)`
: `Something went wrong. Please try again.`}
</Row>
)}
<Column
borderTopLeftRadius={showWarning ? '0' : '12'}
borderTopRightRadius={showWarning ? '0' : '12'}
className={styles.footer}
>
<Box marginBottom="8" style={{ lineHeight: '20px', opacity: '0.54' }} className={subheadSmall}>
Total
</Box>
<Column marginBottom="16">
<Row justifyContent="space-between">
<Box className={header2}>{`${formatWeiToDecimal(totalEthPrice.toString())}`}</Box>
<Row className={styles.ethPill}>
<Box as="img" src={ethereumLogoUrl} alt="Ethereum" width="24" height="24" />
ETH
</Row>
</Row>
<Box fontWeight="normal" style={{ lineHeight: '20px', opacity: '0.6' }} className={bodySmall}>
{`${ethNumberStandardFormatter(totalUsdPrice, true)}`}
</Box>
</Column>
<Row
as="button"
color="explicitWhite"
className={styles.payButton}
disabled={isDisabled}
onClick={() => {
if (!isConnected) {
toggleWalletModal()
} else if (bagStatus === BagStatus.ADDING_TO_BAG) {
fetchReview()
} else if (bagStatus === BagStatus.CONFIRM_REVIEW || bagStatus === BagStatus.WARNING) {
setBagStatus(BagStatus.FETCHING_FINAL_ROUTE)
}
}}
>
{isPending && <Loader size="20px" stroke="white" />}
{!isConnected || walletModalIsOpen
? 'Connect wallet'
: bagStatus === BagStatus.FETCHING_FINAL_ROUTE || bagStatus === BagStatus.CONFIRMING_IN_WALLET
? 'Proceed in wallet'
: bagStatus === BagStatus.PROCESSING_TRANSACTION
? 'Transaction pending'
: 'Pay'}
</Row>
</Column>
</Column>
)
}
import { style } from '@vanilla-extract/css'
import { bodySmall, buttonTextSmall } from 'nft/css/common.css'
import { sprinkles, themeVars, vars } from 'nft/css/sprinkles.css'
export const bagRow = style([
sprinkles({
color: 'blackBlue',
padding: '4',
gap: '12',
cursor: 'pointer',
height: 'full',
borderRadius: '12',
}),
{
marginLeft: '-4px',
marginRight: '-4px',
':hover': {
background: themeVars.colors.darkGray10,
},
},
])
export const unavailableAssetsContainer = sprinkles({
background: 'none',
gap: '12',
color: 'blackBlue',
paddingY: '16',
borderStyle: 'solid',
borderWidth: '1px',
borderColor: 'transparent',
borderTopColor: 'medGray',
borderBottomColor: 'medGray',
height: 'full',
})
export const priceChangeColumn = sprinkles({
background: 'none',
gap: '8',
color: 'blackBlue',
paddingY: '16',
borderStyle: 'solid',
borderWidth: '1px',
borderColor: 'transparent',
borderBottomColor: 'medGray',
height: 'full',
cursor: 'pointer',
})
export const priceChangeRow = style([
sprinkles({
color: 'placeholder',
gap: '4',
fontSize: '14',
fontWeight: 'normal',
}),
{
lineHeight: '20px',
},
])
export const unavailableAssetRow = style([
sprinkles({
gap: '12',
color: 'blackBlue',
paddingX: '12',
paddingY: '4',
}),
{
':hover': {
background: themeVars.colors.lightGrayButton,
},
},
])
export const priceChangeButton = style([
bodySmall,
sprinkles({
width: 'full',
paddingY: '8',
color: 'explicitWhite',
fontWeight: 'semibold',
textAlign: 'center',
borderRadius: '12',
}),
{
':hover': {
color: themeVars.colors.placeholder,
},
},
])
export const keepButton = style([
priceChangeButton,
sprinkles({
backgroundColor: 'blue400',
}),
{
':hover': {
background: `linear-gradient(rgba(76, 130, 251, 0.24), rgba(76, 130, 251, .24)), linear-gradient(${vars.color.blue400}, ${vars.color.blue400})`,
},
},
])
export const removeButton = style([
priceChangeButton,
sprinkles({
backgroundColor: 'lightGrayButton',
}),
{
':hover': {
background: `linear-gradient(rgba(76, 130, 251, 0.24), rgba(76, 130, 251, .24)), linear-gradient(${vars.color.lightGrayButton}, ${vars.color.lightGrayButton})`,
},
},
])
export const bagRowImage = sprinkles({
width: '72',
height: '72',
borderRadius: '8',
})
export const grayscaleImage = style({
filter: 'grayscale(100%)',
})
export const unavailableImage = style([
grayscaleImage,
sprinkles({
width: '44',
height: '44',
borderRadius: '8',
}),
])
export const removeAssetOverlay = style([
sprinkles({
position: 'absolute',
right: '4',
top: '4',
}),
])
export const bagRowPrice = style([
sprinkles({
gap: '4',
fontSize: '16',
fontWeight: 'normal',
}),
{
lineHeight: '24px',
},
])
export const assetName = style([
sprinkles({
fontSize: '14',
fontWeight: 'semibold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
{
lineHeight: '20px',
},
])
export const collectionName = style([
buttonTextSmall,
sprinkles({
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}),
{
lineHeight: '20px',
},
])
export const icon = sprinkles({
flexShrink: '0',
})
export const previewImageGrid = style([
sprinkles({
display: 'grid',
}),
{
gridTemplateColumns: 'repeat(5, 13px)',
},
])
export const toolTip = sprinkles({
color: 'darkGray',
display: 'flex',
flexShrink: '0',
})
import { BigNumber } from '@ethersproject/bignumber'
import clsx from 'clsx'
import { TimedLoader } from 'nft/components/bag/TimedLoader'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import {
ChevronDownBagIcon,
ChevronUpBagIcon,
CircularCloseIcon,
CloseTimerIcon,
SquareArrowDownIcon,
SquareArrowUpIcon,
VerifiedIcon,
} from 'nft/components/icons'
import { loadingBlock } from 'nft/css/loading.css'
import { GenieAsset, UpdatedGenieAsset } from 'nft/types'
import { getAssetHref } from 'nft/utils/asset'
import { formatWeiToDecimal } from 'nft/utils/currency'
import { MouseEvent, useEffect, useReducer, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import * as styles from './BagRow.css'
const NoContentContainer = () => (
<Box position="relative" background="loadingBackground" className={styles.bagRowImage}>
<Box
position="absolute"
textAlign="center"
left="1/2"
top="1/2"
style={{ transform: 'translate3d(-50%, -50%, 0)' }}
color="grey500"
fontSize="12"
fontWeight="normal"
>
Image
<br />
not
<br />
available
</Box>
</Box>
)
interface BagRowProps {
asset: UpdatedGenieAsset
removeAsset: (asset: GenieAsset) => void
showRemove?: boolean
grayscale?: boolean
isMobile: boolean
}
export const BagRow = ({ asset, removeAsset, showRemove, grayscale, isMobile }: BagRowProps) => {
const [cardHovered, setCardHovered] = useState(false)
const [loadedImage, setImageLoaded] = useState(false)
const [noImageAvailable, setNoImageAvailable] = useState(!asset.smallImageUrl)
const handleCardHover = () => setCardHovered(!cardHovered)
const assetCardRef = useRef<HTMLDivElement>(null)
if (cardHovered && assetCardRef.current && assetCardRef.current.matches(':hover') === false) setCardHovered(false)
return (
<Link to={getAssetHref(asset)} style={{ textDecoration: 'none' }}>
<Row ref={assetCardRef} className={styles.bagRow} onMouseEnter={handleCardHover} onMouseLeave={handleCardHover}>
<Box position="relative" display="flex">
<Box
display={showRemove ? 'block' : 'none'}
className={styles.removeAssetOverlay}
onClick={(e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
removeAsset(asset)
}}
transition="250"
style={{ opacity: cardHovered || isMobile ? '1' : '0' }}
zIndex="1"
>
<CircularCloseIcon />
</Box>
{!noImageAvailable && (
<Box
as="img"
src={asset.smallImageUrl}
alt={asset.name}
className={clsx(styles.bagRowImage, grayscale && !cardHovered && styles.grayscaleImage)}
onLoad={() => {
setImageLoaded(true)
}}
onError={() => {
setNoImageAvailable(true)
}}
visibility={loadedImage ? 'visible' : 'hidden'}
/>
)}
{!loadedImage && <Box position="absolute" className={`${styles.bagRowImage} ${loadingBlock}`} />}
{noImageAvailable && <NoContentContainer />}
</Box>
<Column
overflow="hidden"
height="full"
justifyContent="space-between"
color={grayscale ? 'darkGray' : 'blackBlue'}
>
<Column>
<Row overflow="hidden" whiteSpace="nowrap" gap="2">
<Box className={styles.assetName}>{asset.name || asset.tokenId}</Box>
</Row>
<Row overflow="hidden" whiteSpace="nowrap" gap="2">
<Box className={styles.collectionName}>{asset.collectionName}</Box>
{asset.collectionIsVerified && <VerifiedIcon className={styles.icon} />}
</Row>
</Column>
<Row className={styles.bagRowPrice}>
{`${formatWeiToDecimal(
asset.updatedPriceInfo ? asset.updatedPriceInfo.ETHPrice : asset.priceInfo.ETHPrice
)} ETH`}
</Row>
</Column>
</Row>
</Link>
)
}
interface PriceChangeBagRowProps {
asset: UpdatedGenieAsset
markAssetAsReviewed: (asset: UpdatedGenieAsset, toKeep: boolean) => void
top?: boolean
isMobile: boolean
}
export const PriceChangeBagRow = ({ asset, markAssetAsReviewed, top, isMobile }: PriceChangeBagRowProps) => {
const isPriceIncrease = BigNumber.from(asset.updatedPriceInfo?.ETHPrice).gt(BigNumber.from(asset.priceInfo.ETHPrice))
return (
<Column className={styles.priceChangeColumn} borderTopColor={top ? 'medGray' : 'transparent'}>
<Row className={styles.priceChangeRow}>
{isPriceIncrease ? <SquareArrowUpIcon /> : <SquareArrowDownIcon />}
<Box>{`Price ${isPriceIncrease ? 'increased' : 'decreased'} from ${formatWeiToDecimal(
asset.priceInfo.ETHPrice
)} ETH`}</Box>
</Row>
<BagRow asset={asset} removeAsset={() => undefined} isMobile={isMobile} />
<Row gap="12" justifyContent="space-between">
<Box
className={styles.removeButton}
onClick={(e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
markAssetAsReviewed(asset, false)
}}
>
Remove
</Box>
<Box
className={styles.keepButton}
onClick={(e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
markAssetAsReviewed(asset, true)
}}
>
Keep
</Box>
</Row>
</Column>
)
}
interface UnavailableAssetsHeaderRowProps {
assets?: UpdatedGenieAsset[]
clearUnavailableAssets: () => void
didOpenUnavailableAssets: boolean
setDidOpenUnavailableAssets: (didOpen: boolean) => void
isMobile: boolean
}
interface UnavailableAssetsPreviewProps {
assets: UpdatedGenieAsset[]
}
const ASSET_PREVIEW_WIDTH = 32
const ASSET_PREVIEW_OFFSET = 20
const UnavailableAssetsPreview = ({ assets }: UnavailableAssetsPreviewProps) => (
<Column
display="grid"
style={{
gridTemplateColumns: `repeat(${assets.length}, 20px)`,
width: `${ASSET_PREVIEW_WIDTH + (assets.length - 1) * ASSET_PREVIEW_OFFSET}px`,
}}
>
{assets.map((asset, index) => (
<Box
key={`preview${index}`}
as="img"
src={asset.smallImageUrl}
width="32"
height="32"
borderStyle="solid"
borderWidth="1px"
borderColor="lightGray"
borderRadius="4"
style={{ zIndex: assets.length - index }}
className={styles.grayscaleImage}
/>
))}
</Column>
)
export const UnavailableAssetsHeaderRow = ({
assets,
clearUnavailableAssets,
didOpenUnavailableAssets,
setDidOpenUnavailableAssets,
isMobile,
}: UnavailableAssetsHeaderRowProps) => {
const [isOpen, toggleOpen] = useReducer((s) => !s, false)
const timerLimit = 8
const [timeLeft, setTimeLeft] = useState(timerLimit)
useEffect(() => {
if (!timeLeft) {
if (!didOpenUnavailableAssets) {
clearUnavailableAssets()
setDidOpenUnavailableAssets(false)
}
return
}
const intervalId = setInterval(() => {
setTimeLeft(timeLeft - 1)
}, 1000)
return () => clearInterval(intervalId)
}, [timeLeft, clearUnavailableAssets, didOpenUnavailableAssets, setDidOpenUnavailableAssets])
if (!assets || assets.length === 0) return null
return (
<Column className={styles.unavailableAssetsContainer}>
<Row className={styles.priceChangeRow} justifyContent="space-between">
No longer available for sale
{!didOpenUnavailableAssets && (
<Row
position="relative"
width="20"
height="20"
color="blackBlue"
justifyContent="center"
cursor="pointer"
onClick={() => clearUnavailableAssets()}
>
<TimedLoader />
<CloseTimerIcon />
</Row>
)}
</Row>
{assets.length === 1 && <BagRow asset={assets[0]} removeAsset={() => undefined} grayscale isMobile={isMobile} />}
{assets.length > 1 && (
<Column>
<Row
justifyContent="space-between"
marginBottom={isOpen ? '12' : '0'}
cursor="pointer"
onClick={() => {
!didOpenUnavailableAssets && setDidOpenUnavailableAssets(true)
toggleOpen()
}}
>
<Row gap="12" fontSize="14" color="darkGray" fontWeight="normal" style={{ lineHeight: '20px' }}>
{!isOpen && <UnavailableAssetsPreview assets={assets.slice(0, 5)} />}
{`${assets.length} unavailable NFTs`}
</Row>
<Row color="darkGray">{isOpen ? <ChevronUpBagIcon /> : <ChevronDownBagIcon />}</Row>
</Row>
<Column gap="8">
{isOpen &&
assets.map((asset) => (
<BagRow key={asset.id} asset={asset} removeAsset={() => undefined} grayscale isMobile={isMobile} />
))}
</Column>
</Column>
)}
</Column>
)
}
import { Box } from 'nft/components/Box'
import styled, { keyframes } from 'styled-components/macro'
const dash = keyframes`
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
`
const Circle = styled.circle`
stroke-dasharray: 1000;
stroke-dashoffset: 0;
-webkit-animation: ${dash} linear;
animation: ${dash} linear;
animation-duration: 160s;
stroke: ${({ theme }) => theme.accentActive};
`
export const TimedLoader = () => {
const stroke = 1.5
return (
<Box display="flex" position="absolute">
<svg height="18px" width="18px">
<Circle
strokeWidth={`${stroke}`}
strokeLinecap="round"
style={{
transform: 'rotate(90deg)',
transformOrigin: '50% 50%',
}}
fill="transparent"
r="8px"
cx="9px"
cy="9px"
/>
</svg>
</Box>
)
}
...@@ -42,23 +42,24 @@ const baseHref = (asset: GenieAsset) => `/#/nfts/asset/${asset.address}/${asset. ...@@ -42,23 +42,24 @@ const baseHref = (asset: GenieAsset) => `/#/nfts/asset/${asset.address}/${asset.
/* -------- ASSET CARD -------- */ /* -------- ASSET CARD -------- */
interface CardProps { interface CardProps {
asset: GenieAsset asset: GenieAsset
selected: boolean
children: ReactNode children: ReactNode
} }
const Container = ({ asset, children }: CardProps) => { const Container = ({ asset, selected, children }: CardProps) => {
const [hovered, toggleHovered] = useReducer((s) => !s, false) const [hovered, toggleHovered] = useReducer((s) => !s, false)
const [href, setHref] = useState(baseHref(asset)) const [href, setHref] = useState(baseHref(asset))
const providerValue = useMemo( const providerValue = useMemo(
() => ({ () => ({
asset, asset,
selected: false, selected,
hovered, hovered,
toggleHovered, toggleHovered,
href, href,
setHref, setHref,
}), }),
[asset, hovered, href] [asset, hovered, selected, href]
) )
const assetRef = useRef<HTMLDivElement>(null) const assetRef = useRef<HTMLDivElement>(null)
...@@ -412,12 +413,13 @@ const TertiaryInfo = ({ children }: { children: ReactNode }) => { ...@@ -412,12 +413,13 @@ const TertiaryInfo = ({ children }: { children: ReactNode }) => {
interface ButtonProps { interface ButtonProps {
children: ReactNode children: ReactNode
quantity: number
selectedChildren: ReactNode selectedChildren: ReactNode
onClick: (e: MouseEvent) => void onClick: (e: MouseEvent) => void
onSelectedClick: (e: MouseEvent) => void onSelectedClick: (e: MouseEvent) => void
} }
const Button = ({ children, selectedChildren, onClick, onSelectedClick }: ButtonProps) => { const Button = ({ children, quantity, selectedChildren, onClick, onSelectedClick }: ButtonProps) => {
const [buttonHovered, toggleButtonHovered] = useReducer((s) => !s, false) const [buttonHovered, toggleButtonHovered] = useReducer((s) => !s, false)
const { asset, selected, setHref } = useCardContext() const { asset, selected, setHref } = useCardContext()
const buttonRef = useRef<HTMLDivElement>(null) const buttonRef = useRef<HTMLDivElement>(null)
...@@ -490,7 +492,7 @@ const Button = ({ children, selectedChildren, onClick, onSelectedClick }: Button ...@@ -490,7 +492,7 @@ const Button = ({ children, selectedChildren, onClick, onSelectedClick }: Button
> >
<MinusIconLarge width="32" height="32" /> <MinusIconLarge width="32" height="32" />
</Column> </Column>
<Box className={`${styles.erc1155QuantityText} ${subheadSmall}`}></Box> <Box className={`${styles.erc1155QuantityText} ${subheadSmall}`}>{quantity.toString()}</Box>
<Column <Column
as="button" as="button"
className={styles.erc1155PlusButton} className={styles.erc1155PlusButton}
......
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import * as Card from 'nft/components/collection/Card' import { useBag } from 'nft/hooks'
import { GenieAsset, UniformHeight } from 'nft/types' import { GenieAsset, UniformHeight } from 'nft/types'
import { formatWeiToDecimal } from 'nft/utils/currency' import { formatWeiToDecimal } from 'nft/utils/currency'
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 { MouseEvent, useMemo } from 'react' import { MouseEvent, useMemo } from 'react'
import * as Card from './Card'
enum AssetMediaType { enum AssetMediaType {
Image, Image,
Video, Video,
...@@ -14,6 +16,7 @@ enum AssetMediaType { ...@@ -14,6 +16,7 @@ enum AssetMediaType {
interface CollectionAssetProps { interface CollectionAssetProps {
asset: GenieAsset asset: GenieAsset
isMobile: boolean
uniformHeight: UniformHeight uniformHeight: UniformHeight
setUniformHeight: (u: UniformHeight) => void setUniformHeight: (u: UniformHeight) => void
mediaShouldBePlaying: boolean mediaShouldBePlaying: boolean
...@@ -22,11 +25,31 @@ interface CollectionAssetProps { ...@@ -22,11 +25,31 @@ interface CollectionAssetProps {
export const CollectionAsset = ({ export const CollectionAsset = ({
asset, asset,
isMobile,
uniformHeight, uniformHeight,
setUniformHeight, setUniformHeight,
mediaShouldBePlaying, mediaShouldBePlaying,
setCurrentTokenPlayingMedia, setCurrentTokenPlayingMedia,
}: CollectionAssetProps) => { }: CollectionAssetProps) => {
const { addAssetToBag, removeAssetFromBag, itemsInBag, bagExpanded, toggleBag } = useBag((state) => ({
addAssetToBag: state.addAssetToBag,
removeAssetFromBag: state.removeAssetFromBag,
itemsInBag: state.itemsInBag,
bagExpanded: state.bagExpanded,
toggleBag: state.toggleBag,
}))
const { quantity, isSelected } = useMemo(() => {
return {
quantity: itemsInBag.filter(
(x) => x.asset.tokenType === 'ERC1155' && x.asset.tokenId === asset.tokenId && x.asset.address === asset.address
).length,
isSelected: itemsInBag.some(
(item) => asset.tokenId === item.asset.tokenId && asset.address === item.asset.address
),
}
}, [asset, itemsInBag])
const { notForSale, assetMediaType } = useMemo(() => { const { notForSale, assetMediaType } = useMemo(() => {
let notForSale = true let notForSale = true
let assetMediaType = AssetMediaType.Image let assetMediaType = AssetMediaType.Image
...@@ -45,7 +68,7 @@ export const CollectionAsset = ({ ...@@ -45,7 +68,7 @@ export const CollectionAsset = ({
}, [asset]) }, [asset])
return ( return (
<Card.Container asset={asset}> <Card.Container asset={asset} selected={isSelected}>
{assetMediaType === AssetMediaType.Image ? ( {assetMediaType === AssetMediaType.Image ? (
<Card.Image uniformHeight={uniformHeight} setUniformHeight={setUniformHeight} /> <Card.Image uniformHeight={uniformHeight} setUniformHeight={setUniformHeight} />
) : assetMediaType === AssetMediaType.Video ? ( ) : assetMediaType === AssetMediaType.Video ? (
...@@ -82,12 +105,16 @@ export const CollectionAsset = ({ ...@@ -82,12 +105,16 @@ export const CollectionAsset = ({
</Card.SecondaryRow> </Card.SecondaryRow>
</Card.InfoContainer> </Card.InfoContainer>
<Card.Button <Card.Button
quantity={quantity}
selectedChildren={'Remove'} selectedChildren={'Remove'}
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
e.preventDefault() e.preventDefault()
addAssetToBag(asset)
!bagExpanded && !isMobile && toggleBag()
}} }}
onSelectedClick={(e: MouseEvent) => { onSelectedClick={(e: MouseEvent) => {
e.preventDefault() e.preventDefault()
removeAssetFromBag(asset)
}} }}
> >
{'Buy now'} {'Buy now'}
......
...@@ -122,6 +122,7 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => { ...@@ -122,6 +122,7 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
<CollectionAsset <CollectionAsset
key={asset.address + asset.tokenId} key={asset.address + asset.tokenId}
asset={asset} asset={asset}
isMobile={isMobile}
uniformHeight={uniformHeight} uniformHeight={uniformHeight}
setUniformHeight={setUniformHeight} setUniformHeight={setUniformHeight}
mediaShouldBePlaying={asset.tokenId === currentTokenPlayingMedia} mediaShouldBePlaying={asset.tokenId === currentTokenPlayingMedia}
......
...@@ -148,6 +148,7 @@ export const vars = createGlobalTheme(':root', { ...@@ -148,6 +148,7 @@ export const vars = createGlobalTheme(':root', {
...themeVars.colors, ...themeVars.colors,
genieBlue: '#4C82FB', genieBlue: '#4C82FB',
fallbackGradient: 'linear-gradient(270deg, #D1D5DB 0%, #F6F6F6 100%)', fallbackGradient: 'linear-gradient(270deg, #D1D5DB 0%, #F6F6F6 100%)',
loadingBackground: '#24272e',
dropShadow: '0px 4px 16px rgba(70, 115, 250, 0.4)', dropShadow: '0px 4px 16px rgba(70, 115, 250, 0.4)',
green: '#209853', green: '#209853',
orange: '#FA2C38', orange: '#FA2C38',
......
import { BagItem, BagItemStatus, BagStatus, UpdatedGenieAsset } from 'nft/types'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import create from 'zustand' import create from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { BagItem, BagItemStatus, BagStatus, UpdatedGenieAsset } from '../types' interface BagState {
bagStatus: BagStatus
type BagState = { setBagStatus: (state: BagStatus) => void
itemsInBag: BagItem[] itemsInBag: BagItem[]
setItemsInBag: (items: BagItem[]) => void
addAssetToBag: (asset: UpdatedGenieAsset) => void addAssetToBag: (asset: UpdatedGenieAsset) => void
removeAssetFromBag: (asset: UpdatedGenieAsset) => void removeAssetFromBag: (asset: UpdatedGenieAsset) => void
isLocked: boolean markAssetAsReviewed: (asset: UpdatedGenieAsset, toKeep: boolean) => void
setLocked: (isLocked: boolean) => void didOpenUnavailableAssets: boolean
setDidOpenUnavailableAssets: (didOpen: boolean) => void
bagExpanded: boolean bagExpanded: boolean
toggleBag: () => void toggleBag: () => void
isLocked: boolean
setLocked: (isLocked: boolean) => void
reset: () => void
} }
export const useBag = create<BagState>()( export const useBag = create<BagState>()(
devtools( devtools(
(set, get) => ({ (set, get) => ({
bagStatus: BagStatus.ADDING_TO_BAG,
setBagStatus: (newBagStatus) =>
set(() => ({
bagStatus: newBagStatus,
})),
markAssetAsReviewed: (asset, toKeep) =>
set(({ itemsInBag }) => {
if (itemsInBag.length === 0) return { itemsInBag: [] }
const itemsInBagCopy = [...itemsInBag]
const index = itemsInBagCopy.findIndex((item) => item.asset.id === asset.id)
if (!toKeep && index !== -1) itemsInBagCopy.splice(index, 1)
else if (index !== -1) {
itemsInBagCopy[index].status = BagItemStatus.REVIEWED
}
return {
itemsInBag: itemsInBagCopy,
}
}),
didOpenUnavailableAssets: false,
setDidOpenUnavailableAssets: (didOpen) =>
set(() => ({
didOpenUnavailableAssets: didOpen,
})),
bagExpanded: false, bagExpanded: false,
toggleBag: () => toggleBag: () =>
set(({ bagExpanded }) => ({ set(({ bagExpanded }) => ({
...@@ -28,9 +57,13 @@ export const useBag = create<BagState>()( ...@@ -28,9 +57,13 @@ export const useBag = create<BagState>()(
isLocked: _isLocked, isLocked: _isLocked,
})), })),
itemsInBag: [], itemsInBag: [],
setItemsInBag: (items) =>
set(() => ({
itemsInBag: items,
})),
addAssetToBag: (asset) => addAssetToBag: (asset) =>
set(({ itemsInBag }) => { set(({ itemsInBag }) => {
if (get().isLocked) return { itemsInBag } if (get().isLocked) return { itemsInBag: get().itemsInBag }
const assetWithId = { asset: { id: uuidv4(), ...asset }, status: BagItemStatus.ADDED_TO_BAG } const assetWithId = { asset: { id: uuidv4(), ...asset }, status: BagItemStatus.ADDED_TO_BAG }
if (itemsInBag.length === 0) if (itemsInBag.length === 0)
return { return {
...@@ -45,17 +78,28 @@ export const useBag = create<BagState>()( ...@@ -45,17 +78,28 @@ export const useBag = create<BagState>()(
}), }),
removeAssetFromBag: (asset) => { removeAssetFromBag: (asset) => {
set(({ itemsInBag }) => { set(({ itemsInBag }) => {
if (get().isLocked) return { itemsInBag } if (get().isLocked) return { itemsInBag: get().itemsInBag }
if (itemsInBag.length === 0) return { itemsInBag: [] } if (itemsInBag.length === 0) return { itemsInBag: [] }
const itemsCopy = [...itemsInBag] const itemsCopy = [...itemsInBag]
const index = itemsCopy.findIndex((n) => const index = itemsCopy.findIndex((n) =>
asset.id ? n.asset.id === asset.id : n.asset.tokenId === asset.tokenId && n.asset.address === asset.address asset.id ? n.asset.id === asset.id : n.asset.tokenId === asset.tokenId && n.asset.address === asset.address
) )
if (index === -1) return { itemsInBag } if (index === -1) return { itemsInBag: get().itemsInBag }
itemsCopy.splice(index, 1) itemsCopy.splice(index, 1)
return { itemsInBag: itemsCopy } return { itemsInBag: itemsCopy }
}) })
}, },
reset: () =>
set(() => {
if (!get().isLocked)
return {
bagStatus: BagStatus.ADDING_TO_BAG,
itemsInBag: [],
didOpenUnavailableAssets: false,
isLocked: false,
}
else return {}
}),
}), }),
{ name: 'useBag' } { name: 'useBag' }
) )
......
import { AnimatedBox, Box } from 'nft/components/Box' import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionNfts, CollectionStats, Filters } from 'nft/components/collection' import { CollectionNfts, CollectionStats, Filters } from 'nft/components/collection'
import { Column, Row } from 'nft/components/Flex' import { Column, Row } from 'nft/components/Flex'
import { useCollectionFilters, useFiltersExpanded, useIsMobile } from 'nft/hooks' import { useBag, useCollectionFilters, useFiltersExpanded, useIsMobile } from 'nft/hooks'
import * as styles from 'nft/pages/collection/index.css' import * as styles from 'nft/pages/collection/index.css'
import { CollectionStatsFetcher } from 'nft/queries' import { CollectionStatsFetcher } from 'nft/queries'
import { useEffect } from 'react' import { useEffect } from 'react'
...@@ -10,6 +10,7 @@ import { useParams } from 'react-router-dom' ...@@ -10,6 +10,7 @@ import { useParams } from 'react-router-dom'
import { useSpring } from 'react-spring/web' import { useSpring } from 'react-spring/web'
const FILTER_WIDTH = 332 const FILTER_WIDTH = 332
const BAG_WIDTH = 324
const Collection = () => { const Collection = () => {
const { contractAddress } = useParams() const { contractAddress } = useParams()
...@@ -17,6 +18,7 @@ const Collection = () => { ...@@ -17,6 +18,7 @@ const Collection = () => {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [isFiltersExpanded] = useFiltersExpanded() const [isFiltersExpanded] = useFiltersExpanded()
const setMarketCount = useCollectionFilters((state) => state.setMarketCount) const setMarketCount = useCollectionFilters((state) => state.setMarketCount)
const isBagExpanded = useBag((state) => state.bagExpanded)
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () => const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
CollectionStatsFetcher(contractAddress as string) CollectionStatsFetcher(contractAddress as string)
...@@ -24,7 +26,13 @@ const Collection = () => { ...@@ -24,7 +26,13 @@ const Collection = () => {
const { gridX, gridWidthOffset } = useSpring({ const { gridX, gridWidthOffset } = useSpring({
gridX: isFiltersExpanded ? FILTER_WIDTH : 0, gridX: isFiltersExpanded ? FILTER_WIDTH : 0,
gridWidthOffset: isFiltersExpanded ? FILTER_WIDTH : 0, gridWidthOffset: isFiltersExpanded
? isBagExpanded
? BAG_WIDTH + FILTER_WIDTH
: FILTER_WIDTH
: isBagExpanded
? BAG_WIDTH
: 0,
}) })
useEffect(() => { useEffect(() => {
......
...@@ -85,7 +85,7 @@ export interface GenieAsset { ...@@ -85,7 +85,7 @@ export interface GenieAsset {
currentUsdPrice: string currentUsdPrice: string
imageUrl: string imageUrl: string
animationUrl: string animationUrl: string
marketplace: string marketplace: Markets
name: string name: string
priceInfo: PriceInfo priceInfo: PriceInfo
openseaSusFlag: boolean openseaSusFlag: boolean
...@@ -181,3 +181,9 @@ export interface DropDownOption { ...@@ -181,3 +181,9 @@ export interface DropDownOption {
reverseIndex?: number reverseIndex?: number
reverseOnClick?: () => void reverseOnClick?: () => void
} }
export enum DetailsOrigin {
COLLECTION = 'collection',
SELL = 'sell',
EXPLORE = 'explore',
}
import { DetailsOrigin, GenieAsset } from 'nft/types'
export const getAssetHref = (asset: GenieAsset, origin?: DetailsOrigin) => {
return `/nfts/asset/${asset.address}/${asset.tokenId}${origin ? `?origin=${origin}` : ''}`
}
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { BagItem, GenieAsset, Markets, UpdatedGenieAsset } from 'nft/types'
import { GenieAsset, Markets } from '../types'
export const calcPoolPrice = (asset: GenieAsset, position = 0) => { export const calcPoolPrice = (asset: GenieAsset, position = 0) => {
let amountToBuy: BigNumber = BigNumber.from(0) let amountToBuy: BigNumber = BigNumber.from(0)
...@@ -43,3 +42,77 @@ export const calcPoolPrice = (asset: GenieAsset, position = 0) => { ...@@ -43,3 +42,77 @@ export const calcPoolPrice = (asset: GenieAsset, position = 0) => {
return price.toString() return price.toString()
} }
export const calcAvgGroupPoolPrice = (asset: GenieAsset, numberOfAssets: number) => {
let total = BigNumber.from(0)
for (let i = 0; i < numberOfAssets; i++) {
const price = BigNumber.from(calcPoolPrice(asset, i))
total = total.add(price)
}
return total.div(numberOfAssets).toString()
}
export const recalculateBagUsingPooledAssets = (uncheckedItemsInBag: BagItem[]) => {
if (
!uncheckedItemsInBag.some(
(item) => item.asset.marketplace === Markets.NFTX || item.asset.marketplace === Markets.NFT20
)
)
return uncheckedItemsInBag
const isPooledMarket = (market: Markets) => market === Markets.NFTX || market === Markets.NFT20
const itemsInBag = [...uncheckedItemsInBag]
const possibleMarkets = itemsInBag.reduce((markets, item) => {
const asset = item.asset
const market = asset.marketplace
if (!isPooledMarket(market)) return markets
const key = asset.address + asset.marketplace
if (Object.keys(markets).includes(key)) {
markets[key].push(asset.tokenId)
} else {
markets[key] = [asset.tokenId]
}
return markets
}, {} as { [key: string]: [string] })
const updatedPriceMarkets = itemsInBag.reduce((markets, item) => {
const asset = item.asset
const market = asset.marketplace
if (!asset.updatedPriceInfo) return markets
if (!isPooledMarket(market)) return markets
const key = asset.address + asset.marketplace
if (Object.keys(markets).includes(key)) {
markets[key] = [markets[key][0] + 1, asset]
} else {
markets[key] = [1, asset]
}
return markets
}, {} as { [key: string]: [number, UpdatedGenieAsset] })
const calculatedAvgPoolPrices = Object.keys(updatedPriceMarkets).reduce((prices, key) => {
prices[key] = calcAvgGroupPoolPrice(updatedPriceMarkets[key][1], updatedPriceMarkets[key][0])
return prices
}, {} as { [key: string]: string })
itemsInBag.forEach((item) => {
if (isPooledMarket(item.asset.marketplace)) {
const asset = item.asset
const isPriceChangedAsset = !!asset.updatedPriceInfo
const calculatedPrice = isPriceChangedAsset
? calculatedAvgPoolPrices[asset.address + asset.marketplace]
: calcPoolPrice(asset, possibleMarkets[asset.address + asset.marketplace].indexOf(item.asset.tokenId))
if (isPriceChangedAsset && item.asset.updatedPriceInfo)
item.asset.updatedPriceInfo.ETHPrice = item.asset.updatedPriceInfo.basePrice = calculatedPrice
else item.asset.currentEthPrice = item.asset.priceInfo.ETHPrice = calculatedPrice
}
})
return itemsInBag
}
import { BigNumber } from '@ethersproject/bignumber'
import { UpdatedGenieAsset } from 'nft/types'
export const updatedAssetPriceDifference = (asset: UpdatedGenieAsset) => {
if (!asset.updatedPriceInfo) return BigNumber.from(0)
return BigNumber.from(asset.updatedPriceInfo.ETHPrice).sub(BigNumber.from(asset.priceInfo.ETHPrice))
}
export const sortUpdatedAssets = (x: UpdatedGenieAsset, y: UpdatedGenieAsset) => {
return updatedAssetPriceDifference(x).gt(updatedAssetPriceDifference(y)) ? -1 : 1
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment