-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: #110: Explore page #133
Changes from 10 commits
6e40940
32e9985
6bfd41e
acd68cd
9e32ff2
487041c
22b930a
5fa44df
6e0f9ed
3b66dc8
88a283b
f11a7bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,3 @@ | ||
import { ExplorePage } from '@/pages/explore'; | ||
import { ExplorePage } from '@/pages/explore/ui/page'; | ||
|
||
export default ExplorePage; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import { ValueView, AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; | ||
import { ChainRegistryClient } from '@penumbra-labs/registry'; | ||
import { pindexer } from '@/shared/database'; | ||
import { DurationWindow } from '@/shared/utils/duration'; | ||
import { toValueView } from '@/shared/utils/value-view'; | ||
|
||
export interface StatsData { | ||
activePairs: number; | ||
trades: number; | ||
largestPair?: { start: string; end: string }; | ||
topPriceMover?: { start: string; end: string; percent: number }; | ||
directVolume: ValueView; | ||
liquidity: ValueView; | ||
largestPairLiquidity?: ValueView; | ||
} | ||
|
||
export type StatsResponse = StatsData | { error: string }; | ||
|
||
export const getStats = async (): Promise<StatsResponse> => { | ||
try { | ||
const durationWindow: DurationWindow = '1d'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: I'd pull this above and define it as something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or maybe this should be a query param because I can imagine other duration windows being supported on the stats page in the future There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved as a constant out of the function for now. Supporting query params would also require changing some texts on the page, – not sure we need it for now |
||
|
||
const chainId = process.env['PENUMBRA_CHAIN_ID']; | ||
if (!chainId) { | ||
return { error: 'PENUMBRA_CHAIN_ID is not set' }; | ||
} | ||
|
||
const registryClient = new ChainRegistryClient(); | ||
|
||
const [registry, results] = await Promise.all([ | ||
registryClient.remote.get(chainId), | ||
pindexer.stats(durationWindow), | ||
]); | ||
|
||
const stats = results[0]; | ||
if (!stats) { | ||
return { error: `No stats found` }; | ||
} | ||
|
||
// TODO: Add getMetadataBySymbol() helper to registry npm package | ||
const allAssets = registry.getAllAssets(); | ||
// TODO: what asset should be used here? | ||
const usdcMetadata = allAssets.find(asset => asset.symbol.toLowerCase() === 'usdc'); | ||
if (!usdcMetadata) { | ||
return { error: 'USDC not found in registry' }; | ||
} | ||
Comment on lines
+42
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: the database doesn't seem to have the base asset for the global summary. How ok is it to have this check in code? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You mean quote asset right? @cronokirby think it may be nice for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah we can add metadata for this |
||
|
||
const topPriceMoverStart = allAssets.find(asset => { | ||
return asset.penumbraAssetId?.equals(new AssetId({ inner: stats.top_price_mover_start })); | ||
}); | ||
const topPriceMoverEnd = allAssets.find(asset => { | ||
return asset.penumbraAssetId?.equals(new AssetId({ inner: stats.top_price_mover_end })); | ||
}); | ||
const topPriceMover = topPriceMoverStart && | ||
topPriceMoverEnd && { | ||
start: topPriceMoverStart.symbol, | ||
end: topPriceMoverEnd.symbol, | ||
percent: stats.top_price_mover_change_percent, | ||
}; | ||
|
||
const largestPairStart = allAssets.find(asset => { | ||
return asset.penumbraAssetId?.equals( | ||
new AssetId({ inner: stats.largest_dv_trading_pair_start }), | ||
); | ||
}); | ||
const largestPairEnd = allAssets.find(asset => { | ||
return asset.penumbraAssetId?.equals( | ||
new AssetId({ inner: stats.largest_dv_trading_pair_end }), | ||
); | ||
}); | ||
const largestPair = largestPairStart && | ||
largestPairEnd && { | ||
start: largestPairStart.symbol, | ||
end: largestPairEnd.symbol, | ||
}; | ||
|
||
return { | ||
activePairs: stats.active_pairs, | ||
trades: stats.trades, | ||
largestPair, | ||
topPriceMover, | ||
largestPairLiquidity: | ||
largestPairEnd && | ||
toValueView({ | ||
amount: stats.largest_dv_trading_pair_volume, | ||
metadata: largestPairEnd, | ||
}), | ||
liquidity: toValueView({ | ||
amount: parseInt(`${stats.liquidity}`), | ||
metadata: usdcMetadata, | ||
}), | ||
directVolume: toValueView({ amount: stats.direct_volume, metadata: usdcMetadata }), | ||
Comment on lines
+77
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: We may just be lucky that this is working as you aren't serializing before sending this over the wire. As a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but it doesn't send this over the wire since everything is rendered in react server components with |
||
}; | ||
} catch (error) { | ||
return { error: (error as Error).message }; | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1 @@ | ||
'use client'; | ||
|
||
import { Text } from '@penumbra-zone/ui/Text'; | ||
|
||
export const ExplorePage = () => { | ||
return ( | ||
<section> | ||
<Text h2>Hi!</Text> | ||
</section> | ||
); | ||
}; | ||
export { ExplorePage } from './ui/page'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import type { ReactNode } from 'react'; | ||
import { Text } from '@penumbra-zone/ui/Text'; | ||
|
||
export interface InfoCardProps { | ||
title: string; | ||
children: ReactNode; | ||
} | ||
|
||
export const InfoCard = ({ title, children }: InfoCardProps) => { | ||
return ( | ||
<div className='flex flex-col justify-center items-start w-full p-3 desktop:p-6 rounded-lg bg-other-tonalFill5 backdrop-blur-lg'> | ||
<Text detail color='text.secondary'> | ||
{title} | ||
</Text> | ||
<div className='flex items-baseline justify-start gap-2'>{children}</div> | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { ExploreStats } from './stats'; | ||
import { ExplorePairs } from './pairs'; | ||
import { PenumbraWaves } from './waves'; | ||
|
||
export const ExplorePage = () => { | ||
return ( | ||
<section className='flex flex-col gap-6 p-4 max-w-[1062px] mx-auto'> | ||
<PenumbraWaves /> | ||
<ExploreStats /> | ||
<ExplorePairs /> | ||
</section> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; | ||
import { Star, CandlestickChart } from 'lucide-react'; | ||
import { Button } from '@penumbra-zone/ui/Button'; | ||
import { Text } from '@penumbra-zone/ui/Text'; | ||
import { Density } from '@penumbra-zone/ui/Density'; | ||
import { AssetIcon } from '@penumbra-zone/ui/AssetIcon'; | ||
import SparklineChart from './sparkline-chart.svg'; | ||
import { ShortChart } from './short-chart'; | ||
import ChevronDown from './chevron-down.svg'; | ||
|
||
const u8 = (length: number) => Uint8Array.from({ length }, () => Math.floor(Math.random() * 256)); | ||
export const PENUMBRA_METADATA = new Metadata({ | ||
base: 'upenumbra', | ||
name: 'Penumbra', | ||
display: 'penumbra', | ||
symbol: 'UM', | ||
penumbraAssetId: new AssetId({ inner: u8(32) }), | ||
images: [ | ||
{ | ||
svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg', | ||
}, | ||
], | ||
}); | ||
|
||
export const OSMO_METADATA = new Metadata({ | ||
symbol: 'OSMO', | ||
name: 'Osmosis', | ||
penumbraAssetId: new AssetId({ inner: u8(32) }), | ||
base: 'uosmo', | ||
display: 'osmo', | ||
denomUnits: [{ denom: 'uosmo' }, { denom: 'osmo', exponent: 6 }], | ||
images: [ | ||
{ svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/test-usd.svg ' }, | ||
], | ||
}); | ||
|
||
const ShimmeringBars = () => { | ||
return ( | ||
<> | ||
<div className='w-16 h-4 my-1 bg-shimmer rounded-xs' /> | ||
<div className='w-10 h-4 bg-shimmer rounded-xs' /> | ||
</> | ||
); | ||
}; | ||
|
||
export interface PairCardProps { | ||
loading?: boolean; | ||
} | ||
|
||
export const PairCard = ({ loading }: PairCardProps) => { | ||
const change = Number(-5.35); | ||
|
||
return ( | ||
<div className='grid grid-cols-subgrid col-span-6 p-3 rounded-sm cursor-pointer transition-colors hover:bg-action-hoverOverlay'> | ||
<div className='relative h-10 flex items-center gap-2 text-text-primary'> | ||
<Density compact> | ||
<Button icon={Star} iconOnly> | ||
Favorite | ||
</Button> | ||
</Density> | ||
|
||
<div className='z-10'> | ||
<AssetIcon metadata={PENUMBRA_METADATA} size='lg' /> | ||
</div> | ||
<div className='-ml-4'> | ||
<AssetIcon metadata={OSMO_METADATA} size='lg' /> | ||
</div> | ||
|
||
<Text body>UM/TestUSD</Text> | ||
</div> | ||
|
||
<div className='h-10 flex flex-col items-end justify-center'> | ||
{loading ? ( | ||
<ShimmeringBars /> | ||
) : ( | ||
<> | ||
<Text color='text.primary'>0.23</Text> | ||
<Text detail color='text.secondary'> | ||
delUM | ||
</Text> | ||
</> | ||
)} | ||
</div> | ||
|
||
<div className='h-10 flex flex-col items-end justify-center'> | ||
{loading ? ( | ||
<ShimmeringBars /> | ||
) : ( | ||
<> | ||
<Text color='text.primary'>2.34M</Text> | ||
<Text detail color='text.secondary'> | ||
USDC | ||
</Text> | ||
</> | ||
)} | ||
</div> | ||
|
||
<div className='h-10 flex flex-col items-end justify-center'> | ||
{loading ? ( | ||
<ShimmeringBars /> | ||
) : ( | ||
<> | ||
<Text color='text.primary'>1.37K</Text> | ||
<Text detail color='text.secondary'> | ||
USDC | ||
</Text> | ||
</> | ||
)} | ||
</div> | ||
|
||
<div className='h-10 flex items-center justify-end gap-2'> | ||
{loading ? ( | ||
<> | ||
<div className='w-10 h-4 bg-shimmer rounded-xs' /> | ||
<SparklineChart className='w-14 h-8' /> | ||
</> | ||
) : ( | ||
<> | ||
{change >= 0 ? ( | ||
<div className='flex items-center text-success-light'> | ||
<ChevronDown className='size-3 rotate-180 inline-block' /> | ||
<Text>{change}%</Text> | ||
</div> | ||
) : ( | ||
<div className='flex items-center text-destructive-light'> | ||
<ChevronDown className='size-3 inline-block ' /> | ||
<Text>{Math.abs(change)}%</Text> | ||
</div> | ||
)} | ||
|
||
<ShortChart change={change} /> | ||
</> | ||
)} | ||
</div> | ||
|
||
<div className='h-10 flex flex-col items-end justify-center'> | ||
<Density compact> | ||
<Button icon={CandlestickChart} iconOnly> | ||
Actions | ||
</Button> | ||
</Density> | ||
</div> | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
'use client'; | ||
|
||
import { useState } from 'react'; | ||
import { Search } from 'lucide-react'; | ||
import { Text } from '@penumbra-zone/ui/Text'; | ||
import { TextInput } from '@penumbra-zone/ui/TextInput'; | ||
import { Icon } from '@penumbra-zone/ui/Icon'; | ||
import { PairCard } from '@/pages/explore/ui/pair-card'; | ||
|
||
export const ExplorePairs = () => { | ||
const [search, setSearch] = useState(''); | ||
|
||
return ( | ||
<div className='w-full flex flex-col gap-4'> | ||
<div className='flex gap-4 justify-between items-center text-text-primary'> | ||
<Text large whitespace='nowrap'> | ||
Trading Pairs | ||
</Text> | ||
<TextInput | ||
value={search} | ||
placeholder='Search pair' | ||
startAdornment={<Icon size='md' IconComponent={Search} />} | ||
onChange={setSearch} | ||
/> | ||
</div> | ||
|
||
<div className='grid grid-cols-[1fr_1fr_1fr_1fr_128px_56px] gap-2 overflow-auto'> | ||
<div className='grid grid-cols-subgrid col-span-6 py-2 px-3'> | ||
<Text detail color='text.secondary' align='left'> | ||
Pair | ||
</Text> | ||
<Text detail color='text.secondary' align='right'> | ||
Price | ||
</Text> | ||
<Text detail color='text.secondary' align='right'> | ||
Liquidity | ||
</Text> | ||
<Text detail color='text.secondary' align='right' whitespace='nowrap'> | ||
24h Volume | ||
</Text> | ||
<Text detail color='text.secondary' align='right' whitespace='nowrap'> | ||
24h Price Change | ||
</Text> | ||
<Text detail color='text.secondary' align='right'> | ||
Actions | ||
</Text> | ||
</div> | ||
|
||
<PairCard /> | ||
<PairCard /> | ||
</div> | ||
</div> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: what does this do?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Allows the import of
.svg
files as react components. I used it to import PenumbraWaves component.The
removeViewBox: false
is needed to keep the dimensions of the svg and scale it without cutting it