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}
+
+{:else}
+
{#each coins.slice(0, 6) as coin}
-
+
- {#if coin.icon}
-
})
- {/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}
-
- {/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}
-
})
- {: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
+
+
+
+
+
+ {#if loading}
+
+ {: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
+ Portfolio %
+
+
+
+ {#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)}
+
+
+
+ {((holding.value / totalPortfolioValue) * 100).toFixed(1)}%
+
+
+
+ {/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
+ Date
+
+
+
+ {#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)}
+
+
+
+
+ {formatDate(tx.timestamp)}
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {/if}
+