375 lines
16 KiB
TypeScript
375 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useMemo } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useSession, signIn } from 'next-auth/react'
|
|
import useSWR, { mutate } from 'swr'
|
|
import { Header } from '@/components/header'
|
|
import { Button } from '@/components/ui/button'
|
|
import { LgLock } from '@/components/icons'
|
|
import { ArrowDownUp, Calculator, Wallet, ChevronDown, CheckCircle2, Check, ChevronsUpDown, Search } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { Coin, User } from '@/types'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
|
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
|
|
|
interface CoinSelectorProps {
|
|
coins: Coin[];
|
|
selectedId: string;
|
|
onSelect: (id: string) => void;
|
|
placeholder?: string;
|
|
showBalance?: boolean;
|
|
userPortfolio?: User['portfolio'];
|
|
}
|
|
|
|
function CoinSelector({ coins, selectedId, onSelect, placeholder = "Select token", showBalance, userPortfolio }: CoinSelectorProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const selected = coins.find(c => c._id === selectedId)
|
|
|
|
const getBalance = (coinId: string) => {
|
|
if (!userPortfolio) return 0;
|
|
const item = userPortfolio.find(p => p.coinId === coinId);
|
|
return item ? item.amount : 0;
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<div className="flex min-w-[140px] cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 py-2 shadow-sm transition-all hover:border-primary/50 hover:bg-muted/50">
|
|
{selected ? (
|
|
<>
|
|
<img src={selected.image} className="h-6 w-6 rounded-full object-cover" alt="" />
|
|
<span className="font-bold">${selected.ticker}</span>
|
|
</>
|
|
) : (
|
|
<span className="font-medium text-muted-foreground">{placeholder}</span>
|
|
)}
|
|
<ChevronDown className="ml-auto h-4 w-4 opacity-50" />
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[280px] p-0" align="end">
|
|
<Command>
|
|
<CommandInput placeholder="Search ticker..." />
|
|
<CommandList>
|
|
<CommandEmpty>No token found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{coins.map((coin) => {
|
|
const balance = showBalance ? getBalance(coin._id) : 0;
|
|
return (
|
|
<CommandItem
|
|
key={coin._id}
|
|
value={coin.ticker}
|
|
onSelect={() => {
|
|
onSelect(coin._id)
|
|
setOpen(false)
|
|
}}
|
|
className="cursor-pointer"
|
|
>
|
|
<div className="flex w-full items-center gap-2">
|
|
<img src={coin.image} className="h-8 w-8 rounded-full object-cover" />
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="truncate font-bold">${coin.ticker}</span>
|
|
<span className="truncate text-xs text-muted-foreground">{coin.name}</span>
|
|
</div>
|
|
{showBalance && balance > 0 && (
|
|
<div className="ml-auto flex flex-col items-end text-xs">
|
|
<span className="font-medium">{balance.toLocaleString()}</span>
|
|
<span className="text-muted-foreground">Bal</span>
|
|
</div>
|
|
)}
|
|
{!showBalance && selectedId === coin._id && (
|
|
<Check className="ml-auto h-4 w-4 text-primary" />
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
)})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export default function SwapPage() {
|
|
const { data: session } = useSession()
|
|
const router = useRouter()
|
|
|
|
const { data: userData } = useSWR<User>(session ? '/api/user' : null, fetcher)
|
|
const { data: coins } = useSWR<Coin[]>('/api/coins', fetcher)
|
|
|
|
const [fromCoinId, setFromCoinId] = useState<string>('')
|
|
const [toCoinId, setToCoinId] = useState<string>('')
|
|
const [amount, setAmount] = useState<string>('')
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
// Filter owned coins
|
|
const myHoldings = useMemo(() => {
|
|
if (!userData?.portfolio || !coins) return []
|
|
return userData.portfolio
|
|
.map(p => {
|
|
const coin = coins.find(c => c._id === p.coinId)
|
|
return coin ? { ...p, coin } : null
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null && item.amount > 0)
|
|
}, [userData, coins])
|
|
|
|
// Select first owned coin by default if not set
|
|
React.useEffect(() => {
|
|
// Only set if we have holdings and nothing is selected yet
|
|
if (!fromCoinId && myHoldings.length > 0 && !loading) {
|
|
setFromCoinId(myHoldings[0].coinId)
|
|
}
|
|
}, [myHoldings, fromCoinId, loading])
|
|
|
|
const fromCoin = useMemo(() => coins?.find(c => c._id === fromCoinId), [coins, fromCoinId])
|
|
const toCoin = useMemo(() => coins?.find(c => c._id === toCoinId), [coins, toCoinId])
|
|
|
|
// Current balance of selected coin
|
|
const userBalance = useMemo(() => {
|
|
const holding = myHoldings.find(h => h.coinId === fromCoinId)
|
|
return holding ? holding.amount : 0
|
|
}, [myHoldings, fromCoinId])
|
|
|
|
// --- CLIENT SIDE ESTIMATION ---
|
|
const estimates = useMemo(() => {
|
|
if (!fromCoin || !toCoin || !amount) return null;
|
|
const amountIn = parseFloat(amount);
|
|
if (isNaN(amountIn) || amountIn <= 0) return null;
|
|
|
|
if (amountIn > userBalance) return { error: "Insufficient balance" };
|
|
|
|
// 1. Sell FromCoin -> SOL
|
|
const fromVSol = fromCoin.virtualSolReserves || 30;
|
|
const fromVTokens = fromCoin.virtualTokenReserves || 1073000000;
|
|
const fromK = fromVSol * fromVTokens;
|
|
|
|
const newFromVTokens = fromVTokens + amountIn;
|
|
const newFromVSol = fromK / newFromVTokens;
|
|
const solProceeds = fromVSol - newFromVSol;
|
|
|
|
// 2. Buy ToCoin <- SOL
|
|
const toVSol = toCoin.virtualSolReserves || 30;
|
|
const toVTokens = toCoin.virtualTokenReserves || 1073000000;
|
|
const toK = toVSol * toVTokens;
|
|
|
|
// Check if solProceeds is valid (though if > balance it shouldn't be executed)
|
|
if (solProceeds <= 0) return { error: "Amount too low" };
|
|
|
|
const newToVSol = toVSol + solProceeds;
|
|
const newToVTokens = toK / newToVSol;
|
|
const tokensOut = toVTokens - newToVTokens;
|
|
|
|
return {
|
|
solProceeds,
|
|
tokensOut,
|
|
rate: tokensOut / amountIn
|
|
}
|
|
}, [fromCoin, toCoin, amount, userBalance])
|
|
|
|
const handleSwap = async () => {
|
|
if (!session || !fromCoinId || !toCoinId || !amount) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/swap', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
fromCoinId,
|
|
toCoinId,
|
|
amount: parseFloat(amount)
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Swap failed');
|
|
|
|
toast.success(`Swapped ${amount} ${fromCoin?.ticker} to ${data.swapped.received.toFixed(2)} ${toCoin?.ticker}`);
|
|
setAmount('');
|
|
// Refresh data
|
|
await mutate('/api/user');
|
|
await mutate('/api/coins');
|
|
} catch (e: any) {
|
|
toast.error(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const setMax = () => {
|
|
if (userBalance > 0) setAmount(userBalance.toString());
|
|
}
|
|
|
|
// Cap input visually (prevent typing more than balance?)
|
|
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const val = parseFloat(e.target.value);
|
|
if (!Number.isNaN(val) && val > userBalance) {
|
|
setAmount(userBalance.toString());
|
|
toast.info("Max balance reached");
|
|
} else {
|
|
setAmount(e.target.value);
|
|
}
|
|
};
|
|
|
|
if (!session) {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
<div className="flex flex-col items-center justify-center py-32 text-center animate-in fade-in duration-500">
|
|
<LgLock className="mb-4 h-16 w-16 opacity-50" />
|
|
<h1 className="mb-2 text-2xl font-bold">Sign In Required</h1>
|
|
<p className="mb-6 text-muted-foreground">
|
|
Connect your Discord account to swap coins
|
|
</p>
|
|
<Button onClick={() => signIn('discord')}>Sign in with Discord</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
|
<Header />
|
|
|
|
<main className="mx-auto max-w-lg px-4 py-16">
|
|
<div className="mb-8 text-center space-y-2">
|
|
<h1 className="text-4xl font-black tracking-tight">Swap Tokens</h1>
|
|
<p className="text-muted-foreground">Swap between different tokens on pummmp.fun</p>
|
|
</div>
|
|
|
|
<div className="relative overflow-hidden rounded-[2rem] border border-border bg-card/50 p-4 shadow-2xl backdrop-blur-xl">
|
|
|
|
{/* FROM SECTION */}
|
|
<div className="group relative rounded-[1.5rem] bg-muted/40 p-5 transition-all hover:bg-muted/60">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-muted-foreground">You Pay</span>
|
|
{fromCoin && (
|
|
<div
|
|
className="flex cursor-pointer items-center gap-1.5 rounded-full bg-background/50 px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background hover:text-primary"
|
|
onClick={setMax}
|
|
>
|
|
<Wallet className="h-3 w-3" />
|
|
<span>{userBalance.toLocaleString()} Max</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-start gap-4">
|
|
<input
|
|
type="number"
|
|
value={amount}
|
|
onChange={handleAmountChange}
|
|
placeholder="0.00"
|
|
className="w-full bg-transparent text-4xl font-bold tracking-tight outline-none placeholder:text-muted-foreground/20"
|
|
/>
|
|
|
|
<div className="shrink-0">
|
|
{/* Replace Native Select with CoinSelector */}
|
|
<CoinSelector
|
|
coins={myHoldings.map(h => h.coin)}
|
|
selectedId={fromCoinId}
|
|
onSelect={setFromCoinId}
|
|
placeholder="Select"
|
|
showBalance={true}
|
|
userPortfolio={userData?.portfolio}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 h-6">
|
|
{fromCoin && estimates?.solProceeds && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground/80">
|
|
<img src="/solana.svg" className="h-3 w-3 opacity-70" alt="SOL" />
|
|
≈ {estimates.solProceeds.toFixed(4)} SOL Value
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* SWAP INDICATOR */}
|
|
<div className="relative -my-5 z-10 flex justify-center">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl border-4 border-card bg-muted text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:bg-primary hover:text-primary-foreground">
|
|
<ArrowDownUp className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* TO SECTION */}
|
|
<div className="rounded-[1.5rem] bg-muted/40 p-5 pt-8 transition-all hover:bg-muted/60">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-muted-foreground">You Receive</span>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-4">
|
|
<div className={cn(
|
|
"w-full text-4xl font-bold tracking-tight bg-transparent outline-none truncate",
|
|
estimates?.tokensOut ? "text-primary" : "text-muted-foreground/30"
|
|
)}>
|
|
{estimates?.tokensOut ? estimates.tokensOut.toFixed(4) : "0.00"}
|
|
</div>
|
|
|
|
<div className="shrink-0">
|
|
<CoinSelector
|
|
coins={coins?.filter(c => c._id !== fromCoinId) || []}
|
|
selectedId={toCoinId}
|
|
onSelect={setToCoinId}
|
|
placeholder="Select"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 h-6" />
|
|
</div>
|
|
|
|
{/* ACTION */}
|
|
{estimates?.error ? (
|
|
<div className="mt-4 rounded-xl bg-destructive/10 p-3 text-center text-sm font-medium text-destructive animate-in fade-in slide-in-from-top-2">
|
|
{estimates.error}
|
|
</div>
|
|
) : (
|
|
<div className="h-4" />
|
|
)}
|
|
|
|
<Button
|
|
onClick={handleSwap}
|
|
disabled={loading || !estimates || !!estimates.error}
|
|
className="mt-2 h-16 w-full rounded-2xl text-xl font-bold shadow-xl shadow-primary/20 transition-all hover:scale-[1.02] hover:shadow-primary/30 active:scale-[0.98]"
|
|
>
|
|
{loading ? (
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
Swapping...
|
|
</div>
|
|
) : (
|
|
"Swap Tokens"
|
|
)}
|
|
</Button>
|
|
|
|
{/* PRICE INFO */}
|
|
{estimates && !estimates.error && (
|
|
<div className="mt-4 mb-1 flex items-center justify-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
|
<span>1 {fromCoin?.ticker} ≈ {estimates.rate?.toFixed(4)} {toCoin?.ticker}</span>
|
|
<span className="text-muted-foreground/50">•</span>
|
|
<span>This is the best price via bonding curve.</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|