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:
Face 2025-05-23 19:48:23 +03:00
parent 0784e0f3d3
commit a278d0c6a5
13 changed files with 1342 additions and 210 deletions

View 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}

View 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>

View file

@ -10,6 +10,8 @@ export type User = {
isBanned: boolean;
banReason: string | null;
avatarUrl: string | null;
baseCurrencyBalance: number;
bio: string;
} | null;

View file

@ -2,9 +2,10 @@
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
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 SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import { onMount } from 'svelte';
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">
{#each coins.slice(0, 6) as coin}
<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.Title class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if coin.icon}
<img
src={getPublicUrl(coin.icon)}
alt={coin.name}
class="h-6 w-6 rounded-full"
/>
{/if}
<CoinIcon icon={coin.icon} symbol={coin.symbol} name={coin.name} size={6} />
<span>{coin.name} (*{coin.symbol})</span>
</div>
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="ml-2">
@ -145,13 +140,7 @@
href={`/coin/${coin.symbol}`}
class="flex items-center gap-2 hover:underline"
>
{#if coin.icon}
<img
src={getPublicUrl(coin.icon)}
alt={coin.name}
class="h-4 w-4 rounded-full"
/>
{/if}
<CoinIcon icon={coin.icon} symbol={coin.symbol} name={coin.name} size={4} />
{coin.name} <span class="text-muted-foreground">(*{coin.symbol})</span>
</a>
</Table.Cell>

View file

@ -1,73 +1,196 @@
import { error, json } from '@sveltejs/kit';
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';
export async function GET({ params }) {
const { coinSymbol } = params;
function aggregatePriceHistory(priceData: any[], intervalMinutes: number = 60) {
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) {
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;
const [coinData] = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
creatorId: coin.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorBio: user.bio,
creatorImage: user.image,
initialSupply: coin.initialSupply,
circulatingSupply: coin.circulatingSupply,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
icon: coin.icon,
volume24h: coin.volume24h,
change24h: coin.change24h,
poolCoinAmount: coin.poolCoinAmount,
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
createdAt: coin.createdAt,
isListed: coin.isListed
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))
.where(eq(coin.symbol, normalizedSymbol))
.limit(1);
try {
const [coinData] = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
poolCoinAmount: coin.poolCoinAmount,
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
circulatingSupply: coin.circulatingSupply,
initialSupply: coin.initialSupply,
isListed: coin.isListed,
createdAt: coin.createdAt,
creatorId: coin.creatorId,
creatorName: user.name,
creatorUsername: user.username,
creatorBio: user.bio
})
.from(coin)
.leftJoin(user, eq(coin.creatorId, user.id))
.where(eq(coin.symbol, coinSymbol))
.limit(1);
if (!coinData) {
throw error(404, 'Coin not found');
}
if (!coinData) {
throw error(404, 'Coin not found');
}
const priceHistoryData = await db
.select({
price: priceHistory.price,
timestamp: priceHistory.timestamp
})
.from(priceHistory)
.where(eq(priceHistory.coinId, coinData.id))
.orderBy(desc(priceHistory.timestamp))
.limit(720);
const [rawPriceHistory, rawTransactions] = await Promise.all([
db.select({ price: priceHistory.price, timestamp: priceHistory.timestamp })
.from(priceHistory)
.where(eq(priceHistory.coinId, coinData.id))
.orderBy(desc(priceHistory.timestamp))
.limit(5000),
return json({
coin: {
...coinData,
currentPrice: Number(coinData.currentPrice),
marketCap: Number(coinData.marketCap),
volume24h: Number(coinData.volume24h || 0),
change24h: Number(coinData.change24h || 0),
initialSupply: Number(coinData.initialSupply),
circulatingSupply: Number(coinData.circulatingSupply),
poolCoinAmount: Number(coinData.poolCoinAmount),
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount)
},
priceHistory: priceHistoryData.map(p => ({
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({
coin: {
...coinData,
currentPrice: Number(coinData.currentPrice),
marketCap: Number(coinData.marketCap),
volume24h: Number(coinData.volume24h),
change24h: Number(coinData.change24h),
poolCoinAmount: Number(coinData.poolCoinAmount),
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount),
circulatingSupply: Number(coinData.circulatingSupply),
initialSupply: Number(coinData.initialSupply)
},
candlestickData,
volumeData,
timeframe
});
} catch (e) {
console.error('Error fetching coin data:', e);
throw error(500, 'Failed to fetch coin data');
}
}

View 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
});
}
}

View file

