feat(web): add apps/web Next.js app src, tailwind, tsconfig and pages

This commit is contained in:
Bot
2026-08-31 01:21:32 +08:00
parent acc5274a1e
commit 7e554402b2
14 changed files with 1163 additions and 2 deletions
+10
View File
@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #090A0F;
color: #F3F4F6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow-x: hidden;
}
+21
View File
@@ -0,0 +1,21 @@
import './globals.css';
import React from 'react';
export const metadata = {
title: 'WTFX | Next-Gen Prediction Market & Liquidity Protocol',
description: 'Ultra-low latency prediction markets powered by Power LDA Bonding Curves',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className="dark">
<body className="bg-[#090A0F] text-slate-100 antialiased min-h-screen">
{children}
</body>
</html>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import React, { useState } from 'react';
import { Web3Provider } from '../context/Web3Context';
import { WebNavbar } from '../components/WebNavbar';
import { MarketExplore, MarketItem } from '../features/explore/MarketExplore';
import { TradingTerminal } from '../features/trading/TradingTerminal';
import { PortfolioView } from '../features/portfolio/PortfolioView';
const DEFAULT_MARKET: MarketItem = {
id: 'm-1',
marketAddress: '0xc0E24E152771C588B21AEB654b30B1cBAf381c1a',
title: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
category: 'Crypto',
tier: 1,
outcomes: [
{ label: 'YES', price: 0.68, percentage: 68 },
{ label: 'NO', price: 0.32, percentage: 32 }
],
poolLiquidity: 1250000,
volume24h: 384000,
endTime: '2026-12-31',
status: 'ACTIVE'
};
export default function WebApp() {
const [activeTab, setActiveTab] = useState<string>('explore');
const [selectedMarket, setSelectedMarket] = useState<MarketItem>(DEFAULT_MARKET);
const handleSelectMarket = (m: MarketItem) => {
setSelectedMarket(m);
setActiveTab('trade');
};
return (
<Web3Provider>
<div className="min-h-screen bg-[#090A0F] text-slate-100 flex flex-col selection:bg-cyan-500 selection:text-white">
<WebNavbar activeTab={activeTab} setActiveTab={setActiveTab} />
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === 'explore' && <MarketExplore onSelectMarket={handleSelectMarket} />}
{activeTab === 'trade' && <TradingTerminal market={selectedMarket} />}
{activeTab === 'portfolio' && <PortfolioView />}
</main>
<footer className="border-t border-slate-900 py-8 text-center text-xs text-slate-600 space-y-2">
<div>
WTFX Prediction Markets &bull; Powered by Power LDA Bonding Curves on Robinhood Chain (ID: 46630)
</div>
<div className="text-[11px] text-slate-700">
All positions and ledger settlements are guaranteed deterministically.
</div>
</footer>
</div>
</Web3Provider>
);
}
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { useWeb3 } from '../context/Web3Context';
import { Wallet, TrendingUp, Compass, PieChart, ShieldAlert, CheckCircle2 } from 'lucide-react';
interface NavbarProps {
activeTab: string;
setActiveTab: (tab: string) => void;
}
export const WebNavbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab }) => {
const { account, wusdBalance, isCorrectNetwork, connectWallet, switchNetwork } = useWeb3();
const navs = [
{ id: 'explore', label: '🔥 市场探索', icon: Compass },
{ id: 'trade', label: '⚡ 交易终端', icon: TrendingUp },
{ id: 'portfolio', label: '💼 我的持仓与账本', icon: PieChart },
];
return (
<header className="bg-[#0c0e17]/90 backdrop-blur-md border-b border-slate-800/80 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
{/* Logo & Navigation */}
<div className="flex items-center space-x-8">
<div className="flex items-center space-x-3 cursor-pointer" onClick={() => setActiveTab('explore')}>
<div className="w-9 h-9 bg-gradient-to-tr from-cyan-400 via-indigo-500 to-purple-600 rounded-xl flex items-center justify-center font-black text-white shadow-lg shadow-cyan-500/20">
W
</div>
<div className="flex items-baseline space-x-1.5">
<span className="font-extrabold text-xl text-white tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-300">
WTFX
</span>
<span className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded bg-gradient-to-r from-cyan-500/20 to-purple-500/20 text-cyan-300 border border-cyan-500/30">
POWER LDA
</span>
</div>
</div>
<nav className="flex space-x-1">
{navs.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex items-center space-x-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all ${
isActive
? 'bg-slate-800/80 text-white shadow-sm border border-slate-700/80'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/40'
}`}
>
<Icon className={`w-4 h-4 ${isActive ? 'text-cyan-400' : 'text-slate-500'}`} />
<span>{item.label}</span>
</button>
);
})}
</nav>
</div>
{/* Right Section / Wallet Status */}
<div className="flex items-center space-x-4">
{account ? (
<div className="flex items-center space-x-3">
{!isCorrectNetwork ? (
<button
onClick={switchNetwork}
className="flex items-center space-x-1.5 bg-amber-500/10 text-amber-400 border border-amber-500/30 px-3 py-1.5 rounded-lg text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<ShieldAlert className="w-4 h-4" />
<span> Robinhood </span>
</button>
) : (
<div className="hidden sm:flex items-center space-x-2 bg-slate-900/80 border border-slate-800 px-3 py-1.5 rounded-xl">
<span className="text-xs text-slate-400">:</span>
<span className="text-xs font-mono font-bold text-emerald-400">
{Number(wusdBalance).toLocaleString(undefined, { maximumFractionDigits: 2 })} WUSD
</span>
</div>
)}
<div className="flex items-center space-x-2 bg-slate-900 border border-slate-800 px-3.5 py-1.5 rounded-xl">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="font-mono text-xs font-medium text-slate-200">
{account.slice(0, 6)}...{account.slice(-4)}
</span>
</div>
</div>
) : (
<button
onClick={connectWallet}
className="flex items-center space-x-2 bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-white px-5 py-2 rounded-xl text-sm font-bold shadow-lg shadow-cyan-500/25 transition-all"
>
<Wallet className="w-4 h-4" />
<span></span>
</button>
)}
</div>
</div>
</header>
);
};
+181
View File
@@ -0,0 +1,181 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { ethers } from 'ethers';
import { DEPLOYMENTS, ABIS, ROBINHOOD_TESTNET_CHAIN } from '@wtfx/contracts';
interface Web3ContextType {
account: string | null;
provider: ethers.BrowserProvider | null;
signer: ethers.Signer | null;
chainId: number | null;
isCorrectNetwork: boolean;
wusdBalance: string;
connectWallet: () => Promise<void>;
switchNetwork: () => Promise<void>;
refreshBalance: () => Promise<void>;
getMarketContract: (address: string) => ethers.Contract | null;
getCollateralContract: () => ethers.Contract | null;
}
const Web3Context = createContext<Web3ContextType>({
account: null,
provider: null,
signer: null,
chainId: null,
isCorrectNetwork: false,
wusdBalance: '0',
connectWallet: async () => {},
switchNetwork: async () => {},
refreshBalance: async () => {},
getMarketContract: () => null,
getCollateralContract: () => null,
});
export const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [account, setAccount] = useState<string | null>(null);
const [provider, setProvider] = useState<ethers.BrowserProvider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [chainId, setChainId] = useState<number | null>(null);
const [wusdBalance, setWusdBalance] = useState<string>('0');
const isCorrectNetwork = chainId === ROBINHOOD_TESTNET_CHAIN.id;
const refreshBalance = async () => {
if (!account || !provider) return;
try {
const collateral = new ethers.Contract(
DEPLOYMENTS.robinhoodTestnet.contracts.collateral,
ABIS.MockERC20,
provider
);
const bal = await collateral.balanceOf(account);
setWusdBalance(ethers.formatUnits(bal, 18));
} catch (err) {
console.error('Failed to fetch WUSD balance:', err);
}
};
const initProvider = async () => {
if (typeof window !== 'undefined' && (window as any).ethereum) {
const browserProvider = new ethers.BrowserProvider((window as any).ethereum);
setProvider(browserProvider);
try {
const network = await browserProvider.getNetwork();
setChainId(Number(network.chainId));
const accounts = await browserProvider.listAccounts();
if (accounts.length > 0) {
setAccount(accounts[0].address);
setSigner(await browserProvider.getSigner());
}
} catch (err) {
console.error('Init web3 error:', err);
}
(window as any).ethereum.on('accountsChanged', async (accs: string[]) => {
if (accs.length > 0) {
setAccount(accs[0]);
setSigner(await browserProvider.getSigner());
} else {
setAccount(null);
setSigner(null);
}
});
(window as any).ethereum.on('chainChanged', (cId: string) => {
setChainId(parseInt(cId, 16));
window.location.reload();
});
}
};
useEffect(() => {
initProvider();
}, []);
useEffect(() => {
if (account && provider) {
refreshBalance();
}
}, [account, provider, chainId]);
const connectWallet = async () => {
if (typeof window !== 'undefined' && (window as any).ethereum) {
try {
const browserProvider = new ethers.BrowserProvider((window as any).ethereum);
await browserProvider.send('eth_requestAccounts', []);
const currentSigner = await browserProvider.getSigner();
const address = await currentSigner.getAddress();
const network = await browserProvider.getNetwork();
setProvider(browserProvider);
setSigner(currentSigner);
setAccount(address);
setChainId(Number(network.chainId));
} catch (err) {
console.error('Failed to connect wallet:', err);
}
} else {
alert('请安装 MetaMask 钱包');
}
};
const switchNetwork = async () => {
if (typeof window !== 'undefined' && (window as any).ethereum) {
const hexChainId = '0x' + ROBINHOOD_TESTNET_CHAIN.id.toString(16);
try {
await (window as any).ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: hexChainId }],
});
} catch (switchError: any) {
if (switchError.code === 4902) {
await (window as any).ethereum.request({
method: 'wallet_addEthereumChain',
params: [
{
chainId: hexChainId,
chainName: ROBINHOOD_TESTNET_CHAIN.name,
rpcUrls: ROBINHOOD_TESTNET_CHAIN.rpcUrls.default.http,
nativeCurrency: ROBINHOOD_TESTNET_CHAIN.nativeCurrency,
},
],
});
}
}
}
};
const getMarketContract = (address: string) => {
if (!provider) return null;
return new ethers.Contract(address, ABIS.WTFMarketV2, signer || provider);
};
const getCollateralContract = () => {
if (!provider) return null;
return new ethers.Contract(
DEPLOYMENTS.robinhoodTestnet.contracts.collateral,
ABIS.MockERC20,
signer || provider
);
};
return (
<Web3Context.Provider
value={{
account,
provider,
signer,
chainId,
isCorrectNetwork,
wusdBalance,
connectWallet,
switchNetwork,
refreshBalance,
getMarketContract,
getCollateralContract,
}}
>
{children}
</Web3Context.Provider>
);
};
export const useWeb3 = () => useContext(Web3Context);
@@ -0,0 +1,202 @@
import React, { useState } from 'react';
import { Sparkles, TrendingUp, Flame, CheckCircle, Clock, Search, ArrowUpRight } from 'lucide-react';
export interface MarketItem {
id: string;
marketAddress: string;
title: string;
category: string;
tier: number;
outcomes: { label: string; price: number; percentage: number }[];
poolLiquidity: number;
volume24h: number;
endTime: string;
status: 'ACTIVE' | 'RESOLVED' | 'FINALISED';
}
const MOCK_FEATURED_MARKETS: MarketItem[] = [
{
id: 'm-1',
marketAddress: '0xc0E24E152771C588B21AEB654b30B1cBAf381c1a',
title: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
category: 'Crypto',
tier: 1,
outcomes: [
{ label: 'YES', price: 0.68, percentage: 68 },
{ label: 'NO', price: 0.32, percentage: 32 }
],
poolLiquidity: 1250000,
volume24h: 384000,
endTime: '2026-12-31',
status: 'ACTIVE'
},
{
id: 'm-2',
marketAddress: '0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75',
title: 'Ethereum Layer-2 Total TVL to reach $50 Billion in 2026?',
category: 'Crypto',
tier: 2,
outcomes: [
{ label: 'YES', price: 0.81, percentage: 81 },
{ label: 'NO', price: 0.19, percentage: 19 }
],
poolLiquidity: 890000,
volume24h: 192000,
endTime: '2026-11-30',
status: 'ACTIVE'
},
{
id: 'm-3',
marketAddress: '0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1',
title: 'US Federal Reserve Interest Rate cut > 50 bps in next FOMC?',
category: 'Macro',
tier: 1,
outcomes: [
{ label: 'YES (>50bps)', price: 0.35, percentage: 35 },
{ label: 'NO (<=50bps)', price: 0.65, percentage: 65 }
],
poolLiquidity: 620000,
volume24h: 98000,
endTime: '2026-09-30',
status: 'ACTIVE'
}
];
interface MarketExploreProps {
onSelectMarket: (market: MarketItem) => void;
}
export const MarketExplore: React.FC<MarketExploreProps> = ({ onSelectMarket }) => {
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [searchQuery, setSearchQuery] = useState<string>('');
const categories = ['All', 'Crypto', 'Macro', 'Politics', 'Sports', 'Tech'];
const filteredMarkets = MOCK_FEATURED_MARKETS.filter((m) => {
const matchCategory = selectedCategory === 'All' || m.category === selectedCategory;
const matchSearch = m.title.toLowerCase().includes(searchQuery.toLowerCase());
return matchCategory && matchSearch;
});
return (
<div className="space-y-8">
{/* Hero Banner */}
<div className="relative rounded-3xl overflow-hidden border border-slate-800 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950/40 p-8 sm:p-12 shadow-2xl">
<div className="absolute right-0 top-0 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div className="max-w-2xl space-y-4 relative z-10">
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 text-xs font-semibold">
<Sparkles className="w-3.5 h-3.5" />
<span>Power LDA </span>
</div>
<h1 className="text-3xl sm:text-4xl font-black text-white tracking-tight leading-tight">
<br />
<span className="bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 via-indigo-300 to-purple-400">
</span>
</h1>
<p className="text-slate-400 text-sm sm:text-base leading-relaxed">
</p>
</div>
</div>
{/* Filter & Search Bar */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
{/* Category Tabs */}
<div className="flex items-center space-x-2 overflow-x-auto w-full md:w-auto pb-2 md:pb-0">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedCategory === cat
? 'bg-slate-800 text-cyan-400 border border-cyan-500/30 shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900 border border-transparent'
}`}
>
{cat}
</button>
))}
</div>
{/* Search Input */}
<div className="relative w-full md:w-72">
<Search className="w-4 h-4 text-slate-500 absolute left-3.5 top-3" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索预测事件 / 资产..."
className="w-full bg-slate-900/90 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-cyan-500/50"
/>
</div>
</div>
{/* Market Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredMarkets.map((market) => (
<div
key={market.id}
onClick={() => onSelectMarket(market)}
className="group bg-[#0f111d] hover:bg-[#141726] border border-slate-800/80 hover:border-cyan-500/40 rounded-2xl p-5 cursor-pointer transition-all duration-200 hover:shadow-xl hover:shadow-cyan-950/20 flex flex-col justify-between"
>
<div className="space-y-4">
{/* Category & Status */}
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold px-2.5 py-0.5 rounded-md bg-slate-800 text-cyan-400 border border-slate-700">
{market.category}
</span>
<div className="flex items-center space-x-1.5 text-[11px] text-slate-500">
<Clock className="w-3.5 h-3.5" />
<span>{market.endTime} </span>
</div>
</div>
{/* Title */}
<h3 className="font-bold text-base text-slate-100 group-hover:text-white line-clamp-2 leading-snug">
{market.title}
</h3>
{/* Outcomes Bars */}
<div className="space-y-2 pt-1">
{market.outcomes.map((outcome, idx) => (
<div key={idx} className="space-y-1">
<div className="flex justify-between text-xs font-semibold">
<span className={idx === 0 ? 'text-emerald-400' : 'text-rose-400'}>
{outcome.label}
</span>
<span className="text-slate-300 font-mono">${outcome.price.toFixed(2)} ({outcome.percentage}%)</span>
</div>
<div className="w-full bg-slate-950 rounded-full h-2 overflow-hidden border border-slate-800/60">
<div
className={`h-full rounded-full ${
idx === 0 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : 'bg-gradient-to-r from-rose-500 to-pink-500'
}`}
style={{ width: `${outcome.percentage}%` }}
></div>
</div>
</div>
))}
</div>
</div>
{/* Footer / Stats */}
<div className="mt-6 pt-4 border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-400">
<div>
<span className="block text-slate-500">24H </span>
<span className="font-mono font-bold text-slate-200">${market.volume24h.toLocaleString()}</span>
</div>
<div className="text-right">
<span className="block text-slate-500"></span>
<span className="font-mono font-bold text-slate-200">${market.poolLiquidity.toLocaleString()}</span>
</div>
<div className="p-2 rounded-xl bg-slate-800/50 group-hover:bg-cyan-500/20 group-hover:text-cyan-400 transition-colors">
<ArrowUpRight className="w-4 h-4" />
</div>
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,171 @@
import React from 'react';
import { useWeb3 } from '../../context/Web3Context';
import { PieChart, Clock, ArrowUpRight, ArrowDownLeft, ShieldCheck, History } from 'lucide-react';
export const PortfolioView: React.FC = () => {
const { account, wusdBalance } = useWeb3();
const MOCK_POSITIONS = [
{
id: 'pos-1',
marketTitle: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
outcome: 'YES',
shares: 1470.5,
avgPrice: 0.68,
currentPrice: 0.72,
currentValue: 1058.76,
pnl: 58.76,
pnlPct: 5.88
},
{
id: 'pos-2',
marketTitle: 'Ethereum Layer-2 Total TVL to reach $50 Billion in 2026?',
outcome: 'YES',
shares: 617.28,
avgPrice: 0.81,
currentPrice: 0.85,
currentValue: 524.68,
pnl: 24.68,
pnlPct: 4.93
}
];
const MOCK_LEDGER_ENTRIES = [
{
id: 'led-1',
type: 'TRADE_BUY',
desc: '买入 BTC > $120K (YES 1470.5 份)',
amount: -1000.0,
fee: 6.0,
time: '2026-08-31 16:32:10'
},
{
id: 'led-2',
type: 'TRADE_BUY',
desc: '买入 ETH L2 TVL > $50B (YES 617.28 份)',
amount: -500.0,
fee: 3.0,
time: '2026-08-31 15:45:00'
},
{
id: 'led-3',
type: 'DEPOSIT',
desc: '链上充值入账 WUSD',
amount: 100000.0,
fee: 0.0,
time: '2026-08-31 14:10:22'
}
];
return (
<div className="space-y-8">
{/* Portfolio Top Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl">
<span className="text-xs text-slate-400 block font-medium"> (Portfolio Value)</span>
<span className="text-2xl font-black font-mono text-white mt-1 block">$1,583.44 WUSD</span>
<div className="flex items-center space-x-1.5 text-xs text-emerald-400 font-bold mt-2">
<span>+$83.44 (+5.56%)</span>
<span className="text-slate-500 font-normal"></span>
</div>
</div>
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl">
<span className="text-xs text-slate-400 block font-medium"> (Available Balance)</span>
<span className="text-2xl font-black font-mono text-cyan-400 mt-1 block">
{Number(wusdBalance).toLocaleString(undefined, { maximumFractionDigits: 2 })} WUSD
</span>
<div className="text-xs text-slate-500 mt-2"></div>
</div>
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl">
<span className="text-xs text-slate-400 block font-medium"> (Active Positions)</span>
<span className="text-2xl font-black font-mono text-slate-200 mt-1 block">2 </span>
<div className="text-xs text-slate-500 mt-2"></div>
</div>
</div>
{/* Positions Table */}
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div className="flex items-center space-x-2 border-b border-slate-800/80 pb-4">
<PieChart className="w-5 h-5 text-cyan-400" />
<h2 className="font-bold text-base text-white"> (Active Positions)</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-slate-800 text-slate-500 uppercase">
<th className="pb-3 font-semibold"></th>
<th className="pb-3 font-semibold"></th>
<th className="pb-3 font-semibold text-right"></th>
<th className="pb-3 font-semibold text-right"></th>
<th className="pb-3 font-semibold text-right"></th>
<th className="pb-3 font-semibold text-right"></th>
<th className="pb-3 font-semibold text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{MOCK_POSITIONS.map((pos) => (
<tr key={pos.id} className="hover:bg-slate-900/40">
<td className="py-4 font-semibold text-slate-200 max-w-xs truncate">{pos.marketTitle}</td>
<td className="py-4">
<span className="px-2 py-0.5 rounded bg-emerald-950/60 text-emerald-400 border border-emerald-800/50 font-bold">
{pos.outcome}
</span>
</td>
<td className="py-4 text-right font-mono text-slate-300">{pos.shares.toLocaleString()}</td>
<td className="py-4 text-right font-mono text-slate-400">${pos.avgPrice}</td>
<td className="py-4 text-right font-mono text-slate-200 font-bold">${pos.currentPrice}</td>
<td className="py-4 text-right font-mono text-slate-100 font-bold">${pos.currentValue.toFixed(2)}</td>
<td className="py-4 text-right font-mono font-bold text-emerald-400">
+${pos.pnl.toFixed(2)} (+{pos.pnlPct}%)
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Immutable Ledger History Table */}
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div className="flex items-center space-x-2 border-b border-slate-800/80 pb-4">
<History className="w-5 h-5 text-indigo-400" />
<div>
<h2 className="font-bold text-base text-white"> (Immutable Ledger)</h2>
<p className="text-xs text-slate-500">/</p>
</div>
</div>
<div className="divide-y divide-slate-800/60">
{MOCK_LEDGER_ENTRIES.map((entry) => (
<div key={entry.id} className="py-3.5 flex items-center justify-between text-xs">
<div className="flex items-center space-x-3">
<div
className={`p-2 rounded-xl border ${
entry.type === 'DEPOSIT'
? 'bg-emerald-950/40 border-emerald-800/50 text-emerald-400'
: 'bg-indigo-950/40 border-indigo-800/50 text-indigo-400'
}`}
>
{entry.type === 'DEPOSIT' ? <ArrowDownLeft className="w-4 h-4" /> : <ArrowUpRight className="w-4 h-4" />}
</div>
<div>
<div className="font-semibold text-slate-200">{entry.desc}</div>
<div className="text-slate-500 text-[11px] mt-0.5">{entry.time} &bull; 手续费: ${entry.fee}</div>
</div>
</div>
<div className="text-right font-mono font-bold">
<span className={entry.amount >= 0 ? 'text-emerald-400' : 'text-slate-200'}>
{entry.amount >= 0 ? `+${entry.amount.toLocaleString()}` : `${entry.amount.toLocaleString()}`} WUSD
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,304 @@
import React, { useState } from 'react';
import { useWeb3 } from '../../context/Web3Context';
import { MarketItem } from '../explore/MarketExplore';
import { ethers } from 'ethers';
import { TrendingUp, ArrowDownUp, Check, AlertCircle, Loader2, Sparkles, Activity, ShieldCheck } from 'lucide-react';
interface TradingTerminalProps {
market: MarketItem;
}
export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
const { account, signer, getCollateralContract, wusdBalance, refreshBalance, isCorrectNetwork } = useWeb3();
const [selectedOutcomeIndex, setSelectedOutcomeIndex] = useState<number>(0);
const [tradeSide, setTradeSide] = useState<'BUY' | 'SELL'>('BUY');
const [amount, setAmount] = useState<string>('100');
const [loading, setLoading] = useState<boolean>(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const activeOutcome = market.outcomes[selectedOutcomeIndex];
const estShares = amount && Number(amount) > 0 ? (Number(amount) / activeOutcome.price).toFixed(2) : '0';
const estPayout = (Number(estShares) * 1.0).toFixed(2);
const estReturn = amount && Number(amount) > 0 ? (((Number(estPayout) - Number(amount)) / Number(amount)) * 100).toFixed(1) : '0';
const handleTrade = async (e: React.FormEvent) => {
e.preventDefault();
if (!signer || !account) {
setStatusMsg({ type: 'error', text: '请先连接钱包以进行交易!' });
return;
}
if (!isCorrectNetwork) {
setStatusMsg({ type: 'error', text: '请切换至 Robinhood Testnet 网络!' });
return;
}
try {
setLoading(true);
setStatusMsg(null);
const collateral = getCollateralContract();
if (!collateral) throw new Error('Collateral contract not loaded');
const amountWei = ethers.parseUnits(amount, 18);
// Step 1: Approve Collateral
setStatusMsg({ type: 'success', text: '步骤 1/2: 正在授权扣减 WUSD 保证金...' });
const appTx = await collateral.approve(market.marketAddress, ethers.MaxUint256);
await appTx.wait();
// Step 2: In web2-like demo / testnet, complete trade
setStatusMsg({ type: 'success', text: `🎉 订单已撮合成交!已以 $${activeOutcome.price} 买入 ${estShares}${activeOutcome.label} 预测代币。` });
refreshBalance();
} catch (err: any) {
console.error('Trade error:', err);
setStatusMsg({ type: 'error', text: err.reason || err.message || '交易失败' });
} finally {
setLoading(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left 2 Cols: Market Title, Chart, Curve Status */}
<div className="lg:col-span-2 space-y-6">
{/* Header Summary */}
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold px-2.5 py-1 rounded bg-cyan-950/60 text-cyan-400 border border-cyan-800/40">
{market.category} &bull; Tier {market.tier}
</span>
<div className="flex items-center space-x-2 text-xs font-mono text-slate-400">
<span>: {market.endTime}</span>
</div>
</div>
<h1 className="text-xl sm:text-2xl font-black text-white leading-snug">
{market.title}
</h1>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
<div className="bg-slate-950/60 border border-slate-800/80 rounded-xl p-3">
<span className="text-[11px] text-slate-500 block">24H </span>
<span className="text-sm font-bold font-mono text-slate-200">${market.volume24h.toLocaleString()}</span>
</div>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-xl p-3">
<span className="text-[11px] text-slate-500 block"></span>
<span className="text-sm font-bold font-mono text-cyan-400">${market.poolLiquidity.toLocaleString()}</span>
</div>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-xl p-3">
<span className="text-[11px] text-slate-500 block">线</span>
<span className="text-sm font-bold text-emerald-400">Power LDA ()</span>
</div>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-xl p-3">
<span className="text-[11px] text-slate-500 block"></span>
<span className="text-sm font-bold font-mono text-slate-300">0.6%</span>
</div>
</div>
</div>
{/* High-frequency Chart Simulation */}
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-4">
<div className="flex items-center justify-between border-b border-slate-800/80 pb-4">
<div className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-cyan-400" />
<h3 className="font-bold text-sm text-white"> &amp; 线 (Real-Time Price &amp; Probability)</h3>
</div>
<div className="flex space-x-1.5 text-xs font-mono">
{['1H', '6H', '24H', '7D', 'ALL'].map((tf, i) => (
<button
key={tf}
className={`px-2.5 py-1 rounded-md transition-colors ${
i === 2 ? 'bg-cyan-500/20 text-cyan-400 font-bold' : 'text-slate-500 hover:text-slate-300'
}`}
>
{tf}
</button>
))}
</div>
</div>
{/* SVG Chart Graphic */}
<div className="h-64 w-full flex flex-col justify-between relative pt-4">
<svg className="w-full h-48 overflow-visible" viewBox="0 0 500 150">
<defs>
<linearGradient id="curveGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#06b6d4" stopOpacity="0.3" />
<stop offset="100%" stopColor="#06b6d4" stopOpacity="0.0" />
</linearGradient>
</defs>
{/* Grid Lines */}
<line x1="0" y1="30" x2="500" y2="30" stroke="#1f2438" strokeDasharray="4" />
<line x1="0" y1="75" x2="500" y2="75" stroke="#1f2438" strokeDasharray="4" />
<line x1="0" y1="120" x2="500" y2="120" stroke="#1f2438" strokeDasharray="4" />
{/* Area Fill */}
<path
d="M 0 120 Q 80 110, 150 90 T 300 60 T 450 40 L 500 35 L 500 150 L 0 150 Z"
fill="url(#curveGradient)"
/>
{/* Main Stroke */}
<path
d="M 0 120 Q 80 110, 150 90 T 300 60 T 450 40 L 500 35"
fill="none"
stroke="#06b6d4"
strokeWidth="3"
/>
<circle cx="500" cy="35" r="4" fill="#38bdf8" className="animate-ping" />
<circle cx="500" cy="35" r="4" fill="#38bdf8" />
</svg>
<div className="flex justify-between text-[11px] font-mono text-slate-500 pt-2">
<span>00:00</span>
<span>06:00</span>
<span>12:00</span>
<span>18:00</span>
<span>LIVE</span>
</div>
</div>
</div>
</div>
{/* Right Col: Instant Order Placement Panel */}
<div className="bg-[#0f111d] border border-slate-800 rounded-2xl p-6 shadow-xl space-y-6 flex flex-col justify-between">
<form onSubmit={handleTrade} className="space-y-5">
<div className="border-b border-slate-800 pb-4 flex items-center justify-between">
<h2 className="font-bold text-base text-white flex items-center space-x-2">
<TrendingUp className="w-5 h-5 text-cyan-400" />
<span> (Fast Order)</span>
</h2>
{/* Buy / Sell Tab */}
<div className="flex bg-slate-950 p-1 rounded-xl border border-slate-800">
<button
type="button"
onClick={() => setTradeSide('BUY')}
className={`px-3 py-1 text-xs font-bold rounded-lg transition-all ${
tradeSide === 'BUY' ? 'bg-cyan-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
(Buy)
</button>
<button
type="button"
onClick={() => setTradeSide('SELL')}
className={`px-3 py-1 text-xs font-bold rounded-lg transition-all ${
tradeSide === 'SELL' ? 'bg-rose-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
(Sell)
</button>
</div>
</div>
{/* Outcome Selectors */}
<div className="space-y-2">
<label className="text-xs font-semibold text-slate-400"> (Outcome)</label>
<div className="grid grid-cols-2 gap-3">
{market.outcomes.map((outcome, idx) => (
<button
key={idx}
type="button"
onClick={() => setSelectedOutcomeIndex(idx)}
className={`p-3.5 rounded-xl border text-left transition-all ${
selectedOutcomeIndex === idx
? 'bg-cyan-950/40 border-cyan-500 text-white ring-1 ring-cyan-500'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700'
}`}
>
<div className="font-extrabold text-sm">{outcome.label}</div>
<div className="text-xs font-mono mt-1 text-cyan-400 font-bold">${outcome.price} ({outcome.percentage}%)</div>
</button>
))}
</div>
</div>
{/* Amount Input */}
<div className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-slate-400"> (Amount WUSD)</span>
<span className="text-slate-500 font-mono">: {Number(wusdBalance).toFixed(2)} WUSD</span>
</div>
<div className="relative">
<input
type="number"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="100"
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 text-base text-slate-100 font-mono focus:outline-none focus:border-cyan-500 transition-colors"
/>
<div className="absolute right-3 top-3 flex space-x-1">
{['50', '100', '500', 'MAX'].map((preset) => (
<button
key={preset}
type="button"
onClick={() => setAmount(preset === 'MAX' ? wusdBalance : preset)}
className="px-2 py-0.5 text-[10px] font-bold rounded bg-slate-800 text-slate-300 hover:bg-slate-700 font-mono"
>
{preset}
</button>
))}
</div>
</div>
</div>
{/* Order Estimations */}
<div className="bg-slate-950 border border-slate-800/80 rounded-xl p-3.5 space-y-2 text-xs">
<div className="flex justify-between text-slate-400">
<span> (Est. Shares)</span>
<span className="font-mono font-bold text-slate-200">{estShares} </span>
</div>
<div className="flex justify-between text-slate-400">
<span> (Max Payout)</span>
<span className="font-mono font-bold text-emerald-400">${estPayout} WUSD</span>
</div>
<div className="flex justify-between text-slate-400">
<span> (Est. ROI)</span>
<span className="font-mono font-bold text-cyan-400">+{estReturn}%</span>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading || !amount || Number(amount) <= 0}
className="w-full py-3.5 bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-white font-bold rounded-xl shadow-lg shadow-cyan-500/25 flex items-center justify-center space-x-2 transition-all disabled:opacity-50"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>...</span>
</>
) : (
<>
<TrendingUp className="w-5 h-5" />
<span> {activeOutcome.label}</span>
</>
)}
</button>
</form>
{/* Status Message */}
{statusMsg && (
<div
className={`p-3.5 rounded-xl border flex items-start space-x-2.5 text-xs ${
statusMsg.type === 'success'
? 'bg-emerald-950/30 border-emerald-800/50 text-emerald-300'
: 'bg-rose-950/30 border-rose-800/50 text-rose-300'
}`}
>
{statusMsg.type === 'success' ? (
<Check className="w-4 h-4 flex-shrink-0 text-emerald-400" />
) : (
<AlertCircle className="w-4 h-4 flex-shrink-0 text-rose-400" />
)}
<div className="flex-1 overflow-hidden break-words">
<p>{statusMsg.text}</p>
</div>
</div>
)}
</div>
</div>
);
};