feat: sending money / coins

This commit is contained in:
Face 2025-05-31 16:26:51 +03:00
parent 4e58d20e84
commit de0987a007
14 changed files with 2825 additions and 325 deletions

View file

@ -32,7 +32,6 @@
loading = false;
}
});
const marketColumns = [
{
key: 'name',
@ -40,13 +39,11 @@
class: 'font-medium',
render: (value: any, row: any) => {
return {
component: 'link',
href: `/coin/${row.symbol}`,
content: {
icon: row.icon,
symbol: row.symbol,
name: row.name
}
component: 'coin',
icon: row.icon,
symbol: row.symbol,
name: row.name,
size: 6
};
}
},
@ -116,9 +113,8 @@
<p class="text-muted-foreground text-sm">Be the first to create a coin!</p>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each coins.slice(0, 6) as coin}
{:else} <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each coins.slice(0, 6) as coin (coin.symbol)}
<a href={`/coin/${coin.symbol}`} class="block">
<Card.Root class="hover:bg-card/50 h-full transition-all hover:shadow-md">
<Card.Header>

View file

@ -1,8 +1,9 @@
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 { transaction, coin, user } from '$lib/server/db/schema';
import { eq, desc, asc, and, or, ilike, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
export async function GET({ request, url }) {
const authSession = await auth.api.getSession({
@ -18,25 +19,43 @@ export async function GET({ request, url }) {
const typeFilter = url.searchParams.get('type') || 'all';
const sortBy = url.searchParams.get('sortBy') || 'timestamp';
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
const page = parseInt(url.searchParams.get('page') || '1');
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
let whereConditions = and(eq(transaction.userId, userId));
// Validate page parameter
const pageParam = url.searchParams.get('page') || '1';
const page = parseInt(pageParam);
if (isNaN(page) || page < 1) {
throw error(400, 'Invalid page parameter');
}
// Validate limit parameter
const limitParam = url.searchParams.get('limit') || '20';
const parsedLimit = parseInt(limitParam);
const limit = isNaN(parsedLimit) ? 20 : Math.min(Math.max(parsedLimit, 1), 50); const recipientUser = alias(user, 'recipientUser');
const senderUser = alias(user, 'senderUser');
const conditions = [eq(transaction.userId, userId)];
if (searchQuery) {
whereConditions = and(
whereConditions,
conditions.push(
or(
ilike(coin.name, `%${searchQuery}%`),
ilike(coin.symbol, `%${searchQuery}%`)
)
)!
);
}
if (typeFilter !== 'all') {
whereConditions = and(whereConditions, eq(transaction.type, typeFilter as 'BUY' | 'SELL'));
const validTypes = ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT'] as const;
if (validTypes.includes(typeFilter as any)) {
conditions.push(eq(transaction.type, typeFilter as typeof validTypes[number]));
} else {
throw error(400, `Invalid type parameter. Allowed: ${validTypes.join(', ')}`);
}
}
const whereConditions = conditions.length === 1 ? conditions[0] : and(...conditions);
let sortColumn;
switch (sortBy) {
case 'totalBaseCurrencyAmount':
@ -52,12 +71,10 @@ export async function GET({ request, url }) {
sortColumn = transaction.timestamp;
}
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn);
const [{ count }] = await db
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn); const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(transaction)
.leftJoin(coin, eq(transaction.coinId, coin.id))
.innerJoin(coin, eq(transaction.coinId, coin.id))
.where(whereConditions);
const transactions = await db
@ -68,26 +85,64 @@ export async function GET({ request, url }) {
pricePerCoin: transaction.pricePerCoin,
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp,
recipientUserId: transaction.recipientUserId,
senderUserId: transaction.senderUserId,
coin: {
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon
},
recipientUser: {
id: recipientUser.id,
username: recipientUser.username
},
senderUser: {
id: senderUser.id,
username: senderUser.username
}
})
.from(transaction)
.leftJoin(coin, eq(transaction.coinId, coin.id))
}).from(transaction)
.innerJoin(coin, eq(transaction.coinId, coin.id))
.leftJoin(recipientUser, eq(transaction.recipientUserId, recipientUser.id))
.leftJoin(senderUser, eq(transaction.senderUserId, senderUser.id))
.where(whereConditions)
.orderBy(orderBy)
.limit(limit)
.offset((page - 1) * limit);
const formattedTransactions = transactions.map(tx => ({
...tx,
quantity: Number(tx.quantity),
pricePerCoin: Number(tx.pricePerCoin),
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount)
}));
const formattedTransactions = transactions.map(tx => {
const isTransfer = tx.type.startsWith('TRANSFER_');
const isIncoming = tx.type === 'TRANSFER_IN';
const isCoinTransfer = isTransfer && Number(tx.quantity) > 0;
let actualSenderUsername = null;
let actualRecipientUsername = null;
if (isTransfer) {
actualSenderUsername = tx.senderUser?.username;
actualRecipientUsername = tx.recipientUser?.username;
}
return {
...tx,
quantity: Number(tx.quantity),
pricePerCoin: Number(tx.pricePerCoin),
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount),
isTransfer,
isIncoming,
isCoinTransfer,
recipient: actualRecipientUsername,
sender: actualSenderUsername,
transferInfo: isTransfer ? {
isTransfer: true,
isIncoming,
isCoinTransfer,
otherUser: isIncoming ?
(tx.senderUser ? { id: tx.senderUser.id, username: actualSenderUsername } : null) :
(tx.recipientUser ? { id: tx.recipientUser.id, username: actualRecipientUsername } : null)
} : null
};
});
return json({
transactions: formattedTransactions,

View file

@ -0,0 +1,259 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import type { RequestHandler } from './$types';
interface TransferRequest {
recipientUsername: string;
type: 'CASH' | 'COIN';
amount: number;
coinSymbol?: string;
}
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
} try {
const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json();
if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
throw error(400, 'Invalid transfer parameters');
}
if (amount > Number.MAX_SAFE_INTEGER) {
throw error(400, 'Transfer amount too large');
}
if (type === 'COIN' && !coinSymbol) {
throw error(400, 'Coin symbol required for coin transfers');
}
const senderId = Number(session.user.id);
return await db.transaction(async (tx) => {
const [senderData] = await tx
.select({
id: user.id,
username: user.username,
baseCurrencyBalance: user.baseCurrencyBalance
})
.from(user)
.where(eq(user.id, senderId))
.for('update')
.limit(1);
if (!senderData) {
throw error(404, 'Sender not found');
}
const [recipientData] = await tx
.select({
id: user.id,
username: user.username,
baseCurrencyBalance: user.baseCurrencyBalance
})
.from(user)
.where(eq(user.username, recipientUsername))
.for('update')
.limit(1);
if (!recipientData) {
throw error(404, 'Recipient not found');
}
if (senderData.id === recipientData.id) {
throw error(400, 'Cannot transfer to yourself');
}
if (type === 'CASH') {
const senderBalance = Number(senderData.baseCurrencyBalance);
if (senderBalance < amount) {
throw error(400, `Insufficient funds. You have $${senderBalance.toFixed(2)} but trying to send $${amount.toFixed(2)}`);
}
const recipientBalance = Number(recipientData.baseCurrencyBalance);
await tx
.update(user)
.set({
baseCurrencyBalance: (senderBalance - amount).toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, senderId));
await tx
.update(user)
.set({
baseCurrencyBalance: (recipientBalance + amount).toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, recipientData.id));
await tx.insert(transaction).values({
userId: senderId,
coinId: 1,
type: 'TRANSFER_OUT',
quantity: '0',
pricePerCoin: '1',
totalBaseCurrencyAmount: amount.toString(),
timestamp: new Date(),
senderUserId: senderId,
recipientUserId: recipientData.id
});
await tx.insert(transaction).values({
userId: recipientData.id,
coinId: 1,
type: 'TRANSFER_IN',
quantity: '0',
pricePerCoin: '1',
totalBaseCurrencyAmount: amount.toString(),
timestamp: new Date(),
senderUserId: senderId,
recipientUserId: recipientData.id
});
return json({
success: true,
type: 'CASH',
amount,
recipient: recipientData.username,
newBalance: senderBalance - amount
});
} else {
const normalizedSymbol = coinSymbol!.toUpperCase();
const [coinData] = await tx
.select({ id: coin.id, symbol: coin.symbol, name: coin.name, currentPrice: coin.currentPrice })
.from(coin)
.where(eq(coin.symbol, normalizedSymbol))
.limit(1);
if (!coinData) {
throw error(404, 'Coin not found');
}
const [senderHolding] = await tx
.select({
quantity: userPortfolio.quantity
})
.from(userPortfolio)
.where(and(
eq(userPortfolio.userId, senderId),
eq(userPortfolio.coinId, coinData.id)
))
.for('update')
.limit(1);
if (!senderHolding || Number(senderHolding.quantity) < amount) {
const availableAmount = senderHolding ? Number(senderHolding.quantity) : 0;
throw error(400, `Insufficient ${coinData.symbol}. You have ${availableAmount.toFixed(6)} but trying to send ${amount.toFixed(6)}`);
}
const [recipientHolding] = await tx
.select({ quantity: userPortfolio.quantity })
.from(userPortfolio)
.where(and(
eq(userPortfolio.userId, recipientData.id),
eq(userPortfolio.coinId, coinData.id)
))
.for('update')
.limit(1);
const coinPrice = Number(coinData.currentPrice) || 0;
const totalValue = amount * coinPrice;
const newSenderQuantity = Number(senderHolding.quantity) - amount;
if (newSenderQuantity > 0.000001) {
await tx
.update(userPortfolio)
.set({
quantity: newSenderQuantity.toString(),
updatedAt: new Date()
})
.where(and(
eq(userPortfolio.userId, senderId),
eq(userPortfolio.coinId, coinData.id)
));
} else {
await tx
.delete(userPortfolio)
.where(and(
eq(userPortfolio.userId, senderId),
eq(userPortfolio.coinId, coinData.id)
));
}
if (recipientHolding) {
const newRecipientQuantity = Number(recipientHolding.quantity) + amount;
await tx
.update(userPortfolio)
.set({
quantity: newRecipientQuantity.toString(),
updatedAt: new Date()
})
.where(and(
eq(userPortfolio.userId, recipientData.id),
eq(userPortfolio.coinId, coinData.id)
));
} else {
await tx
.insert(userPortfolio)
.values({
userId: recipientData.id,
coinId: coinData.id,
quantity: amount.toString()
});
}
await tx.insert(transaction).values({
userId: senderId,
coinId: coinData.id,
type: 'TRANSFER_OUT',
quantity: amount.toString(),
pricePerCoin: coinPrice.toString(),
totalBaseCurrencyAmount: totalValue.toString(),
timestamp: new Date(),
senderUserId: senderId,
recipientUserId: recipientData.id
});
await tx.insert(transaction).values({
userId: recipientData.id,
coinId: coinData.id,
type: 'TRANSFER_IN',
quantity: amount.toString(),
pricePerCoin: coinPrice.toString(),
totalBaseCurrencyAmount: totalValue.toString(),
timestamp: new Date(),
senderUserId: senderId,
recipientUserId: recipientData.id
});
return json({
success: true,
type: 'COIN',
amount,
coinSymbol: coinData.symbol,
coinName: coinData.name,
recipient: recipientData.username,
newQuantity: newSenderQuantity
});
}
});
} catch (e) {
console.error('Transfer error:', e);
if (e && typeof e === 'object' && 'status' in e) {
throw e;
}
return json({ error: 'Transfer failed' }, { status: 500 });
}
};

