MP-1227: Borrow Page (#24)

* added icon for atom and tokenInfo data update

* borrow page initial commit

* feat: borrow funds to ca and wallet

* close borrow module on tx success

* feat: repay funds initial setup

* repay funds action hook

* repay slider. module state on borrow page component

* styling: minor tweak to text colors

* limit manual input on repay to max value

* borrow funds component slider initial

* style: max button typography

* AssetRow extracted to separate file. organize imports

* ContainerSecondary component added

* loading indicator for pending actions

* style: progress bar colors

* tanstack table added

* tanstack react-table dependency missing

* table cleanup and layout adjustments

* fix account stats formula and update market data to match spreadsheet

* calculate max borrow amount hook

* reset borrow and repay components on account change

* max borrow amount decimals. memorized return

* hook tanstack data with real data

* redefine borrowedAssetsMap to map

* update max borrow amount formulas

* remove unnecessary table component. refactor borrow table
This commit is contained in:
Gustavo Mauricio 2022-10-20 16:39:21 +01:00 committed by GitHub
parent 53eca46b6c
commit bbbdca6950
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1167 additions and 97 deletions

View File

@ -0,0 +1,89 @@
import React, { useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid'
import Image from 'next/image'
import Button from 'components/Button'
import { formatCurrency } from 'utils/formatters'
type AssetRowProps = {
data: {
denom: string
symbol: string
icon: string
chain: string
borrowed: {
amount: number
value: number
} | null
borrowRate: number
marketLiquidity: number
}
onBorrowClick: () => void
onRepayClick: (value: number) => void
}
const AssetRow = ({ data, onBorrowClick, onRepayClick }: AssetRowProps) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div
className="cursor-pointer rounded-md bg-[#D8DAEA] px-4 py-2 text-[#585A74] hover:bg-[#D8DAEA]/90"
onClick={() => setIsExpanded((current) => !current)}
>
<div className="flex">
<div className="flex flex-1 items-center">
<Image src={data.icon} alt="token" width={32} height={32} />
<div className="pl-2">
<div>{data.symbol}</div>
<div className="text-xs">{data.chain}</div>
</div>
</div>
<div className="flex flex-1 items-center text-xs">
{data.borrowRate ? `${(data.borrowRate * 100).toFixed(2)}%` : '-'}
</div>
<div className="flex flex-1 items-center text-xs">
{data.borrowed ? (
<div>
<div className="font-bold">{data.borrowed.amount}</div>
<div>{formatCurrency(data.borrowed.value)}</div>
</div>
) : (
'-'
)}
</div>
<div className="flex flex-1 items-center text-xs">{data.marketLiquidity}</div>
<div className="flex w-[50px] items-center justify-end">
{isExpanded ? <ChevronUpIcon className="w-5" /> : <ChevronDownIcon className="w-5" />}
</div>
</div>
{isExpanded && (
<div className="flex items-center justify-between">
<div>Additional Stuff Placeholder</div>
<div className="flex gap-2">
<Button
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
onBorrowClick()
}}
>
Borrow
</Button>
<Button
disabled={!data.borrowed}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
if (!data.borrowed) return
e.stopPropagation()
onRepayClick(data.borrowed.amount)
}}
>
Repay
</Button>
</div>
</div>
)}
</div>
)
}
export default AssetRow

View File

