303 lines
8.6 KiB
Svelte
303 lines
8.6 KiB
Svelte
<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 { getPublicUrl, 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 { goto } from '$app/navigation';
|
|
|
|
let { data } = $props();
|
|
|
|
let portfolioData = $state<any>(null);
|
|
let transactions = $state<any[]>([]);
|
|
let loading = $state(true);
|
|
|
|
onMount(async () => {
|
|
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
|
|
});
|
|
|
|
async function fetchPortfolioData() {
|
|
try {
|
|
const response = await fetch('/api/portfolio/total');
|
|
if (response.ok) {
|
|
portfolioData = await response.json();
|
|
} else {
|
|
toast.error('Failed to load portfolio data');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch portfolio data:', e);
|
|
toast.error('Failed to load portfolio data');
|
|
}
|
|
}
|
|
|
|
async function fetchRecentTransactions() {
|
|
try {
|
|
const response = await fetch('/api/transactions');
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
transactions = result.transactions.slice(0, 10); // Show last 10 transactions
|
|
} else {
|
|
toast.error('Failed to load transactions');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch transactions:', e);
|
|
toast.error('Failed to load transactions');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
let totalPortfolioValue = $derived(portfolioData ? portfolioData.totalValue : 0);
|
|
let hasHoldings = $derived(portfolioData && portfolioData.coinHoldings.length > 0);
|
|
let hasTransactions = $derived(transactions.length > 0);
|
|
|
|
let holdingsColumns = $derived([
|
|
{
|
|
key: 'coin',
|
|
label: 'Coin',
|
|
class: 'w-[30%] min-w-[120px] md:w-[12%]',
|
|
render: (value: any, row: any) => ({
|
|
component: 'coin',
|
|
icon: row.icon,
|
|
symbol: row.symbol,
|
|
name: `*${row.symbol}`,
|
|
size: 6
|
|
})
|
|
},
|
|
{
|
|
key: 'quantity',
|
|
label: 'Quantity',
|
|
class: 'w-[20%] min-w-[80px] md:w-[12%] font-mono',
|
|
render: (value: any) => formatQuantity(value)
|
|
},
|
|
{
|
|
key: 'currentPrice',
|
|
label: 'Price',
|
|
class: 'w-[15%] min-w-[70px] md:w-[12%] font-mono',
|
|
render: (value: any) => `$${formatPrice(value)}`
|
|
},
|
|
{
|
|
key: 'change24h',
|
|
label: '24h Change',
|
|
class: 'w-[20%] min-w-[80px] md:w-[12%]',
|
|
render: (value: any) => ({
|
|
component: 'badge',
|
|
variant: value >= 0 ? 'success' : 'destructive',
|
|
text: `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`
|
|
})
|
|
},
|
|
{
|
|
key: 'value',
|
|
label: 'Value',
|
|
class: 'w-[15%] min-w-[70px] md:w-[12%] font-mono font-medium',
|
|
render: (value: any) => formatValue(value)
|
|
},
|
|
{
|
|
key: 'portfolioPercent',
|
|
label: 'Portfolio %',
|
|
class: 'hidden md:table-cell md:w-[12%]',
|
|
render: (value: any, row: any) => ({
|
|
component: 'badge',
|
|
variant: 'outline',
|
|
text: `${((row.value / totalPortfolioValue) * 100).toFixed(1)}%`
|
|
})
|
|
}
|
|
]);
|
|
|
|
// Column configurations for transactions table
|
|
let transactionsColumns = $derived([
|
|
{
|
|
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'
|
|
})
|
|
},
|
|
{
|
|
key: 'coin',
|
|
label: 'Coin',
|
|
class: 'w-[30%] min-w-[100px] md:w-[20%]',
|
|
render: (value: any, row: any) => ({
|
|
component: 'coin',
|
|
icon: row.coin.icon,
|
|
symbol: row.coin.symbol,
|
|
name: `*${row.coin.symbol}`,
|
|
size: 4
|
|
})
|
|
},
|
|
{
|
|
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)}`
|
|
},
|
|
{
|
|
key: 'totalBaseCurrencyAmount',
|
|
label: 'Total',
|
|
class: 'w-[20%] min-w-[70px] md:w-[15%] 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',
|
|
render: (value: any) => formatDate(value)
|
|
}
|
|
]);
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Portfolio - Rugplay</title>
|
|
</svelte:head>
|
|
|
|
<div class="container mx-auto max-w-7xl p-6">
|
|
<header class="mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold">Portfolio</h1>
|
|
<p class="text-muted-foreground">View your holdings and portfolio performance</p>
|
|
</div>
|
|
</header>
|
|
|
|
{#if loading}
|
|
<PortfolioSkeleton />
|
|
{:else if !portfolioData}
|
|
<div class="flex h-96 items-center justify-center">
|
|
<div class="text-center">
|
|
<div class="text-muted-foreground mb-4 text-xl">Failed to load portfolio</div>
|
|
<Button onclick={fetchPortfolioData}>Try Again</Button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Portfolio Summary Cards -->
|
|
<div class="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<!-- Total Portfolio Value -->
|
|
<Card.Root class="text-success gap-1">
|
|
<Card.Header>
|
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
|
<Wallet class="h-4 w-4" />
|
|
Total
|
|
</Card.Title>
|
|
</Card.Header>
|
|
<Card.Content>
|
|
<p class="text-3xl font-bold">{formatValue(totalPortfolioValue)}</p>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
|
|
<!-- Base Currency Balance -->
|
|
<Card.Root class="gap-1">
|
|
<Card.Header>
|
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
|
<DollarSign class="h-4 w-4" />
|
|
Cash Balance
|
|
</Card.Title>
|
|
</Card.Header>
|
|
<Card.Content>
|
|
<p class="text-3xl font-bold">
|
|
{formatValue(portfolioData.baseCurrencyBalance)}
|
|
</p>
|
|
<p class="text-muted-foreground text-xs">
|
|
{totalPortfolioValue > 0
|
|
? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio`
|
|
: '100% of portfolio'}
|
|
</p>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
|
|
<!-- Coin Holdings Value -->
|
|
<Card.Root class="gap-1">
|
|
<Card.Header>
|
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
|
<TrendingUp class="h-4 w-4" />
|
|
Coin Holdings
|
|
</Card.Title>
|
|
</Card.Header>
|
|
<Card.Content>
|
|
<p class="text-3xl font-bold">{formatValue(portfolioData.totalCoinValue)}</p>
|
|
<p class="text-muted-foreground text-xs">
|
|
{portfolioData.coinHoldings.length} positions
|
|
</p>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
</div>
|
|
|
|
{#if !hasHoldings}
|
|
<!-- Empty State -->
|
|
<Card.Root>
|
|
<Card.Content class="py-16 text-center">
|
|
<div
|
|
class="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
|
|
>
|
|
<Wallet class="text-muted-foreground h-8 w-8" />
|
|
</div>
|
|
<h3 class="mb-2 text-lg font-semibold">No coin holdings</h3>
|
|
<p class="text-muted-foreground mb-6">
|
|
You haven't invested in any coins yet. Start by buying existing coins.
|
|
</p>
|
|
<div class="flex justify-center">
|
|
<Button variant="outline" onclick={() => goto('/')}>Browse Coins</Button>
|
|
</div>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
{:else}
|
|
<!-- Holdings Table -->
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>Your Holdings</Card.Title>
|
|
<Card.Description>Current positions in your portfolio</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content>
|
|
<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>
|
|
<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>
|
|
{/if}
|
|
</div>
|