feat: live trades (/live & sidebar) + sidebar skeleton
This commit is contained in:
parent
37d76b243b
commit
0ddb431536
12 changed files with 785 additions and 175 deletions
|
|
@ -11,6 +11,7 @@
|
|||
import { invalidateAll } from '$app/navigation';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { page } from '$app/state';
|
||||
import { websocketController } from '$lib/stores/websocket';
|
||||
|
||||
let { data, children } = $props<{
|
||||
data: { userSession?: any };
|
||||
|
|
@ -26,6 +27,8 @@
|
|||
});
|
||||
|
||||
onMount(() => {
|
||||
websocketController.connect();
|
||||
|
||||
console.log(
|
||||
`%c .--
|
||||
.=--:
|
||||
|
|
@ -69,6 +72,10 @@
|
|||
window.history.replaceState({}, '', url);
|
||||
invalidateAll();
|
||||
}
|
||||
|
||||
return () => {
|
||||
websocketController.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
function getPageTitle(routeId: string | null): string {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { error, json } from '@sveltejs/kit';
|
|||
import { db } from '$lib/server/db';
|
||||
import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/server/db/schema';
|
||||
import { eq, and, gte } from 'drizzle-orm';
|
||||
import { redis } from '$lib/server/redis';
|
||||
|
||||
async function calculate24hMetrics(coinId: number, currentPrice: number) {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
|
@ -74,7 +75,11 @@ export async function POST({ params, request }) {
|
|||
throw error(400, 'This coin is delisted and cannot be traded');
|
||||
}
|
||||
|
||||
const [userData] = await db.select({ baseCurrencyBalance: user.baseCurrencyBalance }).from(user).where(eq(user.id, userId)).limit(1);
|
||||
const [userData] = await db.select({
|
||||
baseCurrencyBalance: user.baseCurrencyBalance,
|
||||
username: user.username,
|
||||
image: user.image
|
||||
}).from(user).where(eq(user.id, userId)).limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
|
|
@ -177,6 +182,34 @@ export async function POST({ params, request }) {
|
|||
.where(eq(coin.id, coinData.id));
|
||||
});
|
||||
|
||||
// REDIS
|
||||
const tradeData = {
|
||||
type: 'BUY',
|
||||
username: userData.username,
|
||||
userImage: userData.image || '',
|
||||
amount: coinsBought,
|
||||
coinSymbol: normalizedSymbol,
|
||||
coinName: coinData.name,
|
||||
coinIcon: coinData.icon || '',
|
||||
totalValue: totalCost,
|
||||
price: newPrice,
|
||||
timestamp: Date.now(),
|
||||
userId: userId.toString()
|
||||
};
|
||||
|
||||
await redis.publish('trades:all', JSON.stringify({
|
||||
type: 'all-trades',
|
||||
data: tradeData
|
||||
}));
|
||||
|
||||
if (totalCost >= 1000) {
|
||||
await redis.publish('trades:large', JSON.stringify({
|
||||
type: 'live-trade',
|
||||
data: tradeData
|
||||
}));
|
||||
}
|
||||
// End REDIS
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
type: 'BUY',
|
||||
|
|
@ -282,6 +315,34 @@ export async function POST({ params, request }) {
|
|||
.where(eq(coin.id, coinData.id));
|
||||
});
|
||||
|
||||
// REDIS
|
||||
const tradeData = {
|
||||
type: 'SELL',
|
||||
username: userData.username,
|
||||
userImage: userData.image || '',
|
||||
amount: amount,
|
||||
coinSymbol: normalizedSymbol,
|
||||
coinName: coinData.name,
|
||||
coinIcon: coinData.icon || '',
|
||||
totalValue: totalCost,
|
||||
price: newPrice,
|
||||
timestamp: Date.now(),
|
||||
userId: userId.toString()
|
||||
};
|
||||
|
||||
await redis.publish('trades:all', JSON.stringify({
|
||||
type: 'all-trades',
|
||||
data: tradeData
|
||||
}));
|
||||
|
||||
if (totalCost >= 1000) {
|
||||
await redis.publish('trades:large', JSON.stringify({
|
||||
type: 'live-trade',
|
||||
data: tradeData
|
||||
}));
|
||||
}
|
||||
// End REDIS
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
type: 'SELL',
|
||||
|
|
|
|||
57
website/src/routes/api/trades/recent/+server.ts
Normal file
57
website/src/routes/api/trades/recent/+server.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { transaction, user, coin } from '$lib/server/db/schema';
|
||||
import { desc, gte, eq } from 'drizzle-orm';
|
||||
import { validateSearchParams } from '$lib/utils/validation';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const params = validateSearchParams(url.searchParams);
|
||||
const limit = params.getPositiveInt('limit', 100);
|
||||
const minValue = params.getNonNegativeFloat('minValue', 0);
|
||||
|
||||
try {
|
||||
const trades = await db
|
||||
.select({
|
||||
type: transaction.type,
|
||||
username: user.username,
|
||||
userImage: user.image,
|
||||
amount: transaction.quantity,
|
||||
coinSymbol: coin.symbol,
|
||||
coinName: coin.name,
|
||||
coinIcon: coin.icon,
|
||||
totalValue: transaction.totalBaseCurrencyAmount,
|
||||
price: transaction.pricePerCoin,
|
||||
timestamp: transaction.timestamp,
|
||||
userId: transaction.userId
|
||||
})
|
||||
.from(transaction)
|
||||
.innerJoin(user, eq(user.id, transaction.userId))
|
||||
.innerJoin(coin, eq(coin.id, transaction.coinId))
|
||||
.where(
|
||||
minValue > 0
|
||||
? gte(transaction.totalBaseCurrencyAmount, minValue.toString())
|
||||
: undefined
|
||||
)
|
||||
.orderBy(desc(transaction.timestamp))
|
||||
.limit(limit);
|
||||
|
||||
const formattedTrades = trades.map(trade => ({
|
||||
type: trade.type as 'BUY' | 'SELL',
|
||||
username: trade.username,
|
||||
userImage: trade.userImage,
|
||||
amount: Number(trade.amount),
|
||||
coinSymbol: trade.coinSymbol,
|
||||
coinName: trade.coinName,
|
||||
coinIcon: trade.coinIcon,
|
||||
totalValue: Number(trade.totalValue),
|
||||
price: Number(trade.price),
|
||||
timestamp: trade.timestamp.getTime(),
|
||||
userId: trade.userId.toString()
|
||||
}));
|
||||
|
||||
return json({ trades: formattedTrades });
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent trades:', error);
|
||||
return json({ trades: [] });
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||
import { getPublicUrl } from '$lib/utils.js';
|
||||
import { websocketController } from '$lib/stores/websocket';
|
||||
|
||||
const { data } = $props();
|
||||
const coinSymbol = data.coinSymbol;
|
||||
|
|
@ -38,6 +39,8 @@
|
|||
onMount(async () => {
|
||||
await loadCoinData();
|
||||
await loadUserHolding();
|
||||
|
||||
websocketController.setCoin(coinSymbol.toUpperCase());
|
||||
});
|
||||
|
||||
async function loadCoinData() {
|
||||
|
|
|
|||
167
website/src/routes/live/+page.svelte
Normal file
167
website/src/routes/live/+page.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { Activity, TrendingUp, TrendingDown, Clock } from 'lucide-svelte';
|
||||
import { allTradesStore, isLoadingTrades } 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';
|
||||
|
||||
function handleUserClick(username: string) {
|
||||
goto(`/user/${username}`);
|
||||
}
|
||||
|
||||
function handleCoinClick(coinSymbol: string) {
|
||||
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Live Trades - Rugplay</title>
|
||||
<meta name="description" content="Real-time cryptocurrency trading activity on Rugplay" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-7xl p-6">
|
||||
<header class="mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Live Trades</h1>
|
||||
<p class="text-muted-foreground">Real-time trading activity for all trades</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Activity class="h-5 w-5" />
|
||||
Stream
|
||||
{#if $allTradesStore.length > 0}
|
||||
<Badge variant="secondary" class="ml-auto">
|
||||
{$allTradesStore.length} trade{$allTradesStore.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if $isLoadingTrades}
|
||||
<div class="space-y-3">
|
||||
{#each Array(8) as _, i}
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-8 w-8 rounded-full" />
|
||||
<Skeleton class="h-6 w-12" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-6 w-6 rounded-full" />
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton class="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $allTradesStore.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Activity class="text-muted-foreground/50 mb-4 h-16 w-16" />
|
||||
<h3 class="mb-2 text-lg font-semibold">Waiting for trades...</h3>
|
||||
<p class="text-muted-foreground">All trades will appear here in real-time.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each $allTradesStore as trade (trade.timestamp)}
|
||||
<div
|
||||
class="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if trade.type === 'BUY'}
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-green-500/10"
|
||||
>
|
||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<Badge variant="outline" class="border-green-500 text-green-500">BUY</Badge>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-red-500/10"
|
||||
>
|
||||
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<Badge variant="outline" class="border-red-500 text-red-500">SELL</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => handleCoinClick(trade.coinSymbol)}
|
||||
class="flex cursor-pointer items-center gap-2 transition-opacity hover:underline hover:opacity-80"
|
||||
>
|
||||
<CoinIcon
|
||||
icon={trade.coinIcon}
|
||||
symbol={trade.coinSymbol}
|
||||
name={trade.coinName || trade.coinSymbol}
|
||||
size={6}
|
||||
/>
|
||||
<span class="font-mono font-medium">
|
||||
{formatQuantity(trade.amount)} *{trade.coinSymbol}
|
||||
</span>
|
||||
</button>
|
||||
<span class="text-muted-foreground">
|
||||
{trade.type === 'BUY' ? 'bought by' : 'sold by'}
|
||||
</span>
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger
|
||||
class="cursor-pointer font-medium underline-offset-4 hover:underline"
|
||||
onclick={() => handleUserClick(trade.username)}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
<Avatar.Image
|
||||
src={getPublicUrl(trade.userImage ?? null)}
|
||||
alt={trade.username}
|
||||
/>
|
||||
<Avatar.Fallback class="text-xs"
|
||||
>{trade.username.charAt(0).toUpperCase()}</Avatar.Fallback
|
||||
>
|
||||
</Avatar.Root>
|
||||
<span>@{trade.username}</span>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80" side="top" sideOffset={3}>
|
||||
<UserProfilePreview userId={parseInt(trade.userId)} />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
</div>
|
||||
<div class="text-muted-foreground text-sm">
|
||||
Trade value: {formatValue(trade.totalValue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Clock class="h-4 w-4" />
|
||||
<span class="font-mono">{formatRelativeTime(new Date(trade.timestamp))}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
Reference in a new issue