mango-v4-ui/components/explore/Spot.tsx

429 lines
16 KiB
TypeScript

import Change from '@components/shared/Change'
import FormatNumericValue from '@components/shared/FormatNumericValue'
import TokenLogo from '@components/shared/TokenLogo'
import useListedMarketsWithMarketData, {
SerumMarketWithMarketData,
} from 'hooks/useListedMarketsWithMarketData'
import useMangoGroup from 'hooks/useMangoGroup'
import { useRouter } from 'next/router'
import { ChangeEvent, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SpotTable from './SpotTable'
import { goToTokenPage } from '@components/stats/tokens/TokenOverviewTable'
import {
BoltIcon,
ChevronRightIcon,
FaceFrownIcon,
MagnifyingGlassIcon,
RocketLaunchIcon,
Squares2X2Icon,
TableCellsIcon,
} from '@heroicons/react/20/solid'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { AllowedKeys } from 'utils/markets'
import ButtonGroup from '@components/forms/ButtonGroup'
import SpotCards from './SpotCards'
import Input from '@components/forms/Input'
import EmptyState from '@components/nftMarket/EmptyState'
import { Bank } from '@blockworks-foundation/mango-v4'
import Link from 'next/link'
import useBanks from 'hooks/useBanks'
import SheenLoader from '@components/shared/SheenLoader'
import mangoStore from '@store/mangoStore'
dayjs.extend(relativeTime)
export type BankWithMarketData = {
bank: Bank
market: SerumMarketWithMarketData | undefined
}
const CALLOUT_TILES_WRAPPER_CLASSES =
'col-span-12 flex flex-col rounded-lg border border-th-bkg-3 p-6 lg:col-span-4'
const generateSearchTerm = (item: BankWithMarketData, searchValue: string) => {
const normalizedSearchValue = searchValue.toLowerCase()
const value = item.bank.name.toLowerCase()
const isMatchingWithName =
item.bank.name.toLowerCase().indexOf(normalizedSearchValue) >= 0
const matchingSymbolPercent = isMatchingWithName
? normalizedSearchValue.length / item.bank.name.length
: 0
return {
token: item,
matchingIdx: value.indexOf(normalizedSearchValue),
matchingSymbolPercent,
}
}
const startSearch = (items: BankWithMarketData[], searchValue: string) => {
return items
.map((item) => generateSearchTerm(item, searchValue))
.filter((item) => item.matchingIdx >= 0)
.sort((i1, i2) => i1.matchingIdx - i2.matchingIdx)
.sort((i1, i2) => i2.matchingSymbolPercent - i1.matchingSymbolPercent)
.map((item) => item.token)
}
const sortTokens = (tokens: BankWithMarketData[], sortByKey: AllowedKeys) => {
return tokens.sort((a: BankWithMarketData, b: BankWithMarketData) => {
const aValue: number | undefined = a?.market?.marketData?.[sortByKey]
const bValue: number | undefined = b?.market?.marketData?.[sortByKey]
// Handle marketData[sortByKey] is undefined
if (typeof aValue === 'undefined' && typeof bValue === 'undefined') {
return 0 // Consider them equal
}
if (typeof aValue === 'undefined') {
return 1 // b should come before a
}
if (typeof bValue === 'undefined') {
return -1 // a should come before b
}
return bValue - aValue
})
}
const Spot = () => {
const { t } = useTranslation(['common', 'explore', 'trade'])
const router = useRouter()
const { group } = useMangoGroup()
const { banks } = useBanks()
const { serumMarketsWithData, isLoading: loadingSerumMarkets } =
useListedMarketsWithMarketData()
const groupLoaded = mangoStore((s) => s.groupLoaded)
const [sortByKey, setSortByKey] = useState<AllowedKeys>('quote_volume_24h')
const [search, setSearch] = useState('')
const [showTableView, setShowTableView] = useState(true)
const banksWithMarketData = useMemo(() => {
if (!banks.length || !group || !serumMarketsWithData.length) return []
const banksWithMarketData = []
const usdcQuoteMarkets = serumMarketsWithData.filter(
(market) => market.quoteTokenIndex === 0,
)
for (const bank of banks) {
const market = usdcQuoteMarkets.find(
(market) => market.baseTokenIndex === bank.tokenIndex,
)
if (market) {
banksWithMarketData.push({ bank, market })
} else {
banksWithMarketData.push({ bank, market: undefined })
}
}
return banksWithMarketData
}, [banks, group, serumMarketsWithData])
const newlyListedMintInfo = useMemo(() => {
if (!group) return []
const mintInfos = Array.from(group.mintInfosMapByTokenIndex).map(
([, mintInfo]) => mintInfo,
)
const sortByRegistrationTime = mintInfos
.sort((a, b) => {
return b.registrationTime.toNumber() - a.registrationTime.toNumber()
})
.slice(0, 3)
return sortByRegistrationTime
}, [group])
const newlyListed = useMemo(() => {
if (!newlyListedMintInfo.length || !serumMarketsWithData.length) return []
const newlyListed = []
for (const listing of newlyListedMintInfo) {
const market = serumMarketsWithData.find(
(market) => market.baseTokenIndex === listing.tokenIndex,
)
if (market) {
newlyListed.push(market)
}
}
return newlyListed
}, [newlyListedMintInfo, serumMarketsWithData])
const [gainers, losers] = useMemo(() => {
if (!serumMarketsWithData.length) return [[], []]
const sortByChange = serumMarketsWithData
.filter((market) => market.quoteTokenIndex === 0)
.sort((a, b) => {
const rollingChangeA = a.rollingChange || 0
const rollingChangeB = b.rollingChange || 0
return rollingChangeB - rollingChangeA
})
const gainers = sortByChange.slice(0, 3).filter((item) => {
const change = item.rollingChange || 0
return change > 0
})
const losers = sortByChange
.slice(-3)
.reverse()
.filter((item) => {
const change = item.rollingChange || 0
return change < 0
})
return [gainers, losers]
}, [serumMarketsWithData])
const sortedTokensToShow = useMemo(() => {
if (!banksWithMarketData.length) return []
return search
? startSearch(banksWithMarketData, search)
: sortTokens(banksWithMarketData, sortByKey)
}, [search, banksWithMarketData, sortByKey, showTableView])
const handleUpdateSearch = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
}
return (
<>
<div className="grid grid-cols-12 gap-4 px-4 pb-8 md:px-6 2xl:px-12">
<div className={CALLOUT_TILES_WRAPPER_CLASSES}>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<BoltIcon className="h-5 w-5" />
<h2 className="text-base">{t('explore:recently-listed')}</h2>
</div>
<a className="font-bold">
<Link href="/governance/list" shallow>
<span className="default-transition text-th-active md:hover:text-th-active-dark">
{t('governance:list-token')}
</span>
</Link>
</a>
</div>
{groupLoaded ? (
<div className="border-t border-th-bkg-3">
{newlyListed.map((listing) => {
const bank = group?.getFirstBankByTokenIndex(
listing.baseTokenIndex,
)
const mintInfo = newlyListedMintInfo.find(
(info) => info.tokenIndex === listing.baseTokenIndex,
)
let timeSinceListing = ''
if (mintInfo) {
timeSinceListing = dayjs().to(
mintInfo.registrationTime.toNumber() * 1000,
)
}
if (!bank) return null
return (
<div
className="default-transition flex h-16 cursor-pointer items-center justify-between border-b border-th-bkg-3 px-4 md:hover:bg-th-bkg-2"
key={listing.baseTokenIndex}
onClick={() =>
goToTokenPage(bank.name.split(' ')[0], router)
}
>
<div className="flex items-center">
<TokenLogo bank={bank} />
<p className="ml-3 font-body text-th-fgd-2">
{bank.name}
</p>
</div>
<div className="flex items-center">
<div className="mr-3">
<span className="text-th-fgd-3">
{timeSinceListing}
</span>
</div>
<ChevronRightIcon className="h-5 w-5 text-th-fgd-3" />
</div>
</div>
)
})}
</div>
) : (
<CalloutTilesLoader />
)}
</div>
<div className={CALLOUT_TILES_WRAPPER_CLASSES}>
<div className="mb-4 flex items-center space-x-2">
<RocketLaunchIcon className="h-5 w-5" />
<h2 className="text-base">{t('explore:gainers')}</h2>
</div>
{!loadingSerumMarkets && groupLoaded ? (
<div className="h-full border-t border-th-bkg-3">
{gainers.length ? (
gainers.map((gainer) => {
const bank = group?.getFirstBankByTokenIndex(
gainer.baseTokenIndex,
)
if (!bank) return null
return (
<div
className="default-transition flex h-16 cursor-pointer items-center justify-between border-b border-th-bkg-3 px-4 md:hover:bg-th-bkg-2"
key={gainer.baseTokenIndex}
onClick={() =>
goToTokenPage(bank.name.split(' ')[0], router)
}
>
<div className="flex items-center">
<TokenLogo bank={bank} />
<p className="ml-3 font-body text-th-fgd-2">
{bank.name}
</p>
</div>
<div className="flex items-center">
<div className="mr-3 flex flex-col items-end">
<span className="font-mono">
<FormatNumericValue value={bank.uiPrice} isUsd />
</span>
<Change
change={gainer.rollingChange || 0}
suffix="%"
/>
</div>
<ChevronRightIcon className="h-5 w-5 text-th-fgd-3" />
</div>
</div>
)
})
) : (
<div className="flex h-full flex-col items-center justify-center">
<FaceFrownIcon className="mb-1.5 h-5 w-5" />
<p>{t('explore:no-gainers')}</p>
</div>
)}
</div>
) : (
<CalloutTilesLoader />
)}
</div>
<div className={CALLOUT_TILES_WRAPPER_CLASSES}>
<div className="mb-4 flex items-center space-x-2">
<FaceFrownIcon className="h-5 w-5" />
<h2 className="text-base">{t('explore:losers')}</h2>
</div>
{!loadingSerumMarkets && groupLoaded ? (
<div className="h-full border-t border-th-bkg-3">
{losers.length ? (
losers.map((loser) => {
const bank = group?.getFirstBankByTokenIndex(
loser.baseTokenIndex,
)
if (!bank) return null
return (
<div
className="default-transition flex h-16 cursor-pointer items-center justify-between border-b border-th-bkg-3 px-4 md:hover:bg-th-bkg-2"
key={loser.baseTokenIndex}
onClick={() =>
goToTokenPage(bank.name.split(' ')[0], router)
}
>
<div className="flex items-center">
<TokenLogo bank={bank} />
<p className="ml-3 font-body text-th-fgd-2">
{bank.name}
</p>
</div>
<div className="flex items-center">
<div className="mr-3 flex flex-col items-end">
<span className="font-mono">
<FormatNumericValue value={bank.uiPrice} isUsd />
</span>
<Change
change={loser.rollingChange || 0}
suffix="%"
/>
</div>
<ChevronRightIcon className="h-5 w-5 text-th-fgd-3" />
</div>
</div>
)
})
) : (
<div className="flex h-full flex-col items-center justify-center">
<RocketLaunchIcon className="mb-1.5 h-5 w-5" />
<p>{t('explore:no-losers')}</p>
</div>
)}
</div>
) : (
<CalloutTilesLoader />
)}
</div>
</div>
<div className="border-t border-th-bkg-3 pt-4">
<div className="flex flex-col px-4 sm:flex-row sm:items-center sm:justify-between md:px-6 2xl:px-12">
<h2 className="mb-4 text-base sm:mb-0">{t('tokens')}</h2>
<div className="flex flex-col sm:flex-row sm:space-x-3">
<div className="relative mb-3 w-full sm:mb-0 sm:w-40">
<Input
heightClass="h-10 pl-8"
type="text"
value={search}
onChange={handleUpdateSearch}
/>
<MagnifyingGlassIcon className="absolute left-2 top-3 h-4 w-4" />
</div>
<div className="flex space-x-3">
<div className="w-full sm:w-48">
<ButtonGroup
activeValue={sortByKey}
onChange={(v) => setSortByKey(v)}
names={[t('trade:24h-volume'), t('rolling-change')]}
values={['quote_volume_24h', 'change_24h']}
/>
</div>
<div className="flex">
<button
className={`flex w-10 items-center justify-center rounded-l-md border border-th-bkg-3 focus:outline-none md:hover:bg-th-bkg-3 ${
showTableView ? 'bg-th-bkg-3 text-th-active' : ''
}`}
onClick={() => setShowTableView(!showTableView)}
>
<TableCellsIcon className="h-5 w-5" />
</button>
<button
className={`flex w-10 items-center justify-center rounded-r-md border border-th-bkg-3 focus:outline-none md:hover:bg-th-bkg-3 ${
!showTableView ? 'bg-th-bkg-3 text-th-active' : ''
}`}
onClick={() => setShowTableView(!showTableView)}
>
<Squares2X2Icon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
{sortedTokensToShow.length ? (
showTableView ? (
<div className="mt-6 border-t border-th-bkg-3">
<SpotTable tokens={sortedTokensToShow} />
</div>
) : (
<SpotCards tokens={sortedTokensToShow} />
)
) : (
<div className="px-4 pt-2 md:px-6 2xl:px-12">
<EmptyState text="No results found..." />
</div>
)}
</div>
</>
)
}
export default Spot
const CalloutTilesLoader = () => {
return (
<div className="space-y-1">
{[...Array(3)].map((x, i) => (
<SheenLoader className="flex flex-1" key={i}>
<div className="h-16 w-full rounded-md bg-th-bkg-2" />
</SheenLoader>
))}
</div>
)
}