feat: add CoinIcon component for displaying cryptocurrency icons

feat: implement TradeModal for buying and selling coins with validation and transaction handling

feat: create server-side trade API for executing buy/sell transactions and updating user balances

feat: add transactions API to fetch user transaction history

feat: implement portfolio page to display user's holdings and recent transactions
This commit is contained in:
Face 2025-05-23 19:48:23 +03:00
parent 0784e0f3d3
commit a278d0c6a5
13 changed files with 1342 additions and 210 deletions

View file

@ -4,6 +4,7 @@
import { Button } from '$lib/components/ui/button';
import * as Avatar from '$lib/components/ui/avatar';
import * as HoverCard from '$lib/components/ui/hover-card';
import TradeModal from '$lib/components/self/TradeModal.svelte';
import {
TrendingUp,
TrendingDown,
@ -17,39 +18,47 @@
ColorType,
type Time,
type IChartApi,
CandlestickSeries
CandlestickSeries,
HistogramSeries
} from 'lightweight-charts';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getPublicUrl } from '$lib/utils';
import { toast } from 'svelte-sonner';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
const { data } = $props();
const coinSymbol = data.coinSymbol;
let coin = $state<any>(null);
let priceHistory = $state<any[]>([]);
let loading = $state(true);
let creatorImageUrl = $state<string | null>(null);
let chartData = $state<any[]>([]);
let volumeData = $state<any[]>([]);
let userHolding = $state(0);
let buyModalOpen = $state(false);
let sellModalOpen = $state(false);
let selectedTimeframe = $state('1m');
onMount(async () => {
await loadCoinData();
await loadUserHolding();
});
async function loadCoinData() {
try {
const response = await fetch(`/api/coin/${coinSymbol}`);
const response = await fetch(`/api/coin/${coinSymbol}?timeframe=${selectedTimeframe}`);
if (!response.ok) {
if (response.status === 404) {
toast.error('Coin not found');
} else {
toast.error('Failed to load coin data');
}
toast.error(response.status === 404 ? 'Coin not found' : 'Failed to load coin data');
return;
}
const result = await response.json();
coin = result.coin;
priceHistory = result.priceHistory;
chartData = generateCandlesticksFromHistory(priceHistory);
chartData = result.candlestickData || [];
volumeData = result.volumeData || [];
if (coin.creatorId) {
try {
@ -66,91 +75,144 @@
} finally {
loading = false;
}
});
}
function generateCandlesticksFromHistory(history: any[]) {
const dailyData = new Map();
async function loadUserHolding() {
if (!$USER_DATA) return;
history.forEach((p) => {
const date = new Date(p.timestamp);
const dayKey = Math.floor(date.getTime() / (24 * 60 * 60 * 1000));
if (!dailyData.has(dayKey)) {
dailyData.set(dayKey, {
time: dayKey * 24 * 60 * 60,
open: p.price,
high: p.price,
low: p.price,
close: p.price,
prices: [p.price]
});
} else {
const dayData = dailyData.get(dayKey);
dayData.high = Math.max(dayData.high, p.price);
dayData.low = Math.min(dayData.low, p.price);
dayData.close = p.price;
dayData.prices.push(p.price);
try {
const response = await fetch('/api/portfolio/total');
if (response.ok) {
const result = await response.json();
const holding = result.coinHoldings.find((h: any) => h.symbol === coinSymbol.toUpperCase());
userHolding = holding ? holding.quantity : 0;
}
});
} catch (e) {
console.error('Failed to load user holding:', e);
}
}
return Array.from(dailyData.values())
.map((d) => ({
time: d.time as Time,
open: d.open,
high: d.high,
low: d.low,
close: d.close
}))
.sort((a, b) => (a.time as number) - (b.time as number));
async function handleTradeSuccess() {
await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioData()]);
}
async function handleTimeframeChange(timeframe: string) {
selectedTimeframe = timeframe;
loading = true;
if (chart) {
chart.remove();
chart = null;
}
await loadCoinData();
loading = false;
}
function generateVolumeData(candlestickData: any[], volumeData: any[]) {
return candlestickData.map((candle, index) => {
// Find corresponding volume data for this time period
const volumePoint = volumeData.find(v => v.time === candle.time);
const volume = volumePoint ? volumePoint.volume : 0;
return {
time: candle.time,
value: volume,
color: candle.close >= candle.open ? '#26a69a' : '#ef5350'
};
});
}
let chartContainer = $state<HTMLDivElement>();
let chart: IChartApi | null = null;
$effect(() => {
if (chartContainer && chartData.length > 0 && !chart) {
if (chart && chartData.length > 0) {
chart.remove();
chart = null;
}
if (chartContainer && chartData.length > 0) {
chart = createChart(chartContainer, {
layout: {
textColor: '#666666',
background: { type: ColorType.Solid, color: 'transparent' },
attributionLogo: false
attributionLogo: false,
panes: {
separatorColor: '#2B2B43',
separatorHoverColor: 'rgba(107, 114, 142, 0.3)',
enableResize: true
}
},
grid: {
vertLines: { color: '#2B2B43' },
horzLines: { color: '#2B2B43' }
},
rightPriceScale: {
borderVisible: false
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
alignLabels: true,
entireTextOnly: false
},
timeScale: {
borderVisible: false,
timeVisible: true
timeVisible: true,
barSpacing: 20,
rightOffset: 5,
minBarSpacing: 8
},
crosshair: {
mode: 1
mode: 1,
vertLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true },
horzLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true }
}
});
const candlestickSeries = chart.addSeries(CandlestickSeries, {
upColor: '#26a69a',
downColor: '#ef5350',
borderVisible: false,
borderVisible: true,
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350'
wickDownColor: '#ef5350',
priceFormat: { type: 'price', precision: 8, minMove: 0.00000001 }
});
candlestickSeries.setData(chartData);
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'volume'
}, 1);
const processedChartData = chartData.map((candle) => {
if (candle.open === candle.close) {
const basePrice = candle.open;
const variation = basePrice * 0.001;
return {
...candle,
high: Math.max(candle.high, basePrice + variation),
low: Math.min(candle.low, basePrice - variation)
};
}
return candle;
});
candlestickSeries.setData(processedChartData);
volumeSeries.setData(generateVolumeData(chartData, volumeData));
const volumePane = chart.panes()[1];
if (volumePane) volumePane.setHeight(100);
chart.timeScale().fitContent();
const handleResize = () => {
chart?.applyOptions({
width: chartContainer?.clientWidth
});
};
const handleResize = () => chart?.applyOptions({ width: chartContainer?.clientWidth });
window.addEventListener('resize', handleResize);
handleResize();
candlestickSeries.priceScale().applyOptions({ borderColor: '#71649C' });
volumeSeries.priceScale().applyOptions({ borderColor: '#71649C' });
chart.timeScale().applyOptions({ borderColor: '#71649C' });
return () => {
window.removeEventListener('resize', handleResize);
if (chart) {
@ -190,6 +252,17 @@
<title>{coin ? `${coin.name} (${coin.symbol})` : 'Loading...'} - Rugplay</title>
</svelte:head>
{#if coin}
<TradeModal bind:open={buyModalOpen} type="BUY" {coin} onSuccess={handleTradeSuccess} />
<TradeModal
bind:open={sellModalOpen}
type="SELL"
{coin}
{userHolding}
onSuccess={handleTradeSuccess}
/>
{/if}
{#if loading}
<div class="container mx-auto max-w-7xl p-6">
<div class="flex h-96 items-center justify-center">
@ -213,23 +286,13 @@
<header class="mb-8">
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-4">
<div
class="bg-muted/50 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full border"
>
{#if coin.icon}
<img
src={getPublicUrl(coin.icon)}
alt={coin.name}
class="h-full w-full object-cover"
/>
{:else}
<div
class="flex h-full w-full items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-xl font-bold text-white"
>
{coin.symbol.slice(0, 2)}
</div>
{/if}
</div>
<CoinIcon
icon={coin.icon}
symbol={coin.symbol}
name={coin.name}
size={16}
class="border"
/>
<div>
<h1 class="text-4xl font-bold">{coin.name}</h1>
<div class="mt-1 flex items-center gap-2">
@ -309,13 +372,33 @@
<div class="lg:col-span-2">
<Card.Root>
<Card.Header class="pb-4">
<Card.Title class="flex items-center gap-2">
<ChartColumn class="h-5 w-5" />
Price Chart
</Card.Title>
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-2">
<ChartColumn class="h-5 w-5" />
Price Chart ({selectedTimeframe})
</Card.Title>
<div class="flex gap-1">
{#each ['1m', '5m', '15m', '1h', '4h', '1d'] as timeframe}
<Button
variant={selectedTimeframe === timeframe ? 'default' : 'outline'}
size="sm"
onclick={() => handleTimeframeChange(timeframe)}
disabled={loading}
>
{timeframe}
</Button>
{/each}
</div>
</div>
</Card.Header>
<Card.Content class="pt-0">
<div class="h-[400px] w-full" bind:this={chartContainer}></div>
{#if chartData.length === 0}
<div class="flex h-[500px] items-center justify-center">
<p class="text-muted-foreground">No trading data available yet</p>
</div>
{:else}
<div class="h-[500px] w-full" bind:this={chartContainer}></div>
{/if}
</Card.Content>
</Card.Root>
</div>
@ -326,18 +409,43 @@
<Card.Root>
<Card.Header class="pb-4">
<Card.Title>Trade {coin.symbol}</Card.Title>
{#if userHolding > 0}
<p class="text-muted-foreground text-sm">
You own: {userHolding.toFixed(2)}
{coin.symbol}
</p>
{/if}
</Card.Header>
<Card.Content class="pt-0">
<div class="space-y-3">
<Button class="w-full" variant="default" size="lg">
<TrendingUp class="mr-2 h-4 w-4" />
Buy {coin.symbol}
</Button>
<Button class="w-full" variant="outline" size="lg">
<TrendingDown class="mr-2 h-4 w-4" />
Sell {coin.symbol}
</Button>
</div>
{#if $USER_DATA}
<div class="space-y-3">
<Button
class="w-full"
variant="default"
size="lg"
onclick={() => (buyModalOpen = true)}
disabled={!coin.isListed}
>
<TrendingUp class="h-4 w-4" />
Buy {coin.symbol}
</Button>
<Button
class="w-full"
variant="outline"
size="lg"
onclick={() => (sellModalOpen = true)}
disabled={!coin.isListed || userHolding <= 0}
>
<TrendingDown class="h-4 w-4" />
Sell {coin.symbol}
</Button>
</div>
{:else}
<div class="py-4 text-center">
<p class="text-muted-foreground mb-3 text-sm">Sign in to start trading</p>
<Button onclick={() => goto('/')}>Sign In</Button>
</div>
{/if}
</Card.Content>
</Card.Root>