feat: add CoinIcon component for displaying cryptocurrency icons
feat: implement TradeModal for buying and selling coins with validation and transaction handling feat: create server-side trade API for executing buy/sell transactions and updating user balances feat: add transactions API to fetch user transaction history feat: implement portfolio page to display user's holdings and recent transactions
This commit is contained in:
parent
0784e0f3d3
commit
a278d0c6a5
13 changed files with 1342 additions and 210 deletions
33
website/src/lib/components/self/CoinIcon.svelte
Normal file
33
website/src/lib/components/self/CoinIcon.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getPublicUrl } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
symbol,
|
||||||
|
name = symbol,
|
||||||
|
size = 6,
|
||||||
|
class: className = ''
|
||||||
|
} = $props<{
|
||||||
|
icon?: string | null;
|
||||||
|
symbol: string;
|
||||||
|
name?: string;
|
||||||
|
size?: number;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let sizeClass = $derived(`h-${size} w-${size}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if icon}
|
||||||
|
<img
|
||||||
|
src={getPublicUrl(icon)}
|
||||||
|
alt={name}
|
||||||
|
class="{sizeClass} rounded-full object-cover {className}"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="{sizeClass} bg-primary flex items-center justify-center overflow-hidden rounded-full {className}"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-bold text-white">{symbol.slice(0, 2)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
178
website/src/lib/components/self/TradeModal.svelte
Normal file
178
website/src/lib/components/self/TradeModal.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte';
|
||||||
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
type,
|
||||||
|
coin,
|
||||||
|
userHolding = 0,
|
||||||
|
onSuccess
|
||||||
|
} = $props<{
|
||||||
|
open?: boolean;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
coin: any;
|
||||||
|
userHolding?: number;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let amount = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
let numericAmount = $derived(parseFloat(amount) || 0);
|
||||||
|
let estimatedCost = $derived(numericAmount * coin.currentPrice);
|
||||||
|
let hasValidAmount = $derived(numericAmount > 0);
|
||||||
|
let userBalance = $derived($USER_DATA ? Number($USER_DATA.baseCurrencyBalance) : 0);
|
||||||
|
let hasEnoughFunds = $derived(
|
||||||
|
type === 'BUY'
|
||||||
|
? estimatedCost <= userBalance
|
||||||
|
: numericAmount <= userHolding
|
||||||
|
);
|
||||||
|
let canTrade = $derived(hasValidAmount && hasEnoughFunds && !loading);
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
open = false;
|
||||||
|
amount = '';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTrade() {
|
||||||
|
if (!canTrade) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/coin/${coin.symbol}/trade`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
amount: numericAmount
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Trade failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`${type === 'BUY' ? 'Bought' : 'Sold'} successfully!`, {
|
||||||
|
description: type === 'BUY'
|
||||||
|
? `Purchased ${result.coinsBought.toFixed(2)} ${coin.symbol} for $${result.totalCost.toFixed(2)}`
|
||||||
|
: `Sold ${result.coinsSold.toFixed(2)} ${coin.symbol} for $${result.totalReceived.toFixed(2)}`
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
handleClose();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Trade failed', {
|
||||||
|
description: (e as Error).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMaxAmount() {
|
||||||
|
if (type === 'SELL') {
|
||||||
|
amount = userHolding.toString();
|
||||||
|
} else if ($USER_DATA) {
|
||||||
|
const maxCoins = Math.floor(userBalance / coin.currentPrice * 100) / 100;
|
||||||
|
amount = maxCoins.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
{#if type === 'BUY'}
|
||||||
|
<TrendingUp class="h-5 w-5 text-green-500" />
|
||||||
|
Buy {coin.symbol}
|
||||||
|
{:else}
|
||||||
|
<TrendingDown class="h-5 w-5 text-red-500" />
|
||||||
|
Sell {coin.symbol}
|
||||||
|
{/if}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Current price: ${coin.currentPrice.toFixed(6)} per {coin.symbol}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Amount Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="amount">Amount ({coin.symbol})</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
bind:value={amount}
|
||||||
|
placeholder="0.00"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onclick={setMaxAmount}>
|
||||||
|
Max
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if type === 'SELL'}
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Available: {userHolding.toFixed(2)} {coin.symbol}
|
||||||
|
</p>
|
||||||
|
{:else if $USER_DATA}
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Balance: ${userBalance.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estimated Cost/Return -->
|
||||||
|
{#if hasValidAmount}
|
||||||
|
<div class="bg-muted/50 rounded-lg p-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{type === 'BUY' ? 'Total Cost:' : 'You\'ll Receive:'}
|
||||||
|
</span>
|
||||||
|
<span class="font-bold">
|
||||||
|
${estimatedCost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if !hasEnoughFunds}
|
||||||
|
<Badge variant="destructive" class="mt-2 text-xs">
|
||||||
|
{type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={handleClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onclick={handleTrade}
|
||||||
|
disabled={!canTrade}
|
||||||
|
variant={type === 'BUY' ? 'default' : 'destructive'}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
{:else}
|
||||||
|
{type === 'BUY' ? 'Buy' : 'Sell'} {coin.symbol}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
@ -10,6 +10,8 @@ export type User = {
|
||||||
isBanned: boolean;
|
isBanned: boolean;
|
||||||
banReason: string | null;
|
banReason: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
|
||||||
|
baseCurrencyBalance: number;
|
||||||
bio: string;
|
bio: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { getTimeBasedGreeting, getPublicUrl } from '$lib/utils';
|
import { getTimeBasedGreeting } from '$lib/utils';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||||
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
|
@ -91,17 +92,11 @@
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each coins.slice(0, 6) as coin}
|
{#each coins.slice(0, 6) as coin}
|
||||||
<a href={`/coin/${coin.symbol}`} class="block">
|
<a href={`/coin/${coin.symbol}`} class="block">
|
||||||
<Card.Root class="hover:bg-card/50 h-full transition-shadow hover:shadow-md transition-all">
|
<Card.Root class="hover:bg-card/50 h-full transition-all hover:shadow-md">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title class="flex items-center justify-between">
|
<Card.Title class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if coin.icon}
|
<CoinIcon icon={coin.icon} symbol={coin.symbol} name={coin.name} size={6} />
|
||||||
<img
|
|
||||||
src={getPublicUrl(coin.icon)}
|
|
||||||
alt={coin.name}
|
|
||||||
class="h-6 w-6 rounded-full"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<span>{coin.name} (*{coin.symbol})</span>
|
<span>{coin.name} (*{coin.symbol})</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="ml-2">
|
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="ml-2">
|
||||||
|
|
@ -145,13 +140,7 @@
|
||||||
href={`/coin/${coin.symbol}`}
|
href={`/coin/${coin.symbol}`}
|
||||||
class="flex items-center gap-2 hover:underline"
|
class="flex items-center gap-2 hover:underline"
|
||||||
>
|
>
|
||||||
{#if coin.icon}
|
<CoinIcon icon={coin.icon} symbol={coin.symbol} name={coin.name} size={4} />
|
||||||
<img
|
|
||||||
src={getPublicUrl(coin.icon)}
|
|
||||||
alt={coin.name}
|
|
||||||
class="h-4 w-4 rounded-full"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{coin.name} <span class="text-muted-foreground">(*{coin.symbol})</span>
|
{coin.name} <span class="text-muted-foreground">(*{coin.symbol})</span>
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,196 @@
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { coin, user, priceHistory } from '$lib/server/db/schema';
|
import { coin, user, priceHistory, transaction } from '$lib/server/db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET({ params }) {
|
function aggregatePriceHistory(priceData: any[], intervalMinutes: number = 60) {
|
||||||
const { coinSymbol } = params;
|
if (priceData.length === 0) return [];
|
||||||
|
|
||||||
|
const sortedData = priceData.sort((a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const intervalMs = intervalMinutes * 60 * 1000;
|
||||||
|
const candlesticks = new Map();
|
||||||
|
|
||||||
|
sortedData.forEach(point => {
|
||||||
|
const timestamp = new Date(point.timestamp).getTime();
|
||||||
|
const intervalStart = Math.floor(timestamp / intervalMs) * intervalMs;
|
||||||
|
|
||||||
|
if (!candlesticks.has(intervalStart)) {
|
||||||
|
candlesticks.set(intervalStart, {
|
||||||
|
time: Math.floor(intervalStart / 1000),
|
||||||
|
open: point.price,
|
||||||
|
high: point.price,
|
||||||
|
low: point.price,
|
||||||
|
close: point.price,
|
||||||
|
firstTimestamp: timestamp,
|
||||||
|
lastTimestamp: timestamp
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const candle = candlesticks.get(intervalStart);
|
||||||
|
candle.high = Math.max(candle.high, point.price);
|
||||||
|
candle.low = Math.min(candle.low, point.price);
|
||||||
|
|
||||||
|
if (timestamp < candle.firstTimestamp) {
|
||||||
|
candle.open = point.price;
|
||||||
|
candle.firstTimestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp > candle.lastTimestamp) {
|
||||||
|
candle.close = point.price;
|
||||||
|
candle.lastTimestamp = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const candleArray = Array.from(candlesticks.values()).sort((a, b) => a.time - b.time);
|
||||||
|
const fixedCandles = [];
|
||||||
|
let lastClose = null;
|
||||||
|
const PRICE_CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
for (const candle of candleArray) {
|
||||||
|
if (lastClose !== null && Math.abs(candle.open - lastClose) > lastClose * PRICE_CHANGE_THRESHOLD) {
|
||||||
|
candle.open = lastClose;
|
||||||
|
candle.high = Math.max(candle.high, lastClose);
|
||||||
|
candle.low = Math.min(candle.low, lastClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCandle = {
|
||||||
|
time: candle.time,
|
||||||
|
open: candle.open,
|
||||||
|
high: Math.max(candle.open, candle.close, candle.high),
|
||||||
|
low: Math.min(candle.open, candle.close, candle.low),
|
||||||
|
close: candle.close
|
||||||
|
};
|
||||||
|
|
||||||
|
fixedCandles.push(finalCandle);
|
||||||
|
lastClose = finalCandle.close;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedCandles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateVolumeData(transactionData: any[], intervalMinutes: number = 60) {
|
||||||
|
if (transactionData.length === 0) return [];
|
||||||
|
|
||||||
|
const sortedData = transactionData.sort((a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const intervalMs = intervalMinutes * 60 * 1000;
|
||||||
|
const volumeMap = new Map();
|
||||||
|
|
||||||
|
sortedData.forEach(tx => {
|
||||||
|
const timestamp = new Date(tx.timestamp).getTime();
|
||||||
|
const intervalStart = Math.floor(timestamp / intervalMs) * intervalMs;
|
||||||
|
|
||||||
|
if (!volumeMap.has(intervalStart)) {
|
||||||
|
volumeMap.set(intervalStart, {
|
||||||
|
time: Math.floor(intervalStart / 1000),
|
||||||
|
volume: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const volumePoint = volumeMap.get(intervalStart);
|
||||||
|
volumePoint.volume += tx.totalBaseCurrencyAmount;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(volumeMap.values()).sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET({ params, url }) {
|
||||||
|
const coinSymbol = params.coinSymbol?.toUpperCase();
|
||||||
|
const timeframe = url.searchParams.get('timeframe') || '1m';
|
||||||
|
|
||||||
if (!coinSymbol) {
|
if (!coinSymbol) {
|
||||||
throw error(400, 'Coin symbol is required');
|
throw error(400, 'Coin symbol is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedSymbol = coinSymbol.toUpperCase();
|
const timeframeMap = {
|
||||||
|
'1m': 1, '5m': 5, '15m': 15, '1h': 60, '4h': 240, '1d': 1440
|
||||||
|
} as const;
|
||||||
|
const intervalMinutes = timeframeMap[timeframe as keyof typeof timeframeMap] || 1;
|
||||||
|
|
||||||
|
try {
|
||||||
const [coinData] = await db
|
const [coinData] = await db
|
||||||
.select({
|
.select({
|
||||||
id: coin.id,
|
id: coin.id,
|
||||||
name: coin.name,
|
name: coin.name,
|
||||||
symbol: coin.symbol,
|
symbol: coin.symbol,
|
||||||
creatorId: coin.creatorId,
|
icon: coin.icon,
|
||||||
creatorName: user.name,
|
|
||||||
creatorUsername: user.username,
|
|
||||||
creatorBio: user.bio,
|
|
||||||
creatorImage: user.image,
|
|
||||||
initialSupply: coin.initialSupply,
|
|
||||||
circulatingSupply: coin.circulatingSupply,
|
|
||||||
currentPrice: coin.currentPrice,
|
currentPrice: coin.currentPrice,
|
||||||
marketCap: coin.marketCap,
|
marketCap: coin.marketCap,
|
||||||
icon: coin.icon,
|
|
||||||
volume24h: coin.volume24h,
|
volume24h: coin.volume24h,
|
||||||
change24h: coin.change24h,
|
change24h: coin.change24h,
|
||||||
poolCoinAmount: coin.poolCoinAmount,
|
poolCoinAmount: coin.poolCoinAmount,
|
||||||
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
||||||
|
circulatingSupply: coin.circulatingSupply,
|
||||||
|
initialSupply: coin.initialSupply,
|
||||||
|
isListed: coin.isListed,
|
||||||
createdAt: coin.createdAt,
|
createdAt: coin.createdAt,
|
||||||
isListed: coin.isListed
|
creatorId: coin.creatorId,
|
||||||
|
creatorName: user.name,
|
||||||
|
creatorUsername: user.username,
|
||||||
|
creatorBio: user.bio
|
||||||
})
|
})
|
||||||
.from(coin)
|
.from(coin)
|
||||||
.leftJoin(user, eq(coin.creatorId, user.id))
|
.leftJoin(user, eq(coin.creatorId, user.id))
|
||||||
.where(eq(coin.symbol, normalizedSymbol))
|
.where(eq(coin.symbol, coinSymbol))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!coinData) {
|
if (!coinData) {
|
||||||
throw error(404, 'Coin not found');
|
throw error(404, 'Coin not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceHistoryData = await db
|
const [rawPriceHistory, rawTransactions] = await Promise.all([
|
||||||
.select({
|
db.select({ price: priceHistory.price, timestamp: priceHistory.timestamp })
|
||||||
price: priceHistory.price,
|
|
||||||
timestamp: priceHistory.timestamp
|
|
||||||
})
|
|
||||||
.from(priceHistory)
|
.from(priceHistory)
|
||||||
.where(eq(priceHistory.coinId, coinData.id))
|
.where(eq(priceHistory.coinId, coinData.id))
|
||||||
.orderBy(desc(priceHistory.timestamp))
|
.orderBy(desc(priceHistory.timestamp))
|
||||||
.limit(720);
|
.limit(5000),
|
||||||
|
|
||||||
|
db.select({
|
||||||
|
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||||
|
timestamp: transaction.timestamp
|
||||||
|
})
|
||||||
|
.from(transaction)
|
||||||
|
.where(eq(transaction.coinId, coinData.id))
|
||||||
|
.orderBy(desc(transaction.timestamp))
|
||||||
|
.limit(5000)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const priceData = rawPriceHistory.map(p => ({
|
||||||
|
price: Number(p.price),
|
||||||
|
timestamp: p.timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
const transactionData = rawTransactions.map(t => ({
|
||||||
|
totalBaseCurrencyAmount: Number(t.totalBaseCurrencyAmount),
|
||||||
|
timestamp: t.timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
const candlestickData = aggregatePriceHistory(priceData, intervalMinutes);
|
||||||
|
const volumeData = aggregateVolumeData(transactionData, intervalMinutes);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
coin: {
|
coin: {
|
||||||
...coinData,
|
...coinData,
|
||||||
currentPrice: Number(coinData.currentPrice),
|
currentPrice: Number(coinData.currentPrice),
|
||||||
marketCap: Number(coinData.marketCap),
|
marketCap: Number(coinData.marketCap),
|
||||||
volume24h: Number(coinData.volume24h || 0),
|
volume24h: Number(coinData.volume24h),
|
||||||
change24h: Number(coinData.change24h || 0),
|
change24h: Number(coinData.change24h),
|
||||||
initialSupply: Number(coinData.initialSupply),
|
|
||||||
circulatingSupply: Number(coinData.circulatingSupply),
|
|
||||||
poolCoinAmount: Number(coinData.poolCoinAmount),
|
poolCoinAmount: Number(coinData.poolCoinAmount),
|
||||||
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount)
|
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount),
|
||||||
|
circulatingSupply: Number(coinData.circulatingSupply),
|
||||||
|
initialSupply: Number(coinData.initialSupply)
|
||||||
},
|
},
|
||||||
priceHistory: priceHistoryData.map(p => ({
|
candlestickData,
|
||||||
price: Number(p.price),
|
volumeData,
|
||||||
timestamp: p.timestamp
|
timeframe
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching coin data:', e);
|
||||||
|
throw error(500, 'Failed to fetch coin data');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
279
website/src/routes/api/coin/[coinSymbol]/trade/+server.ts
Normal file
279
website/src/routes/api/coin/[coinSymbol]/trade/+server.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
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';
|
||||||
|
|
||||||
|
async function calculate24hMetrics(coinId: number, currentPrice: number) {
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Get price from 24h ago
|
||||||
|
const [priceData] = await db
|
||||||
|
.select({ price: priceHistory.price })
|
||||||
|
.from(priceHistory)
|
||||||
|
.where(and(
|
||||||
|
eq(priceHistory.coinId, coinId),
|
||||||
|
gte(priceHistory.timestamp, twentyFourHoursAgo)
|
||||||
|
))
|
||||||
|
.orderBy(priceHistory.timestamp)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Calculate 24h change
|
||||||
|
let change24h = 0;
|
||||||
|
if (priceData) {
|
||||||
|
const priceFrom24hAgo = Number(priceData.price);
|
||||||
|
if (priceFrom24hAgo > 0) {
|
||||||
|
change24h = ((currentPrice - priceFrom24hAgo) / priceFrom24hAgo) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate 24h volume
|
||||||
|
const volumeData = await db
|
||||||
|
.select({ totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount })
|
||||||
|
.from(transaction)
|
||||||
|
.where(and(
|
||||||
|
eq(transaction.coinId, coinId),
|
||||||
|
gte(transaction.timestamp, twentyFourHoursAgo)
|
||||||
|
));
|
||||||
|
|
||||||
|
const volume24h = volumeData.reduce((sum, tx) => sum + Number(tx.totalBaseCurrencyAmount), 0);
|
||||||
|
|
||||||
|
return { change24h: Number(change24h.toFixed(4)), volume24h: Number(volume24h.toFixed(4)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ params, request }) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw error(401, 'Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { coinSymbol } = params;
|
||||||
|
const { type, amount } = await request.json();
|
||||||
|
|
||||||
|
if (!['BUY', 'SELL'].includes(type)) {
|
||||||
|
throw error(400, 'Invalid transaction type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
throw error(400, 'Invalid amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
const normalizedSymbol = coinSymbol.toUpperCase();
|
||||||
|
|
||||||
|
const [coinData] = await db.select().from(coin).where(eq(coin.symbol, normalizedSymbol)).limit(1);
|
||||||
|
|
||||||
|
if (!coinData) {
|
||||||
|
throw error(404, 'Coin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coinData.isListed) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
throw error(404, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBalance = Number(userData.baseCurrencyBalance);
|
||||||
|
const poolCoinAmount = Number(coinData.poolCoinAmount);
|
||||||
|
const poolBaseCurrencyAmount = Number(coinData.poolBaseCurrencyAmount);
|
||||||
|
const currentPrice = Number(coinData.currentPrice);
|
||||||
|
|
||||||
|
let newPrice: number;
|
||||||
|
let totalCost: number;
|
||||||
|
|
||||||
|
if (type === 'BUY') {
|
||||||
|
// Calculate price impact for buying
|
||||||
|
const k = poolCoinAmount * poolBaseCurrencyAmount;
|
||||||
|
const newPoolBaseCurrency = poolBaseCurrencyAmount + (amount * currentPrice);
|
||||||
|
const newPoolCoin = k / newPoolBaseCurrency;
|
||||||
|
const coinsBought = poolCoinAmount - newPoolCoin;
|
||||||
|
|
||||||
|
totalCost = amount * currentPrice;
|
||||||
|
newPrice = newPoolBaseCurrency / newPoolCoin;
|
||||||
|
|
||||||
|
if (userBalance < totalCost) {
|
||||||
|
throw error(400, `Insufficient funds. You need $${totalCost.toFixed(2)} but only have $${userBalance.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Update user balance
|
||||||
|
await tx.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: (userBalance - totalCost).toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
// Update user portfolio
|
||||||
|
const [existingHolding] = await tx
|
||||||
|
.select({ quantity: userPortfolio.quantity })
|
||||||
|
.from(userPortfolio)
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, userId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingHolding) {
|
||||||
|
const newQuantity = Number(existingHolding.quantity) + coinsBought;
|
||||||
|
await tx.update(userPortfolio)
|
||||||
|
.set({
|
||||||
|
quantity: newQuantity.toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, userId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await tx.insert(userPortfolio).values({
|
||||||
|
userId,
|
||||||
|
coinId: coinData.id,
|
||||||
|
quantity: coinsBought.toString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record transaction
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId,
|
||||||
|
coinId: coinData.id,
|
||||||
|
type: 'BUY',
|
||||||
|
quantity: coinsBought.toString(),
|
||||||
|
pricePerCoin: currentPrice.toString(),
|
||||||
|
totalBaseCurrencyAmount: totalCost.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record price history
|
||||||
|
await tx.insert(priceHistory).values({
|
||||||
|
coinId: coinData.id,
|
||||||
|
price: newPrice.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate and update 24h metrics
|
||||||
|
const metrics = await calculate24hMetrics(coinData.id, newPrice);
|
||||||
|
|
||||||
|
await tx.update(coin)
|
||||||
|
.set({
|
||||||
|
currentPrice: newPrice.toString(),
|
||||||
|
marketCap: (Number(coinData.circulatingSupply) * newPrice).toString(),
|
||||||
|
poolCoinAmount: newPoolCoin.toString(),
|
||||||
|
poolBaseCurrencyAmount: newPoolBaseCurrency.toString(),
|
||||||
|
change24h: metrics.change24h.toString(),
|
||||||
|
volume24h: metrics.volume24h.toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(coin.id, coinData.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
type: 'BUY',
|
||||||
|
coinsBought,
|
||||||
|
totalCost,
|
||||||
|
newPrice,
|
||||||
|
newBalance: userBalance - totalCost
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// SELL logic
|
||||||
|
const [userHolding] = await db
|
||||||
|
.select({ quantity: userPortfolio.quantity })
|
||||||
|
.from(userPortfolio)
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, userId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userHolding || Number(userHolding.quantity) < amount) {
|
||||||
|
throw error(400, `Insufficient coins. You have ${userHolding ? Number(userHolding.quantity) : 0} but trying to sell ${amount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate price impact for selling
|
||||||
|
const k = poolCoinAmount * poolBaseCurrencyAmount;
|
||||||
|
const newPoolCoin = poolCoinAmount + amount;
|
||||||
|
const newPoolBaseCurrency = k / newPoolCoin;
|
||||||
|
const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency;
|
||||||
|
|
||||||
|
totalCost = baseCurrencyReceived;
|
||||||
|
newPrice = newPoolBaseCurrency / newPoolCoin;
|
||||||
|
|
||||||
|
// Execute sell transaction
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Update user balance
|
||||||
|
await tx.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: (userBalance + totalCost).toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
// Update user portfolio
|
||||||
|
const newQuantity = Number(userHolding.quantity) - amount;
|
||||||
|
if (newQuantity > 0) {
|
||||||
|
await tx.update(userPortfolio)
|
||||||
|
.set({
|
||||||
|
quantity: newQuantity.toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, userId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await tx.delete(userPortfolio)
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, userId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record transaction
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId,
|
||||||
|
coinId: coinData.id,
|
||||||
|
type: 'SELL',
|
||||||
|
quantity: amount.toString(),
|
||||||
|
pricePerCoin: currentPrice.toString(),
|
||||||
|
totalBaseCurrencyAmount: totalCost.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record price history
|
||||||
|
await tx.insert(priceHistory).values({
|
||||||
|
coinId: coinData.id,
|
||||||
|
price: newPrice.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate and update 24h metrics - SINGLE coin table update
|
||||||
|
const metrics = await calculate24hMetrics(coinData.id, newPrice);
|
||||||
|
|
||||||
|
await tx.update(coin)
|
||||||
|
.set({
|
||||||
|
currentPrice: newPrice.toString(),
|
||||||
|
marketCap: (Number(coinData.circulatingSupply) * newPrice).toString(),
|
||||||
|
poolCoinAmount: newPoolCoin.toString(),
|
||||||
|
poolBaseCurrencyAmount: newPoolBaseCurrency.toString(),
|
||||||
|
change24h: metrics.change24h.toString(),
|
||||||
|
volume24h: metrics.volume24h.toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(coin.id, coinData.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
type: 'SELL',
|
||||||
|
coinsSold: amount,
|
||||||
|
totalReceived: totalCost,
|
||||||
|
newPrice,
|
||||||
|
newBalance: userBalance + totalCost
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { auth } from '$lib/auth';
|
import { auth } from '$lib/auth';
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { coin, userPortfolio, user, priceHistory } from '$lib/server/db/schema';
|
import { coin, userPortfolio, user, priceHistory, transaction } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { uploadCoinIcon } from '$lib/server/s3';
|
import { uploadCoinIcon } from '$lib/server/s3';
|
||||||
import { CREATION_FEE, FIXED_SUPPLY, STARTING_PRICE, INITIAL_LIQUIDITY, TOTAL_COST, MAX_FILE_SIZE } from '$lib/data/constants';
|
import { CREATION_FEE, FIXED_SUPPLY, STARTING_PRICE, INITIAL_LIQUIDITY, TOTAL_COST, MAX_FILE_SIZE } from '$lib/data/constants';
|
||||||
|
|
@ -125,6 +125,15 @@ export async function POST({ request }) {
|
||||||
coinId: newCoin.id,
|
coinId: newCoin.id,
|
||||||
price: STARTING_PRICE.toString()
|
price: STARTING_PRICE.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId,
|
||||||
|
coinId: newCoin.id,
|
||||||
|
type: 'BUY',
|
||||||
|
quantity: FIXED_SUPPLY.toString(),
|
||||||
|
pricePerCoin: STARTING_PRICE.toString(),
|
||||||
|
totalBaseCurrencyAmount: (FIXED_SUPPLY * STARTING_PRICE).toString()
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,38 @@
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { coin } from '$lib/server/db/schema';
|
import { coin } from '$lib/server/db/schema';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const topCoins = await db
|
try {
|
||||||
|
const coins = await db
|
||||||
.select({
|
.select({
|
||||||
id: coin.id,
|
|
||||||
name: coin.name,
|
|
||||||
symbol: coin.symbol,
|
symbol: coin.symbol,
|
||||||
|
name: coin.name,
|
||||||
icon: coin.icon,
|
icon: coin.icon,
|
||||||
currentPrice: coin.currentPrice,
|
currentPrice: coin.currentPrice,
|
||||||
|
change24h: coin.change24h, // Read directly from DB
|
||||||
marketCap: coin.marketCap,
|
marketCap: coin.marketCap,
|
||||||
volume24h: coin.volume24h,
|
volume24h: coin.volume24h // Read directly from DB
|
||||||
change24h: coin.change24h,
|
|
||||||
isListed: coin.isListed
|
|
||||||
})
|
})
|
||||||
.from(coin)
|
.from(coin)
|
||||||
.where(eq(coin.isListed, true))
|
.where(eq(coin.isListed, true))
|
||||||
.orderBy(desc(coin.marketCap))
|
.orderBy(desc(coin.marketCap))
|
||||||
.limit(20);
|
.limit(50);
|
||||||
|
|
||||||
return json({
|
const formattedCoins = coins.map(c => ({
|
||||||
coins: topCoins.map(c => ({
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
symbol: c.symbol,
|
symbol: c.symbol,
|
||||||
|
name: c.name,
|
||||||
icon: c.icon,
|
icon: c.icon,
|
||||||
price: Number(c.currentPrice),
|
price: Number(c.currentPrice),
|
||||||
|
change24h: Number(c.change24h),
|
||||||
marketCap: Number(c.marketCap),
|
marketCap: Number(c.marketCap),
|
||||||
volume24h: Number(c.volume24h || 0),
|
volume24h: Number(c.volume24h)
|
||||||
change24h: Number(c.change24h || 0),
|
}));
|
||||||
isListed: c.isListed
|
|
||||||
}))
|
return json({ coins: formattedCoins });
|
||||||
});
|
} catch (e) {
|
||||||
|
console.error('Error fetching top coins:', e);
|
||||||
|
return json({ coins: [] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import { user, userPortfolio, coin } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
headers: request.headers
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
throw error(401, 'Not authenticated');
|
throw error(401, 'Not authenticated');
|
||||||
|
|
@ -15,27 +13,30 @@ export async function GET({ request }) {
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
const [userData] = await db
|
const [userData, holdings] = await Promise.all([
|
||||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
db.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||||
.from(user)
|
.from(user)
|
||||||
.where(eq(user.id, userId))
|
.where(eq(user.id, userId))
|
||||||
.limit(1);
|
.limit(1),
|
||||||
|
|
||||||
if (!userData) {
|
db.select({
|
||||||
throw error(404, 'User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const holdings = await db
|
|
||||||
.select({
|
|
||||||
quantity: userPortfolio.quantity,
|
quantity: userPortfolio.quantity,
|
||||||
currentPrice: coin.currentPrice,
|
currentPrice: coin.currentPrice,
|
||||||
symbol: coin.symbol
|
symbol: coin.symbol,
|
||||||
|
icon: coin.icon,
|
||||||
|
change24h: coin.change24h
|
||||||
})
|
})
|
||||||
.from(userPortfolio)
|
.from(userPortfolio)
|
||||||
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
|
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
|
||||||
.where(eq(userPortfolio.userId, userId));
|
.where(eq(userPortfolio.userId, userId))
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!userData[0]) {
|
||||||
|
throw error(404, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
let totalCoinValue = 0;
|
let totalCoinValue = 0;
|
||||||
|
|
||||||
const coinHoldings = holdings.map(holding => {
|
const coinHoldings = holdings.map(holding => {
|
||||||
const quantity = Number(holding.quantity);
|
const quantity = Number(holding.quantity);
|
||||||
const price = Number(holding.currentPrice);
|
const price = Number(holding.currentPrice);
|
||||||
|
|
@ -44,13 +45,15 @@ export async function GET({ request }) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
symbol: holding.symbol,
|
symbol: holding.symbol,
|
||||||
|
icon: holding.icon,
|
||||||
quantity,
|
quantity,
|
||||||
currentPrice: price,
|
currentPrice: price,
|
||||||
value
|
value,
|
||||||
|
change24h: Number(holding.change24h)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseCurrencyBalance = Number(userData.baseCurrencyBalance);
|
const baseCurrencyBalance = Number(userData[0].baseCurrencyBalance);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
baseCurrencyBalance,
|
baseCurrencyBalance,
|
||||||
|
|
|
||||||
51
website/src/routes/api/transactions/+server.ts
Normal file
51
website/src/routes/api/transactions/+server.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { transaction, coin } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ request }) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw error(401, 'Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
const transactions = await db
|
||||||
|
.select({
|
||||||
|
id: transaction.id,
|
||||||
|
type: transaction.type,
|
||||||
|
quantity: transaction.quantity,
|
||||||
|
pricePerCoin: transaction.pricePerCoin,
|
||||||
|
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||||
|
timestamp: transaction.timestamp,
|
||||||
|
coinSymbol: coin.symbol,
|
||||||
|
coinName: coin.name,
|
||||||
|
coinIcon: coin.icon
|
||||||
|
})
|
||||||
|
.from(transaction)
|
||||||
|
.innerJoin(coin, eq(transaction.coinId, coin.id))
|
||||||
|
.where(eq(transaction.userId, userId))
|
||||||
|
.orderBy(desc(transaction.timestamp))
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
transactions: transactions.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
type: t.type,
|
||||||
|
quantity: Number(t.quantity),
|
||||||
|
pricePerCoin: Number(t.pricePerCoin),
|
||||||
|
totalBaseCurrencyAmount: Number(t.totalBaseCurrencyAmount),
|
||||||
|
timestamp: t.timestamp,
|
||||||
|
coin: {
|
||||||
|
symbol: t.coinSymbol,
|
||||||
|
name: t.coinName,
|
||||||
|
icon: t.coinIcon
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||||
|
import TradeModal from '$lib/components/self/TradeModal.svelte';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
|
@ -17,39 +18,47 @@
|
||||||
ColorType,
|
ColorType,
|
||||||
type Time,
|
type Time,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
CandlestickSeries
|
CandlestickSeries,
|
||||||
|
HistogramSeries
|
||||||
} from 'lightweight-charts';
|
} from 'lightweight-charts';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getPublicUrl } from '$lib/utils';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
|
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const coinSymbol = data.coinSymbol;
|
const coinSymbol = data.coinSymbol;
|
||||||
|
|
||||||
let coin = $state<any>(null);
|
let coin = $state<any>(null);
|
||||||
let priceHistory = $state<any[]>([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let creatorImageUrl = $state<string | null>(null);
|
let creatorImageUrl = $state<string | null>(null);
|
||||||
let chartData = $state<any[]>([]);
|
let chartData = $state<any[]>([]);
|
||||||
|
let volumeData = $state<any[]>([]);
|
||||||
|
let userHolding = $state(0);
|
||||||
|
let buyModalOpen = $state(false);
|
||||||
|
let sellModalOpen = $state(false);
|
||||||
|
let selectedTimeframe = $state('1m');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
await loadCoinData();
|
||||||
|
await loadUserHolding();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCoinData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/coin/${coinSymbol}`);
|
const response = await fetch(`/api/coin/${coinSymbol}?timeframe=${selectedTimeframe}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
toast.error(response.status === 404 ? 'Coin not found' : 'Failed to load coin data');
|
||||||
toast.error('Coin not found');
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to load coin data');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
coin = result.coin;
|
coin = result.coin;
|
||||||
priceHistory = result.priceHistory;
|
chartData = result.candlestickData || [];
|
||||||
chartData = generateCandlesticksFromHistory(priceHistory);
|
volumeData = result.volumeData || [];
|
||||||
|
|
||||||
if (coin.creatorId) {
|
if (coin.creatorId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -66,91 +75,144 @@
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
function generateCandlesticksFromHistory(history: any[]) {
|
|
||||||
const dailyData = new Map();
|
|
||||||
|
|
||||||
history.forEach((p) => {
|
|
||||||
const date = new Date(p.timestamp);
|
|
||||||
const dayKey = Math.floor(date.getTime() / (24 * 60 * 60 * 1000));
|
|
||||||
|
|
||||||
if (!dailyData.has(dayKey)) {
|
|
||||||
dailyData.set(dayKey, {
|
|
||||||
time: dayKey * 24 * 60 * 60,
|
|
||||||
open: p.price,
|
|
||||||
high: p.price,
|
|
||||||
low: p.price,
|
|
||||||
close: p.price,
|
|
||||||
prices: [p.price]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const dayData = dailyData.get(dayKey);
|
|
||||||
dayData.high = Math.max(dayData.high, p.price);
|
|
||||||
dayData.low = Math.min(dayData.low, p.price);
|
|
||||||
dayData.close = p.price;
|
|
||||||
dayData.prices.push(p.price);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(dailyData.values())
|
async function loadUserHolding() {
|
||||||
.map((d) => ({
|
if (!$USER_DATA) return;
|
||||||
time: d.time as Time,
|
|
||||||
open: d.open,
|
try {
|
||||||
high: d.high,
|
const response = await fetch('/api/portfolio/total');
|
||||||
low: d.low,
|
if (response.ok) {
|
||||||
close: d.close
|
const result = await response.json();
|
||||||
}))
|
const holding = result.coinHoldings.find((h: any) => h.symbol === coinSymbol.toUpperCase());
|
||||||
.sort((a, b) => (a.time as number) - (b.time as number));
|
userHolding = holding ? holding.quantity : 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load user holding:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTradeSuccess() {
|
||||||
|
await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioData()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTimeframeChange(timeframe: string) {
|
||||||
|
selectedTimeframe = timeframe;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
if (chart) {
|
||||||
|
chart.remove();
|
||||||
|
chart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCoinData();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateVolumeData(candlestickData: any[], volumeData: any[]) {
|
||||||
|
return candlestickData.map((candle, index) => {
|
||||||
|
// Find corresponding volume data for this time period
|
||||||
|
const volumePoint = volumeData.find(v => v.time === candle.time);
|
||||||
|
const volume = volumePoint ? volumePoint.volume : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: candle.time,
|
||||||
|
value: volume,
|
||||||
|
color: candle.close >= candle.open ? '#26a69a' : '#ef5350'
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let chartContainer = $state<HTMLDivElement>();
|
let chartContainer = $state<HTMLDivElement>();
|
||||||
let chart: IChartApi | null = null;
|
let chart: IChartApi | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chartContainer && chartData.length > 0 && !chart) {
|
if (chart && chartData.length > 0) {
|
||||||
|
chart.remove();
|
||||||
|
chart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartContainer && chartData.length > 0) {
|
||||||
chart = createChart(chartContainer, {
|
chart = createChart(chartContainer, {
|
||||||
layout: {
|
layout: {
|
||||||
textColor: '#666666',
|
textColor: '#666666',
|
||||||
background: { type: ColorType.Solid, color: 'transparent' },
|
background: { type: ColorType.Solid, color: 'transparent' },
|
||||||
attributionLogo: false
|
attributionLogo: false,
|
||||||
|
panes: {
|
||||||
|
separatorColor: '#2B2B43',
|
||||||
|
separatorHoverColor: 'rgba(107, 114, 142, 0.3)',
|
||||||
|
enableResize: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
vertLines: { color: '#2B2B43' },
|
vertLines: { color: '#2B2B43' },
|
||||||
horzLines: { color: '#2B2B43' }
|
horzLines: { color: '#2B2B43' }
|
||||||
},
|
},
|
||||||
rightPriceScale: {
|
rightPriceScale: {
|
||||||
borderVisible: false
|
borderVisible: false,
|
||||||
|
scaleMargins: { top: 0.1, bottom: 0.1 },
|
||||||
|
alignLabels: true,
|
||||||
|
entireTextOnly: false
|
||||||
},
|
},
|
||||||
timeScale: {
|
timeScale: {
|
||||||
borderVisible: false,
|
borderVisible: false,
|
||||||
timeVisible: true
|
timeVisible: true,
|
||||||
|
barSpacing: 20,
|
||||||
|
rightOffset: 5,
|
||||||
|
minBarSpacing: 8
|
||||||
},
|
},
|
||||||
crosshair: {
|
crosshair: {
|
||||||
mode: 1
|
mode: 1,
|
||||||
|
vertLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true },
|
||||||
|
horzLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||||
upColor: '#26a69a',
|
upColor: '#26a69a',
|
||||||
downColor: '#ef5350',
|
downColor: '#ef5350',
|
||||||
borderVisible: false,
|
borderVisible: true,
|
||||||
|
borderUpColor: '#26a69a',
|
||||||
|
borderDownColor: '#ef5350',
|
||||||
wickUpColor: '#26a69a',
|
wickUpColor: '#26a69a',
|
||||||
wickDownColor: '#ef5350'
|
wickDownColor: '#ef5350',
|
||||||
|
priceFormat: { type: 'price', precision: 8, minMove: 0.00000001 }
|
||||||
});
|
});
|
||||||
|
|
||||||
candlestickSeries.setData(chartData);
|
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||||
|
priceFormat: { type: 'volume' },
|
||||||
|
priceScaleId: 'volume'
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
const processedChartData = chartData.map((candle) => {
|
||||||
|
if (candle.open === candle.close) {
|
||||||
|
const basePrice = candle.open;
|
||||||
|
const variation = basePrice * 0.001;
|
||||||
|
return {
|
||||||
|
...candle,
|
||||||
|
high: Math.max(candle.high, basePrice + variation),
|
||||||
|
low: Math.min(candle.low, basePrice - variation)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return candle;
|
||||||
|
});
|
||||||
|
|
||||||
|
candlestickSeries.setData(processedChartData);
|
||||||
|
volumeSeries.setData(generateVolumeData(chartData, volumeData));
|
||||||
|
|
||||||
|
const volumePane = chart.panes()[1];
|
||||||
|
if (volumePane) volumePane.setHeight(100);
|
||||||
|
|
||||||
chart.timeScale().fitContent();
|
chart.timeScale().fitContent();
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => chart?.applyOptions({ width: chartContainer?.clientWidth });
|
||||||
chart?.applyOptions({
|
|
||||||
width: chartContainer?.clientWidth
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
handleResize();
|
handleResize();
|
||||||
|
|
||||||
|
candlestickSeries.priceScale().applyOptions({ borderColor: '#71649C' });
|
||||||
|
volumeSeries.priceScale().applyOptions({ borderColor: '#71649C' });
|
||||||
|
chart.timeScale().applyOptions({ borderColor: '#71649C' });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
if (chart) {
|
if (chart) {
|
||||||
|
|
@ -190,6 +252,17 @@
|
||||||
<title>{coin ? `${coin.name} (${coin.symbol})` : 'Loading...'} - Rugplay</title>
|
<title>{coin ? `${coin.name} (${coin.symbol})` : 'Loading...'} - Rugplay</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if coin}
|
||||||
|
<TradeModal bind:open={buyModalOpen} type="BUY" {coin} onSuccess={handleTradeSuccess} />
|
||||||
|
<TradeModal
|
||||||
|
bind:open={sellModalOpen}
|
||||||
|
type="SELL"
|
||||||
|
{coin}
|
||||||
|
{userHolding}
|
||||||
|
onSuccess={handleTradeSuccess}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="container mx-auto max-w-7xl p-6">
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
<div class="flex h-96 items-center justify-center">
|
<div class="flex h-96 items-center justify-center">
|
||||||
|
|
@ -213,23 +286,13 @@
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<div class="mb-4 flex items-start justify-between">
|
<div class="mb-4 flex items-start justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<CoinIcon
|
||||||
class="bg-muted/50 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full border"
|
icon={coin.icon}
|
||||||
>
|
symbol={coin.symbol}
|
||||||
{#if coin.icon}
|
name={coin.name}
|
||||||
<img
|
size={16}
|
||||||
src={getPublicUrl(coin.icon)}
|
class="border"
|
||||||
alt={coin.name}
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="flex h-full w-full items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-xl font-bold text-white"
|
|
||||||
>
|
|
||||||
{coin.symbol.slice(0, 2)}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold">{coin.name}</h1>
|
<h1 class="text-4xl font-bold">{coin.name}</h1>
|
||||||
<div class="mt-1 flex items-center gap-2">
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
|
@ -309,13 +372,33 @@
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Header class="pb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
<ChartColumn class="h-5 w-5" />
|
<ChartColumn class="h-5 w-5" />
|
||||||
Price Chart
|
Price Chart ({selectedTimeframe})
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#each ['1m', '5m', '15m', '1h', '4h', '1d'] as timeframe}
|
||||||
|
<Button
|
||||||
|
variant={selectedTimeframe === timeframe ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleTimeframeChange(timeframe)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{timeframe}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="pt-0">
|
||||||
<div class="h-[400px] w-full" bind:this={chartContainer}></div>
|
{#if chartData.length === 0}
|
||||||
|
<div class="flex 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>
|
||||||
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -326,18 +409,43 @@
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Header class="pb-4">
|
||||||
<Card.Title>Trade {coin.symbol}</Card.Title>
|
<Card.Title>Trade {coin.symbol}</Card.Title>
|
||||||
|
{#if userHolding > 0}
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
You own: {userHolding.toFixed(2)}
|
||||||
|
{coin.symbol}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="pt-0">
|
||||||
|
{#if $USER_DATA}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Button class="w-full" variant="default" size="lg">
|
<Button
|
||||||
<TrendingUp class="mr-2 h-4 w-4" />
|
class="w-full"
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onclick={() => (buyModalOpen = true)}
|
||||||
|
disabled={!coin.isListed}
|
||||||
|
>
|
||||||
|
<TrendingUp class="h-4 w-4" />
|
||||||
Buy {coin.symbol}
|
Buy {coin.symbol}
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="w-full" variant="outline" size="lg">
|
<Button
|
||||||
<TrendingDown class="mr-2 h-4 w-4" />
|
class="w-full"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onclick={() => (sellModalOpen = true)}
|
||||||
|
disabled={!coin.isListed || userHolding <= 0}
|
||||||
|
>
|
||||||
|
<TrendingDown class="h-4 w-4" />
|
||||||
Sell {coin.symbol}
|
Sell {coin.symbol}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="py-4 text-center">
|
||||||
|
<p class="text-muted-foreground mb-3 text-sm">Sign in to start trading</p>
|
||||||
|
<Button onclick={() => goto('/')}>Sign In</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|
|
||||||
16
website/src/routes/portfolio/+page.server.ts
Normal file
16
website/src/routes/portfolio/+page.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function load({ request }) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw redirect(302, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: session.user
|
||||||
|
};
|
||||||
|
}
|
||||||
340
website/src/routes/portfolio/+page.svelte
Normal file
340
website/src/routes/portfolio/+page.svelte
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
|
import { getPublicUrl } from '$lib/utils';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { TrendingUp, DollarSign, Wallet, TrendingDown, Clock, Receipt } from 'lucide-svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let portfolioData = $state<any>(null);
|
||||||
|
let transactions = $state<any[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchPortfolioData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/portfolio/total');
|
||||||
|
if (response.ok) {
|
||||||
|
portfolioData = await response.json();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load portfolio data');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch portfolio data:', e);
|
||||||
|
toast.error('Failed to load portfolio data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecentTransactions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/transactions');
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
transactions = result.transactions.slice(0, 10); // Show last 10 transactions
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load transactions');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch transactions:', e);
|
||||||
|
toast.error('Failed to load transactions');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
if (price < 0.01) {
|
||||||
|
return price.toFixed(6);
|
||||||
|
} else if (price < 1) {
|
||||||
|
return price.toFixed(4);
|
||||||
|
} else {
|
||||||
|
return price.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number): string {
|
||||||
|
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
||||||
|
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||||
|
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuantity(value: number): string {
|
||||||
|
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
||||||
|
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
||||||
|
if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalPortfolioValue = $derived(portfolioData ? portfolioData.totalValue : 0);
|
||||||
|
let hasHoldings = $derived(portfolioData && portfolioData.coinHoldings.length > 0);
|
||||||
|
let hasTransactions = $derived(transactions.length > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Portfolio - Rugplay</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
|
<header class="mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">Portfolio</h1>
|
||||||
|
<p class="text-muted-foreground">View your holdings and portfolio performance</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-96 items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4 text-xl">Loading portfolio...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !portfolioData}
|
||||||
|
<div class="flex h-96 items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-muted-foreground mb-4 text-xl">Failed to load portfolio</div>
|
||||||
|
<Button onclick={fetchPortfolioData}>Try Again</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Portfolio Summary Cards -->
|
||||||
|
<div class="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<!-- Total Portfolio Value -->
|
||||||
|
<Card.Root class="text-success gap-1">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Wallet class="h-4 w-4" />
|
||||||
|
Total
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="text-3xl font-bold">{formatValue(totalPortfolioValue)}</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Base Currency Balance -->
|
||||||
|
<Card.Root class="gap-1">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<DollarSign class="h-4 w-4" />
|
||||||
|
Cash Balance
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="text-3xl font-bold">
|
||||||
|
{formatValue(portfolioData.baseCurrencyBalance)}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{totalPortfolioValue > 0
|
||||||
|
? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio`
|
||||||
|
: '100% of portfolio'}
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Coin Holdings Value -->
|
||||||
|
<Card.Root class="gap-1">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<TrendingUp class="h-4 w-4" />
|
||||||
|
Coin Holdings
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="text-3xl font-bold">{formatValue(portfolioData.totalCoinValue)}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{portfolioData.coinHoldings.length} positions
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !hasHoldings}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="py-16 text-center">
|
||||||
|
<div
|
||||||
|
class="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Wallet class="text-muted-foreground h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold">No coin holdings</h3>
|
||||||
|
<p class="text-muted-foreground mb-6">
|
||||||
|
You haven't invested in any coins yet. Start by buying existing coins.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button variant="outline" onclick={() => goto('/')}>Browse Coins</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<!-- Holdings Table -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Your Holdings</Card.Title>
|
||||||
|
<Card.Description>Current positions in your portfolio</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Coin</Table.Head>
|
||||||
|
<Table.Head>Quantity</Table.Head>
|
||||||
|
<Table.Head>Price</Table.Head>
|
||||||
|
<Table.Head>24h Change</Table.Head>
|
||||||
|
<Table.Head>Value</Table.Head>
|
||||||
|
<Table.Head class="hidden md:table-cell">Portfolio %</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each portfolioData.coinHoldings as holding}
|
||||||
|
<Table.Row
|
||||||
|
class="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/coin/${holding.symbol}`)}
|
||||||
|
>
|
||||||
|
<Table.Cell class="font-medium">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CoinIcon icon={holding.icon} symbol={holding.symbol} size={6} />
|
||||||
|
<span>*{holding.symbol}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono">
|
||||||
|
{formatQuantity(holding.quantity)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono">
|
||||||
|
${formatPrice(holding.currentPrice)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant={holding.change24h >= 0 ? 'success' : 'destructive'}>
|
||||||
|
{holding.change24h >= 0 ? '+' : ''}{holding.change24h.toFixed(2)}%
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono font-medium">
|
||||||
|
{formatValue(holding.value)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="hidden md:table-cell">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{((holding.value / totalPortfolioValue) * 100).toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent Transactions -->
|
||||||
|
<Card.Root class="mt-8">
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Receipt class="h-5 w-5" />
|
||||||
|
Recent Transactions
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Your latest trading activity</Card.Description>
|
||||||
|
</div>
|
||||||
|
{#if hasTransactions}
|
||||||
|
<Button variant="outline" size="sm" onclick={() => goto('/transactions')}>
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if !hasTransactions}
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<div
|
||||||
|
class="bg-muted mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Receipt class="text-muted-foreground h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold">No transactions yet</h3>
|
||||||
|
<p class="text-muted-foreground mb-4">
|
||||||
|
You haven't made any trades yet. Start by buying or selling coins.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onclick={() => goto('/')}>Browse Coins</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Type</Table.Head>
|
||||||
|
<Table.Head>Coin</Table.Head>
|
||||||
|
<Table.Head>Quantity</Table.Head>
|
||||||
|
<Table.Head>Price</Table.Head>
|
||||||
|
<Table.Head>Total</Table.Head>
|
||||||
|
<Table.Head class="hidden md:table-cell">Date</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each transactions as tx}
|
||||||
|
<Table.Row
|
||||||
|
class="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/coin/${tx.coin.symbol}`)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if tx.type === 'BUY'}
|
||||||
|
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||||
|
<Badge variant="success" class="text-xs">Buy</Badge>
|
||||||
|
{:else}
|
||||||
|
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||||
|
<Badge variant="destructive" class="text-xs">Sell</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-medium">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CoinIcon icon={tx.coin.icon} symbol={tx.coin.symbol} size={4} />
|
||||||
|
<span>*{tx.coin.symbol}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono text-sm">
|
||||||
|
{formatQuantity(tx.quantity)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono text-sm">
|
||||||
|
${formatPrice(tx.pricePerCoin)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono text-sm font-medium">
|
||||||
|
{formatValue(tx.totalBaseCurrencyAmount)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-muted-foreground hidden text-sm md:table-cell">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Clock class="h-3 w-3" />
|
||||||
|
{formatDate(tx.timestamp)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in a new issue