token details page setup and chart
|
@ -0,0 +1,94 @@
|
|||
import { Metadata } from 'next'
|
||||
import { draftMode } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import {
|
||||
fetchTokenPage,
|
||||
fetchTokenPages,
|
||||
} from '../../../../contentful/tokenPage'
|
||||
import { makeApiRequest } from '../../../utils/birdeye'
|
||||
import { DAILY_SECONDS } from '../../../utils/constants'
|
||||
import TokenPriceChart from '../../../components/explore/TokenPriceChart'
|
||||
import TokenMarketStats from '../../../components/explore/TokenMarketStats'
|
||||
|
||||
interface TokenPageParams {
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface TokenPageProps {
|
||||
params: TokenPageParams
|
||||
}
|
||||
|
||||
// Tell Next.js about all our token pages so
|
||||
// they can be statically generated at build time.
|
||||
export async function generateStaticParams(): Promise<TokenPageParams[]> {
|
||||
const tokenPages = await fetchTokenPages({ preview: false })
|
||||
|
||||
return tokenPages.map((page) => ({ slug: page.slug }))
|
||||
}
|
||||
|
||||
// For each token page, tell Next.js which metadata
|
||||
// (e.g. page title) to display.
|
||||
export async function generateMetadata(
|
||||
{ params }: TokenPageProps,
|
||||
// parent: ResolvingMetadata,
|
||||
): Promise<Metadata> {
|
||||
const tokenPage = await fetchTokenPage({
|
||||
slug: params.slug,
|
||||
preview: draftMode().isEnabled,
|
||||
})
|
||||
|
||||
if (!tokenPage) {
|
||||
return notFound()
|
||||
}
|
||||
return {
|
||||
title: tokenPage.seoTitle,
|
||||
description: tokenPage.seoDescription,
|
||||
}
|
||||
}
|
||||
|
||||
async function TokenPage({ params }: TokenPageProps) {
|
||||
const tokenPageData = await fetchTokenPage({
|
||||
slug: params.slug,
|
||||
preview: draftMode().isEnabled,
|
||||
})
|
||||
|
||||
if (!tokenPageData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
// get price history for token price chart
|
||||
const queryEnd = Math.floor(Date.now() / 1000)
|
||||
const queryStart = queryEnd - 30 * DAILY_SECONDS
|
||||
const birdeyeQuery = `defi/history_price?address=${tokenPageData.mint}&address_type=token&type=30m&time_from=${queryStart}&time_to=${queryEnd}`
|
||||
const birdeyePricesResponse = await makeApiRequest(birdeyeQuery)
|
||||
const birdeyePrices = birdeyePricesResponse?.data?.items?.length
|
||||
? birdeyePricesResponse.data.items
|
||||
: []
|
||||
for (const data of birdeyePrices) {
|
||||
data.unixTime = data.unixTime * 1000
|
||||
}
|
||||
|
||||
// const hasCustomIcon = CUSTOM_TOKEN_ICONS[tokenPageData.symbol.toLowerCase()]
|
||||
// const logoPath = hasCustomIcon
|
||||
// ? `/icons/tokens/${mangoSymbol.toLowerCase()}.svg`
|
||||
// : birdeyeData?.logoURI
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 pb-4 border-b border-th-bkg-3 flex items-center">
|
||||
<h1 className="text-4xl">
|
||||
{tokenPageData.tokenName}{' '}
|
||||
<span className="text-xl font-body font-normal text-th-fgd-4">
|
||||
{tokenPageData.symbol}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<TokenPriceChart chartData={birdeyePrices} />
|
||||
<h2 className="mb-4 text-2xl">Mango stats</h2>
|
||||
<TokenMarketStats tokenData={tokenPageData} />
|
||||
<h2 className="mb-4 text-2xl">{`About ${tokenPageData.tokenName}`}</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TokenPage
|
|
@ -0,0 +1,7 @@
|
|||
export default function PageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <div className="px-20 py-12">{children}</div>
|
||||
}
|
|
@ -1,28 +1,26 @@
|
|||
import { fetchTokenPages } from '../../../contentful/tokenPage'
|
||||
import Explore from '../../components/explore/Explore'
|
||||
import SectionWrapper from '../../components/shared/SectionWrapper'
|
||||
// import { fetchMarketData } from '../../utils/mango'
|
||||
import { draftMode } from 'next/headers'
|
||||
import { fetchMangoTokenData } from '../../utils/mango'
|
||||
|
||||
async function ExplorePage() {
|
||||
// const marketData = await fetchMarketData()
|
||||
const tokens = await fetchTokenPages({
|
||||
preview: draftMode().isEnabled,
|
||||
})
|
||||
|
||||
const mangoTokensData = await fetchMangoTokenData()
|
||||
return (
|
||||
<SectionWrapper>
|
||||
<>
|
||||
<h1 className="text-5xl mb-10">Explore</h1>
|
||||
<ul>
|
||||
{tokens && tokens?.length ? (
|
||||
<Explore tokens={tokens} />
|
||||
<Explore tokens={tokens} mangoTokensData={mangoTokensData} />
|
||||
) : (
|
||||
<div className="p-6 rounded-xl border border-th-bkg-3">
|
||||
<p className="text-center">Nothing to see here...</p>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</SectionWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { TokenPageWithData } from '../../../contentful/tokenPage'
|
||||
import { MangoTokenData } from '../../types/mango'
|
||||
import { Table, Td, Th, TrBody, TrHead } from '../shared/TableElements'
|
||||
import { CUSTOM_TOKEN_ICONS } from '../../utils/constants'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'
|
||||
|
||||
const Explore = ({ tokens }: { tokens: TokenPageWithData[] }) => {
|
||||
export const goToTokenPage = (slug: string, router: AppRouterInstance) => {
|
||||
router.push(`/explore/${slug}`, { shallow: true })
|
||||
}
|
||||
|
||||
const Explore = ({
|
||||
tokens,
|
||||
mangoTokensData,
|
||||
}: {
|
||||
tokens: TokenPageWithData[]
|
||||
mangoTokensData: MangoTokenData[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
|
@ -15,16 +31,43 @@ const Explore = ({ tokens }: { tokens: TokenPageWithData[] }) => {
|
|||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => {
|
||||
const { tokenName, slug, symbol, birdeyeData } = token
|
||||
const price = birdeyeData?.price || '–'
|
||||
const change24Hour = birdeyeData?.priceChange24hPercent
|
||||
? `${birdeyeData.priceChange24hPercent.toFixed(2)}%`
|
||||
: '–'
|
||||
const { tokenName, slug, symbol, birdeyeData, mint } = token
|
||||
const mangoTokenData: MangoTokenData | undefined =
|
||||
mangoTokensData.find((mangoToken) => mangoToken?.mint === mint)
|
||||
let price
|
||||
let change24Hour
|
||||
let mangoSymbol
|
||||
if (mangoTokenData) {
|
||||
price = mangoTokenData?.price || '–'
|
||||
const change =
|
||||
mangoTokenData?.price && mangoTokenData?.['24_hr_price']
|
||||
? ((mangoTokenData['24_hr_price'] - mangoTokenData.price) /
|
||||
mangoTokenData['24_hr_price']) *
|
||||
100
|
||||
: '–'
|
||||
change24Hour = change
|
||||
mangoSymbol = mangoTokenData.symbol
|
||||
}
|
||||
const hasCustomIcon = CUSTOM_TOKEN_ICONS[mangoSymbol.toLowerCase()]
|
||||
const logoPath = hasCustomIcon
|
||||
? `/icons/tokens/${mangoSymbol.toLowerCase()}.svg`
|
||||
: birdeyeData?.logoURI
|
||||
return (
|
||||
<TrBody key={slug}>
|
||||
<TrBody
|
||||
key={slug}
|
||||
className="default-transition md:hover:cursor-pointer md:hover:bg-th-bkg-2"
|
||||
onClick={() => goToTokenPage(slug, router)}
|
||||
>
|
||||
<Td>
|
||||
<p>{tokenName}</p>
|
||||
<p>{symbol}</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Image src={logoPath} alt="Logo" height={32} width={32} />
|
||||
<div>
|
||||
<p>{tokenName}</p>
|
||||
<p className="text-sm text-th-fgd-4">
|
||||
{mangoSymbol || symbol}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<p>{price}</p>
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
|
||||
import { TokenPageWithData } from '../../../contentful/tokenPage'
|
||||
import { numberCompacter } from '../../utils/numbers'
|
||||
|
||||
const TokenMarketStats = ({ tokenData }: { tokenData: TokenPageWithData }) => {
|
||||
if (!tokenData?.birdeyeData) return null
|
||||
const { birdeyeData } = tokenData
|
||||
return (
|
||||
<div className="border-t border-th-bkg-3 pt-4 mt-12">
|
||||
<h2 className="mb-4 text-2xl">Market stats</h2>
|
||||
<div className="grid grid-cols-3 grid-flow-row gap-6">
|
||||
<div>
|
||||
<p>FDMC</p>
|
||||
<span className="text-4xl font-display text-th-fgd-1">
|
||||
{birdeyeData?.mc
|
||||
? `$${numberCompacter.format(birdeyeData.mc)}`
|
||||
: '–'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Supply</p>
|
||||
<span className="text-4xl font-display text-th-fgd-1">
|
||||
{birdeyeData?.supply
|
||||
? `$${numberCompacter.format(birdeyeData.supply)}`
|
||||
: '–'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TokenMarketStats
|
|
@ -0,0 +1,31 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BirdeyePriceHistoryData } from '../../types/birdeye'
|
||||
import { formatYAxis } from '../../utils/numbers'
|
||||
import DetailedAreaOrBarChart from '../shared/DetailedAreaOrBarChart'
|
||||
|
||||
const TokenPriceChart = ({
|
||||
chartData,
|
||||
}: {
|
||||
chartData: BirdeyePriceHistoryData[]
|
||||
}) => {
|
||||
const [daysToShow, setDaysToShow] = useState('30')
|
||||
return (
|
||||
<DetailedAreaOrBarChart
|
||||
changeAsPercent
|
||||
data={chartData}
|
||||
daysToShow={daysToShow}
|
||||
setDaysToShow={setDaysToShow}
|
||||
heightClass="h-[180px] lg:h-[205px]"
|
||||
hideAxis
|
||||
prefix="$"
|
||||
tickFormat={(x) => `$${formatYAxis(x)}`}
|
||||
title=""
|
||||
xKey="unixTime"
|
||||
yKey="value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TokenPriceChart
|
|
@ -18,7 +18,7 @@ import ColorBlur from '../shared/ColorBlur'
|
|||
import Ottersec from '../icons/Ottersec'
|
||||
// import TabsText from '../shared/TabsText'
|
||||
// import MarketCard from './MarketCard'
|
||||
import { formatNumericValue, numberCompacter } from '../../utils'
|
||||
import { formatNumericValue, numberCompacter } from '../../utils/numbers'
|
||||
import HeroStat from './HeroStat'
|
||||
import useMarketsData from '../../hooks/useMarketData'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Image from 'next/image'
|
||||
import { MarketsDataItem } from '../../types'
|
||||
import { formatNumericValue, numberCompacter } from '../../utils'
|
||||
import { MarketsDataItem } from '../../types/mango'
|
||||
import { formatNumericValue, numberCompacter } from '../../utils/numbers'
|
||||
import Change from '../shared/Change'
|
||||
import SimpleAreaChart from '../shared/SimpleAreaChart'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/20/solid'
|
||||
|
|
|
@ -184,7 +184,7 @@ const NavigationLink = ({ path, text }: { path: string; text: string }) => {
|
|||
<Link href={path} shallow>
|
||||
<span
|
||||
className={`font-display ${
|
||||
pathname === path
|
||||
pathname.includes(path)
|
||||
? 'text-th-active md:hover:text-th-active'
|
||||
: 'text-th-fgd-2 md:hover:text-th-fgd-1'
|
||||
} default-transition text-base`}
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import { FunctionComponent } from 'react'
|
||||
|
||||
interface ChartRangeButtonsProps {
|
||||
activeValue: string
|
||||
className?: string
|
||||
onChange: (x: string) => void
|
||||
values: Array<string>
|
||||
names?: Array<string>
|
||||
}
|
||||
|
||||
const ChartRangeButtons: FunctionComponent<ChartRangeButtonsProps> = ({
|
||||
activeValue,
|
||||
className,
|
||||
values,
|
||||
onChange,
|
||||
names,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative flex">
|
||||
{activeValue && values.includes(activeValue) ? (
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full transform rounded-md bg-th-bkg-3"
|
||||
style={{
|
||||
transform: `translateX(${
|
||||
values.findIndex((v) => v === activeValue) * 100
|
||||
}%)`,
|
||||
width: `${100 / values.length}%`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{values.map((v, i) => (
|
||||
<button
|
||||
className={`${className} relative h-6 cursor-pointer rounded-md px-3 text-center text-xs focus-visible:bg-th-bkg-3 focus-visible:text-th-fgd-1
|
||||
${
|
||||
v === activeValue
|
||||
? `text-th-active`
|
||||
: `text-th-fgd-3 md:hover:text-th-active`
|
||||
}
|
||||
`}
|
||||
key={`${v}${i}`}
|
||||
onClick={() => onChange(v)}
|
||||
style={{
|
||||
width: `${100 / values.length}%`,
|
||||
}}
|
||||
>
|
||||
{names ? names[i] : v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChartRangeButtons
|
|
@ -0,0 +1,593 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { FunctionComponent, useMemo, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
Label,
|
||||
} from 'recharts'
|
||||
import { ContentType } from 'recharts/types/src/component/Tooltip'
|
||||
import { AxisDomain } from 'recharts/types/src/util/types'
|
||||
import SheenLoader from './SheenLoader'
|
||||
import { IconButton } from './Button'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
NoSymbolIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import ChartRangeButtons from './ChartRangeButtons'
|
||||
import Change from './Change'
|
||||
import FormatNumericValue from './FormatNumericValue'
|
||||
import Tooltip from './Tooltip'
|
||||
import { FadeInFadeOut } from './Transitions'
|
||||
import { DAILY_MILLISECONDS } from '../../utils/constants'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const titleClasses = 'mb-0.5 text-base'
|
||||
|
||||
interface DetailedAreaOrBarChartProps {
|
||||
changeAsPercent?: boolean
|
||||
chartType?: 'area' | 'bar'
|
||||
customTooltip?: ContentType<number, string>
|
||||
data: any[] | undefined
|
||||
daysToShow?: string
|
||||
domain?: AxisDomain
|
||||
heightClass?: string
|
||||
hideChange?: boolean
|
||||
hideChart?: () => void
|
||||
hideAxis?: boolean
|
||||
loaderHeightClass?: string
|
||||
loading?: boolean
|
||||
prefix?: string
|
||||
setDaysToShow?: (x: string) => void
|
||||
small?: boolean
|
||||
suffix?: string
|
||||
tickFormat?: (x: number) => string
|
||||
title?: string
|
||||
tooltipContent?: string
|
||||
xKey: string
|
||||
formatXKeyHeading?: (k: string | number) => string
|
||||
xAxisLabel?: string
|
||||
xAxisType?: 'number' | 'category' | undefined
|
||||
yDecimals?: number
|
||||
yKey: string
|
||||
showZeroLine?: boolean
|
||||
tooltipDateFormat?: string
|
||||
}
|
||||
|
||||
export const formatDateAxis = (date: string, days: number) => {
|
||||
if (days === 1) {
|
||||
return dayjs(date).format('h:mma')
|
||||
} else {
|
||||
return dayjs(date).format('D MMM')
|
||||
}
|
||||
}
|
||||
|
||||
const DetailedAreaOrBarChart: FunctionComponent<
|
||||
DetailedAreaOrBarChartProps
|
||||
> = ({
|
||||
changeAsPercent,
|
||||
chartType = 'area',
|
||||
customTooltip,
|
||||
data,
|
||||
daysToShow = '1',
|
||||
// domain,
|
||||
heightClass,
|
||||
hideChange,
|
||||
hideChart,
|
||||
hideAxis,
|
||||
loaderHeightClass,
|
||||
loading,
|
||||
prefix = '',
|
||||
setDaysToShow,
|
||||
showZeroLine,
|
||||
small,
|
||||
suffix = '',
|
||||
tickFormat,
|
||||
title,
|
||||
tooltipContent,
|
||||
tooltipDateFormat,
|
||||
xKey,
|
||||
formatXKeyHeading,
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
yDecimals,
|
||||
yKey,
|
||||
}) => {
|
||||
const [mouseData, setMouseData] = useState<any>(null)
|
||||
const [showChangePercentage, setShowChangePercentage] = useState(
|
||||
changeAsPercent || false,
|
||||
)
|
||||
|
||||
const handleMouseMove = (coords: any) => {
|
||||
if (coords.activePayload) {
|
||||
setMouseData(coords.activePayload[0].payload)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setMouseData(null)
|
||||
}
|
||||
|
||||
const flipGradientCoords = useMemo(() => {
|
||||
if (!data || !data.length) return
|
||||
return data[0][yKey] <= 0 && data[data.length - 1][yKey] < 0
|
||||
}, [data])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data || !data.length) return []
|
||||
if (xAxisType === 'number') return data
|
||||
const start = Number(daysToShow) * DAILY_MILLISECONDS
|
||||
const filtered = data.filter((d: any) => {
|
||||
const dataTime = new Date(d[xKey]).getTime()
|
||||
const now = new Date().getTime()
|
||||
const limit = now - start
|
||||
return dataTime >= limit
|
||||
})
|
||||
return filtered
|
||||
}, [data, daysToShow, xAxisType])
|
||||
|
||||
const calculateChartChange = () => {
|
||||
if (filteredData.length) {
|
||||
let firstValue = filteredData[0][yKey]
|
||||
if (xAxisType === 'number') {
|
||||
const minValue = filteredData.reduce(
|
||||
(min, current) => (current[xKey] < min[xKey] ? current : min),
|
||||
filteredData[0],
|
||||
)
|
||||
firstValue = minValue[yKey]
|
||||
}
|
||||
if (mouseData) {
|
||||
const index = filteredData.findIndex(
|
||||
(d: any) => d[xKey] === mouseData[xKey],
|
||||
)
|
||||
const currentValue = filteredData[index]?.[yKey]
|
||||
|
||||
const change =
|
||||
index >= 0
|
||||
? showChangePercentage
|
||||
? ((currentValue - firstValue) / Math.abs(firstValue)) * 100
|
||||
: currentValue - firstValue
|
||||
: 0
|
||||
return isNaN(change) ? 0 : change
|
||||
} else {
|
||||
const currentValue = filteredData[filteredData.length - 1][yKey]
|
||||
return showChangePercentage
|
||||
? ((currentValue - firstValue) / Math.abs(firstValue)) * 100
|
||||
: currentValue - firstValue
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
return (
|
||||
<FadeInFadeOut show={true}>
|
||||
<div>
|
||||
{loading ? (
|
||||
<SheenLoader className="flex flex-1">
|
||||
<div
|
||||
className={`${
|
||||
loaderHeightClass ? loaderHeightClass : 'h-96'
|
||||
} w-full rounded-lg bg-th-bkg-2`}
|
||||
/>
|
||||
</SheenLoader>
|
||||
) : filteredData.length ? (
|
||||
<div className="relative">
|
||||
{setDaysToShow ? (
|
||||
<div className="mb-4 sm:absolute sm:-top-1 sm:right-0 sm:mb-0 sm:flex sm:justify-end">
|
||||
<ChartRangeButtons
|
||||
activeValue={daysToShow}
|
||||
names={['24H', '7D', '30D']}
|
||||
values={['1', '7', '30']}
|
||||
onChange={(v) => setDaysToShow(v)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:space-x-6">
|
||||
{hideChart ? (
|
||||
<IconButton
|
||||
className="mb-6"
|
||||
onClick={hideChart}
|
||||
size="medium"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<div>
|
||||
{title ? (
|
||||
tooltipContent ? (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<p
|
||||
className={`${titleClasses}
|
||||
tooltip-underline`}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p className={titleClasses}>{title}</p>
|
||||
)
|
||||
) : null}
|
||||
{mouseData ? (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-end ${
|
||||
small ? 'h-8 text-2xl' : 'mb-1 text-4xl'
|
||||
} font-display text-th-fgd-1`}
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{mouseData[yKey] < 0 ? '-' : ''}
|
||||
{prefix}
|
||||
<FormatNumericValue
|
||||
value={Math.abs(mouseData[yKey])}
|
||||
decimals={yDecimals}
|
||||
/>
|
||||
{suffix}
|
||||
</span>
|
||||
{!hideChange ? (
|
||||
<div
|
||||
className={`ml-3 flex items-center ${
|
||||
small ? 'mb-[3px]' : 'mb-0.5'
|
||||
}`}
|
||||
>
|
||||
<Change
|
||||
change={calculateChartChange()}
|
||||
decimals={!showChangePercentage ? yDecimals : 2}
|
||||
prefix={!showChangePercentage ? prefix : ''}
|
||||
suffix={!showChangePercentage ? suffix : '%'}
|
||||
/>
|
||||
{changeAsPercent ? (
|
||||
<ToggleChangeTypeButton
|
||||
changeType={showChangePercentage}
|
||||
setChangeType={setShowChangePercentage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={`${
|
||||
small ? 'text-xs' : 'text-sm'
|
||||
} text-th-fgd-4`}
|
||||
>
|
||||
{formatXKeyHeading
|
||||
? formatXKeyHeading(mouseData[xKey])
|
||||
: dayjs(mouseData[xKey]).format(
|
||||
tooltipDateFormat
|
||||
? tooltipDateFormat
|
||||
: 'DD MMM YY, h:mma',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-end ${
|
||||
small ? 'h-8 text-2xl' : 'mb-1 text-4xl'
|
||||
} font-display text-th-fgd-1`}
|
||||
>
|
||||
<span>
|
||||
{filteredData[filteredData.length - 1][yKey] < 0
|
||||
? '-'
|
||||
: ''}
|
||||
{prefix}
|
||||
<span className="tabular-nums">
|
||||
<FormatNumericValue
|
||||
value={
|
||||
data ? Math.abs(data[data.length - 1][yKey]) : 0
|
||||
}
|
||||
decimals={yDecimals}
|
||||
/>
|
||||
</span>
|
||||
{suffix}
|
||||
</span>
|
||||
{!hideChange ? (
|
||||
<div
|
||||
className={`ml-3 flex items-center ${
|
||||
small ? 'mb-[3px]' : 'mb-0.5'
|
||||
}`}
|
||||
>
|
||||
<Change
|
||||
change={calculateChartChange()}
|
||||
decimals={!showChangePercentage ? yDecimals : 2}
|
||||
prefix={!showChangePercentage ? prefix : ''}
|
||||
suffix={!showChangePercentage ? suffix : '%'}
|
||||
/>
|
||||
{changeAsPercent ? (
|
||||
<ToggleChangeTypeButton
|
||||
changeType={showChangePercentage}
|
||||
setChangeType={setShowChangePercentage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={`${
|
||||
small ? 'text-xs' : 'text-sm'
|
||||
} text-th-fgd-4`}
|
||||
>
|
||||
{formatXKeyHeading
|
||||
? formatXKeyHeading(
|
||||
filteredData[filteredData.length - 1][xKey],
|
||||
)
|
||||
: dayjs(
|
||||
filteredData[filteredData.length - 1][xKey],
|
||||
).format(
|
||||
tooltipDateFormat
|
||||
? tooltipDateFormat
|
||||
: 'DD MMM YY, h:mma',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`-mt-1 ${heightClass ? heightClass : 'h-96'} w-auto`}
|
||||
>
|
||||
<div className="-mx-6 mt-6 h-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === 'area' ? (
|
||||
<AreaChart
|
||||
data={filteredData}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<RechartsTooltip
|
||||
cursor={{
|
||||
strokeOpacity: 0.09,
|
||||
}}
|
||||
content={customTooltip ? customTooltip : <></>}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`gradientArea-${title?.replace(
|
||||
/[^a-zA-Z]/g,
|
||||
'',
|
||||
)}`}
|
||||
x1="0"
|
||||
y1={flipGradientCoords ? '1' : '0'}
|
||||
x2="0"
|
||||
y2={flipGradientCoords ? '0' : '1'}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={
|
||||
calculateChartChange() >= 0
|
||||
? 'var(--up)'
|
||||
: 'var(--down)'
|
||||
}
|
||||
stopOpacity={0.15}
|
||||
/>
|
||||
<stop
|
||||
offset="99%"
|
||||
stopColor={
|
||||
calculateChartChange() >= 0
|
||||
? 'var(--up)'
|
||||
: 'var(--down)'
|
||||
}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey={yKey}
|
||||
stroke={
|
||||
isNaN(calculateChartChange())
|
||||
? 'var(--fgd-4)'
|
||||
: calculateChartChange() >= 0
|
||||
? 'var(--up)'
|
||||
: 'var(--down)'
|
||||
}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#gradientArea-${title?.replace(
|
||||
/[^a-zA-Z]/g,
|
||||
'',
|
||||
)})`}
|
||||
/>
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey={xKey}
|
||||
hide={hideAxis}
|
||||
minTickGap={20}
|
||||
padding={{ left: 20, right: 20 }}
|
||||
tick={{
|
||||
fill: 'var(--fgd-4)',
|
||||
fontSize: 10,
|
||||
}}
|
||||
tickLine={false}
|
||||
tickFormatter={
|
||||
xAxisType !== 'number'
|
||||
? (d) => formatDateAxis(d, parseInt(daysToShow))
|
||||
: undefined
|
||||
}
|
||||
type={xAxisType}
|
||||
>
|
||||
{xAxisLabel ? (
|
||||
<Label
|
||||
value={xAxisLabel}
|
||||
offset={-2}
|
||||
position="insideBottom"
|
||||
fontSize={10}
|
||||
fill="var(--fgd-3)"
|
||||
/>
|
||||
) : null}
|
||||
</XAxis>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
dataKey={yKey}
|
||||
hide={hideAxis}
|
||||
minTickGap={20}
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
padding={{ top: 20, bottom: 20 }}
|
||||
tick={{
|
||||
fill: 'var(--fgd-4)',
|
||||
fontSize: 10,
|
||||
}}
|
||||
tickFormatter={
|
||||
tickFormat ? (v) => tickFormat(v) : undefined
|
||||
}
|
||||
tickLine={false}
|
||||
/>
|
||||
{showZeroLine ? (
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="var(--fgd-4)"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
) : null}
|
||||
</AreaChart>
|
||||
) : (
|
||||
<BarChart
|
||||
data={filteredData}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<RechartsTooltip
|
||||
cursor={{
|
||||
fill: 'var(--bkg-2)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
content={customTooltip ? customTooltip : <></>}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="greenGradientBar"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--up)"
|
||||
stopOpacity={1}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--up)"
|
||||
stopOpacity={0.7}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="redGradientBar"
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="0"
|
||||
y2="0"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--down)"
|
||||
stopOpacity={1}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--down)"
|
||||
stopOpacity={0.7}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Bar dataKey={yKey}>
|
||||
{filteredData.map((entry, index) => {
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
entry[yKey] > 0
|
||||
? 'url(#greenGradientBar)'
|
||||
: 'url(#redGradientBar)'
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Bar>
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
axisLine={false}
|
||||
dy={10}
|
||||
minTickGap={20}
|
||||
padding={{ left: 20, right: 20 }}
|
||||
tick={{
|
||||
fill: 'var(--fgd-4)',
|
||||
fontSize: 10,
|
||||
}}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) =>
|
||||
formatDateAxis(v, parseInt(daysToShow))
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey={yKey}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={false}
|
||||
dx={-10}
|
||||
padding={{ top: 20, bottom: 20 }}
|
||||
tick={{
|
||||
fill: 'var(--fgd-4)',
|
||||
fontSize: 10,
|
||||
}}
|
||||
tickLine={false}
|
||||
tickFormatter={
|
||||
tickFormat ? (v) => tickFormat(v) : undefined
|
||||
}
|
||||
type="number"
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--bkg-4)" />
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex ${
|
||||
heightClass ? heightClass : 'h-96'
|
||||
} items-center justify-center p-4 text-th-fgd-3`}
|
||||
>
|
||||
<div>
|
||||
<NoSymbolIcon className="mx-auto mb-1 h-6 w-6 text-th-fgd-4" />
|
||||
<p className="text-th-fgd-4">Chart Unavailable</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeInFadeOut>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailedAreaOrBarChart
|
||||
|
||||
const ToggleChangeTypeButton = ({
|
||||
changeType,
|
||||
setChangeType,
|
||||
}: {
|
||||
changeType: boolean
|
||||
setChangeType: (isPercent: boolean) => void
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className="ml-2 flex h-4 w-4 items-center justify-center text-th-fgd-3 focus:outline-none md:hover:text-th-active"
|
||||
onClick={() => setChangeType(!changeType)}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import Decimal from 'decimal.js'
|
||||
import { formatCurrencyValue, formatNumericValue } from '../../utils'
|
||||
import { formatCurrencyValue, formatNumericValue } from '../../utils/numbers'
|
||||
|
||||
const FormatNumericValue = ({
|
||||
classNames,
|
||||
|
|
|
@ -42,14 +42,7 @@ const SimpleAreaChart = ({
|
|||
fill={`url(#gradientArea-${name.replace(/[^a-zA-Z]/g, '')}`}
|
||||
/>
|
||||
<XAxis dataKey={xKey} hide />
|
||||
<YAxis
|
||||
domain={([dataMin, dataMax]) => {
|
||||
const adjustment = (dataMax - dataMin) / 5
|
||||
return [dataMin - adjustment, dataMax + adjustment]
|
||||
}}
|
||||
dataKey={yKey}
|
||||
hide
|
||||
/>
|
||||
<YAxis domain={['dataMin', 'dataMax']} dataKey={yKey} hide />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { Transition } from '@headlessui/react'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const transitionEnterStyle = 'transition-all ease-out duration-300'
|
||||
const transitionExitStyle = 'transition-all ease-in duration-300'
|
||||
|
||||
export const FadeInFadeOut = ({
|
||||
children,
|
||||
className,
|
||||
show,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
show: boolean
|
||||
}) => (
|
||||
<Transition
|
||||
appear={true}
|
||||
className={className}
|
||||
show={show}
|
||||
enter={transitionEnterStyle}
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave={transitionExitStyle}
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{children}
|
||||
</Transition>
|
||||
)
|
|
@ -1,9 +1,9 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchMarketData } from '../utils/mango'
|
||||
import { fetchMangoMarketData } from '../utils/mango'
|
||||
|
||||
export default function useMarketsData() {
|
||||
return useQuery({
|
||||
queryKey: ['market-data'],
|
||||
queryFn: fetchMarketData,
|
||||
queryFn: fetchMangoMarketData,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -206,6 +206,10 @@ li {
|
|||
@apply text-lg md:text-xl text-th-fgd-4;
|
||||
}
|
||||
|
||||
table p {
|
||||
@apply text-th-fgd-1;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
|
||||
.hide-scroll::-webkit-scrollbar {
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
export interface BirdeyeOverviewData {
|
||||
logoURI: string
|
||||
mc: number
|
||||
price: number
|
||||
priceChange24hPercent: number
|
||||
supply: number
|
||||
}
|
||||
|
||||
export interface BirdeyePriceHistoryData {
|
||||
address: string
|
||||
unixTime: number
|
||||
value: number
|
||||
}
|
|
@ -30,3 +30,16 @@ export type AppStatsData = {
|
|||
num_openbook_fills_24h: number
|
||||
open_interest: number
|
||||
}
|
||||
|
||||
type PriceHistoryData = {
|
||||
price: number
|
||||
time: string
|
||||
}
|
||||
|
||||
export type MangoTokenData = {
|
||||
'24_hr_price': number
|
||||
mint: string
|
||||
price: number
|
||||
price_history: PriceHistoryData[]
|
||||
symbol: string
|
||||
}
|
|
@ -6,6 +6,7 @@ export async function makeApiRequest(path: string) {
|
|||
headers: {
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_BIRDEYE_API_KEY!,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
|
|
@ -1 +1,40 @@
|
|||
export const MANGO_DATA_API_URL = 'https://api.mngo.cloud/data/v4'
|
||||
|
||||
export const CUSTOM_TOKEN_ICONS: { [key: string]: boolean } = {
|
||||
all: true,
|
||||
bonk: true,
|
||||
btc: true,
|
||||
chai: true,
|
||||
crown: true,
|
||||
dai: true,
|
||||
dual: true,
|
||||
eth: true,
|
||||
ethpo: true,
|
||||
'eth (portal)': true,
|
||||
guac: true,
|
||||
hnt: true,
|
||||
jitosol: true,
|
||||
jlp: true,
|
||||
kin: true,
|
||||
ldo: true,
|
||||
mngo: true,
|
||||
msol: true,
|
||||
neon: true,
|
||||
orca: true,
|
||||
pyth: true,
|
||||
ray: true,
|
||||
render: true,
|
||||
rlb: true,
|
||||
slcl: true,
|
||||
sol: true,
|
||||
stsol: true,
|
||||
tbtc: true,
|
||||
usdc: true,
|
||||
usdh: true,
|
||||
usdt: true,
|
||||
wbtcpo: true,
|
||||
'wbtc (portal)': true,
|
||||
}
|
||||
|
||||
export const DAILY_SECONDS = 86400
|
||||
export const DAILY_MILLISECONDS = 86400000
|
||||
|
|
|
@ -1,7 +1,25 @@
|
|||
import { FormattedMarketData, MarketData } from '../types'
|
||||
import { FormattedMarketData, MarketData } from '../types/mango'
|
||||
import { MANGO_DATA_API_URL } from './constants'
|
||||
|
||||
export const fetchMarketData = async () => {
|
||||
export const fetchMangoTokenData = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${MANGO_DATA_API_URL}/stats/token-price-history`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
},
|
||||
)
|
||||
const data = await response.json()
|
||||
if (data && data?.length) {
|
||||
return data
|
||||
} else return []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch mango token data', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchMangoMarketData = async () => {
|
||||
const promises = [
|
||||
fetch(`${MANGO_DATA_API_URL}/stats/perp-market-summary`, {
|
||||
cache: 'no-store',
|
||||
|
@ -44,7 +62,7 @@ export const fetchMarketData = async () => {
|
|||
}
|
||||
return { perp: formattedPerpData, spot: formattedSpotData }
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch market data', e)
|
||||
console.error('Failed to fetch mango market data', e)
|
||||
return { perp: [], spot: [] }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,66 @@
|
|||
import Decimal from 'decimal.js'
|
||||
|
||||
export const numberFormat = new Intl.NumberFormat('en', {
|
||||
maximumSignificantDigits: 7,
|
||||
})
|
||||
|
||||
export const floorToDecimal = (
|
||||
value: number | string | Decimal,
|
||||
decimals: number,
|
||||
): Decimal => {
|
||||
const decimal = value instanceof Decimal ? value : new Decimal(value)
|
||||
return decimal.toDecimalPlaces(decimals, Decimal.ROUND_FLOOR)
|
||||
}
|
||||
|
||||
export const numberCompacter = Intl.NumberFormat('en', {
|
||||
maximumFractionDigits: 2,
|
||||
notation: 'compact',
|
||||
})
|
||||
|
||||
const digits2 = new Intl.NumberFormat('en', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const digits3 = new Intl.NumberFormat('en', { maximumFractionDigits: 3 })
|
||||
const digits4 = new Intl.NumberFormat('en', { maximumFractionDigits: 4 })
|
||||
const digits5 = new Intl.NumberFormat('en', { maximumFractionDigits: 5 })
|
||||
const digits6 = new Intl.NumberFormat('en', { maximumFractionDigits: 6 })
|
||||
const digits7 = new Intl.NumberFormat('en', { maximumFractionDigits: 7 })
|
||||
const digits8 = new Intl.NumberFormat('en', { maximumFractionDigits: 8 })
|
||||
const digits9 = new Intl.NumberFormat('en', { maximumFractionDigits: 9 })
|
||||
|
||||
const roundValue = (
|
||||
value: number | string | Decimal,
|
||||
decimals: number,
|
||||
roundUp?: boolean,
|
||||
): string => {
|
||||
const decimalValue = value instanceof Decimal ? value : new Decimal(value)
|
||||
const roundMode = roundUp ? Decimal.ROUND_UP : Decimal.ROUND_FLOOR
|
||||
const roundedValue = decimalValue
|
||||
.toDecimalPlaces(decimals, roundMode)
|
||||
.toNumber()
|
||||
if (decimals === 2) return digits2.format(roundedValue)
|
||||
if (decimals === 3) return digits3.format(roundedValue)
|
||||
if (decimals === 4) return digits4.format(roundedValue)
|
||||
if (decimals === 5) return digits5.format(roundedValue)
|
||||
if (decimals === 6) return digits6.format(roundedValue)
|
||||
if (decimals === 7) return digits7.format(roundedValue)
|
||||
if (decimals === 8) return digits8.format(roundedValue)
|
||||
if (decimals === 9) return digits9.format(roundedValue)
|
||||
return roundedValue.toLocaleString(undefined, {
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
}
|
||||
|
||||
export const countLeadingZeros = (x: number) => {
|
||||
const absoluteX = Math.abs(x)
|
||||
if (absoluteX % 1 == 0) {
|
||||
return 0
|
||||
} else {
|
||||
return -1 - Math.floor(Math.log10(absoluteX % 1))
|
||||
}
|
||||
}
|
||||
|
||||
export const formatNumericValue = (
|
||||
value: number | string | Decimal,
|
||||
decimals?: number,
|
||||
|
@ -31,6 +92,27 @@ export const formatNumericValue = (
|
|||
return formattedValue
|
||||
}
|
||||
|
||||
const usdFormatter0 = Intl.NumberFormat('en', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const usdFormatter2 = Intl.NumberFormat('en', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const usdFormatter3Sig = Intl.NumberFormat('en', {
|
||||
minimumSignificantDigits: 3,
|
||||
maximumSignificantDigits: 3,
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
export const formatCurrencyValue = (
|
||||
value: number | string | Decimal,
|
||||
decimals?: number,
|
||||
|
@ -58,84 +140,10 @@ export const formatCurrencyValue = (
|
|||
return formattedValue
|
||||
}
|
||||
|
||||
const roundValue = (
|
||||
value: number | string | Decimal,
|
||||
decimals: number,
|
||||
roundUp?: boolean,
|
||||
): string => {
|
||||
const decimalValue = value instanceof Decimal ? value : new Decimal(value)
|
||||
const roundMode = roundUp ? Decimal.ROUND_UP : Decimal.ROUND_FLOOR
|
||||
const roundedValue = decimalValue
|
||||
.toDecimalPlaces(decimals, roundMode)
|
||||
.toNumber()
|
||||
if (decimals === 2) return digits2.format(roundedValue)
|
||||
if (decimals === 3) return digits3.format(roundedValue)
|
||||
if (decimals === 4) return digits4.format(roundedValue)
|
||||
if (decimals === 5) return digits5.format(roundedValue)
|
||||
if (decimals === 6) return digits6.format(roundedValue)
|
||||
if (decimals === 7) return digits7.format(roundedValue)
|
||||
if (decimals === 8) return digits8.format(roundedValue)
|
||||
if (decimals === 9) return digits9.format(roundedValue)
|
||||
return roundedValue.toLocaleString(undefined, {
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
export const formatYAxis = (value: number) => {
|
||||
return value === 0
|
||||
? '0'
|
||||
: Math.abs(value) > 1
|
||||
? numberCompacter.format(value)
|
||||
: formatNumericValue(value, 4)
|
||||
}
|
||||
|
||||
const digits2 = new Intl.NumberFormat('en', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const digits3 = new Intl.NumberFormat('en', { maximumFractionDigits: 3 })
|
||||
const digits4 = new Intl.NumberFormat('en', { maximumFractionDigits: 4 })
|
||||
const digits5 = new Intl.NumberFormat('en', { maximumFractionDigits: 5 })
|
||||
const digits6 = new Intl.NumberFormat('en', { maximumFractionDigits: 6 })
|
||||
const digits7 = new Intl.NumberFormat('en', { maximumFractionDigits: 7 })
|
||||
const digits8 = new Intl.NumberFormat('en', { maximumFractionDigits: 8 })
|
||||
const digits9 = new Intl.NumberFormat('en', { maximumFractionDigits: 9 })
|
||||
|
||||
export const numberFormat = new Intl.NumberFormat('en', {
|
||||
maximumSignificantDigits: 7,
|
||||
})
|
||||
|
||||
export const floorToDecimal = (
|
||||
value: number | string | Decimal,
|
||||
decimals: number,
|
||||
): Decimal => {
|
||||
const decimal = value instanceof Decimal ? value : new Decimal(value)
|
||||
return decimal.toDecimalPlaces(decimals, Decimal.ROUND_FLOOR)
|
||||
}
|
||||
|
||||
const usdFormatter0 = Intl.NumberFormat('en', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const usdFormatter2 = Intl.NumberFormat('en', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
const usdFormatter3Sig = Intl.NumberFormat('en', {
|
||||
minimumSignificantDigits: 3,
|
||||
maximumSignificantDigits: 3,
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
export const countLeadingZeros = (x: number) => {
|
||||
const absoluteX = Math.abs(x)
|
||||
if (absoluteX % 1 == 0) {
|
||||
return 0
|
||||
} else {
|
||||
return -1 - Math.floor(Math.log10(absoluteX % 1))
|
||||
}
|
||||
}
|
||||
|
||||
export const numberCompacter = Intl.NumberFormat('en', {
|
||||
maximumFractionDigits: 2,
|
||||
notation: 'compact',
|
||||
})
|
|
@ -3,6 +3,7 @@ import { Entry } from 'contentful'
|
|||
import { Document as RichTextDocument } from '@contentful/rich-text-types'
|
||||
import contentfulClient from './contentfulClient'
|
||||
import { makeApiRequest } from '../app/utils/birdeye'
|
||||
import { BirdeyeOverviewData } from '../app/types/birdeye'
|
||||
|
||||
type TokenPageEntry = Entry<TypeTokenSkeleton, undefined, string>
|
||||
|
||||
|
@ -16,15 +17,12 @@ export interface TokenPage {
|
|||
twitterUrl?: string
|
||||
mint: string
|
||||
coingeckoId: string
|
||||
}
|
||||
|
||||
interface BirdeyeData {
|
||||
price: number
|
||||
priceChange24hPercent: number
|
||||
seoTitle: string
|
||||
seoDescription: string
|
||||
}
|
||||
|
||||
export interface TokenPageWithData extends TokenPage {
|
||||
birdeyeData: BirdeyeData
|
||||
birdeyeData: BirdeyeOverviewData
|
||||
}
|
||||
|
||||
export function parseContentfulTokenPage(
|
||||
|
@ -44,6 +42,8 @@ export function parseContentfulTokenPage(
|
|||
twitterUrl: tokenPageEntry.fields.twitterUrl || undefined,
|
||||
mint: tokenPageEntry.fields.mint,
|
||||
coingeckoId: tokenPageEntry.fields.coingeckoId,
|
||||
seoTitle: tokenPageEntry.fields.seoTitle,
|
||||
seoDescription: tokenPageEntry.fields.seoDescription,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -85,14 +85,25 @@ interface FetchTokenPageOptions {
|
|||
export async function fetchTokenPage({
|
||||
slug,
|
||||
preview,
|
||||
}: FetchTokenPageOptions): Promise<TokenPage | null> {
|
||||
}: FetchTokenPageOptions): Promise<TokenPageWithData | null> {
|
||||
const contentful = contentfulClient({ preview })
|
||||
|
||||
const blogPostsResult = await contentful.getEntries<TypeTokenSkeleton>({
|
||||
const tokenPageResult = await contentful.getEntries<TypeTokenSkeleton>({
|
||||
content_type: 'token',
|
||||
'fields.slug': slug,
|
||||
include: 2,
|
||||
})
|
||||
|
||||
return parseContentfulTokenPage(blogPostsResult.items[0])
|
||||
const parsedTokenPage = parseContentfulTokenPage(tokenPageResult.items[0])
|
||||
|
||||
if (parsedTokenPage) {
|
||||
const birdeyeDataResponse = await makeApiRequest(
|
||||
`defi/token_overview?address=${parsedTokenPage.mint}`,
|
||||
)
|
||||
const birdeyeData = birdeyeDataResponse?.success
|
||||
? birdeyeDataResponse?.data
|
||||
: undefined
|
||||
|
||||
return { ...parsedTokenPage, birdeyeData: birdeyeData }
|
||||
} else return null
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import type {
|
|||
export interface TypeTokenFields {
|
||||
tokenName: EntryFieldTypes.Symbol
|
||||
slug: EntryFieldTypes.Symbol
|
||||
seoTitle: EntryFieldTypes.Symbol
|
||||
seoDescription: EntryFieldTypes.Text
|
||||
description: EntryFieldTypes.RichText
|
||||
tags: EntryFieldTypes.Array<
|
||||
EntryFieldTypes.Symbol<'Governanace' | 'Liquid Staking' | 'Meme'>
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"contentful": "^10.6.12",
|
||||
"dayjs": "^1.11.10",
|
||||
"decimal.js": "^10.4.3",
|
||||
"gsap": "^3.12.2",
|
||||
"i18next": "^23.7.6",
|
||||
|
@ -42,6 +43,7 @@
|
|||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flip-numbers": "^3.0.8",
|
||||
"react-i18next": "^13.5.0",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
|
@ -50,6 +52,7 @@
|
|||
"@types/jest": "^29.5.8",
|
||||
"@types/node": "20.9.2",
|
||||
"@types/react": "18.2.37",
|
||||
"@types/recharts": "^1.8.28",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
|
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 955 B |
|
@ -0,0 +1,36 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3_228)">
|
||||
<g clip-path="url(#clip1_3_228)">
|
||||
<path d="M16 32C24.8366 32 32 24.8366 32 16C32 7.16344 24.8366 0 16 0C7.16344 0 0 7.16344 0 16C0 24.8366 7.16344 32 16 32Z" fill="#F3F3F3"/>
|
||||
<path d="M15.9979 3V12.4613L23.9947 16.0346L15.9979 3Z" fill="#343434"/>
|
||||
<path d="M15.9978 3L8 16.0346L15.9978 12.4613V3Z" fill="#8C8C8C"/>
|
||||
<path d="M15.9979 22.166V28.5948L24 17.5239L15.9979 22.166Z" fill="#3C3C3B"/>
|
||||
<path d="M15.9978 28.5948V22.165L8 17.5239L15.9978 28.5948Z" fill="#8C8C8C"/>
|
||||
<path d="M15.9979 20.6779L23.9947 16.0347L15.9979 12.4635V20.6779Z" fill="#141414"/>
|
||||
<path d="M8 16.0347L15.9978 20.6779V12.4635L8 16.0347Z" fill="#393939"/>
|
||||
</g>
|
||||
<circle cx="27" cy="5" r="5" fill="url(#paint0_radial_3_228)"/>
|
||||
<circle cx="27" cy="5" r="3.75" fill="url(#paint1_radial_3_228)"/>
|
||||
<circle cx="27" cy="5" r="2.5" fill="url(#paint2_radial_3_228)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_3_228" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(27 5) rotate(90) scale(5)">
|
||||
<stop offset="0.739583" stop-color="#49266B"/>
|
||||
<stop offset="1" stop-color="#976EC0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_3_228" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(27 5) rotate(90) scale(3.75)">
|
||||
<stop offset="0.713542" stop-color="#3E2755"/>
|
||||
<stop offset="1" stop-color="#845EAA"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_3_228" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(27 5) rotate(90) scale(2.5)">
|
||||
<stop offset="0.494792"/>
|
||||
<stop offset="1" stop-color="#845EAA"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_3_228">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3_228">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 963 B After Width: | Height: | Size: 963 B |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
@ -0,0 +1,26 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3_219)">
|
||||
<path d="M16 32C24.8366 32 32 24.8366 32 16C32 7.16344 24.8366 0 16 0C7.16344 0 0 7.16344 0 16C0 24.8366 7.16344 32 16 32Z" fill="#F7931A"/>
|
||||
<path d="M23.189 14.02C23.503 11.924 21.906 10.797 19.724 10.045L20.432 7.205L18.704 6.775L18.014 9.54C17.56 9.426 17.094 9.32 16.629 9.214L17.324 6.431L15.596 6L14.888 8.839C14.512 8.753 14.142 8.669 13.784 8.579L13.786 8.57L11.402 7.975L10.942 9.821C10.942 9.821 12.225 10.115 12.198 10.133C12.898 10.308 13.024 10.771 13.003 11.139L12.197 14.374C12.245 14.386 12.307 14.404 12.377 14.431L12.194 14.386L11.064 18.918C10.978 19.13 10.761 19.449 10.271 19.328C10.289 19.353 9.015 19.015 9.015 19.015L8.157 20.993L10.407 21.554C10.825 21.659 11.235 21.769 11.638 21.872L10.923 24.744L12.65 25.174L13.358 22.334C13.83 22.461 14.288 22.579 14.736 22.691L14.03 25.519L15.758 25.949L16.473 23.083C19.421 23.641 21.637 23.416 22.57 20.75C23.322 18.604 22.533 17.365 20.982 16.558C22.112 16.298 22.962 15.555 23.189 14.02V14.02ZM19.239 19.558C18.706 21.705 15.091 20.544 13.919 20.253L14.869 16.448C16.041 16.741 19.798 17.32 19.239 19.558ZM19.774 13.989C19.287 15.942 16.279 14.949 15.304 14.706L16.164 11.256C17.139 11.499 20.282 11.952 19.774 13.989Z" fill="white"/>
|
||||
<circle cx="27" cy="5" r="5" fill="url(#paint0_radial_3_219)"/>
|
||||
<circle cx="27" cy="5" r="3.75" fill="url(#paint1_radial_3_219)"/>
|
||||
<circle cx="27" cy="5" r="2.5" fill="url(#paint2_radial_3_219)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_3_219" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(27 5) rotate(90) scale(5)">
|
||||
<stop offset="0.739583" stop-color="#49266B"/>
|
||||
<stop offset="1" stop-color="#976EC0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_3_219" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(27 5) rotate(90) scale(3.75)">
|
||||
<stop offset="0.713542" stop-color="#3E2755"/>
|
||||
<stop offset="1" stop-color="#845EAA"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_3_219" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(27 5) rotate(90) scale(2.5)">
|
||||
<stop offset="0.494792"/>
|
||||
<stop offset="1" stop-color="#845EAA"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_3_219">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
37
yarn.lock
|
@ -1213,6 +1213,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.2.tgz#4327f4a05d475cf9be46a93fc2e0f8d23380805a"
|
||||
integrity sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==
|
||||
|
||||
"@types/d3-path@^1":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.11.tgz#45420fee2d93387083b34eae4fe6d996edf482bc"
|
||||
integrity sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==
|
||||
|
||||
"@types/d3-scale@^4.0.2":
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb"
|
||||
|
@ -1220,6 +1225,13 @@
|
|||
dependencies:
|
||||
"@types/d3-time" "*"
|
||||
|
||||
"@types/d3-shape@^1":
|
||||
version "1.3.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
|
||||
integrity sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==
|
||||
dependencies:
|
||||
"@types/d3-path" "^1"
|
||||
|
||||
"@types/d3-shape@^3.1.0":
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.5.tgz#ab2f9c1485667be729b68bf2d9219858bc6d4009"
|
||||
|
@ -1317,6 +1329,14 @@
|
|||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/recharts@^1.8.28":
|
||||
version "1.8.28"
|
||||
resolved "https://registry.yarnpkg.com/@types/recharts/-/recharts-1.8.28.tgz#2e4a4468e4390c5db8d822783f925d6e80ae8297"
|
||||
integrity sha512-31D+dVBdVMtBnRMOjfM9210oRsclujQetwDNnCfapy/gF1BruvQkiK9WZ2ZMqDZY2xnDpIV8sWjISBcY+wgkLw==
|
||||
dependencies:
|
||||
"@types/d3-shape" "^1"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/scheduler@*":
|
||||
version "0.16.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.6.tgz#eb26db6780c513de59bee0b869ef289ad3068711"
|
||||
|
@ -2397,6 +2417,11 @@ date-fns@^2.0.1, date-fns@^2.28.0:
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.21.0"
|
||||
|
||||
dayjs@^1.11.10:
|
||||
version "1.11.10"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
|
||||
integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==
|
||||
|
||||
debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||
|
@ -5387,6 +5412,13 @@ react-dom@^18.2.0:
|
|||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-flip-numbers@^3.0.8:
|
||||
version "3.0.8"
|
||||
resolved "https://registry.yarnpkg.com/react-flip-numbers/-/react-flip-numbers-3.0.8.tgz#45f9240c3afd51e55f40d3da2380ac9d68d83456"
|
||||
integrity sha512-iEh4WScZFiGYkIWw3ATA352+XVWBsiKLg97CQHGXvpBetJFYazaYWMqv/mR2fGHdoDLA/2uES74e2wdEgy69BQ==
|
||||
dependencies:
|
||||
react-simple-animate "^3.0.1"
|
||||
|
||||
react-i18next@^13.5.0:
|
||||
version "13.5.0"
|
||||
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-13.5.0.tgz#44198f747628267a115c565f0c736a50a76b1ab0"
|
||||
|
@ -5415,6 +5447,11 @@ react-lifecycles-compat@^3.0.4:
|
|||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||
|
||||
react-simple-animate@^3.0.1:
|
||||
version "3.5.2"
|
||||
resolved "https://registry.yarnpkg.com/react-simple-animate/-/react-simple-animate-3.5.2.tgz#ab08865c8bd47872b92bd1e25902326bf7c695b3"
|
||||
integrity sha512-xLE65euP920QMTOmv5haPlml+hmOPDkbIr5WeF7ADIXWBYt5kW/vwpNfWg8EKMab8aeDxIZ6QjffVh8v2dUyhg==
|
||||
|
||||
react-smooth@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-2.0.5.tgz#d153b7dffc7143d0c99e82db1532f8cf93f20ecd"
|
||||
|
|