View file

@ -1,16 +0,0 @@
import { auth } from '$lib/auth';
import { redirect } from '@sveltejs/kit';
export async function load({ request }) {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw redirect(302, '/');
}
return {
user: session.user
};
}

View file

@ -1,23 +1,23 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import DataTable from '$lib/components/self/DataTable.svelte';
import PortfolioSkeleton from '$lib/components/self/skeletons/PortfolioSkeleton.svelte';
import SEO from '$lib/components/self/SEO.svelte';
import { getPublicUrl, formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
import { formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { TrendingUp, DollarSign, Wallet, TrendingDown, Clock, Receipt } from 'lucide-svelte';
import { TrendingUp, DollarSign, Wallet, Receipt, Send } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { USER_DATA } from '$lib/stores/user-data';
import SendMoneyModal from '$lib/components/self/SendMoneyModal.svelte';
let { data } = $props();
// TODO: add type definitions
let portfolioData = $state<any>(null);
let transactions = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let sendMoneyModalOpen = $state(false);
onMount(async () => {
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
@ -125,51 +125,100 @@
{
key: 'type',
label: 'Type',
class: 'w-[15%] min-w-[60px] md:w-[10%]',
render: (value: any) => ({
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value === 'BUY' ? 'Buy' : 'Sell',
class: 'text-xs'
})
class: 'w-[12%] min-w-[60px] md:w-[8%]',
render: (value: any, row: any) => {
if (row.isTransfer) {
return {
component: 'badge',
variant: 'default',
text: row.isIncoming ? 'Received' : 'Sent',
class: 'text-xs'
};
}
return {
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value === 'BUY' ? 'Buy' : 'Sell',
class: 'text-xs'
};
}
},
{
key: 'coin',
label: 'Coin',
class: 'w-[30%] min-w-[100px] md:w-[20%]',
class: 'w-[20%] min-w-[100px] md:w-[12%]',
render: (value: any, row: any) => {
if (row.isTransfer) {
if (row.isCoinTransfer && row.coin) {
return {
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 4
};
}
return { component: 'text', text: '-' };
}
return {
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 4
};
}
},
{
key: 'sender',
label: 'Sender',
class: 'w-[12%] min-w-[70px] md:w-[10%]',
render: (value: any, row: any) => ({
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 4
component: 'text',
text: row.isTransfer ? row.sender || 'Unknown' : '-',
class:
row.isTransfer && row.sender && row.sender !== 'Unknown'
? 'font-medium'
: 'text-muted-foreground'
})
},
{
key: 'recipient',
label: 'Receiver',
class: 'w-[12%] min-w-[70px] md:w-[10%]',
render: (value: any, row: any) => ({
component: 'text',
text: row.isTransfer ? row.recipient || 'Unknown' : '-',
class:
row.isTransfer && row.recipient && row.recipient !== 'Unknown'
? 'font-medium'
: 'text-muted-foreground'
})
},
{
key: 'quantity',
label: 'Quantity',
class: 'w-[20%] min-w-[80px] md:w-[15%] font-mono text-sm',
render: (value: any) => formatQuantity(value)
},
{
key: 'pricePerCoin',
label: 'Price',
class: 'w-[15%] min-w-[70px] md:w-[15%] font-mono text-sm',
render: (value: any) => `$${formatPrice(value)}`
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm',
render: (value: any, row: any) =>
row.isTransfer && value === 0 ? '-' : formatQuantity(value)
},
{
key: 'totalBaseCurrencyAmount',
label: 'Total',
class: 'w-[20%] min-w-[70px] md:w-[15%] font-mono text-sm font-medium',
label: 'Amount',
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm font-medium',
render: (value: any) => formatValue(value)
},
{
key: 'timestamp',
label: 'Date',
class: 'hidden md:table-cell md:w-[25%] text-muted-foreground text-sm',
class: 'hidden md:table-cell md:w-[18%] text-muted-foreground text-sm',
render: (value: any) => formatDate(value)
}
]);
async function handleTransferSuccess() {
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
}
</script>
<SEO
@ -179,14 +228,9 @@
keywords="virtual portfolio management, crypto holdings game, trading performance simulator, investment tracking game"
/>
<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>
<SendMoneyModal bind:open={sendMoneyModalOpen} onSuccess={handleTransferSuccess} />
<div class="container mx-auto max-w-7xl p-6">
{#if loading}
<PortfolioSkeleton />
{:else if error}
@ -196,122 +240,148 @@
<Button onclick={retryFetch}>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>
{:else if !$USER_DATA}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="text-muted-foreground mb-4 text-xl">
You need to be logged in to view your portfolio
</div>
<Button onclick={() => goto('/login')}>Log In</Button>
</div>
</div>
{:else}
<!-- Portfolio Overview -->
<div class="mb-8">
<div class="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 class="text-3xl font-bold">Portfolio</h1>
<p class="text-muted-foreground">Manage your investments and transactions</p>
</div>
<div class="flex gap-2">
<Button onclick={() => (sendMoneyModalOpen = true)}>
<Send class="h-4 w-4" />
Send Money
</Button>
<!-- ...existing buttons... -->
</div>
</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>
<!-- 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>
<DataTable
columns={holdingsColumns}
data={portfolioData.coinHoldings}
onRowClick={(holding) => goto(`/coin/${holding.symbol}`)}
/>
</Card.Content>
</Card.Root>
{/if}
<!-- Recent Transactions -->
<Card.Root class="mt-8">
<Card.Header>
<Card.Title>Your Holdings</Card.Title>
<Card.Description>Current positions in your portfolio</Card.Description>
<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>
<DataTable
columns={holdingsColumns}
data={portfolioData.coinHoldings}
onRowClick={(holding) => goto(`/coin/${holding.symbol}`)}
columns={transactionsColumns}
data={transactions}
onRowClick={(tx) => !tx.isTransfer && goto(`/coin/${tx.coin.symbol}`)}
emptyIcon={Receipt}
emptyTitle="No transactions yet"
emptyDescription="You haven't made any trades yet. Start by buying or selling coins."
/>
</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>
<DataTable
columns={transactionsColumns}
data={transactions}
onRowClick={(tx) => goto(`/coin/${tx.coin.symbol}`)}
emptyIcon={Receipt}
emptyTitle="No transactions yet"
emptyDescription="You haven't made any trades yet. Start by buying or selling coins."
/>
</Card.Content>
</Card.Root>
</div>
{/if}
</div>

View file

@ -41,7 +41,9 @@
const typeFilterOptions = [
{ value: 'all', label: 'All transactions' },
{ value: 'BUY', label: 'Buys only' },
{ value: 'SELL', label: 'Sells only' }
{ value: 'SELL', label: 'Sells only' },
{ value: 'TRANSFER_IN', label: 'Received transfers' },
{ value: 'TRANSFER_OUT', label: 'Sent transfers' }
];
const sortOrderOptions = [
@ -50,7 +52,7 @@
];
const debouncedSearch = debounce(performSearch, 300);
let previousSearchQueryForEffect = $state(searchQuery);
let previousSearchQueryForEffect = $state('');
onMount(() => {
fetchTransactions();
@ -211,43 +213,110 @@
{
key: 'type',
label: 'Type',
class: 'w-[10%] min-w-[60px]',
render: (value: any) => ({
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value === 'BUY' ? 'Buy' : 'Sell',
class: 'text-xs'
})
class: 'w-[10%] min-w-[80px]',
render: (value: any, row: any) => {
if (row.isTransfer) {
return {
component: 'badge',
variant: 'default',
text: row.isIncoming ? 'Received' : 'Sent',
class: 'text-xs'
};
}
return {
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value === 'BUY' ? 'Buy' : 'Sell',
class: 'text-xs'
};
}
},
{
key: 'coin',
label: 'Coin',
label: 'Asset',
class: 'w-[20%] min-w-[120px]',
render: (value: any, row: any) => {
if (row.isTransfer) {
if (row.isCoinTransfer && row.coin) {
return {
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 6
};
}
return {
component: 'text',
text: 'Cash ($)',
class: 'font-medium'
};
}
return {
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 6
};
}
},
{
key: 'sender',
label: 'Sender',
class: 'w-[12%] min-w-[80px]',
render: (value: any, row: any) => ({
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 6
component: 'text',
text: row.isTransfer ? row.sender || 'Unknown' : '-',
class:
row.isTransfer && row.sender && row.sender !== 'Unknown'
? 'font-medium'
: 'text-muted-foreground'
})
},
{
key: 'recipient',
label: 'Receiver',
class: 'w-[12%] min-w-[80px]',
render: (value: any, row: any) => ({
component: 'text',
text: row.isTransfer ? row.recipient || 'Unknown' : '-',
class:
row.isTransfer && row.recipient && row.recipient !== 'Unknown'
? 'font-medium'
: 'text-muted-foreground'
})
},
{
key: 'quantity',
label: 'Quantity',
class: 'w-[15%] min-w-[100px] font-mono',
render: (value: any) => formatQuantity(value)
render: (value: any, row: any) => {
if (row.isTransfer && value === 0) {
return '-';
}
return formatQuantity(value);
}
},
{
key: 'pricePerCoin',
label: 'Price',
class: 'w-[15%] min-w-[80px] font-mono',
render: (value: any) => `$${formatPrice(value)}`
render: (value: any, row: any) => {
if (row.isTransfer || value === 0) {
return '-';
}
return `$${formatPrice(value)}`;
}
},
{
key: 'totalBaseCurrencyAmount',
label: 'Total',
class: 'w-[15%] min-w-[80px] font-mono font-medium',
render: (value: any) => formatValue(value)
render: (value: any, row: any) => {
const prefix = row.type === 'TRANSFER_IN' || row.type === 'BUY' ? '+' : '-';
return `${prefix}${formatValue(value)}`;
}
},
{
key: 'timestamp',
@ -413,7 +482,7 @@
<Receipt class="h-5 w-5" />
History
</Card.Title>
<Card.Description>Complete record of your trading activity</Card.Description>
<Card.Description>Complete record of your trading activity and transfers</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
@ -426,12 +495,16 @@
<DataTable
columns={transactionsColumns}
data={transactions}
onRowClick={(tx) => goto(`/coin/${tx.coin.symbol}`)}
onRowClick={(tx) => {
if (tx.coin) {
goto(`/coin/${tx.coin.symbol}`);
}
}}
emptyIcon={Receipt}
emptyTitle="No transactions found"
emptyDescription={hasActiveFilters
? 'No transactions match your current filters. Try adjusting your search criteria.'
: "You haven't made any trades yet. Start by buying or selling coins."}
: "You haven't made any trades or transfers yet. Start by buying coins or sending money to other users."}
/>
{/if}
</Card.Content>

View file

@ -21,22 +21,34 @@
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import type { UserProfileData } from '$lib/types/user-profile';
import { USER_DATA } from '$lib/stores/user-data';
let { data } = $props();
const username = data.username;
let profileData = $state<UserProfileData | null>(null);
let recentTransactions = $state<any[]>([]);
let loading = $state(true);
let isOwnProfile = $derived(
$USER_DATA && profileData?.profile && $USER_DATA.username === profileData.profile.username
);
onMount(async () => {
await fetchProfileData();
});
$effect(() => {
if (isOwnProfile && profileData) {
fetchTransactions();
}
});
async function fetchProfileData() {
try {
const response = await fetch(`/api/user/${username}`);
if (response.ok) {
profileData = await response.json();
recentTransactions = profileData?.recentTransactions || [];
} else {
toast.error('Failed to load profile data');
}
@ -48,6 +60,20 @@
}
}
async function fetchTransactions() {
if (!isOwnProfile) return;
try {
const response = await fetch('/api/transactions?limit=10');
if (response.ok) {
const data = await response.json();
recentTransactions = data.transactions || [];
}
} catch (e) {
console.error('Failed to fetch transactions:', e);
}
}
let memberSince = $derived(
profileData?.profile
? new Date(profileData.profile.createdAt).toLocaleDateString('en-US', {
@ -144,52 +170,158 @@
render: (value: any) => formatDate(value)
}
];
const transactionsColumns = [
{
key: 'type',
label: 'Type',
class: 'pl-6',
render: (value: any) => ({
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value
})
class: 'w-[12%] min-w-[60px] md:w-[8%] pl-6',
render: (value: any, row: any) => {
// Handle transfer types (TRANSFER_IN, TRANSFER_OUT) from user profile API
if (value === 'TRANSFER_IN' || value === 'TRANSFER_OUT') {
return {
component: 'badge',
variant: 'default',
text: value === 'TRANSFER_IN' ? 'Received' : 'Sent',
class: 'text-xs'
};
}
// Handle isTransfer format from transactions API
if (row.isTransfer) {
return {
component: 'badge',
variant: 'default',
text: row.isIncoming ? 'Received' : 'Sent',
class: 'text-xs'
};
}
return {
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value === 'BUY' ? 'Buy' : 'Sell',
class: 'text-xs'
};
}
},
{
key: 'coin',
label: 'Coin',
class: 'font-medium',
render: (value: any, row: any) => ({
component: 'coin',
icon: row.coinIcon,
symbol: row.coinSymbol,
name: row.coinName,
size: 6
})
class: 'w-[20%] min-w-[100px] md:w-[12%]',
render: (value: any, row: any) => {
// Handle transfer format from transactions API
if (row.isTransfer) {
if (row.isCoinTransfer && row.coin) {
return {
component: 'coin',
icon: row.coin.icon,
symbol: row.coin.symbol,
name: `*${row.coin.symbol}`,
size: 4
};
}
return { component: 'text', text: '-' };
}
// Handle transfer types from user profile API
if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') {
if (row.coinSymbol && Number(row.quantity) > 0) {
return {
component: 'coin',
icon: row.coinIcon,
symbol: row.coinSymbol,
name: `*${row.coinSymbol}`,
size: 4
};
}
return { component: 'text', text: '-' };
}
// Handle regular transactions from both APIs
return {
component: 'coin',
icon: row.coinIcon || row.coin?.icon,
symbol: row.coinSymbol || row.coin?.symbol,
name: `*${row.coinSymbol || row.coin?.symbol}`,
size: 4
};
}
},
{
key: 'sender',
label: 'Sender',
class: 'w-[12%] min-w-[70px] md:w-[10%]',
render: (value: any, row: any) => {
// Handle transactions API format
if (row.isTransfer) {
return {
component: 'text',
text: row.sender || 'Unknown',
class: row.sender && row.sender !== 'Unknown' ? 'font-medium' : 'text-muted-foreground'
};
}
// Handle user profile API format (no sender/recipient data available)
if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') {
return {
component: 'text',
text: 'Unknown',
class: 'text-muted-foreground'
};
}
return {
component: 'text',
text: '-',
class: 'text-muted-foreground'
};
}
},
{
key: 'recipient',
label: 'Receiver',
class: 'w-[12%] min-w-[70px] md:w-[10%]',
render: (value: any, row: any) => {
if (row.isTransfer) {
return {
component: 'text',
text: row.recipient || 'Unknown',
class:
row.recipient && row.recipient !== 'Unknown' ? 'font-medium' : 'text-muted-foreground'
};
}
if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') {
return {
component: 'text',
text: 'Unknown',
class: 'text-muted-foreground'
};
}
return {
component: 'text',
text: '-',
class: 'text-muted-foreground'
};
}
},
{
key: 'quantity',
label: 'Quantity',
class: 'hidden font-mono sm:table-cell',
render: (value: any) => formatQuantity(parseFloat(value))
},
{
key: 'pricePerCoin',
label: 'Price',
class: 'font-mono',
render: (value: any) => `$${formatPrice(parseFloat(value))}`
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm',
render: (value: any, row: any) => {
if (
(row.isTransfer && value === 0) ||
((row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') && value === 0)
) {
return '-';
}
return formatQuantity(parseFloat(value));
}
},
{
key: 'totalBaseCurrencyAmount',
label: 'Total',
class: 'hidden font-mono font-medium md:table-cell',
label: 'Amount',
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm font-medium',
render: (value: any) => formatValue(parseFloat(value))
},
{
key: 'timestamp',
label: 'Date',
class: 'text-muted-foreground hidden text-sm lg:table-cell',
class: 'hidden md:table-cell md:w-[18%] text-muted-foreground text-sm',
render: (value: any) => formatDate(value)
}
];
@ -203,9 +335,7 @@
? `${profileData.profile.bio} - View ${profileData.profile.name}'s simulated trading activity and virtual portfolio in the Rugplay cryptocurrency simulation game.`
: `View @${username}'s profile and simulated trading activity in Rugplay - cryptocurrency trading simulation game platform.`}
type="profile"
image={profileData?.profile?.image
? getPublicUrl(profileData.profile.image)
: '/rugplay.svg'}
image={profileData?.profile?.image ? getPublicUrl(profileData.profile.image) : '/rugplay.svg'}
imageAlt={profileData?.profile?.name
? `${profileData.profile.name}'s profile picture`
: `@${username}'s profile`}
@ -440,7 +570,7 @@
<Card.Content class="p-0">
<DataTable
columns={transactionsColumns}
data={profileData?.recentTransactions || []}
data={recentTransactions}
emptyIcon={Receipt}
emptyTitle="No recent activity"
emptyDescription="This user hasn't made any trades yet."