Skip to content
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

Merged
merged 12 commits into from
Nov 27, 2024
2 changes: 1 addition & 1 deletion app/explore/page.ts
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;
21 changes: 20 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,26 @@ const nextConfig = {
config.module.rules.push({
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
use: [
{
loader: '@svgr/webpack',
options: {
svgo: false,
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
Comment on lines +47 to +56
Copy link
Contributor

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?

Copy link
Contributor Author

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

},
},
],
},
},
},
],
});

config.experiments.asyncWebAssembly = true;
Expand Down
97 changes: 97 additions & 0 deletions src/pages/explore/api/get-stats.ts
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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I'd pull this above and define it as something like const STATS_DURATION_WINDOW = ...

Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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 dex_ex_aggregate_summary table to return the quote asset id.

Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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 ValueView is a class, if you don't serialize/deserialize, the other end may try call a function on the class that doesn't exist. See src/shared/api/server/types.ts for a reference on return types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 use server (or without use client, to be precise). The data is fetched and immediately transformed to HTML. This valid HTML is then sent over the wire to the client.

};
} catch (error) {
return { error: (error as Error).message };
}
};
12 changes: 1 addition & 11 deletions src/pages/explore/index.tsx
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';
3 changes: 3 additions & 0 deletions src/pages/explore/ui/chevron-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions src/pages/explore/ui/info-card.tsx
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>
);
};
13 changes: 13 additions & 0 deletions src/pages/explore/ui/page.tsx
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>
);
};
145 changes: 145 additions & 0 deletions src/pages/explore/ui/pair-card.tsx
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>
);
};
54 changes: 54 additions & 0 deletions src/pages/explore/ui/pairs.tsx
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>
);
};
15 changes: 15 additions & 0 deletions src/pages/explore/ui/penumbra-waves.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading