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:
parent
4af7e63c5f
commit
ac09862f1f
84
package-lock.json
generated
84
package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
38
src/components/Earn/AvailableVaults.tsx
Normal file
38
src/components/Earn/AvailableVaults.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
36
src/components/Earn/FeaturedVaults.tsx
Normal file
36
src/components/Earn/FeaturedVaults.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
68
src/components/Earn/VaultCard.tsx
Normal file
68
src/components/Earn/VaultCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
25
src/components/Earn/VaultLogo.tsx
Normal file
25
src/components/Earn/VaultLogo.tsx
Normal 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>
|
||||
)
|
||||
}
|
33
src/components/Earn/VaultRow.tsx
Normal file
33
src/components/Earn/VaultRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
170
src/components/Earn/VaultTable.tsx
Normal file
170
src/components/Earn/VaultTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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',
|
||||
|
1
src/types/interfaces/vaults.d.ts
vendored
1
src/types/interfaces/vaults.d.ts
vendored
@ -15,6 +15,7 @@ interface VaultMetaData {
|
||||
primary: string
|
||||
secondary: string
|
||||
}
|
||||
isFeatured?: boolean
|
||||
}
|
||||
|
||||
interface VaultConfig extends VaultMetaData {
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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 }))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user