From a278d0c6a555c8f9496df25dd2c7218c668524c2 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Fri, 23 May 2025 19:48:23 +0300 Subject: [PATCH] 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 --- .../src/lib/components/self/CoinIcon.svelte | 33 ++ .../src/lib/components/self/TradeModal.svelte | 178 +++++++++ website/src/lib/stores/user-data.ts | 2 + website/src/routes/+page.svelte | 21 +- .../routes/api/coin/[coinSymbol]/+server.ts | 237 +++++++++--- .../api/coin/[coinSymbol]/trade/+server.ts | 279 ++++++++++++++ website/src/routes/api/coin/create/+server.ts | 11 +- website/src/routes/api/coins/top/+server.ts | 53 +-- .../src/routes/api/portfolio/total/+server.ts | 45 +-- .../src/routes/api/transactions/+server.ts | 51 +++ .../src/routes/coin/[coinSymbol]/+page.svelte | 286 ++++++++++----- website/src/routes/portfolio/+page.server.ts | 16 + website/src/routes/portfolio/+page.svelte | 340 ++++++++++++++++++ 13 files changed, 1342 insertions(+), 210 deletions(-) create mode 100644 website/src/lib/components/self/CoinIcon.svelte create mode 100644 website/src/lib/components/self/TradeModal.svelte create mode 100644 website/src/routes/api/coin/[coinSymbol]/trade/+server.ts create mode 100644 website/src/routes/api/transactions/+server.ts create mode 100644 website/src/routes/portfolio/+page.server.ts create mode 100644 website/src/routes/portfolio/+page.svelte diff --git a/website/src/lib/components/self/CoinIcon.svelte b/website/src/lib/components/self/CoinIcon.svelte new file mode 100644 index 0000000..73054d3 --- /dev/null +++ b/website/src/lib/components/self/CoinIcon.svelte @@ -0,0 +1,33 @@ + + +{#if icon} + {name} +{:else} +
+ {symbol.slice(0, 2)} +
+{/if} diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte new file mode 100644 index 0000000..b6ec4bb --- /dev/null +++ b/website/src/lib/components/self/TradeModal.svelte @@ -0,0 +1,178 @@ + + + + + + + {#if type === 'BUY'} + + Buy {coin.symbol} + {:else} + + Sell {coin.symbol} + {/if} + + + Current price: ${coin.currentPrice.toFixed(6)} per {coin.symbol} + + + +
+ +
+ +
+ + +
+ {#if type === 'SELL'} +

+ Available: {userHolding.toFixed(2)} {coin.symbol} +

+ {:else if $USER_DATA} +

+ Balance: ${userBalance.toFixed(2)} +

+ {/if} +
+ + + {#if hasValidAmount} +
+
+ + {type === 'BUY' ? 'Total Cost:' : 'You\'ll Receive:'} + + + ${estimatedCost.toFixed(2)} + +
+ {#if !hasEnoughFunds} + + {type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'} + + {/if} +
+ {/if} +
+ + + + + +
+
diff --git a/website/src/lib/stores/user-data.ts b/website/src/lib/stores/user-data.ts index 34b0421..2a4b92d 100644 --- a/website/src/lib/stores/user-data.ts +++ b/website/src/lib/stores/user-data.ts @@ -10,6 +10,8 @@ export type User = { isBanned: boolean; banReason: string | null; avatarUrl: string | null; + + baseCurrencyBalance: number; bio: string; } | null; diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte index 33c3440..52a44e4 100644 --- a/website/src/routes/+page.svelte +++ b/website/src/routes/+page.svelte @@ -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 @@
{#each coins.slice(0, 6) as coin} - +
- {#if coin.icon} - {coin.name} - {/if} + {coin.name} (*{coin.symbol})
= 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} - {coin.name} - {/if} + {coin.name} (*{coin.symbol})
diff --git a/website/src/routes/api/coin/[coinSymbol]/+server.ts b/website/src/routes/api/coin/[coinSymbol]/+server.ts index 37a6f58..7810510 100644 --- a/website/src/routes/api/coin/[coinSymbol]/+server.ts +++ b/website/src/routes/api/coin/[coinSymbol]/+server.ts @@ -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'); + } } diff --git a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts new file mode 100644 index 0000000..97af02e --- /dev/null +++ b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts @@ -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 + }); + } +} diff --git a/website/src/routes/api/coin/create/+server.ts b/website/src/routes/api/coin/create/+server.ts index 2b11ca0..e1a0836 100644 --- a/website/src/routes/api/coin/create/+server.ts +++ b/website/src/routes/api/coin/create/+server.ts @@ -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({ diff --git a/website/src/routes/api/coins/top/+server.ts b/website/src/routes/api/coins/top/+server.ts index f322ad7..fef3fb3 100644 --- a/website/src/routes/api/coins/top/+server.ts +++ b/website/src/routes/api/coins/top/+server.ts @@ -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: [] }); + } } diff --git a/website/src/routes/api/portfolio/total/+server.ts b/website/src/routes/api/portfolio/total/+server.ts index 6f0fdab..38170f1 100644 --- a/website/src/routes/api/portfolio/total/+server.ts +++ b/website/src/routes/api/portfolio/total/+server.ts @@ -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, diff --git a/website/src/routes/api/transactions/+server.ts b/website/src/routes/api/transactions/+server.ts new file mode 100644 index 0000000..96b4658 --- /dev/null +++ b/website/src/routes/api/transactions/+server.ts @@ -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 + } + })) + }); +} diff --git a/website/src/routes/coin/[coinSymbol]/+page.svelte b/website/src/routes/coin/[coinSymbol]/+page.svelte index 3053877..e67440a 100644 --- a/website/src/routes/coin/[coinSymbol]/+page.svelte +++ b/website/src/routes/coin/[coinSymbol]/+page.svelte @@ -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(null); - let priceHistory = $state([]); let loading = $state(true); let creatorImageUrl = $state(null); let chartData = $state([]); + let volumeData = $state([]); + 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(); 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 @@ {coin ? `${coin.name} (${coin.symbol})` : 'Loading...'} - Rugplay +{#if coin} + + +{/if} + {#if loading}
@@ -213,23 +286,13 @@
-
- {#if coin.icon} - {coin.name} - {:else} -
- {coin.symbol.slice(0, 2)} -
- {/if} -
+

{coin.name}

@@ -309,13 +372,33 @@
- - - Price Chart - +
+ + + Price Chart ({selectedTimeframe}) + +
+ {#each ['1m', '5m', '15m', '1h', '4h', '1d'] as timeframe} + + {/each} +
+
-
+ {#if chartData.length === 0} +
+

No trading data available yet

+
+ {:else} +
+ {/if}
@@ -326,18 +409,43 @@ Trade {coin.symbol} + {#if userHolding > 0} +

+ You own: {userHolding.toFixed(2)} + {coin.symbol} +

+ {/if}
-
- - -
+ {#if $USER_DATA} +
+ + +
+ {:else} +
+

Sign in to start trading

+ +
+ {/if}
diff --git a/website/src/routes/portfolio/+page.server.ts b/website/src/routes/portfolio/+page.server.ts new file mode 100644 index 0000000..aae6401 --- /dev/null +++ b/website/src/routes/portfolio/+page.server.ts @@ -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 + }; +} diff --git a/website/src/routes/portfolio/+page.svelte b/website/src/routes/portfolio/+page.svelte new file mode 100644 index 0000000..5f0e3b8 --- /dev/null +++ b/website/src/routes/portfolio/+page.svelte @@ -0,0 +1,340 @@ + + + + Portfolio - Rugplay + + +
+
+
+

Portfolio

+

View your holdings and portfolio performance

+
+
+ + {#if loading} +
+
+
Loading portfolio...
+
+
+ {:else if !portfolioData} +
+
+
Failed to load portfolio
+ +
+
+ {:else} + +
+ + + + + + Total + + + +

{formatValue(totalPortfolioValue)}

+
+
+ + + + + + + Cash Balance + + + +

+ {formatValue(portfolioData.baseCurrencyBalance)} +

+

+ {totalPortfolioValue > 0 + ? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio` + : '100% of portfolio'} +

+
+
+ + + + + + + Coin Holdings + + + +

{formatValue(portfolioData.totalCoinValue)}

+

+ {portfolioData.coinHoldings.length} positions +

+
+
+
+ + {#if !hasHoldings} + + + +
+ +
+

No coin holdings

+

+ You haven't invested in any coins yet. Start by buying existing coins. +

+
+ +
+
+
+ {:else} + + + + Your Holdings + Current positions in your portfolio + + + + + + Coin + Quantity + Price + 24h Change + Value + + + + + {#each portfolioData.coinHoldings as holding} + goto(`/coin/${holding.symbol}`)} + > + +
+ + *{holding.symbol} +
+
+ + {formatQuantity(holding.quantity)} + + + ${formatPrice(holding.currentPrice)} + + + = 0 ? 'success' : 'destructive'}> + {holding.change24h >= 0 ? '+' : ''}{holding.change24h.toFixed(2)}% + + + + {formatValue(holding.value)} + + +
+ {/each} +
+
+
+
+ {/if} + + + + +
+
+ + + Recent Transactions + + Your latest trading activity +
+ {#if hasTransactions} + + {/if} +
+
+ + {#if !hasTransactions} +
+
+ +
+

No transactions yet

+

+ You haven't made any trades yet. Start by buying or selling coins. +

+ +
+ {:else} + + + + Type + Coin + Quantity + Price + Total + + + + + {#each transactions as tx} + goto(`/coin/${tx.coin.symbol}`)} + > + +
+ {#if tx.type === 'BUY'} + + Buy + {:else} + + Sell + {/if} +
+
+ +
+ + *{tx.coin.symbol} +
+
+ + {formatQuantity(tx.quantity)} + + + ${formatPrice(tx.pricePerCoin)} + + + {formatValue(tx.totalBaseCurrencyAmount)} + + +
+ {/each} +
+
+ {/if} +
+
+ {/if} +