Farm components (#158)

* add basic endpoint for vaultConfigs

* implement apy for vaults

* add tab + update routing

* fixed routing issues

* add featured vaults and vault card

* add availablevaults table

* fixed comments
This commit is contained in:
Bob van der Helm 2023-04-16 11:03:48 +02:00 committed by GitHub
parent 4af7e63c5f
commit ac09862f1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 467 additions and 42 deletions

84
package-lock.json generated
View File

@ -19229,6 +19229,66 @@
"optional": true
}
}
},
"node_modules/@next/swc-android-arm-eabi": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.2.4.tgz",
"integrity": "sha512-DWlalTSkLjDU11MY11jg17O1gGQzpRccM9Oes2yTqj2DpHndajrXHGxj9HGtJ+idq2k7ImUdJVWS2h2l/EDJOw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-android-arm64": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-13.2.4.tgz",
"integrity": "sha512-sRavmUImUCf332Gy+PjIfLkMhiRX1Ez4SI+3vFDRs1N5eXp+uNzjFUK/oLMMOzk6KFSkbiK/3Wt8+dHQR/flNg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-freebsd-x64": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.2.4.tgz",
"integrity": "sha512-kkbzKVZGPaXRBPisoAQkh3xh22r+TD+5HwoC5bOkALraJ0dsOQgSMAvzMXKsN3tMzJUPS0tjtRf1cTzrQ0I5vQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm-gnueabihf": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.2.4.tgz",
"integrity": "sha512-7qA1++UY0fjprqtjBZaOA6cas/7GekpjVsZn/0uHvquuITFCdKGFCsKNBx3S0Rpxmx6WYo0GcmhNRM9ru08BGg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
}
},
"dependencies": {
@ -33300,6 +33360,30 @@
"requires": {
"use-sync-external-store": "1.2.0"
}
},
"@next/swc-android-arm-eabi": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.2.4.tgz",
"integrity": "sha512-DWlalTSkLjDU11MY11jg17O1gGQzpRccM9Oes2yTqj2DpHndajrXHGxj9HGtJ+idq2k7ImUdJVWS2h2l/EDJOw==",
"optional": true
},
"@next/swc-android-arm64": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-13.2.4.tgz",
"integrity": "sha512-sRavmUImUCf332Gy+PjIfLkMhiRX1Ez4SI+3vFDRs1N5eXp+uNzjFUK/oLMMOzk6KFSkbiK/3Wt8+dHQR/flNg==",
"optional": true
},
"@next/swc-freebsd-x64": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.2.4.tgz",
"integrity": "sha512-kkbzKVZGPaXRBPisoAQkh3xh22r+TD+5HwoC5bOkALraJ0dsOQgSMAvzMXKsN3tMzJUPS0tjtRf1cTzrQ0I5vQ==",
"optional": true
},
"@next/swc-linux-arm-gnueabihf": {
"version": "13.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.2.4.tgz",
"integrity": "sha512-7qA1++UY0fjprqtjBZaOA6cas/7GekpjVsZn/0uHvquuITFCdKGFCsKNBx3S0Rpxmx6WYo0GcmhNRM9ru08BGg==",
"optional": true
}
}
}

View File

@ -0,0 +1,38 @@
import { Suspense } from 'react'
import Card from 'components/Card'
import { getVaults } from 'utils/api'
import { Text } from 'components/Text'
import { VAULTS } from 'constants/vaults'
import { VaultTable } from './VaultTable'
async function Content() {
const vaults = await getVaults()
if (!vaults.length) return null
return <VaultTable data={vaults} />
}
export default function AvailableVaults() {
return (
<Card title='Available vaults' className='mb-4 h-fit w-full bg-white/5'>
<Suspense fallback={<Fallback />}>
{/* @ts-expect-error Server Component */}
<Content />
</Suspense>
</Card>
)
}
function Fallback() {
// TODO: Replace with loading state of vaulttable
return (
<>
{VAULTS.map((vault) => (
<Text key={vault.address}>{vault.name}</Text>
))}
</>
)
}

View File

@ -0,0 +1,36 @@
import { Suspense } from 'react'
import Card from 'components/Card'
import { getVaults } from 'utils/api'
import { Text } from 'components/Text'
import VaultCard from './VaultCard'
async function Content() {
const vaults = await getVaults()
const featuredVaults = vaults.filter((vault) => vault.isFeatured)
if (!featuredVaults.length) return null
return (
<Card
title='Featured vaults'
className='mb-4 h-fit w-full bg-white/5'
contentClassName='grid grid-cols-3'
>
{featuredVaults.map((vault) => (
<VaultCard key={vault.address} vault={vault} />
))}
</Card>
)
}
export default function FeaturedVaults() {
return (
<Suspense fallback={null}>
{/* @ts-expect-error Server Component */}
<Content />
</Suspense>
)
}

View File