@ -1,7 +1,7 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
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 { uploadCoinIcon } from '$lib/server/s3';
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,
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({

View file

@ -1,37 +1,38 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { coin } from '$lib/server/db/schema';
import { desc, eq } from 'drizzle-orm';
import { eq, desc } from 'drizzle-orm';
export async function GET() {
const topCoins = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
isListed: coin.isListed
})
.from(coin)
.where(eq(coin.isListed, true))
.orderBy(desc(coin.marketCap))
.limit(20);
try {
const coins = await db
.select({
symbol: coin.symbol,
name: coin.name,
icon: coin.icon,
currentPrice: coin.currentPrice,
change24h: coin.change24h, // Read directly from DB
marketCap: coin.marketCap,
volume24h: coin.volume24h // Read directly from DB
})
.from(coin)
.where(eq(coin.isListed, true))
.orderBy(desc(coin.marketCap))
.limit(50);
return json({
coins: topCoins.map(c => ({
id: c.id,
name: c.name,
const formattedCoins = coins.map(c => ({
symbol: c.symbol,
name: c.name,
icon: c.icon,
price: Number(c.currentPrice),
change24h: Number(c.change24h),
marketCap: Number(c.marketCap),
volume24h: Number(c.volume24h || 0),
change24h: Number(c.change24h || 0),
isListed: c.isListed
}))
});
volume24h: Number(c.volume24h)
}));
return json({ coins: formattedCoins });
} catch (e) {
console.error('Error fetching top coins:', e);
return json({ coins: [] });
}
}

View file

@ -5,9 +5,7 @@ import { user, userPortfolio, coin } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export async function GET({ request }) {
const session = await auth.api.getSession({
headers: request.headers
});
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
throw error(401, 'Not authenticated');
@ -15,27 +13,30 @@ export async function GET({ request }) {
const userId = Number(session.user.id);
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1);
const [userData, holdings] = await Promise.all([
db.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.limit(1),
if (!userData) {
db.select({
quantity: userPortfolio.quantity,
currentPrice: coin.currentPrice,
symbol: coin.symbol,
icon: coin.icon,
change24h: coin.change24h
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId))
]);
if (!userData[0]) {
throw error(404, 'User not found');
}
const holdings = await db
.select({
quantity: userPortfolio.quantity,
currentPrice: coin.currentPrice,
symbol: coin.symbol
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId));
let totalCoinValue = 0;
const coinHoldings = holdings.map(holding => {
const quantity = Number(holding.quantity);
const price = Number(holding.currentPrice);
@ -44,13 +45,15 @@ export async function GET({ request }) {
return {
symbol: holding.symbol,
icon: holding.icon,
quantity,
currentPrice: price,
value
value,
change24h: Number(holding.change24h)
};
});
const baseCurrencyBalance = Number(userData.baseCurrencyBalance);
const baseCurrencyBalance = Number(userData[0].baseCurrencyBalance);
return json({
baseCurrencyBalance,

View 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
}
}))
});
}

View file

