Files
nofx/web/src/components/agent/MarketTicker.tsx
shinchan-zhai a21479a358 feat: redesign AgentChatPage - Agent-centric UI with side panels
- Rewrite AgentChatPage as primary interface with Perplexity-style layout
- Add right sidebar with Market Ticker, Positions, and Trader Status panels
- Sidebar is collapsible and responsive (auto-hides on mobile)
- Each sidebar section independently collapsible
- Chat area: centered 720px max-width with smooth streaming effect
- Simple markdown rendering (bold, code blocks, inline code)
- Quick action chips redesigned as pill buttons with hover effects
- Input upgraded to auto-resizing textarea
- New components: MarketTicker, PositionsPanel, TraderStatusPanel
- Dark theme consistent with NOFXi brand colors
- Real-time data via SWR for positions and trader status
2026-03-25 01:05:54 +08:00

133 lines
3.8 KiB
TypeScript

import { useState, useEffect } from 'react'
import { TrendingUp, TrendingDown, RefreshCw } from 'lucide-react'
interface TickerData {
symbol: string
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
}
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
const fetchTickers = async () => {
try {
const results = await Promise.all(
SYMBOLS.map(async (symbol) => {
const res = await fetch(`/api/agent/ticker?symbol=${symbol}`)
const data = await res.json()
return { symbol, ...data }
})
)
const map: Record<string, TickerData> = {}
results.forEach((r) => {
if (r.lastPrice) map[r.symbol] = r
})
setTickers(map)
} catch {
// ignore
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTickers()
const interval = setInterval(fetchTickers, 15000)
return () => clearInterval(interval)
}, [])
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
const formatVolume = (vol: string) => {
const n = parseFloat(vol)
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
return n.toFixed(0)
}
if (loading) {
return (
<div style={{ padding: '12px 14px', color: '#5c5c72', fontSize: 12 }}>
<RefreshCw size={14} className="animate-spin inline mr-2" />
Loading market data...
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
const isUp = pct >= 0
const color = isUp ? '#00e5a0' : '#F6465D'
const label = sym.replace('USDT', '')
return (
<div
key={sym}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: isUp ? 'rgba(0,229,160,0.08)' : 'rgba(246,70,93,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{isUp ? (
<TrendingUp size={14} color={color} />
) : (
<TrendingDown size={14} color={color} />
)}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
{label}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
${formatPrice(t.lastPrice)}
</div>
<div style={{ fontSize: 11, fontWeight: 500, color }}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
</div>
</div>
</div>
)
})}
</div>
)
}