feat: add Top Holders
This commit is contained in:
parent
ff60529b3f
commit
36175c990d
13 changed files with 522 additions and 62 deletions
107
website/src/routes/api/coin/[coinSymbol]/holders/+server.ts
Normal file
107
website/src/routes/api/coin/[coinSymbol]/holders/+server.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin, userPortfolio, user } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { validateSearchParams } from '$lib/utils/validation';
|
||||
|
||||
function calculateLiquidationValue(tokensToSell: number, poolCoinAmount: number, poolBaseCurrencyAmount: number): number {
|
||||
if (tokensToSell <= 0 || poolCoinAmount <= 0 || poolBaseCurrencyAmount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const maxSellable = poolCoinAmount * 0.995;
|
||||
const actualTokensToSell = Math.min(tokensToSell, maxSellable);
|
||||
const k = poolCoinAmount * poolBaseCurrencyAmount;
|
||||
const newPoolCoin = poolCoinAmount + actualTokensToSell;
|
||||
const newPoolBaseCurrency = k / newPoolCoin;
|
||||
const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency;
|
||||
|
||||
return Math.max(0, baseCurrencyReceived);
|
||||
}
|
||||
|
||||
export async function GET({ params, url }) {
|
||||
const coinSymbol = params.coinSymbol?.toUpperCase();
|
||||
const validator = validateSearchParams(url.searchParams);
|
||||
const limit = validator.getPositiveInt('limit', 50);
|
||||
|
||||
if (!coinSymbol) {
|
||||
throw error(400, 'Coin symbol is required');
|
||||
}
|
||||
|
||||
if (limit > 200) {
|
||||
throw error(400, 'Limit cannot exceed 200');
|
||||
}
|
||||
|
||||
try {
|
||||
const [coinData] = await db
|
||||
.select({
|
||||
id: coin.id,
|
||||
symbol: coin.symbol,
|
||||
circulatingSupply: coin.circulatingSupply,
|
||||
poolCoinAmount: coin.poolCoinAmount,
|
||||
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
||||
currentPrice: coin.currentPrice
|
||||
})
|
||||
.from(coin)
|
||||
.where(eq(coin.symbol, coinSymbol))
|
||||
.limit(1);
|
||||
|
||||
if (!coinData) {
|
||||
throw error(404, 'Coin not found');
|
||||
}
|
||||
|
||||
const holders = await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
quantity: userPortfolio.quantity
|
||||
})
|
||||
.from(userPortfolio)
|
||||
.innerJoin(user, eq(userPortfolio.userId, user.id))
|
||||
.where(eq(userPortfolio.coinId, coinData.id))
|
||||
.orderBy(desc(userPortfolio.quantity))
|
||||
.limit(limit);
|
||||
|
||||
const totalCirculating = Number(coinData.circulatingSupply);
|
||||
const poolCoinAmount = Number(coinData.poolCoinAmount);
|
||||
const poolBaseCurrencyAmount = Number(coinData.poolBaseCurrencyAmount);
|
||||
|
||||
const processedHolders = holders.map((holder, index) => {
|
||||
const quantity = Number(holder.quantity);
|
||||
const percentage = totalCirculating > 0 ? (quantity / totalCirculating) * 100 : 0;
|
||||
const liquidationValue = calculateLiquidationValue(quantity, poolCoinAmount, poolBaseCurrencyAmount);
|
||||
|
||||
return {
|
||||
rank: index + 1,
|
||||
userId: holder.userId,
|
||||
username: holder.username,
|
||||
name: holder.name,
|
||||
image: holder.image,
|
||||
quantity,
|
||||
percentage,
|
||||
liquidationValue
|
||||
};
|
||||
});
|
||||
|
||||
return json({
|
||||
coinSymbol: coinData.symbol,
|
||||
totalHolders: holders.length,
|
||||
circulatingSupply: totalCirculating,
|
||||
poolInfo: {
|
||||
coinAmount: poolCoinAmount,
|
||||
baseCurrencyAmount: poolBaseCurrencyAmount,
|
||||
currentPrice: Number(coinData.currentPrice)
|
||||
},
|
||||
holders: processedHolders
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'status' in e) {
|
||||
throw e;
|
||||
}
|
||||
console.error('Unexpected error in holders API:', e);
|
||||
throw error(500, 'Internal server error');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import CommentSection from '$lib/components/self/CommentSection.svelte';
|
||||
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
||||
import CoinSkeleton from '$lib/components/self/skeletons/CoinSkeleton.svelte';
|
||||
import TopHolders from '$lib/components/self/TopHolders.svelte';
|
||||
import { TrendingUp, TrendingDown, DollarSign, Coins, ChartColumn } from 'lucide-svelte';
|
||||
import {
|
||||
createChart,
|
||||
|
|
@ -469,7 +470,7 @@
|
|||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Chart (2/3 width) -->
|
||||
<div class="lg:col-span-2">
|
||||
<Card.Root>
|
||||
<Card.Root class="flex h-full flex-col">
|
||||
<Card.Header class="pb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
|
|
@ -499,23 +500,23 @@
|
|||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content class="flex-1 pt-0">
|
||||
{#if chartData.length === 0}
|
||||
<div class="flex h-[500px] items-center justify-center">
|
||||
<div class="flex h-full min-h-[500px] items-center justify-center">
|
||||
<p class="text-muted-foreground">No trading data available yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-[500px] w-full" bind:this={chartContainer}></div>
|
||||
<div class="h-full min-h-[500px] w-full" bind:this={chartContainer}></div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Trading Actions + Liquidity Pool (1/3 width) -->
|
||||
<!-- Right side - Trading Actions + Liquidity Pool + Top Holders (1/3 width) -->
|
||||
<div class="space-y-6 lg:col-span-1">
|
||||
<!-- Trading Actions -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Header>
|
||||
<Card.Title>Trade {coin.symbol}</Card.Title>
|
||||
{#if userHolding > 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
|
|
@ -524,7 +525,7 @@
|
|||
</p>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
{#if $USER_DATA}
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
|
|
@ -558,10 +559,7 @@
|
|||
</Card.Root>
|
||||
<!-- Liquidity Pool -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Title class="flex items-center gap-2">Liquidity Pool</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">Pool Composition</h4>
|
||||
|
|
@ -598,6 +596,8 @@
|
|||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<!-- Top Holders -->
|
||||
<TopHolders coinSymbol={coin.symbol} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -611,7 +611,7 @@
|
|||
Market Cap
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<p class="text-xl font-bold">
|
||||
{formatMarketCap(coin.marketCap)}
|
||||
</p>
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
24h Volume
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<p class="text-xl font-bold">
|
||||
{formatMarketCap(coin.volume24h)}
|
||||
</p>
|
||||
|
|
@ -641,7 +641,7 @@
|
|||
Circulating Supply
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<p class="text-xl font-bold">
|
||||
{formatSupply(coin.circulatingSupply)}<span
|
||||
class="text-muted-foreground ml-1 text-xs"
|
||||
|
|
@ -657,7 +657,7 @@
|
|||
<Card.Header>
|
||||
<Card.Title class="text-sm font-medium">24h Change</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if coin.change24h >= 0}
|
||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@
|
|||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { Activity, TrendingUp, TrendingDown, Clock } from 'lucide-svelte';
|
||||
import { allTradesStore, isLoadingTrades } from '$lib/stores/websocket';
|
||||
import { allTradesStore, isLoadingTrades, loadInitialTrades } from '$lib/stores/websocket';
|
||||
import { goto } from '$app/navigation';
|
||||
import { formatQuantity, formatRelativeTime, formatValue, getPublicUrl } from '$lib/utils';
|
||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
||||
import LiveTradeSkeleton from '$lib/components/self/skeletons/LiveTradeSkeleton.svelte';
|
||||
import SEO from '$lib/components/self/SEO.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
function handleUserClick(username: string) {
|
||||
goto(`/user/${username}`);
|
||||
|
|
@ -19,6 +20,10 @@
|
|||
function handleCoinClick(coinSymbol: string) {
|
||||
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadInitialTrades("expanded");
|
||||
})
|
||||
</script>
|
||||
|
||||
<SEO
|
||||
|
|
|
|||
Reference in a new issue