This repository has been archived on 2025-08-19. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
coinstorge/website/src/lib/components/self/TradeModal.svelte

217 lines
6 KiB
Svelte
Raw Normal View History

<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { toast } from 'svelte-sonner';
let {
open = $bindable(false),
type,
coin,
userHolding = 0,
onSuccess
} = $props<{
open?: boolean;
type: 'BUY' | 'SELL';
coin: any;
userHolding?: number;
onSuccess?: () => void;
}>();
let amount = $state('');
let loading = $state(false);
let numericAmount = $derived(parseFloat(amount) || 0);
let currentPrice = $derived(coin.currentPrice || 0);
let maxSellableAmount = $derived(
type === 'SELL' && coin
? Math.min(userHolding, Math.floor(Number(coin.poolCoinAmount) * 0.995))
: userHolding
);
let estimatedResult = $derived(calculateEstimate(numericAmount, type, currentPrice));
let hasValidAmount = $derived(numericAmount > 0);
let userBalance = $derived($USER_DATA ? Number($USER_DATA.baseCurrencyBalance) : 0);
let hasEnoughFunds = $derived(
type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding
);
let canTrade = $derived(hasValidAmount && hasEnoughFunds && !loading);
function calculateEstimate(amount: number, tradeType: 'BUY' | 'SELL', price: number) {
if (!amount || !price || !coin) return { result: 0 };
const poolCoin = Number(coin.poolCoinAmount);
const poolBase = Number(coin.poolBaseCurrencyAmount);
if (poolCoin <= 0 || poolBase <= 0) return { result: 0 };
const k = poolCoin * poolBase;
if (tradeType === 'BUY') {
// AMM formula: how many coins for spending 'amount' dollars
const newPoolBase = poolBase + amount;
const newPoolCoin = k / newPoolBase;
return { result: poolCoin - newPoolCoin };
} else {
// AMM formula: how many dollars for selling 'amount' coins
const newPoolCoin = poolCoin + amount;
const newPoolBase = k / newPoolCoin;
return { result: poolBase - newPoolBase };
}
}
function handleClose() {
open = false;
amount = '';
loading = false;
}
async function handleTrade() {
if (!canTrade) return;
loading = true;
try {
const response = await fetch(`/api/coin/${coin.symbol}/trade`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type,
amount: numericAmount
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Trade failed');
}
toast.success(`${type === 'BUY' ? 'Bought' : 'Sold'} successfully!`, {
description:
type === 'BUY'
? `Purchased ${result.coinsBought.toFixed(6)} ${coin.symbol} for $${result.totalCost.toFixed(6)}`
: `Sold ${result.coinsSold.toFixed(6)} ${coin.symbol} for $${result.totalReceived.toFixed(6)}`
});
onSuccess?.();
handleClose();
} catch (e) {
toast.error('Trade failed', {
description: (e as Error).message
});
} finally {
loading = false;
}
}
function setMaxAmount() {
if (type === 'SELL') {
amount = maxSellableAmount.toString();
} else if ($USER_DATA) {
// For BUY, max is user's balance
amount = userBalance.toString();
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
{#if type === 'BUY'}
<TrendingUp class="h-5 w-5 text-green-500" />
Buy {coin.symbol}
{:else}
<TrendingDown class="h-5 w-5 text-red-500" />
Sell {coin.symbol}
{/if}
</Dialog.Title>
<Dialog.Description>
Current price: ${coin.currentPrice.toFixed(6)} per {coin.symbol}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<!-- Amount Input -->
<div class="space-y-2">
<Label for="amount">
{type === 'BUY' ? 'Amount to spend ($)' : `Amount (${coin.symbol})`}
</Label>
<div class="flex gap-2">
<Input
id="amount"
type="number"
step={type === 'BUY' ? '0.01' : '1'}
min="0"
bind:value={amount}
placeholder="0.00"
class="flex-1"
/>
<Button variant="outline" size="sm" onclick={setMaxAmount}>Max</Button>
</div>
{#if type === 'SELL'}
<p class="text-muted-foreground text-xs">
Available: {userHolding.toFixed(6)}
{coin.symbol}
{#if maxSellableAmount < userHolding}
<br />Max sellable: {maxSellableAmount.toFixed(0)} {coin.symbol} (pool limit)
{/if}
</p>
{:else if $USER_DATA}
<p class="text-muted-foreground text-xs">
Balance: ${userBalance.toFixed(6)}
</p>
{/if}
</div>
<!-- Estimated Cost/Return with explicit fees -->
{#if hasValidAmount}
<div class="bg-muted/50 rounded-lg p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
{type === 'BUY' ? `${coin.symbol} you'll get:` : "You'll receive:"}
</span>
<span class="font-bold">
{type === 'BUY'
? `~${estimatedResult.result.toFixed(6)} ${coin.symbol}`
: `~$${estimatedResult.result.toFixed(6)}`}
</span>
</div>
<p class="text-muted-foreground mt-1 text-xs">
AMM estimation - includes slippage from pool impact
</p>
</div>
{/if}
{#if !hasEnoughFunds && hasValidAmount}
<Badge variant="destructive" class="text-xs">
{type === 'BUY' ? 'Insufficient funds' : 'Insufficient coins'}
</Badge>
{/if}
</div>
<Dialog.Footer class="flex gap-2">
<Button variant="outline" onclick={handleClose} disabled={loading}>Cancel</Button>
<Button
onclick={handleTrade}
disabled={!canTrade}
variant={type === 'BUY' ? 'default' : 'destructive'}
>
{#if loading}
<Loader2 class="h-4 w-4 animate-spin" />
Processing...
{:else}
{type === 'BUY' ? 'Buy' : 'Sell'} {coin.symbol}
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>