pummmp.fun/app/swap/page.tsx
2026-07-04 12:49:09 -07:00

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>
)
}