"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowDownUp, Check, ChevronDown, Loader2, Search, Send, Settings, Wallet, X, } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; /* ============================================================ * Types + mock data * ============================================================ */ export type Chain = { id: string; name: string; color: string; symbol: string }; export type Token = { id: string; symbol: string; name: string; chainId: string; address?: string; balance?: number; usd?: number; trending?: boolean; popular?: boolean; }; const CHAINS: Chain[] = [ { id: "eth", name: "Ethereum", color: "#627EEA", symbol: "Ξ" }, { id: "sol", name: "Solana", color: "#9945FF", symbol: "◎" }, { id: "base", name: "Base", color: "#0052FF", symbol: "B" }, { id: "arb", name: "Arbitrum", color: "#28A0F0", symbol: "A" }, { id: "op", name: "Optimism", color: "#FF0420", symbol: "O" }, { id: "poly", name: "Polygon", color: "#8247E5", symbol: "P" }, { id: "bnb", name: "BNB", color: "#F0B90B", symbol: "B" }, { id: "avax", name: "Avalanche", color: "#E84142", symbol: "A" }, ]; const TOKENS: Token[] = [ { id: "eth-eth", symbol: "ETH", name: "Ether", chainId: "eth", balance: 1.245, usd: 3142, popular: true, }, { id: "eth-weth", symbol: "WETH", name: "Wrapped Ether", chainId: "eth", balance: 0.5, usd: 3142, popular: true, }, { id: "eth-usdc", symbol: "USDC", name: "USD Coin", chainId: "eth", balance: 4521, usd: 1, popular: true, }, { id: "eth-usdt", symbol: "USDT", name: "Tether", chainId: "eth", balance: 2100, usd: 1, popular: true, }, { id: "eth-dai", symbol: "DAI", name: "Dai Stablecoin", chainId: "eth", balance: 800, usd: 1, popular: true, }, { id: "eth-wbtc", symbol: "WBTC", name: "Wrapped Bitcoin", chainId: "eth", balance: 0.04, usd: 96000, popular: true, }, { id: "eth-link", symbol: "LINK", name: "ChainLink Token", chainId: "eth", balance: 120, usd: 22, address: "0x51...86ca", trending: true, }, { id: "eth-aztec", symbol: "AZTEC", name: "AZTEC", chainId: "eth", balance: 0, address: "0xa2...62d2", trending: true, }, { id: "eth-cfg", symbol: "CFG", name: "Centrifuge", chainId: "eth", balance: 0, address: "0xcc...8a94", trending: true, }, { id: "eth-ondo", symbol: "ONDO", name: "Ondo", chainId: "eth", balance: 0, address: "0xfa...9be3", trending: true, }, { id: "sol-sol", symbol: "SOL", name: "Solana", chainId: "sol", balance: 12.4, usd: 168, }, { id: "sol-usdc", symbol: "USDC", name: "USD Coin", chainId: "sol", balance: 985.32, usd: 1, }, { id: "base-eth", symbol: "ETH", name: "Ether", chainId: "base", balance: 0.65, usd: 3142, }, { id: "base-usdc", symbol: "USDC", name: "USD Coin", chainId: "base", balance: 1200, usd: 1, }, { id: "arb-arb", symbol: "ARB", name: "Arbitrum", chainId: "arb", balance: 800, usd: 0.95, }, { id: "arb-eth", symbol: "ETH", name: "Ether", chainId: "arb", balance: 0.32, usd: 3142, }, { id: "op-op", symbol: "OP", name: "Optimism", chainId: "op", balance: 450, usd: 2.8, }, { id: "poly-matic", symbol: "MATIC", name: "Polygon", chainId: "poly", balance: 1500, usd: 0.75, }, { id: "bnb-bnb", symbol: "BNB", name: "BNB", chainId: "bnb", balance: 0, usd: 620, }, { id: "avax-avax", symbol: "AVAX", name: "Avalanche", chainId: "avax", balance: 0, usd: 38, }, ]; const EASE = [0.16, 1, 0.3, 1] as const; const DRAWER_EASE = [0.32, 0.72, 0, 1] as const; /* ============================================================ * MultiChainSwap * ============================================================ */ export interface MultiChainSwapProps { chains?: Chain[]; tokens?: Token[]; defaultFromId?: string; defaultToId?: string; className?: string; } export function MultiChainSwap({ chains = CHAINS, tokens = TOKENS, defaultFromId = "eth-eth", defaultToId = "sol-sol", className, }: MultiChainSwapProps) { const reduce = useReducedMotion(); const [fromId, setFromId] = useState(defaultFromId); const [toId, setToId] = useState(defaultToId); const [amount, setAmount] = useState("1"); const [flipRot, setFlipRot] = useState(0); const [quoting, setQuoting] = useState(false); const [picking, setPicking] = useState<"from" | "to" | null>(null); const [showDest, setShowDest] = useState(false); const [destAddress, setDestAddress] = useState(""); const from = tokens.find((t) => t.id === fromId)!; const to = tokens.find((t) => t.id === toId)!; const fromChain = chains.find((c) => c.id === from.chainId)!; const toChain = chains.find((c) => c.id === to.chainId)!; const numericAmount = Number(amount) || 0; const rate = useMemo(() => { if (!from.usd || !to.usd) return 1; return from.usd / to.usd; }, [from.usd, to.usd]); const toAmount = numericAmount * rate; const networkFee = 0.42; const eta = "≈ 24s"; useEffect(() => { if (numericAmount === 0) return; setQuoting(true); const id = setTimeout(() => setQuoting(false), 450); return () => clearTimeout(id); }, [numericAmount, fromId, toId]); const flip = () => { setFlipRot((r) => r + 180); setFromId(toId); setToId(fromId); }; const pickToken = (id: string) => { if (!picking) return; if (picking === "from") { if (id === toId) setToId(fromId); setFromId(id); } else { if (id === fromId) setFromId(toId); setToId(id); } setPicking(null); }; return (
{/* Header */}
Swap
{/* Swap form (always mounted, fixed height) */}
setPicking("from")} /> 0 ? formatAmount(toAmount) : ""} editable={false} quoting={quoting} onOpenPicker={() => setPicking("to")} /> { if (showDest) setDestAddress(""); setShowDest((v) => !v); }} address={destAddress} onAddress={setDestAddress} reduce={!!reduce} />
{/* Token picker — bottom sheet anchored to this card */} setPicking(null)} reduce={!!reduce} />
); } /* ============================================================ * Field * ============================================================ */ function Field({ side, token, chain, amount, onAmount, editable, quoting, onOpenPicker, }: { side: "from" | "to"; token: Token; chain: Chain; amount: string; onAmount?: (v: string) => void; editable: boolean; quoting: boolean; onOpenPicker: () => void; }) { const id = useId(); const usdValue = (Number(amount) || 0) * (token.usd ?? 0); return (
{editable ? ( onAmount?.(sanitizeAmount(e.target.value))} placeholder="0" className="w-full bg-transparent text-2xl font-semibold tracking-tight text-(--color-fg) tabular-nums outline-none placeholder:text-(--color-fg-muted)/60" /> ) : (
{amount || "0"} {quoting ? ( ) : null}
)}

≈ ${formatAmount(usdValue, 2)}

{token.balance ? formatAmount(token.balance) : "0.00"} · {chain.name} {side === "from" && token.balance ? ( ) : null}
); } /* ============================================================ * Flip button * ============================================================ */ function FlipButton({ rotation, reduce, onClick, }: { rotation: number; reduce: boolean; onClick: () => void; }) { return (
); } /* ============================================================ * Quote row * ============================================================ */ function QuoteRow({ from, to, rate, fee, slippage, eta, quoting, }: { from: Token; to: Token; rate: number; fee: number; slippage: number; eta: string; quoting: boolean; }) { return (
Rate {quoting ? ( ) : ( <> 1 {from.symbol} ≈ {formatAmount(rate)} {to.symbol} )} Network fee ${fee.toFixed(2)} Slippage {slippage.toFixed(2)}% ETA {eta}
); } /* ============================================================ * Action button * ============================================================ */ function ActionButton({ from, to, amount, destAddress, }: { from: Token; to: Token; amount: number; destAddress: string; }) { const noAmount = amount <= 0; const overBalance = from.balance !== undefined && amount > from.balance; const validDest = destAddress && isValidAddress(destAddress); const label = noAmount ? "Enter an amount" : overBalance ? `Insufficient ${from.symbol}` : validDest ? `Swap + Send to ${truncateAddress(destAddress)}` : `Swap ${from.symbol} → ${to.symbol}`; const disabled = noAmount || overBalance; return ( {label} ); } /* ============================================================ * Destination address row * ============================================================ */ function DestinationRow({ show, onToggle, address, onAddress, reduce, }: { show: boolean; onToggle: () => void; address: string; onAddress: (v: string) => void; reduce: boolean; }) { const inputRef = useRef(null); const hasAddress = address.length > 0; const valid = isValidAddress(address); useEffect(() => { if (!show) return; requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }), ); }, [show]); return (
{show ? (
onAddress(e.target.value.trim())} placeholder="0x... or name.eth" spellCheck={false} className="min-w-0 flex-1 bg-transparent font-mono text-[12px] text-(--color-fg) outline-none placeholder:text-(--color-fg-muted)/60" /> {hasAddress ? ( valid ? ( ) : ( onAddress("")} aria-label="Clear address" initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.7 }} transition={{ duration: 0.14, ease: EASE }} className="shrink-0 text-(--color-fg-muted) hover:text-(--color-fg)" > ) ) : null}
) : null}
); } /* ============================================================ * TokenPicker — bottom sheet anchored to swap card * ============================================================ */ function TokenPicker({ open, side, chains, tokens, selectedId, onPick, onClose, reduce, }: { open: boolean; side: "from" | "to" | null; chains: Chain[]; tokens: Token[]; selectedId: string; onPick: (id: string) => void; onClose: () => void; reduce: boolean; }) { const [chainFilter, setChainFilter] = useState("all"); // "all" or chain.id const [q, setQ] = useState(""); const inputRef = useRef(null); useEffect(() => { if (!open) return; setQ(""); // Focus without letting the browser scroll the page to bring the input // into view — that's what causes the swap form behind to "shift up". requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }), ); }, [open]); // Esc to close useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); const popular = useMemo( () => tokens.filter((t) => t.popular).slice(0, 6), [tokens], ); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); return tokens.filter((t) => { if (chainFilter !== "all" && t.chainId !== chainFilter) return false; if (!needle) return true; const chain = chains.find((c) => c.id === t.chainId); return [t.symbol, t.name, chain?.name, t.address].some((h) => h?.toLowerCase().includes(needle), ); }); }, [q, chainFilter, tokens, chains]); return ( {open ? ( <> {/* Backdrop inside the card */} {/* Sheet panel */} {/* Drag handle (visual only) */}
{/* Search */}
setQ(e.target.value)} placeholder="Search name or paste address" className="w-full bg-transparent text-sm text-(--color-fg) outline-none placeholder:text-(--color-fg-muted)/70" />
{/* Chain chips */}
setChainFilter("all")} label="All" /> {chains.map((c) => ( setChainFilter(c.id)} chain={c} /> ))}
{/* Scroll area */}
{/* Popular */} {!q && chainFilter === "all" ? ( <>

Most popular

{popular.map((t) => { const chain = chains.find((c) => c.id === t.chainId)!; return ( ); })}
) : null} {/* Trending / All */}