@ -0,0 +1,188 @@
import React, { useMemo, useState } from 'react'
import { XMarkIcon } from '@heroicons/react/24/solid'
import { toast } from 'react-toastify'
import * as Slider from '@radix-ui/react-slider'
import Button from 'components/Button'
import Container from 'components/Container'
import { getTokenDecimals, getTokenSymbol } from 'utils/tokens'
import useBorrowFunds from 'hooks/useBorrowFunds'
import useTokenPrices from 'hooks/useTokenPrices'
import { formatCurrency } from 'utils/formatters'
import { Switch } from '@headlessui/react'
import BigNumber from 'bignumber.js'
import useAllBalances from 'hooks/useAllBalances'
import useMarkets from 'hooks/useMarkets'
import Tooltip from 'components/Tooltip'
import ContainerSecondary from 'components/ContainerSecondary'
import Spinner from 'components/Spinner'
import useCalculateMaxBorrowAmount from 'hooks/useCalculateMaxBorrowAmount'
const BorrowFunds = ({ tokenDenom, onClose }: any) => {
const [amount, setAmount] = useState(0)
const [borrowToCreditAccount, setBorrowToCreditAccount] = useState(false)
const tokenSymbol = getTokenSymbol(tokenDenom)
const { mutate, isLoading } = useBorrowFunds(amount, tokenDenom, !borrowToCreditAccount, {
onSuccess: () => {
onClose()
toast.success(`${amount} ${tokenSymbol} successfully Borrowed`)
},
})
const { data: tokenPrices } = useTokenPrices()
const { data: balancesData } = useAllBalances()
const { data: marketsData } = useMarkets()
const handleSubmit = () => {
mutate()
}
const walletAmount = useMemo(() => {
return BigNumber(balancesData?.find((balance) => balance.denom === tokenDenom)?.amount ?? 0)
.div(10 ** getTokenDecimals(tokenDenom))
.toNumber()
}, [balancesData, tokenDenom])
const tokenPrice = tokenPrices?.[tokenDenom] ?? 0
const borrowRate = Number(marketsData?.[tokenDenom].borrow_rate)
const maxValue = useCalculateMaxBorrowAmount(tokenDenom, borrowToCreditAccount)
const percentageValue = useMemo(() => {
if (isNaN(amount) || maxValue === 0) return 0
return (amount * 100) / maxValue
}, [amount, maxValue])
const isSubmitDisabled = !amount || amount < 0
const handleValueChange = (value: number) => {
if (value > maxValue) {
setAmount(maxValue)
return
}
setAmount(value)
}
const handleBorrowTargetChange = () => {
setBorrowToCreditAccount((c) => !c)
// reset amount due to max value calculations changing depending on borrow target
setAmount(0)
}
return (
<Container className="flex w-[350px] flex-col justify-between text-sm">
{isLoading && (
<div className="fixed inset-0 z-40 grid place-items-center bg-black/50">
<Spinner />
</div>
)}
<div className="mb-3 flex justify-between text-base font-bold">
<h3>Borrow {tokenSymbol}</h3>
<XMarkIcon className="w-5 cursor-pointer" onClick={onClose} />
</div>
<div className="mb-4 flex flex-col gap-2">
<ContainerSecondary>
<p className="mb-2">
In wallet:{' '}
<span className="text-[#585A74]/50">
{walletAmount.toLocaleString()} {tokenSymbol}
</span>
</p>
<p className="mb-5">
Borrow Rate: <span className="text-[#585A74]/50">{(borrowRate * 100).toFixed(2)}%</span>
</p>
<div className="mb-2 flex justify-between">
<div>Amount</div>
<input
type="number"
className="border border-black/50 bg-transparent px-2"
value={amount}
onChange={(e) => handleValueChange(e.target.valueAsNumber)}
/>
</div>
<div className="flex justify-between">
<div>
1 {tokenSymbol} ={' '}
<span className="text-[#585A74]/50">{formatCurrency(tokenPrice)}</span>
</div>
<div className="text-[#585A74]/50">{formatCurrency(tokenPrice * amount)}</div>
</div>
</ContainerSecondary>
<ContainerSecondary>
<div className="relative mb-4 flex flex-1 items-center">
<Slider.Root
className="relative flex h-[20px] w-full cursor-pointer touch-none select-none items-center"
value={[percentageValue]}
min={0}
max={100}
step={1}
onValueChange={(value) => {
const decimal = value[0] / 100
const tokenDecimals = getTokenDecimals(tokenDenom)
// limit decimal precision based on token contract decimals
const newAmount = Number((decimal * maxValue).toFixed(tokenDecimals))
setAmount(newAmount)
}}
>
<Slider.Track className="relative h-[6px] grow rounded-full bg-gray-400">
<Slider.Range className="absolute h-[100%] rounded-full bg-blue-600" />
</Slider.Track>
<Slider.Thumb className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-white !outline-none">
<div className="relative top-5 text-xs">{percentageValue.toFixed(0)}%</div>
</Slider.Thumb>
</Slider.Root>
<button
className="ml-4 rounded-md bg-blue-600 py-1 px-2 text-xs font-semibold text-white"
onClick={() => setAmount(maxValue)}
>
MAX
</button>
</div>
</ContainerSecondary>
<ContainerSecondary className="flex items-center justify-between">
<div className="flex">
Borrow to Credit Account{' '}
<Tooltip
className="ml-2"
content={
<>
<p className="mb-2">
OFF = Borrow directly into your wallet by using your account Assets as
collateral. The borrowed asset will become a liability in your account.
</p>
<p>
ON = Borrow into your Account. The borrowed asset will be available in the
account as an Asset and appear also as a liability in your account.
</p>
</>
}
/>
</div>
<Switch
checked={borrowToCreditAccount}
onChange={handleBorrowTargetChange}
className={`${
borrowToCreditAccount ? 'bg-blue-600' : 'bg-gray-400'
} relative inline-flex h-6 w-11 items-center rounded-full`}
>
<span
className={`${
borrowToCreditAccount ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition`}
/>
</Switch>
</ContainerSecondary>
</div>
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitDisabled}>
Borrow
</Button>
</Container>
)
}
export default BorrowFunds

View File

