feat: revamp dashboard, trades, and add landing/legal pages

- Major UI updates across dashboard, analytics, posts, trades, settings
- New landing page, robots/sitemap, contact/privacy/terms pages
- Updated globals.css with extensive styling and new landing.css
- Refactor signedRequest, realtime data hook, and dashboard store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-04-25 16:04:57 +08:00
parent 83e5892ddf
commit 040e1df685
28 changed files with 4133 additions and 690 deletions
+144 -42
View File
@@ -7,8 +7,7 @@ import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData'
import { getPrices, getUserPublic } from '@/lib/api'
import ChartPanel from '@/components/dashboard/ChartPanel'
import BotPanel from '@/components/dashboard/BotPanel'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
interface Props {
initialPosts: TrumpPost[]
@@ -47,7 +46,7 @@ function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void })
<SourceIcon source={post.source} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>@realDonaldTrump</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>{timeAgo(post.published_at)} ago · {new Date(post.published_at).toLocaleString()}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}><TimeAgo iso={post.published_at} suffix=" ago" /> · <LocalDateTime iso={post.published_at} /></div>
</div>
</div>
@@ -63,9 +62,9 @@ function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void })
</div>
{/* AI confidence */}
<div className="row between" style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 6 }}>
<div className="ai-metric-head">
<span>AI confidence</span>
<span className="mono" style={{ color: 'var(--ink)', fontWeight: 600 }}>{post.ai_confidence}%</span>
<strong>{post.ai_confidence}%</strong>
</div>
<div className="confidence-bar" style={{ marginBottom: 16 }}>
<div style={{ width: post.ai_confidence + '%' }} />
@@ -74,19 +73,8 @@ function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void })
{/* AI reasoning */}
{post.ai_reasoning && (
<>
<div className="tiny" style={{ marginBottom: 8 }}>AI reasoning</div>
<div style={{
padding: 12,
background: 'var(--bg-sunk)',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--line)',
fontSize: 12,
lineHeight: 1.6,
color: 'var(--ink-2)',
marginBottom: 16,
maxHeight: 120,
overflowY: 'auto',
}}>
<div className="ai-reasoning-label">AI reasoning</div>
<div className="ai-reasoning-card scroll" style={{ marginBottom: 16 }}>
{post.ai_reasoning}
</div>
</>
@@ -144,26 +132,35 @@ function SelectHint() {
// ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setHlApiKeySet, isSubscribed } = useDashboardStore()
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness } = useDashboardStore()
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount()
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [candles, setCandles] = useState<Candle[]>([])
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
useEffect(() => {
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
return
}
// Clear account-scoped bot state immediately when the connected wallet
// changes so a previous wallet's status never leaks into the next one.
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
getUserPublic(address.toLowerCase())
.then((user) => {
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
})
.catch(() => {})
}, [address, isConnected, setSubscribed, setHlApiKeySet])
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price),
@@ -187,9 +184,13 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
: 0
const totalPosts = posts.length
const todayKey = new Date().toISOString().slice(0, 10)
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const winRate = initialPerformance?.win_rate ?? 0
const netPnl = initialPerformance?.net_pnl_usd ?? 0
const hasPriceData = candles.length > 0
const hasPerformanceData = Boolean(initialPerformance)
return (
<div className="page wide">
@@ -197,7 +198,7 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div>
<h1 className="page-title">Signal monitor</h1>
<p className="page-sub">
{actionablePosts} actionable signals · Auto-trader {isSubscribed ? 'running' : 'standby'} · Model v4-selective
{actionablePosts} actionable signals · Auto-trader {botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · {hasPriceData ? 'Live market context' : 'Waiting for market data'}
</p>
</div>
<div className="row gap-s">
@@ -212,24 +213,24 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div>
<div className="value">{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}</div>
<div className="foot">
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)}</span>
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span>
<span>{timeframe}</span>
</div>
</div>
<div className="kpi">
<div className="label">Signals today</div>
<div className="value">{actionablePosts}</div>
<div className="foot"><span>Actionable posts</span></div>
<div className="value">{signalsToday}</div>
<div className="foot"><span>{actionablePosts} actionable total</span></div>
</div>
<div className="kpi accent">
<div className="label">30d Net P&amp;L</div>
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>Bot performance</span></div>
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div>
</div>
<div className="kpi">
<div className="label">Win rate</div>
<div className="value">{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{initialPerformance?.total_trades ?? 0} trades</span></div>
<div className="foot"><span>{hasPerformanceData ? `${initialPerformance?.total_trades ?? 0} trades` : 'Waiting for trade history'}</span></div>
</div>
</div>
@@ -244,7 +245,7 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div className="hero-value mono" style={{ fontSize: 32 }}>
{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}
</div>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)} · {timeframe}</span>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · {timeframe}</span>
</div>
</div>
<div className="stack gap-s" style={{ alignItems: 'flex-end' }}>
@@ -269,7 +270,14 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
posts={posts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={setSelectedPostId}
onSelectPost={(id) => {
setSelectedDayPosts(null)
setSelectedPostId(id)
}}
onSelectDayPosts={(dayPosts) => {
setSelectedDayPosts(dayPosts)
setSelectedPostId(null)
}}
/>
</div>
@@ -289,26 +297,120 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
</div>
<div className="post-stream">
{recentPosts.map(p => (
<PostRow
key={p.id}
post={p}
selected={selectedPostId === p.id}
onClick={() => setSelectedPostId(selectedPostId === p.id ? null : p.id)}
/>
<PostRow key={p.id} post={p} />
))}
</div>
</div>
</div>
{/* Right rail: bot stats + post detail */}
{/* Right rail */}
<div className="rail">
<BotPanel performance={initialPerformance} />
{/* Day-view: all posts from clicked day */}
{selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}>
<div className="row between" style={{ marginBottom: 14 }}>
<div>
<div className="tiny">{selectedDayPosts.length} posts · this candle</div>
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 2 }}>
<LocalDateTime iso={selectedDayPosts[0].published_at} opts={{ month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }} />
</div>
</div>
<button className="icon-btn" onClick={() => { setSelectedDayPosts(null); setSelectedPostId(null) }}
style={{ width:26, height:26, borderRadius:'50%' }}>
<svg width="10" height="10" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
{selectedDayPosts.map(p => {
const expanded = selectedPostId === p.id
const impact = p.price_impact
function fmtImpactPct(v: number | null | undefined) {
if (v == null || isNaN(v)) return '—'
const s = Math.abs(v).toFixed(2) + '%'
return v >= 0 ? '+' + s : '-' + s
}
return (
<div key={p.id}
style={{ border:`1px solid ${expanded?'var(--amber)':'var(--line)'}`,
borderRadius:'var(--r-sm)', overflow:'hidden', transition:'border-color 0.15s' }}>
{/* collapsed row — always visible */}
<div onClick={() => setSelectedPostId(expanded ? null : p.id)}
style={{ padding:12, cursor:'pointer',
background: expanded ? 'var(--bg-sunk)' : 'transparent' }}>
<div className="row between" style={{ marginBottom:6 }}>
<div className="row gap-s">
<SignalPill signal={p.signal} />
<span className={`chip ${p.sentiment === 'bullish' ? 'up' : p.sentiment === 'bearish' ? 'down' : 'neutral'}`}
style={{ fontSize:10 }}>{p.sentiment}</span>
</div>
<span style={{ fontSize:11, color:'var(--ink-3)' }}><TimeAgo iso={p.published_at} /></span>
</div>
<p style={{ fontSize:12, lineHeight:1.5, margin:'0 0 8px', color:'var(--ink-2)',
...(expanded ? {} : {
display:'-webkit-box', WebkitLineClamp: 3,
WebkitBoxOrient:'vertical', overflow:'hidden'
}) }}>
{p.text}
</p>
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
<div style={{ flex:1, height:3, background:'var(--line)', borderRadius:2, overflow:'hidden' }}>
<div style={{ width:p.ai_confidence+'%', height:'100%', background:'var(--amber)', borderRadius:2 }} />
</div>
<span style={{ fontSize:10, color:'var(--ink-3)', whiteSpace:'nowrap' }}>{p.ai_confidence}% conf</span>
</div>
</div>
{/* Post detail — shown when something is selected, else hint */}
{selectedPost
? <PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
: <SelectHint />
}
{/* expanded detail */}
{expanded && (
<div style={{ padding:'0 12px 12px', borderTop:'1px solid var(--line)' }}>
{p.ai_reasoning && (
<>
<div className="ai-reasoning-label" style={{ margin:'10px 0 8px' }}>AI reasoning</div>
<div className="ai-reasoning-card" style={{ padding: '14px 15px', fontSize: 14, lineHeight: 1.7 }}>
{p.ai_reasoning}
</div>
</>
)}
{impact && (
<>
<div className="tiny" style={{ margin:'10px 0 6px' }}>Price impact · {impact.asset}</div>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6 }}>
{(['m5','m15','m1h'] as const).map((key) => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
const v = impact[key]
const correct = impact[`correct_${key}` as 'correct_m5'|'correct_m15'|'correct_m1h']
return (
<div key={key} style={{ padding:8, background:'var(--bg-sunk)',
borderRadius:'var(--r-sm)', border:'1px solid var(--line)', textAlign:'center' }}>
<div style={{ fontSize:10, color:'var(--ink-3)', marginBottom:3 }}>{label}</div>
<div className={`delta ${(v??0)>=0?'up':'down'}`} style={{ fontSize:13, fontWeight:600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize:10, marginTop:3, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)}
</div>
)
})}
</div>
</div>
) : selectedPost ? (
<PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
) : (
<SelectHint />
)}
</div>
</div>
</div>
+56 -15
View File
@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react'
import { getPerformance, getTrades } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types'
type Period = '7d' | '30d' | '90d' | 'All'
function fmtMoney(n: number) {
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
@@ -20,30 +22,65 @@ function fmtHold(s: number) {
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
function inPeriod(iso: string, period: Period) {
if (period === 'All') return true
const days = Number.parseInt(period, 10)
if (Number.isNaN(days)) return true
const time = new Date(iso).getTime()
return time >= Date.now() - days * 24 * 60 * 60 * 1000
}
function calcDrawdownPct(trades: BotTrade[]) {
const ordered = [...trades].sort(
(a, b) => new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime()
)
let equity = 0
let peak = 0
let maxDrawdownPct = 0
for (const trade of ordered) {
equity += trade.pnl_usd
peak = Math.max(peak, equity)
if (peak > 0) {
maxDrawdownPct = Math.max(maxDrawdownPct, ((peak - equity) / peak) * 100)
}
}
return maxDrawdownPct
}
export default function AnalyticsPage() {
const [perf, setPerf] = useState<BotPerformance | null>(null)
const [trades, setTrades] = useState<BotTrade[]>([])
const [period, setPeriod] = useState('30d')
const [period, setPeriod] = useState<Period>('30d')
useEffect(() => {
Promise.all([
getPerformance().catch(() => null),
getTrades(200, 1).catch(() => []),
getTrades(100, 1).catch(() => []),
]).then(([p, t]) => {
setPerf(p)
setTrades(t)
})
}, [])
const winRate = perf ? perf.win_rate * 100 : 0
const bestTrade = trades.length ? Math.max(...trades.map(t => t.pnl_usd)) : 0
const worstTrade = trades.length ? Math.min(...trades.map(t => t.pnl_usd)) : 0
const avgTrade = trades.length ? trades.reduce((s, t) => s + t.pnl_usd, 0) / trades.length : 0
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
const totalPnl = filteredTrades.reduce((sum, trade) => sum + trade.pnl_usd, 0)
const wins = filteredTrades.filter((trade) => trade.pnl_usd > 0).length
const winRate = filteredTrades.length ? (wins / filteredTrades.length) * 100 : 0
const bestTrade = filteredTrades.length ? Math.max(...filteredTrades.map(t => t.pnl_usd)) : 0
const worstTrade = filteredTrades.length ? Math.min(...filteredTrades.map(t => t.pnl_usd)) : 0
const avgTrade = filteredTrades.length ? filteredTrades.reduce((s, t) => s + t.pnl_usd, 0) / filteredTrades.length : 0
const avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.length : 0
const maxDrawdown = period === '30d' && perf ? perf.max_drawdown_pct : calcDrawdownPct(filteredTrades)
const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length
? perf.net_pnl_usd
: totalPnl
const metrics = [
{ k: 'Max drawdown', v: perf ? perf.max_drawdown_pct.toFixed(1) + '%' : '—', sub: 'Worst peak-to-trough', down: true },
{ k: 'Total trades', v: String(perf?.total_trades ?? '—'), sub: 'Bot executions' },
{ k: 'Avg hold time', v: perf ? fmtHold(perf.avg_hold_seconds) : '—', sub: 'Per trade' },
{ k: 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: 'Worst peak-to-trough', down: true },
{ k: 'Total trades', v: String(filteredTrades.length || '—'), sub: 'Closed bot executions' },
{ k: 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: 'Per trade' },
{ k: 'Avg trade P&L', v: fmtMoney(avgTrade), sub: 'Mean per trade', up: avgTrade > 0 },
{ k: 'Best trade', v: fmtMoney(bestTrade), sub: 'Largest single win', up: true },
{ k: 'Worst trade', v: fmtMoney(worstTrade), sub: 'Largest single loss', down: true },
@@ -57,7 +94,7 @@ export default function AnalyticsPage() {
<p className="page-sub">Deep dive into {period} of signals and trades.</p>
</div>
<div className="nav-tabs">
{['7d', '30d', '90d', 'All'].map(p => (
{(['7d', '30d', '90d', 'All'] as const).map(p => (
<button key={p} className={`nav-tab ${period === p ? 'active' : ''}`} onClick={() => setPeriod(p)}>{p}</button>
))}
</div>
@@ -69,11 +106,11 @@ export default function AnalyticsPage() {
<div>
<div className="tiny">Performance · {period}</div>
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
{perf ? fmtMoney(perf.net_pnl_usd) : '—'}
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
</div>
<div className="row gap-s" style={{ marginTop: 8 }}>
<span className={`chip ${(perf?.win_rate ?? 0) >= 0.5 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% win rate</span>
<span className="chip">{perf?.total_trades ?? 0} trades</span>
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% win rate</span>
<span className="chip">{filteredTrades.length} trades</span>
</div>
</div>
</div>
@@ -91,9 +128,13 @@ export default function AnalyticsPage() {
</div>
{/* No data message */}
{!perf && !trades.length && (
{!filteredTrades.length && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}>No trade data yet. The bot will populate analytics once it starts executing.</p>
<p style={{ fontSize: 14 }}>
{trades.length
? `No trades closed in the ${period} window yet.`
: 'No trade data yet. The bot will populate analytics once it starts executing.'}
</p>
</div>
)}
</div>
+46
View File
@@ -0,0 +1,46 @@
export default function ContactPage() {
return (
<div className="page" style={{ maxWidth: 560 }}>
<div className="page-head">
<div>
<h1 className="page-title">Contact Us</h1>
<p className="page-sub">Questions, feedback, or support requests.</p>
</div>
</div>
<div className="card" style={{ padding: 32 }}>
<form
action="mailto:support@bitnews.day"
method="get"
encType="text/plain"
>
<div className="field" style={{ marginBottom: 16 }}>
<label htmlFor="subject">Subject</label>
<input id="subject" name="subject" type="text" placeholder="e.g. Bot not executing trades" />
</div>
<div className="field" style={{ marginBottom: 24 }}>
<label htmlFor="body">Message</label>
<textarea
id="body"
name="body"
rows={6}
placeholder="Describe your issue or question in detail…"
style={{ width: '100%', resize: 'vertical' }}
/>
</div>
<button type="submit" className="btn amber lg">
Open in mail client
</button>
</form>
<div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--line)', fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
<div style={{ marginBottom: 6 }}>You can also reach us directly:</div>
<div>
<a href="mailto:support@bitnews.day" style={{ color: 'var(--amber)' }}>support@bitnews.day</a>
</div>
<div style={{ marginTop: 8 }}>Response time: typically within 24 hours.</div>
</div>
</div>
</div>
)
}
+457 -11
View File
@@ -6,6 +6,7 @@
/* Surfaces — warm off-whites */
--bg: oklch(99% 0.004 85);
--bg-sunk: oklch(97.8% 0.006 85);
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: oklch(97.2% 0.006 85);
--surface-3: oklch(94% 0.008 85);
@@ -54,6 +55,7 @@
html[data-theme="dark"] {
--bg: oklch(15% 0.008 85);
--bg-sunk: oklch(12% 0.008 85);
--bg-elev: oklch(20% 0.008 85);
--surface: oklch(18% 0.008 85);
--surface-2: oklch(21% 0.008 85);
--surface-3: oklch(25% 0.01 85);
@@ -271,6 +273,31 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
background: linear-gradient(135deg, var(--amber), oklch(65% 0.2 35));
}
.wallet-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 180px;
padding: 6px;
z-index: 1000;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--r-sm);
box-shadow: var(--shadow-2);
}
.wallet-menu.open { display: block; }
.wallet-menu button {
width: 100%;
padding: 8px 10px;
font-size: 13px;
text-align: left;
border-radius: 6px;
color: var(--ink);
}
.wallet-menu button:hover { background: var(--bg-sunk); }
.wallet-menu button.danger { color: var(--down); }
/* ============================================================
Page shell
============================================================ */
@@ -405,6 +432,158 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
.delta.up { color: oklch(42% 0.16 148); }
.delta.down { color: oklch(46% 0.2 25); }
/* ── iOS-style toggle switch ──────────────────────────────── */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
flex-shrink: 0;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch-track {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--line-2);
border-radius: 999px;
transition: background 160ms;
}
.switch-track::before {
content: '';
position: absolute;
height: 16px;
width: 16px;
left: 2px;
top: 2px;
background: #fff;
border-radius: 50%;
transition: transform 160ms;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
}
.switch input:checked + .switch-track { background: var(--amber); }
.switch input:checked + .switch-track::before { transform: translateX(14px); }
.switch.up input:checked + .switch-track { background: var(--up); }
.switch.down input:checked + .switch-track { background: var(--down); }
/* ── Form row: label | control, strict-aligned ─────────────── */
.form-row {
display: grid;
grid-template-columns: 200px 1fr;
align-items: center;
gap: 24px;
padding: 14px 0;
}
.form-row + .form-row { border-top: 1px solid var(--line); }
.form-row-label {
font-size: 13px;
font-weight: 500;
color: var(--ink);
}
.form-row-label .hint {
display: block;
font-size: 11px;
font-weight: 400;
color: var(--ink-4);
margin-top: 3px;
}
.form-row-control {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* ── Section divider with label ────────────────────────────── */
.section-head {
display: flex;
align-items: center;
gap: 12px;
margin: 10px 0 2px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.section-head:first-child { border-top: 0; padding-top: 0; }
.section-head-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-4);
}
/* ── Inline $ prefix input ─────────────────────────────────── */
.num-field {
display: inline-flex;
align-items: center;
background: var(--bg-sunk);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 0 10px;
transition: border-color 120ms, background 120ms;
}
.num-field:focus-within { border-color: var(--amber-ring); background: var(--bg); }
.num-field[data-disabled='true'] { opacity: 0.4; pointer-events: none; }
.num-field .prefix, .num-field .suffix {
font-size: 12px;
color: var(--ink-3);
font-family: var(--mono);
}
.num-field input {
border: 0;
background: transparent;
font-family: var(--mono);
font-size: 13px;
padding: 8px 6px;
width: 80px;
outline: none;
color: var(--ink);
}
.num-field input::-webkit-inner-spin-button,
.num-field input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
/* ── Slider: unified look ──────────────────────────────────── */
.slider-field { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.slider-field input[type=range] {
-webkit-appearance: none;
width: 100%;
height: 4px;
background: var(--line-2);
border-radius: 999px;
outline: none;
}
.slider-field input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--amber);
border: 2px solid var(--bg);
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.18);
cursor: pointer;
}
.slider-field .ticks {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
.slider-readout {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
min-width: 48px;
text-align: right;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
/* ============================================================
Dashboard specific
============================================================ */
@@ -607,6 +786,70 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
transition: width 600ms ease-out;
}
.ai-metric-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
font-size: 13px;
color: var(--ink-3);
}
.ai-metric-head strong {
font-size: 18px;
line-height: 1;
color: var(--ink);
font-family: var(--mono);
font-weight: 600;
letter-spacing: -0.02em;
}
.ai-reasoning-label {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--amber-ink);
}
.ai-reasoning-label::before {
content: '';
width: 8px;
height: 8px;
border-radius: 999px;
background: linear-gradient(135deg, var(--amber), oklch(68% 0.2 45));
box-shadow: 0 0 0 4px color-mix(in oklab, var(--amber) 18%, transparent);
}
.ai-reasoning-card {
padding: 16px 18px;
background:
linear-gradient(180deg, color-mix(in oklab, var(--amber-soft) 55%, var(--surface)) 0%, var(--bg-sunk) 100%);
border-radius: 18px;
border: 1px solid color-mix(in oklab, var(--amber-ring) 55%, var(--line));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), var(--shadow-1);
font-size: 15px;
line-height: 1.78;
color: var(--ink);
}
.ai-reasoning-card.scroll {
max-height: 180px;
overflow-y: auto;
padding-right: 14px;
}
html[data-theme="dark"] .ai-reasoning-card {
background:
linear-gradient(180deg, color-mix(in oklab, var(--amber-soft) 30%, var(--surface-2)) 0%, var(--surface) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), var(--shadow-1);
}
/* Bot status */
.bot-status {
background: linear-gradient(160deg, oklch(22% 0.01 85) 0%, oklch(14% 0.01 85) 100%);
@@ -672,19 +915,31 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
gap: 10px;
}
.post-row {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
transition: border-color 120ms, background 120ms;
cursor: pointer;
overflow: hidden;
}
.post-row:hover { border-color: var(--line-2); }
.post-row.selected { border-color: var(--amber-ring); background: oklch(99% 0.02 85); }
.post-row-main {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 14px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
align-items: flex-start;
transition: border-color 120ms, background 120ms;
cursor: pointer;
}
.post-row:hover { border-color: var(--line-2); }
.post-row.selected { border-color: var(--amber-ring); background: oklch(99% 0.02 85); }
.post-row-detail {
border-top: 1px solid var(--line);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.src-ico {
width: 36px;
height: 36px;
@@ -827,6 +1082,7 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
border-radius: var(--r-sm);
padding: 11px 14px;
font-size: 14px;
width: 100%;
outline: none;
transition: border-color 120ms, box-shadow 120ms;
color: var(--ink);
@@ -836,7 +1092,7 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
box-shadow: 0 0 0 3px oklch(88% 0.12 80 / 0.4);
}
.switch {
.settings-switch {
--w: 40px;
width: var(--w);
height: 24px;
@@ -848,7 +1104,7 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
border: 1px solid var(--line);
flex-shrink: 0;
}
.switch::after {
.settings-switch::after {
content: '';
position: absolute;
top: 2px;
@@ -860,11 +1116,11 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
box-shadow: var(--shadow-1);
transition: transform 180ms;
}
.switch.on {
.settings-switch.on {
background: var(--ink);
border-color: var(--ink);
}
.switch.on::after { transform: translateX(16px); }
.settings-switch.on::after { transform: translateX(16px); }
.setting-row {
display: flex;
@@ -884,6 +1140,196 @@ html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2);
gap: 16px;
}
@media (max-width: 1180px) {
.dash-grid {
grid-template-columns: 1fr;
}
.rail {
order: -1;
}
}
@media (max-width: 900px) {
.nav {
height: auto;
min-height: 64px;
padding: 12px 18px;
flex-wrap: wrap;
gap: 12px;
}
.brand {
flex: 1;
}
.nav-tabs {
order: 3;
width: 100%;
overflow-x: auto;
justify-content: flex-start;
}
.nav-right {
margin-left: auto;
}
.page {
padding: 24px 18px 64px;
}
.page-head {
align-items: flex-start;
flex-direction: column;
gap: 12px;
}
.page-title {
font-size: 28px;
}
.kpi-row,
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-grid,
.form-row {
grid-template-columns: 1fr;
gap: 10px;
}
.settings-side {
position: static;
}
.chart-footnote {
flex-wrap: wrap;
}
.chart-footnote > div:last-child {
margin-left: 0 !important;
width: 100%;
}
.table {
min-width: 760px;
}
.card.flush {
overflow-x: auto !important;
}
}
@media (max-width: 900px) {
.nav-tabs {
/* Edge-to-edge horizontal scroll with snap on mobile */
margin: 0 -18px;
padding: 0 18px;
gap: 4px;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.nav-tabs::-webkit-scrollbar { display: none; }
.nav-tab {
flex: 0 0 auto;
scroll-snap-align: start;
padding: 8px 12px;
font-size: 13px;
}
}
@media (max-width: 640px) {
.nav {
padding: 10px 14px;
}
.brand span:last-child {
display: none;
}
.connect-btn.lg {
padding: 9px 12px;
font-size: 13px;
}
.wallet-chip {
padding: 6px 10px !important;
font-size: 12px;
}
.page {
padding: 20px 14px 56px;
}
.kpi-row,
.metric-grid {
grid-template-columns: 1fr;
}
.row.between {
align-items: flex-start;
}
.post-row-main {
grid-template-columns: 32px 1fr;
}
.post-aside {
grid-column: 2;
align-items: flex-start;
min-width: 0;
}
.src-ico {
width: 32px;
height: 32px;
}
.chart-wrap,
.chart-wrap > div {
height: 300px !important;
}
.hero-value {
font-size: 34px;
}
.asset-switch,
.tf-bar {
max-width: 100%;
overflow-x: auto;
}
footer {
flex-wrap: wrap;
padding: 18px 14px !important;
}
.card[style*='grid-template-columns: 1fr auto'] {
grid-template-columns: 1fr !important;
}
/* Tables: horizontal-scroll with subtle hint rather than page overflow */
.card.flush { border-radius: 10px; }
.table { font-size: 12px; }
.table th, .table td { padding: 10px 12px !important; }
/* Modals / dialogs on small screens */
.modal, .dialog { width: calc(100vw - 24px) !important; max-width: calc(100vw - 24px) !important; }
}
@media (max-width: 380px) {
.nav { padding: 8px 12px; }
.page { padding: 16px 12px 48px; }
.page-title { font-size: 24px; }
.hero-value { font-size: 28px; }
.connect-btn.lg { padding: 8px 10px; font-size: 12px; }
}
/* Global safety: no page-level horizontal scroll */
html, body { max-width: 100%; overflow-x: hidden; }
/* Misc utilities */
.stack { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; }
+24 -25
View File
@@ -1,16 +1,10 @@
import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { locales } from '@/i18n'
import Providers from './Providers'
import Navbar from '@/components/nav/Navbar'
import './globals.css'
export const metadata: Metadata = {
title: 'Trump Alpha — AI signal trading',
description: 'Trade crypto based on Trump social media signals with AI confidence scoring.',
}
interface LayoutProps {
children: React.ReactNode
@@ -23,25 +17,30 @@ export default async function LocaleLayout({ children, params: { locale } }: Lay
}
const messages = await getMessages()
const href = (path: string) => `/${locale}${path}`
return (
<html lang={locale}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<NextIntlClientProvider messages={messages}>
<Providers>
<Navbar />
<main>{children}</main>
</Providers>
</NextIntlClientProvider>
</body>
</html>
<NextIntlClientProvider messages={messages}>
<Providers>
<Navbar />
<main>{children}</main>
<footer style={{
borderTop: '1px solid var(--line)',
padding: '20px 32px',
display: 'flex',
gap: 16,
alignItems: 'center',
flexWrap: 'wrap',
fontSize: 12,
color: 'var(--ink-3)',
marginTop: 48,
}}>
<span>© {new Date().getFullYear()} TrumpSignal</span>
<Link href={href('/privacy')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Privacy</Link>
<Link href={href('/terms')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Terms</Link>
<Link href={href('/contact')} style={{ color: 'var(--ink-3)', textDecoration: 'none' }}>Contact</Link>
</footer>
</Providers>
</NextIntlClientProvider>
)
}
+8 -69
View File
@@ -3,74 +3,12 @@
import { useState, useEffect } from 'react'
import type { TrumpPost } from '@/types'
import { getPosts } from '@/lib/api'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards'
function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) {
return (
<div className="card" style={{ padding: 24, position: 'sticky', top: 84 }}>
<div className="row between" style={{ marginBottom: 16 }}>
<span className="tiny">Signal detail</span>
<button className="icon-btn" onClick={onClose} style={{ width: 28, height: 28 }}>
<svg width="12" height="12" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
</button>
</div>
<div className="row gap-s" style={{ marginBottom: 14 }}>
<SourceIcon source={post.source} />
<div>
<div style={{ fontSize: 13, fontWeight: 500 }} className="mono">@realDonaldTrump</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{new Date(post.published_at).toLocaleString()}</div>
</div>
</div>
<p style={{ fontSize: 15, lineHeight: 1.55, margin: '0 0 20px' }}>{post.text}</p>
<div className="row gap-s" style={{ marginBottom: 20 }}>
<SignalPill signal={post.signal} />
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}>{post.sentiment}</span>
</div>
<div className="tiny" style={{ marginBottom: 8 }}>AI reasoning</div>
<div style={{ padding: 14, background: 'var(--bg-sunk)', borderRadius: 10, border: '1px solid var(--line)', fontSize: 13, lineHeight: 1.55, color: 'var(--ink-2)', marginBottom: 20 }}>
{post.ai_reasoning || 'No reasoning available.'}
</div>
<div className="row between" style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 6 }}>
<span>AI confidence</span>
<span className="mono" style={{ color: 'var(--ink)', fontWeight: 500 }}>{post.ai_confidence}%</span>
</div>
<div className="confidence-bar"><div style={{ width: post.ai_confidence + '%' }} /></div>
{post.price_impact && (
<div style={{ marginTop: 24 }}>
<div className="tiny" style={{ marginBottom: 10 }}>Actual price impact · {post.price_impact.asset}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
{(['m5', 'm15', 'm1h'] as const).map(k => {
const v = post.price_impact![k]
const correct = post.price_impact!['correct_' + k as 'correct_m5' | 'correct_m15' | 'correct_m1h']
const label = k === 'm5' ? '5m' : k === 'm15' ? '15m' : '1h'
return (
<div key={k} style={{ padding: 12, background: 'var(--bg-sunk)', borderRadius: 10, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
<div className={`delta ${(v ?? 0) >= 0 ? 'up' : 'down'}`} style={{ fontSize: 15 }}>{fmtPct(v)}</div>
{correct != null && (
<div style={{ fontSize: 11, color: correct ? 'var(--up)' : 'var(--down)', marginTop: 4 }}>
{correct ? '✓ correct' : '✗ missed'}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
import PostRow from '@/components/dashboard/PostCards'
export default function PostsPage() {
const [posts, setPosts] = useState<TrumpPost[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('all')
const [selected, setSelected] = useState<number | null>(null)
useEffect(() => {
getPosts(200, 1).then(setPosts).catch(() => {}).finally(() => setLoading(false))
@@ -88,8 +26,6 @@ export default function PostsPage() {
neutral: posts.filter(p => p.sentiment === 'neutral').length,
}
const selectedPost = posts.find(p => p.id === selected)
return (
<div className="page">
<div className="page-head">
@@ -112,14 +48,17 @@ export default function PostsPage() {
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading</div>}
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 420px' : '1fr', gap: 24, alignItems: 'start' }}>
{!loading && filtered.length === 0 ? (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No posts match the current sentiment filter.
</div>
) : (
<div className="post-stream">
{filtered.map(p => (
<PostRow key={p.id} post={p} selected={selected === p.id} onClick={() => setSelected(selected === p.id ? null : p.id)} />
<PostRow key={p.id} post={p} />
))}
</div>
{selected && selectedPost && <PostDetail post={selectedPost} onClose={() => setSelected(null)} />}
</div>
)}
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import Link from 'next/link'
export default function PrivacyPage({ params: { locale } }: { params: { locale: string } }) {
return (
<div className="page" style={{ maxWidth: 720 }}>
<div className="page-head">
<div>
<h1 className="page-title">Privacy Policy</h1>
<p className="page-sub">Last updated: April 2026</p>
</div>
</div>
<div className="card" style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>1. Information We Collect</h2>
<p style={{ marginBottom: 20 }}>
We collect your Ethereum wallet address when you connect your wallet. This address is used solely to
authenticate your session and manage your subscription. We do not collect your name, email address, or
any other personally identifiable information unless you voluntarily provide it via the contact form.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>2. Hyperliquid API Keys</h2>
<p style={{ marginBottom: 20 }}>
If you choose to connect a Hyperliquid API key, it is encrypted at rest using industry-standard symmetric
encryption (Fernet/AES-128-CBC). The plaintext key is never logged, cached, or transmitted except to
Hyperliquid&apos;s official API endpoints to execute trades on your behalf.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>3. How We Use Your Data</h2>
<p style={{ marginBottom: 20 }}>
Your wallet address and trading configuration are used exclusively to operate the automated trading bot
on your behalf. We do not sell, rent, or share your data with third parties, except as required to
process trades (Hyperliquid API) or comply with applicable law.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>4. Cookies &amp; Analytics</h2>
<p style={{ marginBottom: 20 }}>
We use minimal analytics to understand aggregate usage patterns (page views, error rates). No
personal identifiers are associated with analytics events. We do not use advertising cookies or
cross-site tracking.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>5. Data Retention</h2>
<p style={{ marginBottom: 20 }}>
Subscription data is retained for as long as your account is active. You may request deletion of your
data at any time by contacting us. Trade history is retained for up to 24 months for dispute resolution
purposes.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>6. Contact</h2>
<p>
For privacy-related inquiries, please use our <Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>contact form</Link>.
</p>
</div>
</div>
)
}
+36 -311
View File
@@ -1,324 +1,49 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useAccount, useSignMessage } from 'wagmi'
import { useDashboardStore } from '@/store/dashboard'
import { getUser, getUserPublic, setUserSettings, type UserSettings } from '@/lib/api'
import { getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest'
import BotPanel from '@/components/dashboard/BotPanel'
const ACTION_SET_SETTINGS = 'set_settings'
const ACTION_VIEW_USER = 'view_user'
const DEFAULT_SETTINGS: UserSettings = {
leverage: 3,
position_size_usd: 20,
take_profit_pct: null,
stop_loss_pct: null,
min_confidence: 80,
}
function LockedCard({ label }: { label: string }) {
return (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-sunk)', margin: '0 auto 12px', display: 'grid', placeItems: 'center' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="2"/><path d="M8 11V8a4 4 0 118 0v3" stroke="currentColor" strokeWidth="2"/></svg>
</div>
{label}
</div>
)
}
function BotSettings({
connected, subscribed, wallet, initial,
}: {
connected: boolean
subscribed: boolean
wallet?: string
initial: UserSettings
}) {
if (!connected) {
return (
<div className="card" style={{ padding: 40, textAlign: 'center' }}>
<div style={{ width: 56, height: 56, borderRadius: 16, background: 'var(--bg-sunk)', margin: '0 auto 16px', display: 'grid', placeItems: 'center' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="3" stroke="currentColor" strokeWidth="2"/><path d="M3 10h18" stroke="currentColor" strokeWidth="2"/></svg>
</div>
<h3 style={{ margin: '0 0 8px', fontSize: 18 }}>Connect wallet to configure</h3>
<p style={{ color: 'var(--ink-3)', fontSize: 14, maxWidth: 380, margin: '0 auto 20px', lineHeight: 1.5 }}>
Your wallet is used to verify access and sign bot commands. We never custody your funds.
</p>
<button className="btn amber lg" onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
Connect wallet · free
</button>
</div>
)
}
if (!subscribed) {
return (
<div className="card" style={{ padding: 32 }}>
<div className="row between" style={{ marginBottom: 20 }}>
<div>
<h3 style={{ margin: '0 0 4px', fontSize: 18 }}>Subscribe to unlock the bot</h3>
<p style={{ color: 'var(--ink-3)', fontSize: 13, margin: 0 }}>Get automated execution, real-time alerts, and advanced configuration.</p>
</div>
<span className="chip amber">14-day free trial</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
{['Auto-execute trades on your exchange', 'SMS + Telegram alerts within 2s', 'Custom confidence + position-size rules', 'Backtest any strategy on 2y of data'].map(f => (
<div key={f} className="row gap-s" style={{ padding: 12, background: 'var(--bg-sunk)', borderRadius: 10, fontSize: 13 }}>
<span style={{ color: 'var(--up)' }}></span>{f}
</div>
))}
</div>
<Link href="/" className="btn amber lg" style={{ textAlign: 'center' }}>
Activate on dashboard
</Link>
</div>
)
}
return <BotSettingsForm wallet={wallet!} initial={initial} />
}
function BotSettingsForm({ wallet, initial }: { wallet: string; initial: UserSettings }) {
const { signMessageAsync } = useSignMessage()
const [s, setS] = useState<UserSettings>(initial)
const [useTp, setUseTp] = useState<boolean>(initial.take_profit_pct != null)
const [useSl, setUseSl] = useState<boolean>(initial.stop_loss_pct != null)
const [state, setState] = useState<'idle' | 'signing' | 'saving' | 'ok' | 'err'>('idle')
const [err, setErr] = useState('')
// Re-sync when initial changes (after /user fetch resolves)
useEffect(() => {
setS(initial)
setUseTp(initial.take_profit_pct != null)
setUseSl(initial.stop_loss_pct != null)
}, [initial])
const dirty = JSON.stringify(s) !== JSON.stringify(initial) ||
useTp !== (initial.take_profit_pct != null) ||
useSl !== (initial.stop_loss_pct != null)
async function save() {
setErr('')
try {
const payload: UserSettings = {
...s,
take_profit_pct: useTp ? s.take_profit_pct ?? 2 : null,
stop_loss_pct: useSl ? s.stop_loss_pct ?? 1.5 : null,
}
setState('signing')
const env = await signRequest({
action: ACTION_SET_SETTINGS,
wallet,
body: payload,
signMessageAsync,
})
setState('saving')
await setUserSettings(env, payload)
setS(payload)
setState('ok')
setTimeout(() => setState('idle'), 2000)
} catch (e: unknown) {
const m = e instanceof Error ? e.message : 'Save failed'
setErr(m.includes('User rejected') || m.includes('denied') ? 'Signature cancelled' : m.slice(0, 140))
setState('err')
}
}
const label = state === 'signing' ? 'Waiting for signature…' : state === 'saving' ? 'Saving…' : state === 'ok' ? '✓ Saved' : 'Save changes'
return (
<div>
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="section-title"><h2>Execution</h2></div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Position size (USD)</div>
<div className="desc" style={{ marginBottom: 8 }}>Fixed notional per trade. Margin = size / leverage.</div>
<input
type="number" min={5} max={10000} step={5}
value={s.position_size_usd}
onChange={e => setS({ ...s, position_size_usd: Math.max(5, +e.target.value || 0) })}
style={{ width: 140 }}
/>
</div>
<div className="tiny mono" style={{ color: 'var(--ink-3)' }}>margin ${(s.position_size_usd / s.leverage).toFixed(2)}</div>
</div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Leverage</div>
<div className="desc" style={{ marginBottom: 8 }}>Isolated margin, 150×. Higher = bigger PnL swings.</div>
<div className="row gap-m">
<input type="range" min={1} max={50} value={s.leverage}
onChange={e => setS({ ...s, leverage: +e.target.value })}
style={{ flex: 1, accentColor: 'var(--amber)' }} />
<div className="mono" style={{ minWidth: 60, fontSize: 16, fontWeight: 500 }}>{s.leverage}×</div>
</div>
</div>
</div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Minimum AI confidence</div>
<div className="desc" style={{ marginBottom: 8 }}>Platform floor is 80%. Raise to be pickier; can&apos;t lower.</div>
<div className="row gap-m">
<input type="range" min={80} max={100} value={s.min_confidence}
onChange={e => setS({ ...s, min_confidence: +e.target.value })}
style={{ flex: 1, accentColor: 'var(--amber)' }} />
<div className="mono" style={{ minWidth: 60, fontSize: 16, fontWeight: 500 }}>{s.min_confidence}%</div>
</div>
</div>
</div>
</div>
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="section-title"><h2>Risk management</h2></div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div className="row gap-s" style={{ marginBottom: 4 }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>Take profit</div>
<label style={{ fontSize: 12, color: 'var(--ink-3)', cursor: 'pointer' }}>
<input type="checkbox" checked={useTp} onChange={e => setUseTp(e.target.checked)} style={{ marginRight: 6 }} />
enable
</label>
</div>
<div className="desc" style={{ marginBottom: 8 }}>Auto-close when price moves your way by this %.</div>
<div className="row gap-s" style={{ opacity: useTp ? 1 : 0.45 }}>
<input type="number" min={0.1} max={50} step={0.1}
value={s.take_profit_pct ?? 2}
disabled={!useTp}
onChange={e => setS({ ...s, take_profit_pct: +e.target.value })}
style={{ width: 100 }} />
<span className="muted">% gain</span>
</div>
</div>
</div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div className="row gap-s" style={{ marginBottom: 4 }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>Stop loss</div>
<label style={{ fontSize: 12, color: 'var(--ink-3)', cursor: 'pointer' }}>
<input type="checkbox" checked={useSl} onChange={e => setUseSl(e.target.checked)} style={{ marginRight: 6 }} />
enable
</label>
</div>
<div className="desc" style={{ marginBottom: 8 }}>Auto-close when price moves against you by this %.</div>
<div className="row gap-s" style={{ opacity: useSl ? 1 : 0.45 }}>
<input type="number" min={0.1} max={50} step={0.1}
value={s.stop_loss_pct ?? 1.5}
disabled={!useSl}
onChange={e => setS({ ...s, stop_loss_pct: +e.target.value })}
style={{ width: 100 }} />
<span className="muted">% loss</span>
</div>
</div>
</div>
</div>
<div className="row gap-s" style={{ alignItems: 'center' }}>
<button
className={`btn ${state === 'ok' ? 'ghost' : 'amber'} lg`}
onClick={save}
disabled={!dirty || state === 'signing' || state === 'saving'}
>
{label}
</button>
{!dirty && state === 'idle' && <span className="tiny" style={{ color: 'var(--ink-3)' }}>No changes to save</span>}
{state === 'err' && <span className="tiny" style={{ color: 'var(--down)' }}>{err}</span>}
</div>
</div>
)
}
function ExchangeSettings({ subscribed }: { subscribed: boolean }) {
if (!subscribed) return <LockedCard label="Subscribe to add Hyperliquid API key" />
return <BotPanel />
}
function AccountSettings({ address }: { address?: string }) {
return (
<div className="card" style={{ padding: 24 }}>
<div className="section-title"><h2>Account</h2></div>
<div className="field">
<label>Wallet</label>
<input disabled value={address ? `${address.slice(0, 8)}${address.slice(-6)}` : 'Not connected'} />
</div>
</div>
)
}
import { usePathname } from 'next/navigation'
import { useAccount } from 'wagmi'
export default function SettingsClient() {
const [section, setSection] = useState('bot')
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const { isSubscribed, setSubscribed, setHlApiKeySet } = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
const [loadErr, setLoadErr] = useState<string>('')
useEffect(() => {
if (!isConnected || !address) return
let cancelled = false
// First fetch public state (no sig). Only request a view signature if user
// is actually subscribed — otherwise there's nothing private to fetch.
;(async () => {
try {
const pub = await getUserPublic(address.toLowerCase())
if (cancelled) return
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
if (!pub.active) return
const env = await getOrCreateViewEnvelope({
action: ACTION_VIEW_USER,
wallet: address,
signMessageAsync,
})
if (cancelled) return
const u = await getUser(address, env)
if (cancelled) return
setSubscribed(u.active)
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
if (u.settings) setSettings(u.settings)
} catch (e: unknown) {
const m = e instanceof Error ? e.message : 'Failed to load settings'
// Swallow cancelled signatures; surface real failures
if (!m.includes('User rejected') && !m.includes('denied')) {
setLoadErr(m.slice(0, 140))
}
}
})()
return () => { cancelled = true }
}, [address, isConnected, setSubscribed, setHlApiKeySet, signMessageAsync])
const sections = [
{ key: 'bot', label: 'Trading bot' },
{ key: 'exchange', label: 'Exchange keys' },
{ key: 'account', label: 'Account' },
]
const pathname = usePathname()
const locale = pathname.split('/')[1] || 'en'
const href = (path: string) => `/${locale}${path}`
return (
<div className="settings-grid">
<div className="settings-side">
{sections.map(s => (
<button key={s.key} className={section === s.key ? 'on' : ''} onClick={() => setSection(s.key)}>
{s.label}
</button>
))}
<div style={{ maxWidth: 560 }}>
{/* Account card */}
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="section-title" style={{ marginBottom: 16 }}><h2>Account</h2></div>
<div className="field">
<label>Connected wallet</label>
<input disabled value={isConnected && address ? `${address.slice(0, 10)}${address.slice(-8)}` : 'Not connected'} />
</div>
</div>
<div>
{loadErr && (
<div className="card" style={{ padding: 12, marginBottom: 12, border: '1px solid var(--down)', color: 'var(--down)', fontSize: 13 }}>
{loadErr}
{/* Link to Trades page for bot config */}
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="row between" style={{ alignItems: 'center' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Bot &amp; exchange settings</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)' }}>
Position size, leverage, TP/SL, Hyperliquid API key, and subscription are managed on the Trades page.
</div>
</div>
)}
{section === 'bot' && <BotSettings connected={isConnected} subscribed={isSubscribed} wallet={address} initial={settings} />}
{section === 'exchange' && <ExchangeSettings subscribed={isSubscribed} />}
{section === 'account' && <AccountSettings address={address} />}
<Link href={href('/trades')} className="btn ghost" style={{ whiteSpace: 'nowrap', marginLeft: 16 }}>
Go to Trades
</Link>
</div>
</div>
{/* Legal links */}
<div className="card" style={{ padding: 24 }}>
<div className="section-title" style={{ marginBottom: 16 }}><h2>Legal</h2></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Link href={href('/privacy')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Privacy Policy </Link>
<Link href={href('/terms')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Terms of Service </Link>
<Link href={href('/contact')} style={{ fontSize: 14, color: 'var(--ink-2)', textDecoration: 'none' }}>Contact Us </Link>
</div>
</div>
</div>
)
+62
View File
@@ -0,0 +1,62 @@
export default function TermsPage() {
return (
<div className="page" style={{ maxWidth: 720 }}>
<div className="page-head">
<div>
<h1 className="page-title">Terms of Service</h1>
<p className="page-sub">Last updated: April 2026</p>
</div>
</div>
<div className="card" style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>1. Acceptance</h2>
<p style={{ marginBottom: 20 }}>
By connecting your wallet or using TrumpSignal, you agree to these Terms of Service. If you do not
agree, do not use the service.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>2. Not Financial Advice</h2>
<p style={{ marginBottom: 20 }}>
<strong style={{ color: 'var(--ink)' }}>TrumpSignal is a research and automation tool, not a financial advisor.</strong>{' '}
All signals, AI analysis, and automated trades are provided for informational purposes only and do not
constitute investment advice. Past performance is not indicative of future results. You are solely
responsible for your trading decisions and any losses incurred.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>3. Risk Disclosure</h2>
<p style={{ marginBottom: 20 }}>
Cryptocurrency trading involves significant risk of loss. Leveraged trading can result in losses
exceeding your initial investment. By enabling the automated bot, you acknowledge these risks and
accept full responsibility for all trades executed on your behalf.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>4. Permitted Use</h2>
<p style={{ marginBottom: 20 }}>
You may use TrumpSignal for personal, non-commercial purposes. You may not reverse-engineer, scrape,
resell, or redistribute any data or signals from the platform. You are responsible for ensuring your
use complies with applicable laws in your jurisdiction.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>5. Service Availability</h2>
<p style={{ marginBottom: 20 }}>
We strive for high availability but make no guarantee of uptime. Scheduled maintenance, infrastructure
failures, or third-party API outages may interrupt service. We are not liable for missed trades or
losses resulting from service interruptions.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>6. Modifications</h2>
<p style={{ marginBottom: 20 }}>
We may update these Terms at any time. Continued use of the service after changes constitutes
acceptance of the updated Terms.
</p>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>7. Limitation of Liability</h2>
<p>
To the maximum extent permitted by law, TrumpSignal and its operators shall not be liable for any
indirect, incidental, or consequential damages arising from your use of the service, including trading
losses.
</p>
</div>
</div>
)
}
+697 -90
View File
@@ -1,9 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import type { BotTrade, TrumpPost } from '@/types'
import { getTrades, getPosts } from '@/lib/api'
import { getTrades, getPosts, getUserPublic, getUser, setUserSettings, setHlApiKey, subscribe, type UserSettings } from '@/lib/api'
import { useDashboardStore } from '@/store/dashboard'
import { signRequest, getOrCreateViewEnvelope, getCachedViewEnvelope } from '@/lib/signedRequest'
// ─── helpers ──────────────────────────────────────────────────────────────────
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
const { decimals = 2, sign = false } = opts
if (n == null || isNaN(n)) return '—'
@@ -13,12 +17,7 @@ function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
if (sign && n > 0) return '+$' + s
return '$' + s
}
function fmtPct(n: number) {
const s = n.toFixed(2) + '%'
return n >= 0 ? '+' + s : s
}
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
@@ -26,11 +25,636 @@ function fmtHold(s: number) {
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
function SourceIcon({ source }: { source: string }) {
if (source === 'x') return <div className="src-ico x" style={{ width: 28, height: 28, fontSize: 12 }}>𝕏</div>
return <div className="src-ico truth" style={{ width: 28, height: 28, fontSize: 12 }}>T</div>
// Defaults shown to a brand-new, un-configured wallet. take_profit_pct,
// stop_loss_pct and daily_budget_usd are all REQUIRED before the bot will
// trade — we still initialise them so the inputs have sensible placeholders.
const DEFAULT_SETTINGS: UserSettings = {
leverage: 3, position_size_usd: 20, take_profit_pct: 2, stop_loss_pct: 1.5,
min_confidence: 70, daily_budget_usd: 15,
active_from: null, active_until: null,
}
// ── datetime-local helpers (browser local tz ↔ ISO UTC) ──
function isoToLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function localInputToIso(v: string): string | null {
if (!v) return null
const d = new Date(v) // interpreted as local time
if (isNaN(d.getTime())) return null
return d.toISOString()
}
// ─── Bot config panel ─────────────────────────────────────────────────────────
function BotConfigPanel() {
const { address, isConnected } = useAccount()
const { connect, connectors } = useConnect()
const { signMessageAsync } = useSignMessage()
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setSubscribed, setBotReadiness, setHlApiKeySet } = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
// Tracks whether the SERVER already has values for these required fields.
// Used to flag "setup incomplete" until the user saves for the first time.
const [tpConfigured, setTpConfigured] = useState(false)
const [slConfigured, setSlConfigured] = useState(false)
const [budgetConfigured, setBudgetConfigured] = useState(false)
const [useSchedule, setUseSchedule] = useState(false)
const [fromLocal, setFromLocal] = useState('')
const [untilLocal, setUntilLocal] = useState('')
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [saveErr, setSaveErr] = useState('')
// HL key state
const [apiKey, setApiKey] = useState('')
const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [keyErr, setKeyErr] = useState('')
// Subscribe state
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [subErr, setSubErr] = useState('')
// Mounted flag: avoid SSR/CSR mismatch since wagmi state differs between the two.
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// Settings-load state ("idle" | "loading" | "loaded" | "err"). We do NOT
// auto-pop MetaMask — only fetch with a cached signature; otherwise wait
// for the user to click "Load settings".
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
const [loadErr, setLoadErr] = useState('')
// Apply a fetched user payload into local form state.
function applyUserPayload(u: { active: boolean; hl_api_key_set: boolean; hl_api_key_masked: string | null; settings: UserSettings }) {
setSubscribed(u.active)
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
if (u.settings) {
// Fall back to defaults when server returns null so the inputs are
// still usable, but remember which required fields weren't configured
// so we can show the "Setup incomplete" warning.
const s = u.settings
setTpConfigured(s.take_profit_pct != null)
setSlConfigured(s.stop_loss_pct != null)
setBudgetConfigured(s.daily_budget_usd != null)
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
setSettings({
...s,
take_profit_pct: s.take_profit_pct ?? 2,
stop_loss_pct: s.stop_loss_pct ?? 1.5,
daily_budget_usd: s.daily_budget_usd ?? 15,
})
const hasSched = s.active_from != null && s.active_until != null
setUseSchedule(hasSched)
setFromLocal(isoToLocalInput(s.active_from))
setUntilLocal(isoToLocalInput(s.active_until))
}
}
// On connect: fetch public state only (no signature). If a fresh view
// envelope is already cached in sessionStorage, silently hydrate full data.
useEffect(() => {
if (!mounted || !isConnected || !address) return
let cancelled = false
;(async () => {
const pub = await getUserPublic(address.toLowerCase()).catch(() => null)
if (!pub || cancelled) return
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
if (!pub.active) return
// Silent rehydrate: only if an unexpired cached envelope exists.
const cached = getCachedViewEnvelope('view_user', address)
if (!cached) return
const u = await getUser(address, cached).catch(() => null)
if (!u || cancelled) return
applyUserPayload(u)
setLoadState('loaded')
})()
return () => { cancelled = true }
}, [address, isConnected, mounted])
// Explicit "Load settings" — the only place that may pop MetaMask.
async function handleLoadSettings() {
if (!address) return
setLoadErr(''); setLoadState('loading')
try {
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
const u = await getUser(address, env)
applyUserPayload(u)
setLoadState('loaded')
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setLoadErr(m.includes('rejected') || m.includes('denied') ? 'Cancelled' : m.slice(0, 120))
setLoadState('err')
}
}
async function refreshUserState(wallet: string) {
const pub = await getUserPublic(wallet.toLowerCase())
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
return pub
}
function updateSettings(patch: Partial<UserSettings>) {
setSettings(s => ({ ...s, ...patch }))
setDirty(true)
}
async function handleSubscribe() {
if (!address) return
setSubErr(''); setSubState('signing')
try {
const env = await signRequest({ action: 'subscribe', wallet: address, body: null, signMessageAsync })
setSubState('saving')
await subscribe(env)
setSubscribed(true)
void refreshUserState(address)
setSubState('idle')
// Fresh subscribers have no settings/trades/api-key yet — skip the
// view_user fetch entirely so they don't have to sign a SECOND popup
// immediately after subscribing. The defaults in local state are fine;
// we mark loadState='loaded' so the UI shows the configuration form.
setTpConfigured(false)
setSlConfigured(false)
setBudgetConfigured(false)
setLoadState('loaded')
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setSubErr(m.includes('rejected') || m.includes('denied') ? 'Cancelled' : m.slice(0, 100))
setSubState('err')
}
}
async function handleSaveSettings() {
if (!address) return
setSaveErr(''); setSaveState('signing')
try {
let schedFrom: string | null = null
let schedUntil: string | null = null
if (useSchedule) {
schedFrom = localInputToIso(fromLocal)
schedUntil = localInputToIso(untilLocal)
if (!schedFrom || !schedUntil) {
setSaveErr('Pick both a start and end time'); setSaveState('err'); return
}
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) {
setSaveErr('End must be after start'); setSaveState('err'); return
}
}
// TP, SL and daily budget are mandatory; enforce here before signing.
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
if (tp == null || tp < 0.1 || tp > 50) { setSaveErr('Take profit must be 0.1 50%'); setSaveState('err'); return }
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.1 50%'); setSaveState('err'); return }
if (bd == null || bd <= 0 || bd > 100000) { setSaveErr('Daily budget must be > $0'); setSaveState('err'); return }
const payload: UserSettings = {
...settings,
take_profit_pct: tp,
stop_loss_pct: sl,
daily_budget_usd: bd,
active_from: schedFrom,
active_until: schedUntil,
}
const env = await signRequest({ action: 'set_settings', wallet: address, body: payload, signMessageAsync })
setSaveState('saving')
await setUserSettings(env, payload)
setSettings(payload)
setTpConfigured(true); setSlConfigured(true); setBudgetConfigured(true)
setBotReadiness('saved')
setDirty(false)
setSaveState('ok')
setTimeout(() => setSaveState('idle'), 2000)
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setSaveErr(m.includes('rejected') ? 'Cancelled' : m.slice(0, 100))
setSaveState('err')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
const trimmed = apiKey.trim()
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
setKeyErr('Must be 0x + 64 hex chars'); setKeyState('err'); return
}
setKeyErr(''); setKeyState('signing')
try {
const env = await signRequest({ action: 'set_hl_api_key', wallet: address, body: { api_key: trimmed }, signMessageAsync })
setKeyState('saving')
const res = await setHlApiKey(env, trimmed)
setHlApiKeySet(true, res.masked_key)
setBotReadiness('saved')
void refreshUserState(address)
setApiKey('')
setKeyState('ok')
} catch (e: unknown) {
const m = e instanceof Error ? e.message : ''
setKeyErr(m.includes('rejected') ? 'Cancelled' : m.slice(0, 100))
setKeyState('err')
}
}
function handleConnectWallet() {
const connector = connectors[0]
if (connector) connect({ connector })
}
// Before client mount, render a stable placeholder so SSR output matches
// the first client paint (avoids hydration mismatch — wagmi state differs).
if (!mounted) {
return <div className="card" style={{ padding: 32, marginBottom: 28, minHeight: 120 }} />
}
if (!isConnected) {
return (
<div className="card" style={{ padding: 32, textAlign: 'center', marginBottom: 28 }}>
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>Connect your wallet to configure the bot and view your trades.</p>
<button className="btn amber" onClick={handleConnectWallet}>
Connect wallet
</button>
</div>
)
}
// ── small local helpers ──
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' | 'down' }) => (
<label className={`switch ${tone === 'amber' ? '' : tone}`}>
<input type="checkbox" checked={on} onChange={e => { onChange(e.target.checked); setDirty(true) }} />
<span className="switch-track" />
</label>
)
const saveBtnLabel =
saveState === 'signing' ? 'Sign in wallet…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'ok' ? '✓ Saved'
: 'Save changes'
const keyHint =
hlApiKeySet && !apiKey
? 'Saved in your bot profile. If you rotate the API wallet in Hyperliquid, update it here before trading again.'
: 'Use the Hyperliquid API wallet private key from app.hyperliquid.xyz/API. Never paste your MetaMask secret phrase or private key here.'
const keyStatusHint =
keyState === 'ok'
? 'API wallet saved. Recommended next step: keep size tiny and run your own first BTC test before treating the setup as production-ready.'
: null
// ── Hyperliquid key strip (shown above the settings card) ──
const hlKeyStrip = (
<div className="card" style={{ padding: '16px 20px', marginBottom: 16, display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: hlApiKeySet ? 'var(--up-soft)' : 'var(--bg-sunk)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: hlApiKeySet ? 'var(--up)' : 'var(--ink-4)' }}>
{hlApiKeySet ? '✓' : '◇'}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Hyperliquid API wallet</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 1 }}>
{!isSubscribed ? 'Subscribe first to link your HL account'
: hlApiKeySet && !apiKey ? <>Linked · <span className="mono">{hlApiKeyMasked ?? '···'}</span> · trade-only scope</>
: 'Paste your API wallet private key — trade-only, never withdraw'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isSubscribed && hlApiKeySet && !apiKey && (
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }} onClick={() => setApiKey(' ')}>Rotate</button>
)}
{isSubscribed && (!hlApiKeySet || apiKey) && (
<>
<input value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={e => { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }}
placeholder="0x…"
style={{ width: 220, fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }} />
<button className={`btn ${keyState === 'ok' ? 'ghost' : 'amber'}`} style={{ padding: '8px 14px', fontSize: 12 }}
onClick={handleSaveKey} disabled={keyState === 'signing' || keyState === 'saving' || !apiKey.trim()}>
{keyState === 'signing' ? 'Sign…' : keyState === 'saving' ? 'Saving…' : keyState === 'ok' ? '✓' : 'Save'}
</button>
</>
)}
</div>
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.55 }}>
{keyHint}
</div>
{keyStatusHint && (
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--up)', lineHeight: 1.55 }}>
{keyStatusHint}
</div>
)}
{keyState === 'err' && (
<div style={{ gridColumn: '1 / -1', fontSize: 12, color: 'var(--down)' }}>{keyErr}</div>
)}
</div>
)
// ── Is the account fully configured? Bot only trades when every box is ticked. ──
const missingSetup: string[] = []
if (!isSubscribed) missingSetup.push('subscribe to the bot')
if (!hlApiKeySet) missingSetup.push('link a Hyperliquid API wallet')
if (!tpConfigured) missingSetup.push('take-profit')
if (!slConfigured) missingSetup.push('stop-loss')
if (!budgetConfigured) missingSetup.push('daily budget')
const botReady = missingSetup.length === 0
const setupSteps = [
{ label: '1. Subscribe', done: isSubscribed },
{ label: '2. Add HL API wallet', done: hlApiKeySet },
{ label: '3. Save risk limits', done: tpConfigured && slConfigured && budgetConfigured },
]
const setupGuideMessage =
!isSubscribed ? 'Start by subscribing with the connected wallet. That unlocks the bot profile for this address.'
: botReadiness === 'ready' ? 'Your setup is verified. Before relying on automation, run one tiny BTC trade with your own wallet and Hyperliquid API wallet to confirm the live path end to end.'
: !hlApiKeySet ? 'Next, paste the Hyperliquid API wallet private key you generated inside Hyperliquid. Do not paste your MetaMask private key or seed phrase here.'
: 'Your exchange key is saved. Finish take-profit, stop-loss, and daily budget, then save changes. The backend still needs to verify this setup before we mark it ready.'
return (
<div style={{ marginBottom: 28 }}>
<div
className="card"
style={{
padding: '14px 18px',
marginBottom: 12,
background: botReady ? 'var(--up-soft)' : 'var(--amber-soft)',
borderColor: botReady ? 'color-mix(in oklab, var(--up) 18%, var(--line))' : 'color-mix(in oklab, var(--amber) 22%, var(--line))',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{setupSteps.map((step) => (
<span
key={step.label}
className={`chip ${step.done ? 'up' : ''}`}
style={{ fontSize: 11 }}
>
{step.done ? '✓ ' : '○ '}
{step.label}
</span>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.55 }}>
{setupGuideMessage}
</div>
</div>
{hlKeyStrip}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* ─── Header bar ─── */}
<div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid var(--line)' }}>
<div>
<div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>Trading bot</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>Executes on Hyperliquid when a Trump post clears your filters.</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{dirty && isSubscribed && <span style={{ fontSize: 12, color: 'var(--amber-ink)' }}> Unsaved</span>}
{saveState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{saveErr}</span>}
{!isSubscribed ? (
<button className="btn amber" style={{ padding: '8px 18px', fontSize: 13 }}
onClick={handleSubscribe} disabled={subState === 'signing' || subState === 'saving'}>
{subState === 'signing' ? 'Sign…' : subState === 'saving' ? 'Activating…' : 'Subscribe'}
</button>
) : (
<>
<span className={`chip ${botReady ? 'up' : 'down'}`} style={{ fontSize: 11 }}>
{botReady ? '● Bot ready' : '● Bot paused'}
</span>
<button className={`btn ${saveState === 'ok' ? 'ghost' : 'amber'}`}
style={{ padding: '8px 18px', fontSize: 13 }}
onClick={handleSaveSettings}
disabled={!dirty || saveState === 'signing' || saveState === 'saving'}>
{saveBtnLabel}
</button>
</>
)}
</div>
</div>
{/* ─── Setup-incomplete warning (persists until fully configured) ─── */}
{isSubscribed && loadState === 'loaded' && !botReady && (
<div style={{
padding: '14px 24px',
background: 'var(--down-soft)',
borderBottom: '1px solid var(--line)',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<span style={{ fontSize: 16, color: 'var(--down)', lineHeight: 1 }}></span>
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup incomplete the bot will not open any trades.</div>
<div style={{ color: 'var(--ink-3)' }}>
Finish configuring: <span style={{ color: 'var(--down)', fontWeight: 500 }}>{missingSetup.join(' · ')}</span>. Fill every required field below and save. The bot stays paused until every box is ticked.
</div>
</div>
</div>
)}
{isSubscribed && loadState === 'loaded' && botReady && (
<div style={{
padding: '14px 24px',
background: 'var(--up-soft)',
borderBottom: '1px solid var(--line)',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<span style={{ fontSize: 16, color: 'var(--up)', lineHeight: 1 }}></span>
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup complete on this page.</div>
<div style={{ color: 'var(--ink-3)' }}>
Wallet subscription, API wallet storage, and risk fields are all saved. Before you rely on automation, use your own account to run one tiny BTC test and confirm the live trade path behaves the way you expect.
</div>
</div>
</div>
)}
{!isSubscribed ? (
<div style={{ padding: 28, color: 'var(--ink-3)', fontSize: 13, lineHeight: 1.65 }}>
Subscribe to activate automated trading. The bot signs positions on Hyperliquid using an API wallet scoped to trade-only it can never withdraw. You pick the size, leverage, risk limits, and signal threshold below.
{subState === 'err' && <div style={{ marginTop: 10, color: 'var(--down)' }}>{subErr}</div>}
</div>
) : loadState !== 'loaded' ? (
<div style={{ padding: 28 }}>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 16 }}>
Sign a one-time message with your wallet to load your bot settings and trade history. The signature is cached for 4 minutes so repeat visits won't prompt again.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button className="btn amber" onClick={handleLoadSettings}
disabled={loadState === 'loading'}>
{loadState === 'loading' ? 'Waiting for signature' : 'Load my settings'}
</button>
{loadState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{loadErr}</span>}
</div>
</div>
) : (
<div style={{ padding: '8px 24px 24px' }}>
{/* ─── EXECUTION ─── */}
<div className="section-head">
<span className="section-head-label">Execution</span>
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="form-row">
<div className="form-row-label">
Per-trade size
<span className="hint">Notional in USD. margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
</div>
<div className="form-row-control">
<div className="num-field">
<span className="prefix">$</span>
<input type="number" min={5} max={10000} step={5} value={settings.position_size_usd}
onChange={e => updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>$5 $10,000</span>
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Leverage
<span className="hint">Applied to every position</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={1} max={50} value={settings.leverage}
onChange={e => updateSettings({ leverage: +e.target.value })} />
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
</div>
<span className="slider-readout">{settings.leverage}×</span>
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Skip any signal below this score (0100)</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={0} max={100} value={settings.min_confidence}
onChange={e => updateSettings({ min_confidence: +e.target.value })} />
<div className="ticks"><span>0</span><span>50</span><span>100</span></div>
</div>
<span className="slider-readout">{settings.min_confidence}%</span>
</div>
</div>
{/* ─── LIMITS ─── */}
<div className="section-head" style={{ marginTop: 20 }}>
<span className="section-head-label">Limits &amp; risk</span>
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="form-row">
<div className="form-row-label">
Daily budget <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Stop opening new trades once the day's total notional reaches this (UTC). Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<span className="prefix">$</span>
<input type="number" min={1} max={100000} step={5}
value={settings.daily_budget_usd ?? ''}
onChange={e => updateSettings({ daily_budget_usd: e.target.value === '' ? null : Math.max(0, +e.target.value) })} />
<span className="suffix">/day</span>
</div>
{!budgetConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when unrealised gain hits target. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<input type="number" min={0.1} max={50} step={0.1}
value={settings.take_profit_pct ?? ''}
onChange={e => updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} />
<span className="suffix">% gain</span>
</div>
{!tpConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when drawdown hits limit. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<input type="number" min={0.1} max={50} step={0.1}
value={settings.stop_loss_pct ?? ''}
onChange={e => updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} />
<span className="suffix">% loss</span>
</div>
{!slConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
</div>
</div>
{/* ─── SCHEDULE ─── */}
<div className="section-head" style={{ marginTop: 20 }}>
<span className="section-head-label">Active schedule</span>
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div className="form-row">
<div className="form-row-label">
Only run in a window
<span className="hint">Bot ignores signals outside this range. Shown in your browser's local time.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
<Switch on={useSchedule} onChange={(v) => { setUseSchedule(v); setDirty(true) }} />
{useSchedule && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="num-field">
<span className="prefix">From</span>
<input type="datetime-local" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }}
style={{ width: 190 }} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
<div className="num-field">
<span className="prefix">Until</span>
<input type="datetime-local" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }}
style={{ width: 190 }} />
</div>
</div>
)}
</div>
</div>
{useSchedule && fromLocal && untilLocal && (() => {
const fromMs = new Date(fromLocal).getTime()
const untilMs = new Date(untilLocal).getTime()
const nowMs = Date.now()
if (isNaN(fromMs) || isNaN(untilMs) || untilMs <= fromMs) return null
const inWindow = nowMs >= fromMs && nowMs < untilMs
const durMs = untilMs - fromMs
const durH = durMs / 3600000
const durLabel = durH < 1 ? `${Math.round(durH * 60)} min`
: durH < 48 ? `${durH.toFixed(1)} h`
: `${(durH / 24).toFixed(1)} d`
return (
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, padding: '0 0 14px' }}>
<div />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12 }}>
<span className={`chip ${inWindow ? 'up' : ''}`} style={{ fontSize: 11 }}>
{inWindow ? ' In window now' : ' Outside window'}
</span>
<span style={{ color: 'var(--ink-4)' }}>Duration: {durLabel}</span>
</div>
</div>
)
})()}
</div>
)}
</div>
</div>
)
}
// ─── Trades table ─────────────────────────────────────────────────────────────
export default function TradesPage() {
const [trades, setTrades] = useState<BotTrade[]>([])
const [posts, setPosts] = useState<TrumpPost[]>([])
@@ -40,12 +664,10 @@ export default function TradesPage() {
useEffect(() => {
Promise.all([
getTrades(200, 1).catch(() => []),
getTrades(100, 1).catch(() => []),
getPosts(500, 1).catch(() => []),
]).then(([t, p]) => {
setTrades(t)
setPosts(p)
}).finally(() => setLoading(false))
]).then(([t, p]) => { setTrades(t); setPosts(p) })
.finally(() => setLoading(false))
}, [])
const filtered = trades.filter(t => {
@@ -54,8 +676,11 @@ export default function TradesPage() {
return true
})
const totalPnl = filtered.reduce((s, t) => s + t.pnl_usd, 0)
const wins = filtered.filter(t => t.pnl_usd > 0).length
// Trades closed externally on HL may have null pnl_usd — exclude from aggregates.
const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined)
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length
const losses = priced.length - wins
const avgHold = filtered.length > 0 ? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length) : 0
return (
@@ -63,108 +688,90 @@ export default function TradesPage() {
<div className="page-head">
<div>
<h1 className="page-title">Trades</h1>
<p className="page-sub">Every trade your bot executed. Transparent and auditable.</p>
</div>
<div className="row gap-s">
<span className="chip">30 days</span>
<p className="page-sub">Bot configuration · execution history · P&amp;L</p>
</div>
</div>
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
<div className="kpi">
<div className="label">Total trades</div>
<div className="value">{filtered.length}</div>
<div className="foot"><span>Filtered</span></div>
{/* Bot config: settings + HL key */}
<BotConfigPanel />
{/* Stats */}
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
<div className="kpi"><div className="label">Total trades</div><div className="value">{filtered.length}</div></div>
<div className="kpi"><div className="label">Win rate</div><div className="value">{priced.length ? ((wins/priced.length)*100).toFixed(1)+'%' : ''}</div><div className="foot"><span>{wins}W · {losses}L</span></div></div>
<div className="kpi accent"><div className="label">Net P&amp;L</div><div className="value">{fmtMoney(totalPnl,{sign:true,decimals:0})}</div></div>
<div className="kpi"><div className="label">Avg hold</div><div className="value">{avgHold ? fmtHold(avgHold) : ''}</div></div>
</div>
{/* Filters */}
<div className="row gap-s" style={{ marginBottom: 14 }}>
<div className="nav-tabs">
{(['all','BTC','ETH'] as const).map(a => (
<button key={a} className={`nav-tab ${assetFilter===a?'active':''}`} onClick={() => setAssetFilter(a)}>
{a==='all'?'All assets':a}
</button>
))}
</div>
<div className="kpi">
<div className="label">Win rate</div>
<div className="value">{filtered.length ? ((wins / filtered.length) * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{wins}W · {filtered.length - wins}L</span></div>
</div>
<div className="kpi accent">
<div className="label">Net P&amp;L</div>
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
<div className="foot"><span>All assets</span></div>
</div>
<div className="kpi">
<div className="label">Avg hold</div>
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
<div className="foot"><span>Per trade</span></div>
<div className="nav-tabs">
{(['all','long','short'] as const).map(s => (
<button key={s} className={`nav-tab ${sideFilter===s?'active':''}`} onClick={() => setSideFilter(s)}>
{s.charAt(0).toUpperCase()+s.slice(1)}
</button>
))}
</div>
</div>
<div className="row between" style={{ margin: '20px 0 14px' }}>
<div className="row gap-s">
<div className="nav-tabs">
{(['all', 'BTC', 'ETH'] as const).map(a => (
<button key={a} className={`nav-tab ${assetFilter === a ? 'active' : ''}`} onClick={() => setAssetFilter(a)}>
{a === 'all' ? 'All assets' : a}
</button>
))}
</div>
<div className="nav-tabs">
{(['all', 'long', 'short'] as const).map(s => (
<button key={s} className={`nav-tab ${sideFilter === s ? 'active' : ''}`} onClick={() => setSideFilter(s)}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
</div>
</div>
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading</div>}
{loading && <div style={{ textAlign:'center', padding:60, color:'var(--ink-3)' }}>Loading…</div>}
{!loading && (
<div className="card flush" style={{ overflow: 'hidden' }}>
<div className="card flush" style={{ overflow:'hidden' }}>
<table className="table">
<thead>
<tr>
<th>Asset</th>
<th>Side</th>
<th>Entry</th>
<th>Exit</th>
<th>Hold</th>
<th>Trigger</th>
<th>P&amp;L</th>
<th>Asset</th><th>Side</th><th>Entry</th><th>Exit</th>
<th>Hold</th><th>Trigger post</th><th>P&amp;L</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>No trades found</td></tr>
<tr><td colSpan={7} style={{ textAlign:'center', padding:40, color:'var(--ink-3)' }}>No trades found</td></tr>
)}
{filtered.map(t => {
const triggerPost = posts.find(p => p.id === t.trigger_post_id)
const roiPct = ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
const tp = posts.find(p => p.id === t.trigger_post_id)
const roi = ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
return (
<tr key={t.id}>
<td>
<div className="row gap-s">
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
<span style={{ fontWeight: 500 }}>{t.asset}</span>
<span style={{ fontWeight:500 }}>{t.asset}</span>
</div>
</td>
<td><span className={`side-pill ${t.side}`}>{t.side === 'long' ? '↗ LONG' : '↘ SHORT'}</span></td>
<td><span className={`side-pill ${t.side}`}>{t.side==='long'?' LONG':' SHORT'}</span></td>
<td className="mono">${t.entry_price.toLocaleString()}</td>
<td className="mono">${t.exit_price.toLocaleString()}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 280 }}>
{triggerPost ? (
<div className="row gap-s" style={{ alignItems: 'center' }}>
<SourceIcon source={triggerPost.source} />
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{triggerPost.text.slice(0, 50)}
</span>
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
<td className="mono" style={{ color:'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth:260 }}>
{tp ? (
<span style={{ fontSize:12, color:'var(--ink-2)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', display:'block' }}>
{tp.text.slice(0,60)}…
</span>
) : <span style={{ fontSize:12, color:'var(--ink-4)' }}>—</span>}
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-end' }}>
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
{fmtMoney(t.pnl_usd, { sign: true })}
</span>
<span className={`delta ${roiPct >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>{fmtPct(roiPct)}</span>
<div className="stack" style={{ alignItems:'flex-end' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span style={{ fontSize:12, color:'var(--ink-4)' }} title="Closed externally on Hyperliquid — PnL not recorded">
n/a
</span>
) : (
<>
<span className={`delta ${t.pnl_usd>=0?'up':'down'}`} style={{ fontWeight:600, fontSize:14 }}>
{fmtMoney(t.pnl_usd,{sign:true})}
</span>
<span className={`delta ${roi>=0?'up':'down'}`} style={{ fontSize:11, opacity:0.7 }}>{fmtPct(roi)}</span>
</>
)}
</div>
</td>
</tr>