@ -4,6 +4,7 @@
import { Button } from '$lib/components/ui/button';
import * as Avatar from '$lib/components/ui/avatar';
import * as HoverCard from '$lib/components/ui/hover-card';
import TradeModal from '$lib/components/self/TradeModal.svelte';
import {
TrendingUp,
TrendingDown,
@ -17,39 +18,47 @@
ColorType,
type Time,
type IChartApi,
CandlestickSeries
CandlestickSeries,
HistogramSeries
} from 'lightweight-charts';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getPublicUrl } from '$lib/utils';
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 coinSymbol = data.coinSymbol;
let coin = $state<any>(null);
let priceHistory = $state<any[]>([]);
let loading = $state(true);
let creatorImageUrl = $state<string | null>(null);
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 () => {
await loadCoinData();
await loadUserHolding();
});
async function loadCoinData() {
try {
const response = await fetch(`/api/coin/${coinSymbol}`);
const response = await fetch(`/api/coin/${coinSymbol}?timeframe=${selectedTimeframe}`);
if (!response.ok) {
if (response.status === 404) {
toast.error('Coin not found');
} else {
toast.error('Failed to load coin data');
}
toast.error(response.status === 404 ? 'Coin not found' : 'Failed to load coin data');
return;
}
const result = await response.json();
coin = result.coin;
priceHistory = result.priceHistory;
chartData = generateCandlesticksFromHistory(priceHistory);
chartData = result.candlestickData || [];
volumeData = result.volumeData || [];
if (coin.creatorId) {
try {
@ -66,91 +75,144 @@
} finally {
loading = false;
}
});
}
function generateCandlesticksFromHistory(history: any[]) {
const dailyData = new Map();
async function loadUserHolding() {
if (!$USER_DATA) return;
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);
try {
const response = await fetch('/api/portfolio/total');
if (response.ok) {
const result = await response.json();
const holding = result.coinHoldings.find((h: any) => h.symbol === coinSymbol.toUpperCase());
userHolding = holding ? holding.quantity : 0;
}
});
} catch (e) {
console.error('Failed to load user holding:', e);
}
}
return Array.from(dailyData.values())
.map((d) => ({
time: d.time as Time,
open: d.open,
high: d.high,
low: d.low,
close: d.close
}))
.sort((a, b) => (a.time as number) - (b.time as number));
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 chart: IChartApi | null = null;
$effect(() => {
if (chartContainer && chartData.length > 0 && !chart) {
if (chart && chartData.length > 0) {
chart.remove();
chart = null;
}
if (chartContainer && chartData.length > 0) {
chart = createChart(chartContainer, {
layout: {
textColor: '#666666',
background: { type: ColorType.Solid, color: 'transparent' },
attributionLogo: false
attributionLogo: false,
panes: {
separatorColor: '#2B2B43',
separatorHoverColor: 'rgba(107, 114, 142, 0.3)',
enableResize: true
}
},
grid: {
vertLines: { color: '#2B2B43' },
horzLines: { color: '#2B2B43' }
},
rightPriceScale: {
borderVisible: false
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
alignLabels: true,
entireTextOnly: false
},
timeScale: {
borderVisible: false,
timeVisible: true
timeVisible: true,
barSpacing: 20,
rightOffset: 5,
minBarSpacing: 8
},
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, {
upColor: '#26a69a',
downColor: '#ef5350',
borderVisible: false,
borderVisible: true,
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
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();
const handleResize = () => {
chart?.applyOptions({
width: chartContainer?.clientWidth
});
};
const handleResize = () => chart?.applyOptions({ width: chartContainer?.clientWidth });
window.addEventListener('resize', handleResize);
handleResize();
candlestickSeries.priceScale().applyOptions({ borderColor: '#71649C' });
volumeSeries.priceScale().applyOptions({ borderColor: '#71649C' });
chart.timeScale().applyOptions({ borderColor: '#71649C' });
return () => {
window.removeEventListener('resize', handleResize);
if (chart) {
@ -190,6 +252,17 @@
<title>{coin ? `${coin.name} (${coin.symbol})` : 'Loading...'} - Rugplay</title>
</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}
<div class="container mx-auto max-w-7xl p-6">
<div class="flex h-96 items-center justify-center">
@ -213,23 +286,13 @@
<header class="mb-8">
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-4">
<div
class="bg-muted/50 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full border"
>
{#if coin.icon}
<img
src={getPublicUrl(coin.icon)}
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>
<CoinIcon
icon={coin.icon}
symbol={coin.symbol}
name={coin.name}
size={16}
class="border"
/>
<div>
<h1 class="text-4xl font-bold">{coin.name}</h1>
<div class="mt-1 flex items-center gap-2">
@ -309,13 +372,33 @@
<div class="lg:col-span-2">
<Card.Root>
<Card.Header class="pb-4">
<Card.Title class="flex items-center gap-2">
<ChartColumn class="h-5 w-5" />
Price Chart
</Card.Title>
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-2">
<ChartColumn class="h-5 w-5" />
Price Chart ({selectedTimeframe})
</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.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.Root>
</div>
@ -326,18 +409,43 @@
<Card.Root>
<Card.Header class="pb-4">
<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.Content class="pt-0">
<div class="space-y-3">
<Button class="w-full" variant="default" size="lg">
<TrendingUp class="mr-2 h-4 w-4" />
Buy {coin.symbol}
</Button>
<Button class="w-full" variant="outline" size="lg">
<TrendingDown class="mr-2 h-4 w-4" />
Sell {coin.symbol}
</Button>
</div>
{#if $USER_DATA}
<div class="space-y-3">
<Button
class="w-full"
variant="default"
size="lg"
onclick={() => (buyModalOpen = true)}
disabled={!coin.isListed}
>
<TrendingUp class="h-4 w-4" />
Buy {coin.symbol}
</Button>
<Button
class="w-full"
variant="outline"
size="lg"
onclick={() => (sellModalOpen = true)}
disabled={!coin.isListed || userHolding <= 0}
>
<TrendingDown class="h-4 w-4" />
Sell {coin.symbol}
</Button>
</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.Root>

View 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
};
}

View 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>