@ -0,0 +1,197 @@
import React from 'react'
import Image from 'next/image'
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from '@tanstack/react-table'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid'
import { formatCurrency } from 'utils/formatters'
import AssetRow from './AssetRow'
interface Market {
denom: string
symbol: string
icon: string
chain: string
borrowed: {
amount: number
value: number
} | null
borrowRate: number
marketLiquidity: number
}
// const data = [
// {
// denom: 'uosmo',
// symbol: 'OSMO',
// icon: '/tokens/osmo.svg',
// chain: 'Osmosis',
// borrowed: {
// amount: 2.005494,
// value: 2.2060434000000004,
// },
// borrowRate: 0.1,
// marketLiquidity: 1000000,
// },
// {
// denom: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2',
// symbol: 'ATOM',
// icon: '/tokens/atom.svg',
// chain: 'Cosmos',
// borrowed: null,
// borrowRate: 0.25,
// marketLiquidity: 1000,
// },
// {
// denom: 'uusdc',
// symbol: 'USDC',
// icon: '/tokens/atom.svg',
// chain: 'Ethereum',
// borrowed: {
// amount: 100,
// value: 99.9776,
// },
// borrowRate: 0.35,
// marketLiquidity: 333,
// },
// ]
type Props = {
data: Market[]
onBorrowClick: (denom: string) => void
onRepayClick: (denom: string, repayAmount: number) => void
}
const BorrowTable = ({ data, onBorrowClick, onRepayClick }: Props) => {
const [sorting, setSorting] = React.useState<SortingState>([])
const columns = React.useMemo<ColumnDef<Market>[]>(
() => [
{
header: 'Asset',
id: 'symbol',
accessorFn: (row) => (
<div className="flex flex-1 items-center">
<Image src={row.icon} alt="token" width={32} height={32} />
<div className="pl-2">
<div>{row.symbol}</div>
<div className="text-xs">{row.chain}</div>
</div>
</div>
),
cell: (info) => info.getValue(),
},
{
accessorKey: 'borrowRate',
header: 'Borrow Rate',
accessorFn: (row) => (
<div className="flex flex-1 items-center text-xs">
{row.borrowRate ? `${(row.borrowRate * 100).toFixed(2)}%` : '-'}
</div>
),
cell: (info) => info.getValue(),
},
{
accessorKey: 'age',
header: 'Borrowed',
accessorFn: (row) => (
<div className="flex flex-1 items-center text-xs">
{row.borrowed ? (
<div>
<div className="font-bold">{row.borrowed.amount}</div>
<div>{formatCurrency(row.borrowed.value)}</div>
</div>
) : (
'-'
)}
</div>
),
cell: (info) => info.getValue(),
},
{
accessorKey: 'marketLiquidity',
header: 'Liquidity Available',
},
{
accessorKey: 'status',
enableSorting: false,
header: 'Manage',
width: 150,
cell: ({ row }) => (
<div className="flex items-center justify-end">
{row.getIsExpanded() ? (
<ChevronUpIcon className="w-5" />
) : (
<ChevronDownIcon className="w-5" />
)}
</div>
),
},
],
[]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: true,
})
return (
<div className="w-full table-fixed border-spacing-10 text-sm">
{table.getHeaderGroups().map((headerGroup) => (
<div
key={headerGroup.id}
className="mb-2 flex rounded-md bg-[#D8DAEA] px-4 py-2 text-xs text-[#585A74]/50"
>
{headerGroup.headers.map((header) => {
return (
<div key={header.id} className={`${header.index === 4 ? 'w-[50px]' : 'flex-1'}`}>
{header.isPlaceholder ? null : (
<div
{...{
className: header.column.getCanSort() ? 'cursor-pointer select-none' : '',
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</div>
)}
</div>
)
})}
</div>
))}
<div className="flex flex-col gap-2">
{table.getRowModel().rows.map((row) => {
return (
<AssetRow
key={row.index}
data={row.original}
onBorrowClick={() => onBorrowClick(row.original.denom)}
onRepayClick={(repayAmount: number) => onRepayClick(row.original.denom, repayAmount)}
/>
)
})}
</div>
</div>
)
}
export default BorrowTable

View File

@ -0,0 +1,133 @@
import React, { useMemo, useState } from 'react'
import { XMarkIcon } from '@heroicons/react/24/solid'
import { toast } from 'react-toastify'
import * as Slider from '@radix-ui/react-slider'
import Button from 'components/Button'
import Container from 'components/Container'
import { getTokenDecimals, getTokenSymbol } from 'utils/tokens'
import useRepayFunds from 'hooks/useRepayFunds'
import useTokenPrices from 'hooks/useTokenPrices'
import { formatCurrency } from 'utils/formatters'
import BigNumber from 'bignumber.js'
import useAllBalances from 'hooks/useAllBalances'
import ContainerSecondary from 'components/ContainerSecondary'
import Spinner from 'components/Spinner'
const RepayFunds = ({ tokenDenom, amount: repayAmount, onClose }: any) => {
const [amount, setAmount] = useState(0)
const tokenSymbol = getTokenSymbol(tokenDenom)
const { mutate, isLoading } = useRepayFunds(amount, tokenDenom, {
onSuccess: () => {
onClose()
toast.success(`${amount} ${tokenSymbol} successfully repaid`)
},
})
const { data: tokenPrices } = useTokenPrices()
const { data: balancesData } = useAllBalances()
const handleSubmit = () => {
mutate()
}
const walletAmount = useMemo(() => {
return BigNumber(balancesData?.find((balance) => balance.denom === tokenDenom)?.amount ?? 0)
.div(10 ** getTokenDecimals(tokenDenom))
.toNumber()
}, [balancesData, tokenDenom])
const tokenPrice = tokenPrices?.[tokenDenom] ?? 0
const maxValue = walletAmount > repayAmount ? repayAmount : walletAmount
const percentageValue = isNaN(amount) ? 0 : (amount * 100) / maxValue
const isSubmitDisabled = !amount || amount < 0
const handleValueChange = (value: number) => {
if (value > maxValue) {
setAmount(maxValue)
return
}
setAmount(value)
}
return (
<Container className="flex w-[350px] flex-col justify-between text-sm">
{isLoading && (
<div className="fixed inset-0 z-40 grid place-items-center bg-black/50">
<Spinner />
</div>
)}
<div className="mb-3 flex justify-between text-base font-bold">
<h3>Repay {tokenSymbol}</h3>
<XMarkIcon className="w-5 cursor-pointer" onClick={onClose} />
</div>
<div className="mb-4 flex flex-col gap-2">
<ContainerSecondary>
<p className="mb-2">
In wallet:{' '}
<span className="text-[#585A74]/50">
{walletAmount.toLocaleString()} {tokenSymbol}
</span>
</p>
<div className="mb-2 flex justify-between">
<div>Amount</div>
<input
type="number"
className="border border-black/50 bg-transparent px-2"
value={amount}
onChange={(e) => handleValueChange(e.target.valueAsNumber)}
/>
</div>
<div className="flex justify-between">
<div>
1 {tokenSymbol} ={' '}
<span className="text-[#585A74]/50">{formatCurrency(tokenPrice)}</span>
</div>
<div className="text-[#585A74]/50">{formatCurrency(tokenPrice * amount)}</div>
</div>
</ContainerSecondary>
<ContainerSecondary>
<div className="relative mb-4 flex flex-1 items-center">
<Slider.Root
className="relative flex h-[20px] w-full cursor-pointer touch-none select-none items-center"
value={[percentageValue]}
min={0}
max={100}
step={1}
onValueChange={(value) => {
const decimal = value[0] / 100
const tokenDecimals = getTokenDecimals(tokenDenom)
// limit decimal precision based on token contract decimals
const newAmount = Number((decimal * maxValue).toFixed(tokenDecimals))
setAmount(newAmount)
}}
>
<Slider.Track className="relative h-[6px] grow rounded-full bg-gray-400">
<Slider.Range className="absolute h-[100%] rounded-full bg-blue-600" />
</Slider.Track>
<Slider.Thumb className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-white !outline-none">
<div className="relative top-5 text-xs">{percentageValue.toFixed(0)}%</div>
</Slider.Thumb>
</Slider.Root>
<button
className="ml-4 rounded-md bg-blue-600 py-1 px-2 text-xs font-semibold text-white"
onClick={() => setAmount(maxValue)}
>
MAX
</button>
</div>
</ContainerSecondary>
</div>
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitDisabled}>
Repay
</Button>
</Container>
)
}
export default RepayFunds