{q ? "Results" : chainFilter === "all" ? "Trending" : "Tokens"}

    {filtered.length === 0 ? (
  • No tokens found
  • ) : null} {filtered.map((t) => { const chain = chains.find((c) => c.id === t.chainId)!; const active = t.id === selectedId; return (
  • ); })}
) : null}
); } function ChainChip({ active, onClick, chain, label, }: { active: boolean; onClick: () => void; chain?: Chain; label?: string; }) { return ( ); } /* ============================================================ * Bits * ============================================================ */ function ChainDot({ chain, size = 16 }: { chain: Chain; size?: number }) { return ( {chain.symbol} ); } function TokenDot({ token, chain, size = 28, }: { token: Token; chain: Chain; size?: number; }) { return ( {token.symbol.slice(0, 2)} {chain.symbol} ); } function isValidAddress(v: string) { if (/^0x[0-9a-fA-F]{40}$/.test(v)) return true; if (/\.(eth|sol|bnb)$/.test(v) && v.length > 5) return true; return false; } function truncateAddress(v: string) { if (v.startsWith("0x") && v.length === 42) return v.slice(0, 6) + "…" + v.slice(-4); return v; } function sanitizeAmount(v: string) { const cleaned = v.replace(/[^0-9.]/g, ""); const parts = cleaned.split("."); if (parts.length <= 1) return cleaned; return parts[0] + "." + parts.slice(1).join(""); } function formatAmount(n: number, max = 6) { if (!isFinite(n)) return "0"; if (n === 0) return "0"; if (n >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 2 }); return n.toLocaleString(undefined, { maximumFractionDigits: max }); } // Drawer ease retained for callers that import the curve. export const SWAP_DRAWER_EASE = DRAWER_EASE;