@ -1,40 +0,0 @@
import { Suspense } from 'react'
import Card from 'components/Card'
import Loading from 'components/Loading'
import { Text } from 'components/Text'
import { getVaults } from 'utils/api'
async function Content(props: PageProps) {
const vaults = await getVaults()
const address = props.params.address
if (!address)
return (
<Text size='sm' className='w-full text-center'>
You need to be connected to use the earn page
</Text>
)
return <Text size='sm'>{`Earn page for ${address}`}</Text>
}
function Fallback() {
return <Loading className='h-4 w-50' />
}
export default function Overview(props: PageProps) {
return (
<Card
className='h-fit w-full justify-center bg-white/5'
title='Earn'
contentClassName='px-4 py-6'
>
<Suspense fallback={<Fallback />}>
{/* @ts-expect-error Server Component */}
<Content params={props.params} />
</Suspense>
</Card>
)
}

View File

@ -0,0 +1,68 @@
'use client'
import Image from 'next/image'
import { Text } from 'components/Text'
import { getAssetByDenom } from 'utils/assets'
import TitleAndSubCell from 'components/TitleAndSubCell'
import { formatPercent, formatValue } from 'utils/formatters'
import { Button } from 'components/Button'
import VaultLogo from 'components/Earn/VaultLogo'
interface Props {
vault: Vault
}
export default function VaultCard(props: Props) {
function openVaultModal() {}
return (
<div className='border-r-[1px] border-r-white/10 p-4'>
<div className='align-center mb-8 flex justify-between'>
<div>
<Text size='xs' className='mb-2 text-white/60'>
Hot off the presses
</Text>
<span className='flex'>
<Text className='mr-2 font-bold'>{props.vault.name}</Text>
<Text size='sm' className='text-white/60'>
via {props.vault.provider}
</Text>
</span>
</div>
<VaultLogo vault={props.vault} />
</div>
<div className='mb-6 flex justify-between'>
<TitleAndSubCell
className='text-xs'
title={props.vault.apy ? formatPercent(props.vault.apy) : '-'}
sub={'APY'}
/>
<TitleAndSubCell
className='text-xs'
title={`${props.vault.lockup.duration} ${props.vault.lockup.timeframe}`}
sub={'Lockup'}
/>
<TitleAndSubCell
className='text-xs'
title={formatValue(props.vault.cap.used || '0', {
abbreviated: true,
decimals: getAssetByDenom(props.vault.cap.denom)?.decimals,
})}
sub={'TVL'}
/>
<TitleAndSubCell
className='text-xs'
title={formatValue(props.vault.cap.max || '0', {
abbreviated: true,
decimals: getAssetByDenom(props.vault.cap.denom)?.decimals,
})}
sub={'Depo. Cap'}
/>
</div>
<Button color='secondary' onClick={openVaultModal} className='w-full'>
Deposit
</Button>
</div>
)
}

View File

