diff --git a/src/app/leaderboard/layout.tsx b/src/app/leaderboard/layout.tsx new file mode 100644 index 0000000..74e0aaa --- /dev/null +++ b/src/app/leaderboard/layout.tsx @@ -0,0 +1,11 @@ +import { DashboardLayout } from 'src/layouts/dashboard' + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode +} + +export default function Layout({ children }: Props) { + return {children} +} diff --git a/src/app/leaderboard/loading.tsx b/src/app/leaderboard/loading.tsx new file mode 100644 index 0000000..eb8c308 --- /dev/null +++ b/src/app/leaderboard/loading.tsx @@ -0,0 +1,7 @@ +import { LoadingScreen } from 'src/components/loading-screen' + +// ---------------------------------------------------------------------- + +export default function Loading() { + return +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx new file mode 100644 index 0000000..17cc5d7 --- /dev/null +++ b/src/app/leaderboard/page.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Box, Typography } from '@mui/material' +import { LeaderboardTable } from 'src/components/leaderboard' +import { DashboardContent } from 'src/layouts/dashboard' +import { Iconify } from 'src/components/iconify' + +export default function LeaderboardPage() { + return ( + + {/* Page Header */} + + + + Leaderboard + + + Top bridge users ranked by volume and transaction activity + + + + {/* Leaderboard Table */} + + + ) +} diff --git a/src/components/leaderboard/activity-sparkline.tsx b/src/components/leaderboard/activity-sparkline.tsx new file mode 100644 index 0000000..98c36b3 --- /dev/null +++ b/src/components/leaderboard/activity-sparkline.tsx @@ -0,0 +1,129 @@ +import { Box } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { useMemo, useId } from 'react' + +interface ActivitySparklineProps { + data: number[] + width?: number + height?: number + color?: string +} + +interface Point { + x: number + y: number +} + +export function ActivitySparkline({ + data, + width = 80, + height = 30, + color, +}: ActivitySparklineProps) { + const theme = useTheme() + const strokeColor = color || theme.palette.primary.main + const gradientId = useId() + + const { path, points, hasActivity } = useMemo(() => { + if (!data || data.length === 0) { + return { path: '', points: [] as Point[], hasActivity: false } + } + + const max = Math.max(...data, 1) // Ensure at least 1 to avoid division by zero + const hasAct = data.some(v => v > 0) + + const padding = 2 + const chartWidth = width - padding * 2 + const chartHeight = height - padding * 2 + + const pts = data.map((value, index) => { + const x = padding + (index / (data.length - 1 || 1)) * chartWidth + const y = padding + chartHeight - (value / max) * chartHeight + return { x, y } + }) + + // Create SVG path + const pathData = pts + .map((point, index) => { + if (index === 0) return `M ${point.x} ${point.y}` + return `L ${point.x} ${point.y}` + }) + .join(' ') + + return { path: pathData, points: pts, hasActivity: hasAct } + }, [data, width, height]) + + // Create area path (for fill) - reuse points from above + const areaPath = useMemo(() => { + if (points.length === 0 || !path) return '' + + const padding = 2 + const chartHeight = height - padding * 2 + const bottomY = padding + chartHeight + const firstX = points[0]?.x || padding + const lastX = points[points.length - 1]?.x || width - padding + + return `${path} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z` + }, [points, path, width, height]) + + // No activity - show placeholder (after all hooks) + if (!hasActivity) { + return ( + + + + ) + } + + return ( + + + {/* Gradient definition */} + + + + + + + + {/* Area fill */} + + + {/* Line */} + + + + ) +} diff --git a/src/components/leaderboard/address-cell.tsx b/src/components/leaderboard/address-cell.tsx new file mode 100644 index 0000000..806e8f8 --- /dev/null +++ b/src/components/leaderboard/address-cell.tsx @@ -0,0 +1,101 @@ +import { Box, IconButton, Tooltip, Typography } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { useState } from 'react' +import { Iconify } from 'src/components/iconify' +import { truncateAddress } from 'src/config/helper' + +interface AddressCellProps { + address: string + addressType: 'sui' | 'eth' + onClick?: () => void +} + +export function AddressCell({ address, addressType, onClick }: AddressCellProps) { + const theme = useTheme() + const [copied, setCopied] = useState(false) + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + const fullAddress = `0x${address}` + await navigator.clipboard.writeText(fullAddress) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy address:', err) + } + } + + const chainColor = addressType === 'sui' ? '#4DA2FF' : '#627EEA' + const chainIcon = + addressType === 'sui' ? '/assets/icons/brands/sui.svg' : '/assets/icons/brands/eth.svg' + + return ( + + {/* Chain icon */} + + + {addressType} + + + + {/* Address text */} + + {truncateAddress(address, 6)} + + + {/* Copy button */} + + + + + + + ) +} diff --git a/src/components/leaderboard/index.ts b/src/components/leaderboard/index.ts new file mode 100644 index 0000000..eb0a217 --- /dev/null +++ b/src/components/leaderboard/index.ts @@ -0,0 +1,5 @@ +export * from './leaderboard-table' +export * from './leaderboard-stats' +export * from './medal-rank' +export * from './activity-sparkline' +export * from './address-cell' diff --git a/src/components/leaderboard/leaderboard-stats.tsx b/src/components/leaderboard/leaderboard-stats.tsx new file mode 100644 index 0000000..c7970d8 --- /dev/null +++ b/src/components/leaderboard/leaderboard-stats.tsx @@ -0,0 +1,125 @@ +import { Grid, Card, Box, Typography, Skeleton } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { Iconify } from 'src/components/iconify' +import AnimatedNumbers from 'react-animated-numbers' + +interface LeaderboardStatsProps { + totalUsers: number + totalVolumeUsd: number + avgVolumePerUser: number + topUserVolume: number + isLoading?: boolean +} + +interface StatCardProps { + title: string + value: number | string + icon: string + color: string + isDollar?: boolean + isLoading?: boolean +} + +function StatCard({ title, value, icon, color, isDollar, isLoading }: StatCardProps) { + const textStyle = { + fontSize: '1.5rem', + fontWeight: 'bold', + fontFamily: 'Barlow', + } + + return ( + + + + + {title} + + + + {isLoading ? ( + + ) : typeof value === 'number' ? ( + + {isDollar && $} + + + ) : ( + + {value} + + )} + + ) +} + +export function LeaderboardStats({ + totalUsers, + totalVolumeUsd, + avgVolumePerUser, + topUserVolume, + isLoading, +}: LeaderboardStatsProps) { + const theme = useTheme() + + const stats = [ + { + title: 'Total Users', + value: totalUsers, + icon: 'solar:users-group-rounded-bold-duotone', + color: theme.palette.info.main, + isDollar: false, + }, + { + title: 'Total Volume', + value: totalVolumeUsd, + icon: 'solar:wallet-money-bold-duotone', + color: theme.palette.success.main, + isDollar: true, + }, + { + title: 'Avg Volume/User', + value: avgVolumePerUser, + icon: 'solar:chart-square-bold-duotone', + color: theme.palette.warning.main, + isDollar: true, + }, + { + title: 'Top User Volume', + value: topUserVolume, + icon: 'solar:crown-bold-duotone', + color: '#FFD700', + isDollar: true, + }, + ] + + return ( + + {stats.map((stat, index) => ( + + + + ))} + + ) +} diff --git a/src/components/leaderboard/leaderboard-table.tsx b/src/components/leaderboard/leaderboard-table.tsx new file mode 100644 index 0000000..3dc3f74 --- /dev/null +++ b/src/components/leaderboard/leaderboard-table.tsx @@ -0,0 +1,389 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { + Box, + Card, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Tabs, + Tab, + Typography, + Skeleton, + Chip, + ToggleButtonGroup, + ToggleButton, +} from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { useRouter } from 'next/navigation' +import useSWR from 'swr' +import { endpoints, fetcher } from 'src/utils/axios' +import { getNetwork } from 'src/hooks/get-network-storage' +import { useGlobalContext } from 'src/provider/global-provider' +import { fCurrency, fNumber } from 'src/utils/format-number' +import { fDate } from 'src/utils/format-time' +import { Iconify } from 'src/components/iconify' +import { paths } from 'src/routes/paths' + +import { MedalRank } from './medal-rank' +import { AddressCell } from './address-cell' +import { ActivitySparkline } from './activity-sparkline' +import { LeaderboardStats } from './leaderboard-stats' + +import type { LeaderboardResponse } from 'src/pages/api/leaderboard' +import type { SparklineResponse } from 'src/pages/api/leaderboard-sparklines' + +type AddressTypeFilter = 'all' | 'sui' | 'eth' +type SortByOption = 'volume' | 'count' + +const ROWS_PER_PAGE = 25 + +export function LeaderboardTable() { + const theme = useTheme() + const router = useRouter() + const network = getNetwork() + const { timePeriod } = useGlobalContext() + + const [page, setPage] = useState(0) + const [addressType, setAddressType] = useState('all') + const [sortBy, setSortBy] = useState('volume') + + // Reset page when filters change + useEffect(() => { + setPage(0) + }, [addressType, sortBy, timePeriod]) + + // Build query string + const queryString = useMemo(() => { + const params = new URLSearchParams({ + network, + period: timePeriod, + addressType, + sortBy, + limit: String(ROWS_PER_PAGE), + offset: String(page * ROWS_PER_PAGE), + }) + return params.toString() + }, [network, timePeriod, addressType, sortBy, page]) + + // Fetch leaderboard data + const { data, isLoading } = useSWR( + `${endpoints.leaderboard}?${queryString}`, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 30000, + }, + ) + + // Get addresses for sparklines + const addresses = useMemo(() => { + return data?.users?.map(u => u.address) || [] + }, [data?.users]) + + // Fetch sparklines data + const { data: sparklineData } = useSWR( + addresses.length > 0 + ? `${endpoints.leaderboardSparklines}?network=${network}&period=${encodeURIComponent(timePeriod)}&addresses=${addresses.join(',')}` + : null, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 60000, + }, + ) + + const handleChangePage = (_: unknown, newPage: number) => { + setPage(newPage) + } + + const handleAddressTypeChange = (_: React.SyntheticEvent, newValue: AddressTypeFilter) => { + if (newValue !== null) { + setAddressType(newValue) + } + } + + const handleSortChange = (_: React.MouseEvent, newSort: SortByOption) => { + if (newSort !== null) { + setSortBy(newSort) + } + } + + const handleRowClick = (address: string, addressType: 'sui' | 'eth') => { + const queryParam = addressType === 'sui' ? 'suiAddress' : 'ethAddress' + router.push(`${paths.profile.root}?${queryParam}=0x${address}`) + } + + const users = data?.users || [] + const total = data?.total || 0 + const stats = data?.stats + + return ( + + {/* Stats Cards */} + + + {/* Main Card */} + + {/* Header with tabs and filters */} + + {/* Address Type Tabs */} + + } + iconPosition="start" + /> + + } + iconPosition="start" + /> + + } + iconPosition="start" + /> + + + {/* Sort By Toggle */} + + + Sort by: + + + + + Volume + + + + Tx Count + + + + + + {/* Table */} + + + + + + Rank + + Address + Volume (USD) + Transactions + Top Token + + Activity + + First Active + Last Active + + + + {isLoading ? ( + // Loading skeletons + Array.from({ length: 10 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )) + ) : users.length === 0 ? ( + + + + + + No users found for the selected filters + + + + + ) : ( + users.map((user, index) => { + const globalRank = page * ROWS_PER_PAGE + index + 1 + const sparklineValues = + sparklineData?.sparklines?.[user.address] || [] + + return ( + + handleRowClick(user.address, user.address_type) + } + > + + + + + + + + + {fCurrency(user.total_volume_usd)} + + + + + {fNumber(user.transaction_count)} + + + + + + + + + + + {fDate(user.first_tx_date)} + + + + + {fDate(user.last_tx_date)} + + + + ) + }) + )} + +
+
+ + {/* Pagination */} + +
+
+ ) +} diff --git a/src/components/leaderboard/medal-rank.tsx b/src/components/leaderboard/medal-rank.tsx new file mode 100644 index 0000000..e6baf1b --- /dev/null +++ b/src/components/leaderboard/medal-rank.tsx @@ -0,0 +1,76 @@ +import { Box, Typography } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' + +interface MedalRankProps { + rank: number +} + +const MEDAL_COLORS = { + 1: { primary: '#FFD700', secondary: '#FFA500' }, // Gold + 2: { primary: '#C0C0C0', secondary: '#A8A8A8' }, // Silver + 3: { primary: '#CD7F32', secondary: '#B87333' }, // Bronze +} + +export function MedalRank({ rank }: MedalRankProps) { + const theme = useTheme() + const medalConfig = MEDAL_COLORS[rank as keyof typeof MEDAL_COLORS] + + if (!medalConfig) { + // Regular rank display for non-medal positions + return ( + + + {rank} + + + ) + } + + // Medal display for top 3 + return ( + + + {rank} + + + ) +} diff --git a/src/layouts/config-nav-dashboard.tsx b/src/layouts/config-nav-dashboard.tsx index 531edcc..5a7e976 100644 --- a/src/layouts/config-nav-dashboard.tsx +++ b/src/layouts/config-nav-dashboard.tsx @@ -53,6 +53,10 @@ export const navData = [ ], }, + { + items: [{ title: 'Leaderboard', path: paths.leaderboard.root, icon: ICONS.order }], + }, + { items: [{ title: 'Profile', path: paths.profile.root, icon: ICONS.course }], }, diff --git a/src/pages/api/leaderboard-sparklines.ts b/src/pages/api/leaderboard-sparklines.ts new file mode 100644 index 0000000..0908329 --- /dev/null +++ b/src/pages/api/leaderboard-sparklines.ts @@ -0,0 +1,108 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getNetworkConfig, TimePeriod } from 'src/config/helper' +import { sendError, sendReply } from './utils' +import db from './database' +import { computerIntervals } from './cards' + +export type SparklineData = { + address: string + data: number[] // Transaction counts per segment +} + +export type SparklineResponse = { + sparklines: Record +} + +// Number of segments for the sparkline based on time period +const SPARKLINE_SEGMENTS = 7 + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const networkConfig = getNetworkConfig({ req }) + const timePeriod = (req.query.period as TimePeriod) || 'Last Week' + + // Get addresses from query (comma-separated) + const addressesParam = req.query.addresses?.toString() || '' + const addresses = addressesParam + .split(',') + .map(a => a.trim().toLowerCase()) + .filter(a => a.length > 0) + + if (addresses.length === 0) { + sendReply(res, { sparklines: {} }) + return + } + + // Limit to max 50 addresses per request + const limitedAddresses = addresses.slice(0, 50) + + // Use same time intervals as leaderboard + const { fromInterval, toInterval } = computerIntervals(timePeriod, false) + + // Calculate segment duration based on time period + const totalDuration = toInterval - fromInterval + const segmentDuration = Math.floor(totalDuration / SPARKLINE_SEGMENTS) + + // Generate segment boundaries + const segments: { start: number; end: number }[] = [] + for (let i = 0; i < SPARKLINE_SEGMENTS; i++) { + segments.push({ + start: fromInterval + i * segmentDuration, + end: fromInterval + (i + 1) * segmentDuration, + }) + } + + // Query to get transaction counts per segment for each address + const sparklineQuery = await db[networkConfig.network]` + WITH user_transactions AS ( + SELECT + encode(sender_address, 'hex') as address, + timestamp_ms + FROM public.token_transfer_data + WHERE + is_finalized = true + AND timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND sender_address IS NOT NULL + AND encode(sender_address, 'hex') = ANY(${limitedAddresses}) + ) + SELECT + address, + ${db[networkConfig.network].unsafe( + segments + .map( + (seg, i) => + `COUNT(*) FILTER (WHERE timestamp_ms >= ${seg.start} AND timestamp_ms < ${seg.end}) as seg_${i}`, + ) + .join(', '), + )} + FROM user_transactions + GROUP BY address + ` + + // Build response map + const sparklines: Record = {} + + sparklineQuery.forEach((row: any) => { + const counts: number[] = [] + for (let i = 0; i < SPARKLINE_SEGMENTS; i++) { + counts.push(Number(row[`seg_${i}`]) || 0) + } + sparklines[row.address] = counts + }) + + // Fill in empty arrays for addresses with no data + limitedAddresses.forEach(addr => { + if (!sparklines[addr]) { + sparklines[addr] = Array(SPARKLINE_SEGMENTS).fill(0) + } + }) + + sendReply(res, { sparklines }) + } catch (error) { + console.error('Sparklines API error:', error) + sendError(res, { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + }) + } +} diff --git a/src/pages/api/leaderboard.ts b/src/pages/api/leaderboard.ts new file mode 100644 index 0000000..f28ae96 --- /dev/null +++ b/src/pages/api/leaderboard.ts @@ -0,0 +1,211 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getNetworkConfig, TimePeriod } from 'src/config/helper' +import { sendError, sendReply } from './utils' +import db from './database' +import { computerIntervals } from './cards' +import { getPrices } from './prices' + +export type LeaderboardUser = { + rank: number + address: string + address_type: 'sui' | 'eth' + total_volume_usd: number + transaction_count: number + tokens_used: number[] + most_used_token: string + most_used_token_count: number + first_tx_date: number + last_tx_date: number +} + +export type LeaderboardResponse = { + users: LeaderboardUser[] + total: number + stats: { + total_users: number + total_volume_usd: number + avg_volume_per_user: number + top_user_address: string + top_user_volume: number + } +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const networkConfig = getNetworkConfig({ req }) + const timePeriod = (req.query.period as TimePeriod) || 'Last Week' + const addressType = (req.query.addressType as 'all' | 'sui' | 'eth') || 'all' + const sortBy = (req.query.sortBy as 'volume' | 'count') || 'volume' + const limit = Math.min(Number(req.query.limit) || 25, 100) + const offset = Number(req.query.offset) || 0 + + const { fromInterval, toInterval } = computerIntervals(timePeriod, false) + const prices = await getPrices(networkConfig.network) + + // Build price lookup map + const priceMap: Record = {} + prices.forEach((p: any) => { + priceMap[p.token_id] = { + price: Number(p.price), + deno: networkConfig.config.coins[p.token_id]?.deno || 1, + name: networkConfig.config.coins[p.token_id]?.name || 'Unknown', + } + }) + + // Build address type filter for WHERE clause + const addressTypeFilter = + addressType === 'all' + ? db[networkConfig.network]`` + : addressType === 'sui' + ? db[ + networkConfig.network + ]`AND t.destination_chain = ${networkConfig.config.networkId.ETH}` + : db[ + networkConfig.network + ]`AND t.destination_chain = ${networkConfig.config.networkId.SUI}` + + const orderByClause = + sortBy === 'volume' + ? db[networkConfig.network]`total_volume_usd DESC NULLS LAST` + : db[networkConfig.network]`transaction_count DESC` + + // Run both queries in parallel for better performance + const [leaderboardQuery, globalStatsQuery] = await Promise.all([ + // Main leaderboard query + db[networkConfig.network]` + WITH base_transactions AS ( + SELECT + encode(t.sender_address, 'hex') as address, + t.destination_chain, + t.token_id, + t.amount, + t.timestamp_ms, + p.price, + p.denominator, + (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd + FROM public.token_transfer_data t + JOIN public.prices p ON t.token_id = p.token_id + WHERE + t.is_finalized = true + AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND t.sender_address IS NOT NULL + ${addressTypeFilter} + ), + user_stats AS ( + SELECT + address, + COUNT(*) as transaction_count, + array_agg(DISTINCT token_id) as tokens_used, + MIN(timestamp_ms) as first_tx_date, + MAX(timestamp_ms) as last_tx_date, + SUM(amount_usd) as total_volume_usd, + -- Determine address type based on majority of transactions + CASE + WHEN SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.SUI} THEN 1 ELSE 0 END) > + SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.ETH} THEN 1 ELSE 0 END) + THEN 'eth' + ELSE 'sui' + END as address_type + FROM base_transactions + GROUP BY address + ), + ranked_tokens AS ( + SELECT + address, + token_id, + COUNT(*) as token_count, + ROW_NUMBER() OVER ( + PARTITION BY address + ORDER BY COUNT(*) DESC + ) as rn + FROM base_transactions + GROUP BY address, token_id + ) + SELECT + us.address, + us.address_type, + us.transaction_count, + us.tokens_used, + us.first_tx_date, + us.last_tx_date, + COALESCE(us.total_volume_usd, 0) as total_volume_usd, + rt.token_id as most_used_token_id, + rt.token_count as most_used_token_count + FROM user_stats us + LEFT JOIN ranked_tokens rt ON us.address = rt.address AND rt.rn = 1 + ORDER BY ${orderByClause} + LIMIT ${limit} OFFSET ${offset} + `, + // Global stats query - always calculates across ALL users (not affected by sort/pagination) + db[networkConfig.network]` + WITH base_transactions AS ( + SELECT + encode(t.sender_address, 'hex') as address, + t.token_id, + t.amount, + p.price, + p.denominator, + (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd + FROM public.token_transfer_data t + JOIN public.prices p ON t.token_id = p.token_id + WHERE + t.is_finalized = true + AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND t.sender_address IS NOT NULL + ${addressTypeFilter} + ), + user_volumes AS ( + SELECT + address, + SUM(amount_usd) as total_volume_usd + FROM base_transactions + GROUP BY address + ) + SELECT + COUNT(*) as total_users, + COALESCE(SUM(total_volume_usd), 0) as total_volume_usd, + COALESCE(AVG(total_volume_usd), 0) as avg_volume_per_user, + (SELECT address FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_address, + (SELECT total_volume_usd FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_volume + FROM user_volumes + `, + ]) + + // Transform results + const users: LeaderboardUser[] = leaderboardQuery.map((row: any, index: number) => ({ + rank: offset + index + 1, + address: row.address, + address_type: row.address_type as 'sui' | 'eth', + total_volume_usd: Number(row.total_volume_usd) || 0, + transaction_count: Number(row.transaction_count), + tokens_used: row.tokens_used || [], + most_used_token: priceMap[row.most_used_token_id]?.name || 'Unknown', + most_used_token_count: Number(row.most_used_token_count) || 0, + first_tx_date: Number(row.first_tx_date), + last_tx_date: Number(row.last_tx_date), + })) + + // Use global stats from the dedicated query + const globalStats = globalStatsQuery[0] + + const response: LeaderboardResponse = { + users, + total: Number(globalStats?.total_users) || 0, + stats: { + total_users: Number(globalStats?.total_users) || 0, + total_volume_usd: Number(globalStats?.total_volume_usd) || 0, + avg_volume_per_user: Number(globalStats?.avg_volume_per_user) || 0, + top_user_address: globalStats?.top_user_address || '', + top_user_volume: Number(globalStats?.top_user_volume) || 0, + }, + } + + sendReply(res, response) + } catch (error) { + console.error('Leaderboard API error:', error) + sendError(res, { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + }) + } +} diff --git a/src/routes/paths.ts b/src/routes/paths.ts index ff8baff..b1bacd3 100644 --- a/src/routes/paths.ts +++ b/src/routes/paths.ts @@ -5,6 +5,7 @@ const ROOTS = { DASHBOARD: '/dashboard', TRANSACTIONS: '/transactions', PROFILE: '/profile', + LEADERBOARD: '/leaderboard', } // ---------------------------------------------------------------------- @@ -63,4 +64,9 @@ export const paths = { profile: { root: ROOTS.PROFILE, }, + + // LEADERBOARD + leaderboard: { + root: ROOTS.LEADERBOARD, + }, } diff --git a/src/utils/axios.ts b/src/utils/axios.ts index 6954bf5..5677f29 100644 --- a/src/utils/axios.ts +++ b/src/utils/axios.ts @@ -40,6 +40,8 @@ export const endpoints = { outflows: '/api/outflows', bridgeMetrics: '/api/bridge-metrics', // Add this line fees: '/api/fees', + leaderboard: '/api/leaderboard', + leaderboardSparklines: '/api/leaderboard-sparklines', volume: { daily: '/api/volume/daily', hourly: '/api/volume/hourly',