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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { 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({
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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 * 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>
|
||||
|
||||
|
|
|
|||
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