@ -0,0 +1,25 @@
import Image from 'next/image'
import { getAssetByDenom } from 'utils/assets'
interface Props {
vault: Vault
}
export default function VaultLogo(props: Props) {
const primaryAsset = getAssetByDenom(props.vault.denoms.primary)
const secondaryAsset = getAssetByDenom(props.vault.denoms.secondary)
if (!primaryAsset || !secondaryAsset) return null
return (
<div className='relative grid w-12 place-items-center'>
<div className='absolute'>
<Image src={primaryAsset.logo} alt={`${primaryAsset.symbol} logo`} width={24} height={24} />
</div>
<div className='absolute'>
<Image className='ml-5 mt-5' src={secondaryAsset.logo} alt='token' width={16} height={16} />
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import { flexRender, Row } from '@tanstack/react-table'
import classNames from 'classnames'
type AssetRowProps = {
row: Row<Vault>
resetExpanded: (defaultState?: boolean | undefined) => void
}
export const VaultRow = (props: AssetRowProps) => {
return (
<tr
key={props.row.id}
className={classNames(
'cursor-pointer transition-colors',
props.row.getIsExpanded() ? ' bg-black/20' : 'bg-white/0 hover:bg-white/5',
)}
onClick={(e) => {
e.preventDefault()
const isExpanded = props.row.getIsExpanded()
props.resetExpanded()
!isExpanded && props.row.toggleExpanded()
}}
>
{props.row.getVisibleCells().map((cell, index) => {
return (
<td key={cell.id} className={'p-4 text-right'}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
)
})}
</tr>
)
}

View File

@ -0,0 +1,170 @@
'use client'
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from '@tanstack/react-table'
import classNames from 'classnames'
import React from 'react'
import { ChevronDown, SortAsc, SortDesc, SortNone } from 'components/Icons'
import { Text } from 'components/Text'
import { getAssetByDenom, getMarketAssets } from 'utils/assets'
import VaultLogo from 'components/Earn/VaultLogo'
import TitleAndSubCell from 'components/TitleAndSubCell'
import { convertPercentage, formatPercent, formatValue } from 'utils/formatters'
import { VAULT_DEPOSIT_BUFFER } from 'constants/vaults'
import { VaultRow } from 'components/Earn/VaultRow'
type Props = {
data: Vault[]
}
export const VaultTable = (props: Props) => {
const [sorting, setSorting] = React.useState<SortingState>([])
const marketAssets = getMarketAssets()
const columns = React.useMemo<ColumnDef<Vault>[]>(
() => [
{
header: 'Vault',
id: 'address',
cell: ({ row }) => {
return <VaultLogo vault={row.original} />
},
},
{
accessorKey: 'apy',
header: 'APY',
cell: ({ row }) => {
return <Text size='xs'>{row.original.apy ? formatPercent(row.original.apy) : '-'}</Text>
},
},
{
accessorKey: 'tvl',
header: 'TVL',
cell: ({ row }) => {
// TODO: Replace with DisplayCurrency
const symbol = getAssetByDenom(row.original.cap.denom)?.symbol ?? ''
return (
<Text size='xs'>
{formatValue(row.original.cap.used, { abbreviated: true, suffix: ` ${symbol}` })}
</Text>
)
},
},
{
accessorKey: 'cap',
header: 'Depo. Cap',
cell: ({ row }) => {
const percent = convertPercentage(
(row.original.cap.used / (row.original.cap.max * VAULT_DEPOSIT_BUFFER)) * 100,
)
const decimals = getAssetByDenom(row.original.cap.denom)?.decimals ?? 6
// TODO: Replace with DisplayCurrency
return (
<TitleAndSubCell
className='text-xs'
title={formatValue(row.original.cap.max, { abbreviated: true, decimals })}
sub={`${percent}% Filled`}
/>
)
},
},
{
accessorKey: 'cap',
enableSorting: false,
header: 'Details',
cell: ({ row }) => (
<div className='flex items-center justify-end'>
<div className={classNames('w-4', row.getIsExpanded() && 'rotate-180')}>
<ChevronDown />
</div>
</div>
),
},
],
[marketAssets, props.data],
)
const table = useReactTable({
data: props.data,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: true,
})
return (
<table className='w-full'>
<thead className='bg-black/20'>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
return (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className={classNames(
'px-4 py-3',
header.column.getCanSort() && 'cursor-pointer',
header.id === 'symbol' ? 'text-left' : 'text-right',
)}
>
<div
className={classNames(
'flex',
header.id === 'symbol' ? 'justify-start' : 'justify-end',
'align-center',
)}
>
<span className='h-6 w-6 text-white'>
{header.column.getCanSort()
? {
asc: <SortAsc />,
desc: <SortDesc />,
false: <SortNone />,
}[header.column.getIsSorted() as string] ?? null
: null}
</span>
<Text
tag='span'
size='xs'
className='flex items-center font-normal text-white/40'
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Text>
</div>
</th>
)
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
if (row.getIsExpanded()) {
return (
<React.Fragment key={`${row.id}_subrow`}>
<VaultRow row={row} resetExpanded={table.resetExpanded} />
</React.Fragment>
)
}
return (
<VaultRow key={row.original.address} row={row} resetExpanded={table.resetExpanded} />
)
})}
</tbody>
</table>
)
}

View File

@ -1,3 +1,5 @@
export const VAULT_DEPOSIT_BUFFER = 0.999
export const VAULTS: VaultMetaData[] = [
{
address: 'osmo108q2krqr0y9g0rtesenvsw68sap2xefelwwjs0wedyvdl0cmrntqvllfjk',
@ -16,6 +18,7 @@ export const VAULTS: VaultMetaData[] = [
primary: 'OSMO',
secondary: 'ATOM',
},
isFeatured: true,
},
{
address: 'osmo1g5hryv0gp9dzlchkp3yxk8fmcf5asjun6cxkvyffetqzkwmvy75qfmeq3f',

View File

@ -15,6 +15,7 @@ interface VaultMetaData {
primary: string
secondary: string
}
isFeatured?: boolean
}
interface VaultConfig extends VaultMetaData {

View File

@ -65,7 +65,7 @@ export async function getPrices() {
}
export async function getVaults() {
return callAPI<Coin[]>(getEndpoint(Endpoints.VAULTS), 'default')
return callAPI<Vault[]>(getEndpoint(Endpoints.VAULTS), 'default')
}
export async function getWalletBalancesSWR(url: string) {

View File

@ -1,6 +1,6 @@
import { ASSETS } from 'constants/assets'
export function getAssetByDenom(denom: string) {
export function getAssetByDenom(denom: string): Asset | undefined {
return ASSETS.find((asset) => asset.denom === denom)
}

View File

@ -136,3 +136,10 @@ export function formatAmountWithSymbol(coin: Coin) {
rounded: true,
})
}
export const convertPercentage = (percent: number) => {
let percentage = percent
if (percent >= 100) percentage = 100
if (percent !== 0 && percent < 0.01) percentage = 0.01
return Number(formatValue(percentage, { minDecimals: 0, maxDecimals: 0 }))
}