mango-ui-v3/components/SwapTokenInfo.tsx

810 lines
33 KiB
TypeScript

import { FunctionComponent, useEffect, useMemo, useState } from 'react'
import { ExternalLinkIcon, EyeOffIcon } from '@heroicons/react/outline'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { AreaChart, Area, XAxis, YAxis, Tooltip } from 'recharts'
import useDimensions from 'react-cool-dimensions'
import { IconButton } from './Button'
import { LineChartIcon } from './icons'
import { useTranslation } from 'next-i18next'
import { ExpandableRow } from './TableElements'
dayjs.extend(relativeTime)
interface SwapTokenInfoProps {
inputTokenId?: string
outputTokenId?: string
}
export const numberFormatter = Intl.NumberFormat('en', {
minimumFractionDigits: 1,
maximumFractionDigits: 5,
})
export const numberCompacter = Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 2,
})
const SwapTokenInfo: FunctionComponent<SwapTokenInfoProps> = ({
inputTokenId,
outputTokenId,
}) => {
const [chartData, setChartData] = useState([])
const [hideChart, setHideChart] = useState(false)
const [baseTokenId, setBaseTokenId] = useState('')
const [quoteTokenId, setQuoteTokenId] = useState('')
const [inputTokenInfo, setInputTokenInfo] = useState<any>(null)
const [outputTokenInfo, setOutputTokenInfo] = useState<any>(null)
const [mouseData, setMouseData] = useState<string | null>(null)
const [daysToShow, setDaysToShow] = useState(1)
const [topHolders, setTopHolders] = useState(null)
const { observe, width, height } = useDimensions()
const { t } = useTranslation(['common', 'swap'])
const getTopHolders = async (inputMint, outputMint) => {
const inputResponse = await fetch(
`https://public-api.solscan.io/token/holders?tokenAddress=${inputMint}&offset=0&limit=10`
)
const outputResponse = await fetch(
`https://public-api.solscan.io/token/holders?tokenAddress=${outputMint}&offset=0&limit=10`
)
const inputData = await inputResponse.json()
const outputData = await outputResponse.json()
setTopHolders({
inputHolders: inputData.data,
outputHolders: outputData.data,
})
}
useEffect(() => {
if (inputTokenInfo && outputTokenInfo) {
getTopHolders(
inputTokenInfo.contract_address,
outputTokenInfo.contract_address
)
}
}, [inputTokenInfo, outputTokenInfo])
const handleMouseMove = (coords) => {
if (coords.activePayload) {
setMouseData(coords.activePayload[0].payload)
}
}
const handleMouseLeave = () => {
setMouseData(null)
}
useEffect(() => {
if (!inputTokenId || !outputTokenId) {
return
}
if (['usd-coin', 'tether'].includes(inputTokenId)) {
setBaseTokenId(outputTokenId)
setQuoteTokenId(inputTokenId)
} else {
setBaseTokenId(inputTokenId)
setQuoteTokenId(outputTokenId)
}
}, [inputTokenId, outputTokenId])
// Use ohlc data
const getChartData = async () => {
const inputResponse = await fetch(
`https://api.coingecko.com/api/v3/coins/${baseTokenId}/ohlc?vs_currency=usd&days=${daysToShow}`
)
const outputResponse = await fetch(
`https://api.coingecko.com/api/v3/coins/${quoteTokenId}/ohlc?vs_currency=usd&days=${daysToShow}`
)
const inputData = await inputResponse.json()
const outputData = await outputResponse.json()
let data = []
if (Array.isArray(inputData)) {
data = data.concat(inputData)
}
if (Array.isArray(outputData)) {
data = data.concat(outputData)
}
const formattedData = data.reduce((a, c) => {
const found = a.find((price) => price.time === c[0])
if (found) {
if (['usd-coin', 'tether'].includes(quoteTokenId)) {
found.price = found.inputPrice / c[4]
} else {
found.price = c[4] / found.inputPrice
}
} else {
a.push({ time: c[0], inputPrice: c[4] })
}
return a
}, [])
formattedData[formattedData.length - 1].time = Date.now()
setChartData(formattedData.filter((d) => d.price))
}
// Alternative chart data. Needs a timestamp tolerance to get data points for each asset
// const getChartData = async () => {
// const now = Date.now() / 1000
// const inputResponse = await fetch(
// `https://api.coingecko.com/api/v3/coins/${inputTokenId}/market_chart/range?vs_currency=usd&from=${
// now - 1 * 86400
// }&to=${now}`
// )
// const outputResponse = await fetch(
// `https://api.coingecko.com/api/v3/coins/${outputTokenId}/market_chart/range?vs_currency=usd&from=${
// now - 1 * 86400
// }&to=${now}`
// )
// const inputData = await inputResponse.json()
// const outputData = await outputResponse.json()
// const data = inputData?.prices.concat(outputData?.prices)
// const formattedData = data.reduce((a, c) => {
// const found = a.find(
// (price) => c[0] >= price.time - 120000 && c[0] <= price.time + 120000
// )
// if (found) {
// found.price = found.inputPrice / c[1]
// } else {
// a.push({ time: c[0], inputPrice: c[1] })
// }
// return a
// }, [])
// setChartData(formattedData.filter((d) => d.price))
// }
const getInputTokenInfo = async () => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${inputTokenId}?localization=false&tickers=false&developer_data=false&sparkline=false
`
)
const data = await response.json()
setInputTokenInfo(data)
}
const getOutputTokenInfo = async () => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${outputTokenId}?localization=false&tickers=false&developer_data=false&sparkline=false
`
)
const data = await response.json()
setOutputTokenInfo(data)
}
useMemo(() => {
if (baseTokenId && quoteTokenId) {
getChartData()
}
}, [daysToShow, baseTokenId, quoteTokenId])
useMemo(() => {
if (baseTokenId) {
getInputTokenInfo()
}
if (quoteTokenId) {
getOutputTokenInfo()
}
}, [baseTokenId, quoteTokenId])
const chartChange = chartData.length
? ((chartData[chartData.length - 1]['price'] - chartData[0]['price']) /
chartData[0]['price']) *
100
: 0
return (
<div>
{chartData.length && baseTokenId && quoteTokenId ? (
<div className="pb-6">
<div className="flex items-start justify-between">
<div>
{inputTokenInfo && outputTokenInfo ? (
<div className="text-sm text-th-fgd-3">
{`${outputTokenInfo?.symbol?.toUpperCase()}/${inputTokenInfo?.symbol?.toUpperCase()}`}
</div>
) : null}
{mouseData ? (
<>
<div className="text-lg font-bold text-th-fgd-1">
{numberFormatter.format(mouseData['price'])}
<span
className={`ml-2 text-xs ${
chartChange >= 0 ? 'text-th-green' : 'text-th-red'
}`}
>
{chartChange.toFixed(2)}%
</span>
</div>
<div className="text-xs font-normal text-th-fgd-3">
{dayjs(mouseData['time']).format('DD MMM YY, h:mma')}
</div>
</>
) : (
<>
<div className="text-lg font-bold text-th-fgd-1">
{numberFormatter.format(
chartData[chartData.length - 1]['price']
)}
<span
className={`ml-2 text-xs ${
chartChange >= 0 ? 'text-th-green' : 'text-th-red'
}`}
>
{chartChange.toFixed(2)}%
</span>
</div>
<div className="text-xs font-normal text-th-fgd-3">
{dayjs(chartData[chartData.length - 1]['time']).format(
'DD MMM YY, h:mma'
)}
</div>
</>
)}
</div>
<IconButton onClick={() => setHideChart(!hideChart)}>
{hideChart ? (
<LineChartIcon className="h-4 w-4" />
) : (
<EyeOffIcon className="h-4 w-4" />
)}
</IconButton>
</div>
{!hideChart ? (
<div className="mt-4 h-52 w-full" ref={observe}>
<AreaChart
width={width}
height={height}
data={chartData}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<Tooltip
cursor={{
strokeOpacity: 0,
}}
content={<></>}
/>
<defs>
<linearGradient id="gradientArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ffba24" stopOpacity={0.9} />
<stop offset="90%" stopColor="#ffba24" stopOpacity={0} />
</linearGradient>
</defs>
<Area
isAnimationActive={true}
type="monotone"
dataKey="price"
stroke="#ffba24"
fill="url(#gradientArea)"
/>
<XAxis dataKey="time" hide />
<YAxis
dataKey="price"
type="number"
domain={['dataMin', 'dataMax']}
hide
/>
</AreaChart>
<div className="flex justify-end">
<button
className={`default-transition px-3 py-2 text-xs font-bold text-th-fgd-1 hover:text-th-primary focus:outline-none ${
daysToShow === 1 && 'text-th-primary'
}`}
onClick={() => setDaysToShow(1)}
>
24H
</button>
<button
className={`default-transition px-3 py-2 text-xs font-bold text-th-fgd-1 hover:text-th-primary focus:outline-none ${
daysToShow === 7 && 'text-th-primary'
}`}
onClick={() => setDaysToShow(7)}
>
7D
</button>
<button
className={`default-transition px-3 py-2 text-xs font-bold text-th-fgd-1 hover:text-th-primary focus:outline-none ${
daysToShow === 30 && 'text-th-primary'
}`}
onClick={() => setDaysToShow(30)}
>
30D
</button>
</div>
</div>
) : null}
</div>
) : (
<div className="mt-4 rounded-md bg-th-bkg-3 p-4 text-center text-th-fgd-3 md:mt-0">
<LineChartIcon className="mx-auto h-6 w-6 text-th-primary" />
{t('swap:chart-not-available')}
</div>
)}
<div className="pt-8">
{inputTokenInfo && outputTokenInfo && baseTokenId ? (
<ExpandableRow
buttonTemplate={
<div className="text-fgd-1 flex w-full items-center justify-between">
<div className="flex items-center">
{inputTokenInfo.image?.small ? (
<img
className="rounded-full"
src={inputTokenInfo.image?.small}
width="32"
height="32"
alt={inputTokenInfo.name}
/>
) : null}
<div className="ml-2.5 text-left">
<h2 className="text-base font-bold text-th-fgd-1">
{inputTokenInfo?.symbol?.toUpperCase()}
</h2>
<div className="text-xs font-normal text-th-fgd-3">
{inputTokenInfo.name}
</div>
</div>
</div>
<div className="flex items-center">
<div className="flex items-center space-x-3">
{inputTokenInfo.market_data?.current_price?.usd ? (
<div className="font-normal text-th-fgd-1">
$
{numberFormatter.format(
inputTokenInfo.market_data?.current_price.usd
)}
</div>
) : null}
{inputTokenInfo.market_data?.price_change_percentage_24h ? (
<div
className={`font-normal text-th-fgd-1 ${
inputTokenInfo.market_data
.price_change_percentage_24h >= 0
? 'text-th-green'
: 'text-th-red'
}`}
>
{inputTokenInfo.market_data.price_change_percentage_24h.toFixed(
2
)}
%
</div>
) : null}
</div>
</div>
</div>
}
panelTemplate={
<div>
<div className="m-1 mt-0 pb-2 text-base font-bold text-th-fgd-1">
{t('market-data')}
</div>
<div className="grid grid-flow-row grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 xl:grid-cols-3">
{inputTokenInfo.market_cap_rank ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:market-cap-rank')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
#{inputTokenInfo.market_cap_rank}
</div>
</div>
) : null}
{inputTokenInfo.market_data?.market_cap &&
inputTokenInfo.market_data?.market_cap?.usd !== 0 ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:market-cap')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
$
{numberCompacter.format(
inputTokenInfo.market_data?.market_cap?.usd
)}
</div>
</div>
) : null}
{inputTokenInfo.market_data?.total_volume?.usd ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('daily-volume')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
$
{numberCompacter.format(
inputTokenInfo.market_data?.total_volume?.usd
)}
</div>
</div>
) : null}
{inputTokenInfo.market_data?.circulating_supply ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:token-supply')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
{numberCompacter.format(
inputTokenInfo.market_data.circulating_supply
)}
</div>
{inputTokenInfo.market_data?.max_supply ? (
<div className="text-xs text-th-fgd-2">
{t('swap:max-supply')}:{' '}
{numberCompacter.format(
inputTokenInfo.market_data.max_supply
)}
</div>
) : null}
</div>
) : null}
{inputTokenInfo.market_data?.ath?.usd ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:ath')}
</div>
<div className="flex">
<div className="text-lg font-bold text-th-fgd-1">
$
{numberFormatter.format(
inputTokenInfo.market_data.ath.usd
)}
</div>
{inputTokenInfo.market_data?.ath_change_percentage
?.usd ? (
<div
className={`ml-1.5 mt-2 text-xs ${
inputTokenInfo.market_data?.ath_change_percentage
?.usd >= 0
? 'text-th-green'
: 'text-th-red'
}`}
>
{(inputTokenInfo.market_data?.ath_change_percentage?.usd).toFixed(
2
)}
%
</div>
) : null}
</div>
{inputTokenInfo.market_data?.ath_date?.usd ? (
<div className="text-xs text-th-fgd-2">
{dayjs(
inputTokenInfo.market_data.ath_date.usd
).fromNow()}
</div>
) : null}
</div>
) : null}
{inputTokenInfo.market_data?.atl?.usd ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:atl')}
</div>
<div className="flex">
<div className="text-lg font-bold text-th-fgd-1">
$
{numberFormatter.format(
inputTokenInfo.market_data.atl.usd
)}
</div>
{inputTokenInfo.market_data?.atl_change_percentage
?.usd ? (
<div
className={`ml-1.5 mt-2 text-xs ${
inputTokenInfo.market_data?.atl_change_percentage
?.usd >= 0
? 'text-th-green'
: 'text-th-red'
}`}
>
{(inputTokenInfo.market_data?.atl_change_percentage?.usd).toLocaleString(
undefined,
{
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}
)}
%
</div>
) : null}
</div>
{inputTokenInfo.market_data?.atl_date?.usd ? (
<div className="text-xs text-th-fgd-2">
{dayjs(
inputTokenInfo.market_data.atl_date.usd
).fromNow()}
</div>
) : null}
</div>
) : null}
</div>
{topHolders?.inputHolders ? (
<div className="pt-4">
<div className="m-1 pb-3 text-base font-bold text-th-fgd-1">
{t('swap:top-ten')}
</div>
{topHolders.inputHolders.map((holder) => (
<a
className="default mx-1 flex justify-between border-t border-th-bkg-4 px-2 py-2.5 text-th-fgd-3 transition hover:bg-th-bkg-2"
href={`https://explorer.solana.com/address/${holder.owner}`}
target="_blank"
rel="noopener noreferrer"
key={holder.owner}
>
<div className="text-th-fgd-3">
{holder.owner.slice(0, 5) +
'…' +
holder.owner.slice(-5)}
</div>
<div className="flex items-center">
<div className="text-th-fgd-1">
{numberFormatter.format(
holder.amount / Math.pow(10, holder.decimals)
)}
</div>
<ExternalLinkIcon className="ml-2 h-4 w-4" />
</div>
</a>
))}
</div>
) : null}
</div>
}
/>
) : (
<div className="mt-3 rounded-md bg-th-bkg-3 p-4 text-center text-th-fgd-3">
{t('swap:input-info-unavailable')}
</div>
)}
{outputTokenInfo && quoteTokenId ? (
<div className="w-full border-b border-th-bkg-4">
<ExpandableRow
buttonTemplate={
<div className="text-fgd-1 flex w-full items-center justify-between">
<div className="flex items-center">
{outputTokenInfo.image?.small ? (
<img
className="rounded-full"
src={outputTokenInfo.image?.small}
width="32"
height="32"
alt={outputTokenInfo.name}
/>
) : null}
<div className="ml-2.5 text-left">
<h2 className="text-base font-bold text-th-fgd-1">
{outputTokenInfo?.symbol?.toUpperCase()}
</h2>
<div className="text-xs font-normal text-th-fgd-3">
{outputTokenInfo.name}
</div>
</div>
</div>
<div className="flex items-center">
<div className="flex items-center space-x-3">
{outputTokenInfo.market_data?.current_price?.usd ? (
<div className="font-normal text-th-fgd-1">
$
{numberFormatter.format(
outputTokenInfo.market_data?.current_price.usd
)}
</div>
) : null}
{outputTokenInfo.market_data
?.price_change_percentage_24h ? (
<div
className={`font-normal text-th-fgd-1 ${
outputTokenInfo.market_data
.price_change_percentage_24h >= 0
? 'text-th-green'
: 'text-th-red'
}`}
>
{outputTokenInfo.market_data.price_change_percentage_24h.toFixed(
2
)}
%
</div>
) : null}
</div>
</div>
</div>
}
panelTemplate={
<div>
<div className="m-1 mt-0 pb-2 text-base font-bold text-th-fgd-1">
{t('market-data')}
</div>
<div className="grid grid-flow-row grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 xl:grid-cols-3">
{outputTokenInfo.market_cap_rank ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:market-cap-rank')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
#{outputTokenInfo.market_cap_rank}
</div>
</div>
) : null}
{outputTokenInfo.market_data?.market_cap &&
outputTokenInfo.market_data?.market_cap?.usd !== 0 ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:market-cap')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
$
{numberCompacter.format(
outputTokenInfo.market_data?.market_cap?.usd
)}
</div>
</div>
) : null}
{outputTokenInfo.market_data?.total_volume?.usd ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('daily-volume')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
$
{numberCompacter.format(
outputTokenInfo.market_data?.total_volume?.usd
)}
</div>
</div>
) : null}
{outputTokenInfo.market_data?.circulating_supply ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:token-supply')}
</div>
<div className="text-lg font-bold text-th-fgd-1">
{numberCompacter.format(
outputTokenInfo.market_data.circulating_supply
)}
</div>
{outputTokenInfo.market_data?.max_supply ? (
<div className="text-xs text-th-fgd-2">
{t('swap:max-supply')}:{' '}
{numberCompacter.format(
outputTokenInfo.market_data.max_supply
)}
</div>
) : null}
</div>
) : null}
{outputTokenInfo.market_data?.ath?.usd ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:ath')}
</div>
<div className="flex">
<div className="text-lg font-bold text-th-fgd-1">
$
{numberFormatter.format(
outputTokenInfo.market_data.ath.usd
)}
</div>
{outputTokenInfo.market_data?.ath_change_percentage
?.usd ? (
<div
className={`ml-1.5 mt-2 text-xs ${
outputTokenInfo.market_data
?.ath_change_percentage?.usd >= 0
? 'text-th-green'
: 'text-th-red'
}`}
>
{(outputTokenInfo.market_data?.ath_change_percentage?.usd).toFixed(
2
)}
%
</div>
) : null}
</div>
{outputTokenInfo.market_data?.ath_date?.usd ? (
<div className="text-xs text-th-fgd-2">
{dayjs(
outputTokenInfo.market_data.ath_date.usd
).fromNow()}
</div>
) : null}
</div>
) : null}
{outputTokenInfo.market_data?.atl?.usd ? (
<div className="m-1 rounded-md border border-th-bkg-4 p-3">
<div className="text-xs text-th-fgd-3">
{t('swap:atl')}
</div>
<div className="flex">
<div className="text-lg font-bold text-th-fgd-1">
$
{numberFormatter.format(
outputTokenInfo.market_data.atl.usd
)}
</div>
{outputTokenInfo.market_data?.atl_change_percentage
?.usd ? (
<div
className={`ml-1.5 mt-2 text-xs ${
outputTokenInfo.market_data
?.atl_change_percentage?.usd >= 0
? 'text-th-green'
: 'text-th-red'
}`}
>
{(outputTokenInfo.market_data?.atl_change_percentage?.usd).toLocaleString(
undefined,
{
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}
)}
%
</div>
) : null}
</div>
{outputTokenInfo.market_data?.atl_date?.usd ? (
<div className="text-xs text-th-fgd-2">
{dayjs(
outputTokenInfo.market_data.atl_date.usd
).fromNow()}
</div>
) : null}
</div>
) : null}
</div>
{topHolders?.inputHolders ? (
<div className="pt-4">
<div className="m-1 pb-3 text-base font-bold text-th-fgd-1">
{t('swap:top-ten')}
</div>
{topHolders.inputHolders.map((holder) => (
<a
className="default mx-1 flex justify-between border-t border-th-bkg-4 px-2 py-2.5 text-th-fgd-3 transition hover:bg-th-bkg-2"
href={`https://explorer.solana.com/address/${holder.owner}`}
target="_blank"
rel="noopener noreferrer"
key={holder.owner}
>
<div className="text-th-fgd-3">
{holder.owner.slice(0, 5) +
'…' +
holder.owner.slice(-5)}
</div>
<div className="flex items-center">
<div className="text-th-fgd-1">
{numberFormatter.format(
holder.amount / Math.pow(10, holder.decimals)
)}
</div>
<ExternalLinkIcon className="ml-2 h-4 w-4" />
</div>
</a>
))}
</div>
) : null}
</div>
}
/>
</div>
) : (
<div className="mt-3 rounded-md bg-th-bkg-3 p-4 text-center text-th-fgd-3">
{t('swap:output-info-unavailable')}
</div>
)}
</div>
</div>
)
}
export default SwapTokenInfo