View File

@ -0,0 +1,3 @@
export { default as AssetRow } from './AssetRow'
export { default as BorrowFunds } from './BorrowFunds'
export { default as RepayFunds } from './RepayFunds'

View File

@ -3,7 +3,7 @@ import React from 'react'
type Props = {
children: string
className?: string
onClick: () => void
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean
}

View File

@ -0,0 +1,17 @@
import React from 'react'
const ContainerSecondary = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
return (
<div className={`rounded-md bg-[#D8DAEA] px-3 py-2 text-[#585A74] ${className}`}>
{children}
</div>
)
}
export default ContainerSecondary

View File

@ -1,14 +0,0 @@
import React from 'react'
// move this component outside and probably adapt generic container component to different UI variants
export const CreditManagerContainer = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
return <div className={`rounded-lg bg-[#D8DAEA] p-2 text-[#585A74] ${className}`}>{children}</div>
}
export default CreditManagerContainer

View File

@ -10,7 +10,7 @@ import useDepositCreditAccount from 'hooks/useDepositCreditAccount'
import useCreditManagerStore from 'stores/useCreditManagerStore'
import useAllBalances from 'hooks/useAllBalances'
import { getTokenDecimals, getTokenSymbol } from 'utils/tokens'
import CreditManagerContainer from './CreditManagerContainer'
import ContainerSecondary from 'components/ContainerSecondary'
const FundAccount = () => {
const [amount, setAmount] = useState(0)
@ -61,7 +61,7 @@ const FundAccount = () => {
return (
<>
<CreditManagerContainer className="mb-2 p-3">
<ContainerSecondary className="mb-2 p-3">
<p className="mb-6 text-sm">
Transfer assets from your injective wallet to your Mars credit account. If you dont have
any assets in your injective wallet use the injective bridge to transfer funds to your
@ -126,7 +126,7 @@ const FundAccount = () => {
</Slider.Thumb>
</Slider.Root>
<button
className="ml-4 rounded-md bg-blue-600 py-1 px-2 text-sm text-white"
className="ml-4 rounded-md bg-blue-600 py-1 px-2 text-xs font-semibold text-white"
onClick={() => setAmount(maxValue)}
>
MAX
@ -134,11 +134,11 @@ const FundAccount = () => {
</div>
</>
)}
</CreditManagerContainer>
<CreditManagerContainer className="mb-2 flex items-center justify-between">
</ContainerSecondary>
<ContainerSecondary className="mb-2 flex items-center justify-between">
<div>
<h3 className="font-bold">Lending Assets</h3>
<div className="text-sm opacity-50">Lend assets from account to earn yield.</div>
<div className="text-sm text-[#585A74]/50">Lend assets from account to earn yield.</div>
</div>
<Switch
@ -154,7 +154,7 @@ const FundAccount = () => {
} inline-block h-4 w-4 transform rounded-full bg-white transition`}
/>
</Switch>
</CreditManagerContainer>
</ContainerSecondary>
<Button
className="w-full !rounded-lg"
onClick={() => mutate()}

View File

@ -8,10 +8,10 @@ import useWalletStore from 'stores/useWalletStore'
import useCreditAccountPositions from 'hooks/useCreditAccountPositions'
import { getTokenDecimals, getTokenSymbol } from 'utils/tokens'
import FundAccount from './FundAccount'
import CreditManagerContainer from './CreditManagerContainer'
import useTokenPrices from 'hooks/useTokenPrices'
import useAccountStats from 'hooks/useAccountStats'
import useMarkets from 'hooks/useMarkets'
import ContainerSecondary from 'components/ContainerSecondary'
const CreditManager = () => {
const [isFund, setIsFund] = useState(false)
@ -41,14 +41,14 @@ const CreditManager = () => {
if (!address) {
return (
<div className="absolute inset-0 left-auto w-[400px] border-l border-white/20 bg-background-2 p-2">
<CreditManagerContainer>You must have a connected wallet</CreditManagerContainer>
<ContainerSecondary>You must have a connected wallet</ContainerSecondary>
</div>
)
}
return (
<div className="absolute inset-0 left-auto w-[400px] border-l border-white/20 bg-background-2 p-2">
<CreditManagerContainer className="mb-2">
<ContainerSecondary className="mb-2">
{isFund ? (
<div className="flex items-center justify-between">
<h3 className="font-bold">Fund Account</h3>
@ -66,12 +66,12 @@ const CreditManager = () => {
</Button>
</div>
)}
</CreditManagerContainer>
</ContainerSecondary>
{isFund ? (
<FundAccount />
) : (
<>
<CreditManagerContainer className="mb-2 text-sm">
<ContainerSecondary className="mb-2 text-sm">
<div className="mb-1 flex justify-between">
<div>Total Position:</div>
<div className="font-semibold">
@ -82,8 +82,8 @@ const CreditManager = () => {
<div>Total Liabilities:</div>
<div className="font-semibold">{formatCurrency(accountStats?.totalDebt ?? 0)}</div>
</div>
</CreditManagerContainer>
<CreditManagerContainer>
</ContainerSecondary>
<ContainerSecondary>
<h4 className="font-bold">Balances</h4>
{isLoadingPositions ? (
<div>Loading...</div>
@ -106,7 +106,7 @@ const CreditManager = () => {
.div(10 ** getTokenDecimals(coin.denom))
.toNumber()
.toLocaleString(undefined, {
maximumFractionDigits: 6,
maximumFractionDigits: getTokenDecimals(coin.denom),
})}
</div>
<div className="flex-1">-</div>
@ -134,7 +134,7 @@ const CreditManager = () => {
))}
</>
)}
</CreditManagerContainer>
</ContainerSecondary>
</>
)}
</div>

View File

@ -7,10 +7,17 @@ type Props = {
const ProgressBar = ({ value }: Props) => {
const percentageValue = `${(value * 100).toFixed(0)}%`
let bgColorClass = 'bg-green-500'
if (value < 1 / 3) {
bgColorClass = 'bg-red-500'
} else if (value < 2 / 3) {
bgColorClass = 'bg-yellow-500'
}
return (
<div className="relative z-0 h-4 w-[130px] rounded-full bg-black">
<div
className="absolute z-10 h-4 rounded-full bg-green-500"
className={`absolute z-10 h-4 rounded-full ${bgColorClass}`}
style={{ width: percentageValue }}
/>
<div className="absolute z-20 flex w-full items-center justify-center gap-x-2 text-xs font-medium text-white">

View File

@ -50,6 +50,13 @@ const SemiCircleProgress = ({
}
}
let strokeColorClass = 'stroke-green-500'
if (value > 2 / 3) {
strokeColorClass = 'stroke-red-500'
} else if (value > 1 / 3) {
strokeColorClass = 'stroke-yellow-500'
}
return (
<div className="semicircle-container" style={{ position: 'relative' }}>
<svg
@ -70,11 +77,12 @@ const SemiCircleProgress = ({
}}
/>
<circle
className={strokeColorClass}
cx={coordinateForCircle}
cy={coordinateForCircle}
r={radius}
fill="none"
stroke={stroke}
// stroke={stroke}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
style={{

23
components/Tooltip.tsx Normal file
View File

@ -0,0 +1,23 @@
import Tippy from '@tippyjs/react'
import { ReactNode } from 'react'
import { InformationCircleIcon } from '@heroicons/react/24/solid'
interface TooltipProps {
content: string | ReactNode
className?: string
}
const Tooltip = ({ content, className }: TooltipProps) => {
return (
<Tippy
appendTo={() => document.body}
className="rounded-md bg-[#ED512F] p-2 text-xs"
content={<span>{content}</span>}
interactive={true}
>
<InformationCircleIcon className={`w-5 cursor-pointer ${className}`} />
</Tippy>
)
}
export default Tooltip

View File

@ -2,6 +2,7 @@ type Token = {
symbol: string
decimals: number
icon: string
chain: string
}
const tokenInfo: { [key in string]: Token } = {
@ -9,11 +10,13 @@ const tokenInfo: { [key in string]: Token } = {
symbol: 'OSMO',
decimals: 6,
icon: '/tokens/osmo.svg',
chain: 'Osmosis',
},
'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2': {
symbol: 'ATOM',
icon: '',
icon: '/tokens/atom.svg',
decimals: 6,
chain: 'Cosmos',
},
}

View File

@ -49,7 +49,7 @@ const useAccountStats = () => {
const totalWeightedPositions = positionsData.coins.reduce((acc, coin) => {
const tokenWeightedValue = BigNumber(getTokenTotalUSDValue(coin.amount, coin.denom)).times(
Number(marketsData[coin.denom].max_loan_to_value)
Number(marketsData[coin.denom].liquidation_threshold)
)
return tokenWeightedValue.plus(acc).toNumber()

108
hooks/useBorrowFunds.tsx Normal file
View File

@ -0,0 +1,108 @@
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import BigNumber from 'bignumber.js'
import useWalletStore from 'stores/useWalletStore'
import { chain } from 'utils/chains'
import { contractAddresses } from 'config/contracts'
import { hardcodedFee } from 'utils/contants'
import useCreditManagerStore from 'stores/useCreditManagerStore'
import { queryKeys } from 'types/query-keys-factory'
const useBorrowFunds = (
amount: string | number,
denom: string,
withdraw = false,
options: Omit<UseMutationOptions, 'onError'>
) => {
const [signingClient, setSigningClient] = useState<SigningCosmWasmClient>()
const selectedAccount = useCreditManagerStore((s) => s.selectedAccount)
const address = useWalletStore((s) => s.address)
const queryClient = useQueryClient()
useEffect(() => {
;(async () => {
if (!window.keplr) return
const offlineSigner = window.keplr.getOfflineSigner(chain.chainId)
const clientInstance = await SigningCosmWasmClient.connectWithSigner(chain.rpc, offlineSigner)
setSigningClient(clientInstance)
})()
}, [address])
const executeMsg = useMemo(() => {
if (!withdraw) {
return {
update_credit_account: {
account_id: selectedAccount,
actions: [
{
borrow: {
denom: denom,
amount: BigNumber(amount)
.times(10 ** 6)
.toString(),
},
},
],
},
}
}
return {
update_credit_account: {
account_id: selectedAccount,
actions: [
{
borrow: {
denom: denom,
amount: BigNumber(amount)
.times(10 ** 6)
.toString(),
},
},
{
withdraw: {
denom: denom,
amount: BigNumber(amount)
.times(10 ** 6)
.toString(),
},
},
],
},
}
}, [amount, denom, withdraw, selectedAccount])
return useMutation(
async () =>
await signingClient?.execute(
address,
contractAddresses.creditManager,
executeMsg,
hardcodedFee
),
{
onSettled: () => {
queryClient.invalidateQueries(queryKeys.creditAccountsPositions(selectedAccount ?? ''))
// if withdrawing to wallet, need to explicility invalidate balances queries
if (withdraw) {
queryClient.invalidateQueries(queryKeys.tokenBalance(address, denom))
queryClient.invalidateQueries(queryKeys.allBalances(address))
}
},
onError: (err: Error) => {
toast.error(err.message)
},
...options,
}
)
}
export default useBorrowFunds

View File

@ -0,0 +1,63 @@
import { useMemo } from 'react'
import BigNumber from 'bignumber.js'
import useCreditManagerStore from 'stores/useCreditManagerStore'
import { getTokenDecimals } from 'utils/tokens'
import useCreditAccountPositions from './useCreditAccountPositions'
import useMarkets from './useMarkets'
import useTokenPrices from './useTokenPrices'
const useCalculateMaxBorrowAmount = (denom: string, isUnderCollateralized: boolean) => {
const selectedAccount = useCreditManagerStore((s) => s.selectedAccount)
const { data: positionsData } = useCreditAccountPositions(selectedAccount ?? '')
const { data: marketsData } = useMarkets()
const { data: tokenPrices } = useTokenPrices()
return useMemo(() => {
if (!marketsData || !tokenPrices || !positionsData) return 0
const getTokenTotalUSDValue = (amount: string, denom: string) => {
// early return if prices are not fetched yet
if (!tokenPrices) return 0
return BigNumber(amount)
.div(10 ** getTokenDecimals(denom))
.times(tokenPrices[denom])
.toNumber()
}
const totalWeightedPositions = positionsData?.coins.reduce((acc, coin) => {
const tokenWeightedValue = BigNumber(getTokenTotalUSDValue(coin.amount, coin.denom)).times(
Number(marketsData[coin.denom].max_loan_to_value)
)
return tokenWeightedValue.plus(acc).toNumber()
}, 0)
const totalLiabilitiesValue = positionsData?.debts.reduce((acc, coin) => {
const tokenUSDValue = BigNumber(getTokenTotalUSDValue(coin.amount, coin.denom))
return tokenUSDValue.plus(acc).toNumber()
}, 0)
const borrowTokenPrice = tokenPrices[denom]
const tokenDecimals = getTokenDecimals(denom)
if (isUnderCollateralized) {
return BigNumber(totalLiabilitiesValue)
.minus(totalWeightedPositions)
.div(borrowTokenPrice * Number(marketsData[denom].max_loan_to_value) - borrowTokenPrice)
.decimalPlaces(tokenDecimals)
.toNumber()
} else {
return BigNumber(totalWeightedPositions)
.minus(totalLiabilitiesValue)
.div(borrowTokenPrice)
.decimalPlaces(tokenDecimals)
.toNumber()
}
}, [denom, isUnderCollateralized, marketsData, positionsData, tokenPrices])
}
export default useCalculateMaxBorrowAmount

View File

@ -38,8 +38,8 @@ const useMarkets = () => {
wasm: {
uosmo: {
denom: 'uosmo',
max_loan_to_value: '0.7',
liquidation_threshold: '0.65',
max_loan_to_value: '0.65',
liquidation_threshold: '0.7',
liquidation_bonus: '0.1',
reserve_factor: '0.2',
interest_rate_model: {
@ -61,8 +61,8 @@ const useMarkets = () => {
},
'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2': {
denom: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2',
max_loan_to_value: '0.8',
liquidation_threshold: '0.7',
max_loan_to_value: '0.77',
liquidation_threshold: '0.8',
liquidation_bonus: '0.1',
reserve_factor: '0.2',
interest_rate_model: {

77
hooks/useRepayFunds.tsx Normal file
View File

@ -0,0 +1,77 @@
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import BigNumber from 'bignumber.js'
import useWalletStore from 'stores/useWalletStore'
import { chain } from 'utils/chains'
import { contractAddresses } from 'config/contracts'
import { hardcodedFee } from 'utils/contants'
import useCreditManagerStore from 'stores/useCreditManagerStore'
import { queryKeys } from 'types/query-keys-factory'
const useRepayFunds = (
amount: string | number,
denom: string,
options: Omit<UseMutationOptions, 'onError'>
) => {
const [signingClient, setSigningClient] = useState<SigningCosmWasmClient>()
const selectedAccount = useCreditManagerStore((s) => s.selectedAccount)
const address = useWalletStore((s) => s.address)
const queryClient = useQueryClient()
useEffect(() => {
;(async () => {
if (!window.keplr) return
const offlineSigner = window.keplr.getOfflineSigner(chain.chainId)
const clientInstance = await SigningCosmWasmClient.connectWithSigner(chain.rpc, offlineSigner)
setSigningClient(clientInstance)
})()
}, [address])
const executeMsg = useMemo(() => {
return {
update_credit_account: {
account_id: selectedAccount,
actions: [
{
repay: {
denom: denom,
amount: BigNumber(amount)
.times(10 ** 6)
.toString(),
},
},
],
},
}
}, [amount, denom, selectedAccount])
return useMutation(
async () =>
await signingClient?.execute(
address,
contractAddresses.creditManager,
executeMsg,
hardcodedFee
),
{
onSettled: () => {
queryClient.invalidateQueries(queryKeys.creditAccountsPositions(selectedAccount ?? ''))
queryClient.invalidateQueries(queryKeys.tokenBalance(address, denom))
queryClient.invalidateQueries(queryKeys.allBalances(address))
},
onError: (err: Error) => {
toast.error(err.message)
},
...options,
}
)
}
export default useRepayFunds

View File

@ -18,6 +18,8 @@
"@radix-ui/react-slider": "^1.0.0",
"@sentry/nextjs": "^7.12.1",
"@tanstack/react-query": "^4.3.4",
"@tanstack/react-table": "^8.5.15",
"@tippyjs/react": "^4.2.6",
"bech32": "^2.0.0",
"bignumber.js": "^9.1.0",
"ethereumjs-util": "^7.1.5",

View File

@ -1,69 +1,152 @@
import React from 'react'
import Image from 'next/image'
import React, { useMemo, useState } from 'react'
import BigNumber from 'bignumber.js'
import Container from 'components/Container'
import useAllowedCoins from 'hooks/useAllowedCoins'
import { getTokenDecimals, getTokenInfo } from 'utils/tokens'
import useCreditAccountPositions from 'hooks/useCreditAccountPositions'
import useCreditManagerStore from 'stores/useCreditManagerStore'
import useMarkets from 'hooks/useMarkets'
import useTokenPrices from 'hooks/useTokenPrices'
import { BorrowFunds, RepayFunds } from 'components/Borrow'
import BorrowTable from 'components/Borrow/BorrowTable'
const AssetRow = () => {
return (
<div className="flex rounded-md bg-[#D8DAEA] p-2 text-[#585A74]">
<div className="flex flex-1">
<Image src="/tokens/osmo.svg" alt="token" width={24} height={24} />
<div className="pl-2">
<div>DENOM</div>
<div className="text-xs">Name</div>
</div>
</div>
<div className="flex-1">10.00%</div>
<div className="flex-1">
<div>Amount</div>
<div>Value</div>
</div>
<div className="flex-1">
<div>Amount</div>
<div>Value</div>
</div>
<div className="w-[50px]">ACTION</div>
</div>
)
}
type ModuleState =
| {
show: 'borrow'
data: {
tokenDenom: string
}
}
| {
show: 'repay'
data: {
tokenDenom: string
amount: number
}
}
const Borrow = () => {
const [moduleState, setModuleState] = useState<ModuleState | null>(null)
const selectedAccount = useCreditManagerStore((s) => s.selectedAccount)
const { data: allowedCoinsData } = useAllowedCoins()
const { data: positionsData } = useCreditAccountPositions(selectedAccount ?? '')
const { data: marketsData } = useMarkets()
const { data: tokenPrices } = useTokenPrices()
const borrowedAssetsMap = useMemo(() => {
let borrowedAssetsMap: Map<string, string> = new Map()
positionsData?.debts.forEach((coin) => {
borrowedAssetsMap.set(coin.denom, coin.amount)
})
return borrowedAssetsMap
}, [positionsData])
const { borrowedAssets, notBorrowedAssets } = useMemo(() => {
return {
borrowedAssets:
allowedCoinsData
?.filter((denom) => borrowedAssetsMap.has(denom))
.map((denom) => {
const { symbol, chain, icon } = getTokenInfo(denom)
const borrowRate = Number(marketsData?.[denom].borrow_rate) || 0
const marketLiquidity = BigNumber(marketsData?.[denom].deposit_cap ?? '')
.div(10 ** getTokenDecimals(denom))
.toNumber()
const borrowAmount = BigNumber(borrowedAssetsMap.get(denom) as string)
.div(10 ** getTokenDecimals(denom))
.toNumber()
const borrowValue = borrowAmount * (tokenPrices?.[denom] ?? 0)
const rowData = {
denom,
symbol,
icon,
chain,
borrowed: {
amount: borrowAmount,
value: borrowValue,
},
borrowRate,
marketLiquidity,
}
return rowData
}) ?? [],
notBorrowedAssets:
allowedCoinsData
?.filter((denom) => !borrowedAssetsMap.has(denom))
.map((denom) => {
const { symbol, chain, icon } = getTokenInfo(denom)
const borrowRate = Number(marketsData?.[denom].borrow_rate) || 0
const marketLiquidity = BigNumber(marketsData?.[denom].deposit_cap ?? '')
.div(10 ** getTokenDecimals(denom))
.toNumber()
const rowData = {
denom,
symbol,
icon,
chain,
borrowed: null,
borrowRate,
marketLiquidity,
}
return rowData
}) ?? [],
}
}, [allowedCoinsData, borrowedAssetsMap, marketsData, tokenPrices])
const handleBorrowClick = (denom: string) => {
setModuleState({ show: 'borrow', data: { tokenDenom: denom } })
}
const handleRepayClick = (denom: string, repayAmount: number) => {
setModuleState({ show: 'repay', data: { tokenDenom: denom, amount: repayAmount } })
}
return (
<div className="flex gap-4">
<Container className="flex-1">
<div className="mb-5">
<h3 className="mb-1 text-center font-medium uppercase">Borrowed</h3>
<div className="mb-2 flex rounded-md bg-[#D8DAEA] p-2 text-sm text-[#585A74]/50">
<div className="flex-1">Asset</div>
<div className="flex-1">Borrow Rate</div>
<div className="flex-1">Borrowed</div>
<div className="flex-1">Liquidity Available</div>
<div className="w-[50px]">Manage</div>
<div className="flex items-start gap-4">
<div className="flex-1">
<Container>
<div className="mb-5">
<h3 className="mb-1 text-center font-medium uppercase">Borrowed</h3>
<BorrowTable
data={borrowedAssets}
onBorrowClick={handleBorrowClick}
onRepayClick={handleRepayClick}
/>
</div>
<div className="flex flex-col gap-2">
{Array.from(Array(3).keys()).map(() => (
// eslint-disable-next-line react/jsx-key
<AssetRow />
))}
<div>
<h3 className="mb-1 text-center font-medium uppercase">Not Borrowed Yet</h3>
<BorrowTable
data={notBorrowedAssets}
onBorrowClick={handleBorrowClick}
onRepayClick={handleRepayClick}
/>
</div>
</div>
<div>
<h3 className="mb-1 text-center font-medium uppercase">Not Borrowed Yet</h3>
<div className="mb-2 flex rounded-md bg-[#D8DAEA] p-2 text-sm text-[#585A74]/50">
<div className="flex-1">Asset</div>
<div className="flex-1">Borrow Rate</div>
<div className="flex-1">Borrowed</div>
<div className="flex-1">Liquidity Available</div>
<div className="w-[50px]">Manage</div>
</div>
<div className="flex flex-col gap-2">
{Array.from(Array(5).keys()).map(() => (
// eslint-disable-next-line react/jsx-key
<AssetRow />
))}
</div>
</div>
</Container>
</Container>
</div>
{moduleState?.show === 'borrow' && (
<BorrowFunds
key={`borrow_${selectedAccount}_${moduleState.data.tokenDenom}`}
{...moduleState.data}
onClose={() => setModuleState(null)}
/>
)}
{moduleState?.show === 'repay' && (
<RepayFunds
key={`repay_${selectedAccount}_${moduleState.data.tokenDenom}`}
{...moduleState.data}
onClose={() => setModuleState(null)}
/>
)}
</div>
)
}

44
public/tokens/atom.svg Normal file
View File

@ -0,0 +1,44 @@
<svg
id='Layer_1'
data-name='Layer 1'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 2500 2500'
>
<title>cosmos-atom-logo</title>
<circle cx='1250' cy='1250' r='1250' style='fill:#2e3148' />
<circle cx='1250' cy='1250' r='725.31' style='fill:#1b1e36' />
<path
d='M1252.57,159.47c-134.93,0-244.34,489.4-244.34,1093.11s109.41,1093.11,244.34,1093.11,244.34-489.4,244.34-1093.11S1387.5,159.47,1252.57,159.47ZM1269.44,2284c-15.43,20.58-30.86,5.14-30.86,5.14-62.14-72-93.21-205.76-93.21-205.76-108.69-349.79-82.82-1100.82-82.82-1100.82,51.08-596.24,144-737.09,175.62-768.36a19.29,19.29,0,0,1,24.74-2c45.88,32.51,84.36,168.47,84.36,168.47,113.63,421.81,103.34,817.9,103.34,817.9,10.29,344.65-56.94,730.45-56.94,730.45C1341.92,2222.22,1269.44,2284,1269.44,2284Z'
style='fill:#6f7390'
/>
<path
d='M2200.72,708.59c-67.18-117.08-546.09,31.58-1070,332s-893.47,638.89-826.34,755.92,546.09-31.58,1070-332,893.47-638.89,826.34-755.92h0ZM366.36,1780.45c-25.72-3.24-19.91-24.38-19.91-24.38C378,1666.36,478.4,1572.84,478.4,1572.84c249.43-268.36,913.79-619.65,913.79-619.65,542.54-252.42,711.06-241.77,753.81-230a19.29,19.29,0,0,1,14,20.58c-5.14,56-104.17,157-104.17,157C1746.71,1209.36,1398,1397.58,1398,1397.58c-293.83,180.5-661.93,314.09-661.93,314.09-280.09,100.93-369.7,68.78-369.7,68.78h0Z'
style='fill:#6f7390'
/>
<path
d='M2198.35,1800.41c67.7-116.77-300.93-456.79-823-759.47S374.43,587.76,306.79,704.73s300.93,456.79,823.3,759.47S2130.71,1917.39,2198.35,1800.41ZM351.65,749.85c-10-23.71,11.11-29.42,11.11-29.42C456.22,702.78,587.5,743,587.5,743c357.15,81.33,994,480.25,994,480.25,490.33,343.11,565.53,494.24,576.8,537.14a19.29,19.29,0,0,1-10.7,22.43c-51.13,23.41-188.07-11.47-188.07-11.47-422.07-113.17-759.62-320.52-759.62-320.52-303.29-163.58-603.19-415.28-603.19-415.28-227.88-191.87-245-285.44-245-285.44Z'
style='fill:#6f7390'
/>
<circle cx='1250' cy='1250' r='128.6' style='fill:#b7b9c8' />
<ellipse
cx='1777.26'
cy='756.17'
rx='74.59'
ry='77.16'
style='fill:#b7b9c8'
/>
<ellipse
cx='552.98'
cy='1018.52'
rx='74.59'
ry='77.16'
style='fill:#b7b9c8'
/>
<ellipse
cx='1098.25'
cy='1965.02'
rx='74.59'
ry='77.16'
style='fill:#b7b9c8'
/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -7,3 +7,11 @@ export const getTokenSymbol = (denom: string) => {
export const getTokenDecimals = (denom: string) => {
return tokenInfo[denom]?.decimals ?? 6
}
export const getTokenIcon = (denom: string) => {
return tokenInfo[denom].icon
}
export const getTokenInfo = (denom: string) => {
return tokenInfo[denom]
}

View File

@ -1704,6 +1704,11 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@popperjs/core@^2.9.0":
version "2.11.6"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"
integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
@ -2148,6 +2153,25 @@
"@tanstack/query-core" "4.3.4"
use-sync-external-store "^1.2.0"
"@tanstack/react-table@^8.5.15":
version "8.5.15"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.5.15.tgz#8179d24d7fdf909799a517e8897501c44e51284d"
integrity sha512-9rSvhIFeMpfXksFgQNTWnVoJbkae/U8CkHnHYGWAIB/O0Ca51IKap0Rjp5WkIUVBWxJ7Wfl2y13oY+aWcyM6Rg==
dependencies:
"@tanstack/table-core" "8.5.15"
"@tanstack/table-core@8.5.15":
version "8.5.15"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.5.15.tgz#e1e674135cd6c36f29a1562a2b846f824861149b"
integrity sha512-k+BcCOAYD610Cij6p1BPyEqjMQjZIdAnVDoIUKVnA/tfHbF4JlDP7pKAftXPBxyyX5Z1yQPurPnOdEY007Snyg==
"@tippyjs/react@^4.2.6":
version "4.2.6"
resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71"
integrity sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw==
dependencies:
tippy.js "^6.3.1"
"@trysound/sax@0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
@ -6037,6 +6061,13 @@ tiny-secp256k1@^1.1.3:
elliptic "^6.4.0"
nan "^2.13.2"
tippy.js@^6.3.1:
version "6.3.7"
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
dependencies:
"@popperjs/core" "^2.9.0"
to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz"