diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts new file mode 100644 index 00000000..afd5102c --- /dev/null +++ b/app/api/stats/route.ts @@ -0,0 +1 @@ +export { GET } from '@/shared/api/server/stats'; diff --git a/app/explore/page.ts b/app/explore/page.ts index 868ea07d..72d31490 100644 --- a/app/explore/page.ts +++ b/app/explore/page.ts @@ -1,3 +1,3 @@ -import { ExplorePage } from '@/pages/explore'; +import { ExplorePage } from '@/pages/explore/ui/page'; export default ExplorePage; diff --git a/next.config.mjs b/next.config.mjs index 919f3a5f..017258d6 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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, + }, + }, + }, + ], + }, + }, + }, + ], }); config.experiments.asyncWebAssembly = true; diff --git a/src/pages/explore/api/use-stats.ts b/src/pages/explore/api/use-stats.ts new file mode 100644 index 00000000..968db2c8 --- /dev/null +++ b/src/pages/explore/api/use-stats.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { StatsResponse, StatsData } from '@/shared/api/server/stats'; +import { DurationWindow } from '@/shared/utils/duration'; + +export const useStats = () => { + return useQuery({ + queryKey: ['stats'], + queryFn: async () => { + const baseUrl = '/api/stats'; + const urlParams = new URLSearchParams({ + durationWindow: '1d' satisfies DurationWindow, + }).toString(); + + const fetchRes = await fetch(`${baseUrl}?${urlParams}`); + const jsonRes = (await fetchRes.json()) as StatsResponse; + if ('error' in jsonRes) { + throw new Error(jsonRes.error); + } + return jsonRes; + }, + }); +}; diff --git a/src/pages/explore/index.tsx b/src/pages/explore/index.tsx index 5899e6e8..d38df3d9 100644 --- a/src/pages/explore/index.tsx +++ b/src/pages/explore/index.tsx @@ -1,11 +1 @@ -'use client'; - -import { Text } from '@penumbra-zone/ui/Text'; - -export const ExplorePage = () => { - return ( -
- Hi! -
- ); -}; +export { ExplorePage } from './ui/page'; diff --git a/src/pages/explore/ui/chevron-down.svg b/src/pages/explore/ui/chevron-down.svg new file mode 100644 index 00000000..0e038207 --- /dev/null +++ b/src/pages/explore/ui/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/pages/explore/ui/info-card.tsx b/src/pages/explore/ui/info-card.tsx new file mode 100644 index 00000000..2119f43d --- /dev/null +++ b/src/pages/explore/ui/info-card.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { useAutoAnimate } from '@formkit/auto-animate/react'; +import { Text } from '@penumbra-zone/ui/Text'; +import { Skeleton } from '@/shared/ui/skeleton'; + +export interface InfoCardProps { + title: string; + loading?: boolean; + children: ReactNode; +} + +export const InfoCard = ({ title, loading, children }: InfoCardProps) => { + const [parent] = useAutoAnimate(); + + return ( +
+ + {title} + + {loading ? ( +
+ +
+ ) : ( +
{children}
+ )} +
+ ); +}; diff --git a/src/pages/explore/ui/page.tsx b/src/pages/explore/ui/page.tsx new file mode 100644 index 00000000..e5964872 --- /dev/null +++ b/src/pages/explore/ui/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { ExploreStats } from './stats'; +import { ExplorePairs } from './pairs'; +import PenumbraWaves from './penumbra-waves.svg'; + +export const ExplorePage = () => { + return ( +
+ + + +
+ ); +}; diff --git a/src/pages/explore/ui/pair-card.tsx b/src/pages/explore/ui/pair-card.tsx new file mode 100644 index 00000000..0186d72e --- /dev/null +++ b/src/pages/explore/ui/pair-card.tsx @@ -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 ( + <> +
+
+ + ); +}; + +export interface PairCardProps { + loading?: boolean; +} + +export const PairCard = ({ loading }: PairCardProps) => { + const change = -5.35; + + return ( +
+
+ + + + +
+ +
+
+ +
+ + UM/TestUSD +
+ +
+ {loading ? ( + + ) : ( + <> + 0.23 + + delUM + + + )} +
+ +
+ {loading ? ( + + ) : ( + <> + 2.34M + + USDC + + + )} +
+ +
+ {loading ? ( + + ) : ( + <> + 1.37K + + USDC + + + )} +
+ +
+ {loading ? ( + <> +
+ + + ) : ( + <> + {change >= 0 ? ( +
+ + {change}% +
+ ) : ( +
+ + {Math.abs(change)}% +
+ )} + + + + )} +
+ +
+ + + +
+
+ ); +}; diff --git a/src/pages/explore/ui/pairs.tsx b/src/pages/explore/ui/pairs.tsx new file mode 100644 index 00000000..7e1a166c --- /dev/null +++ b/src/pages/explore/ui/pairs.tsx @@ -0,0 +1,52 @@ +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 ( +
+
+ + Trading Pairs + + } + onChange={setSearch} + /> +
+ +
+
+ + Pair + + + Price + + + Liquidity + + + 24h Volume + + + 24h Price Change + + + Actions + +
+ + + +
+
+ ); +}; diff --git a/src/pages/explore/ui/penumbra-waves.svg b/src/pages/explore/ui/penumbra-waves.svg new file mode 100644 index 00000000..bb1167db --- /dev/null +++ b/src/pages/explore/ui/penumbra-waves.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/pages/explore/ui/short-chart.tsx b/src/pages/explore/ui/short-chart.tsx new file mode 100644 index 00000000..64d876fe --- /dev/null +++ b/src/pages/explore/ui/short-chart.tsx @@ -0,0 +1,70 @@ +export interface ShortChartProps { + /** Percentage change value. If positive, renders green chart. Otherwise, renders red chart */ + change: number; +} + +export const ShortChart = ({ change }: ShortChartProps) => { + if (change >= 0) { + return ( + + + + + + + + + + + ); + } + + return ( + + + + + + + + + + + ); +}; diff --git a/src/pages/explore/ui/sparkline-chart.svg b/src/pages/explore/ui/sparkline-chart.svg new file mode 100644 index 00000000..f69e934e --- /dev/null +++ b/src/pages/explore/ui/sparkline-chart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/pages/explore/ui/stats.tsx b/src/pages/explore/ui/stats.tsx new file mode 100644 index 00000000..e55e556f --- /dev/null +++ b/src/pages/explore/ui/stats.tsx @@ -0,0 +1,51 @@ +import { Text } from '@penumbra-zone/ui/Text'; +import { InfoCard } from './info-card'; +import { useStats } from '../api/use-stats'; + +export const ExploreStats = () => { + const { data, isLoading, error } = useStats(); + console.log(data); + + if (error) { + return ( + {error.name}: {error.message} + ) + } + + return ( +
+ + + $125.6M + + + + + 12,450 trades + + + + + 12,450 trades + + + + + 12,450 trades + + + + {data && ( + + {data.active_pairs} pairs + + )} + + + + 12,450 trades + + +
+ ); +}; diff --git a/src/shared/api/server/stats.ts b/src/shared/api/server/stats.ts new file mode 100644 index 00000000..b7160d89 --- /dev/null +++ b/src/shared/api/server/stats.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { DexExAggregateSummary } from '@/shared/database/schema'; +import { pindexer } from '@/shared/database'; +import { durationWindows, isDurationWindow } from '@/shared/utils/duration'; + +export type StatsData = DexExAggregateSummary; +export type StatsResponse = StatsData | { error: string }; + +export async function GET(req: NextRequest): Promise> { + const { searchParams } = new URL(req.url); + + const durationWindow = searchParams.get('durationWindow'); + if (!durationWindow || !isDurationWindow(durationWindow)) { + return NextResponse.json( + { error: `durationWindow missing or invalid window. Options: ${durationWindows.join(', ')}` }, + { status: 400 }, + ); + } + + const results = await pindexer.stats(durationWindow); + + const stats = results[0]; + if (!stats) { + return NextResponse.json( + { error: `No stats found` }, + { status: 400 }, + ); + } + + return NextResponse.json(stats); +} diff --git a/src/shared/database/index.ts b/src/shared/database/index.ts index 57800527..6fc35e3c 100644 --- a/src/shared/database/index.ts +++ b/src/shared/database/index.ts @@ -1,9 +1,9 @@ import { Pool, types } from 'pg'; import fs from 'fs'; import { Kysely, PostgresDialect } from 'kysely'; -import { DB } from '@/shared/database/schema.ts'; import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { DurationWindow } from '@/shared/utils/duration.ts'; +import { DB, DexExAggregateSummary } from './schema'; const MAINNET_CHAIN_ID = 'penumbra-1'; @@ -45,6 +45,14 @@ class Pindexer { .execute(); } + async stats(window: DurationWindow): Promise { + return this.db + .selectFrom('dex_ex_aggregate_summary') + .selectAll() + .where('the_window', '=', window) + .execute(); + } + async candles({ baseAsset, quoteAsset,