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:
@@ -0,0 +1,11 @@
|
||||
# Copy to .env.local and fill in your values.
|
||||
|
||||
# Backend REST API base URL (no trailing slash)
|
||||
# Dev: http://localhost:8000
|
||||
# Production: https://api.yourdomain.com
|
||||
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
|
||||
|
||||
# Backend WebSocket base URL
|
||||
# Dev: ws://localhost:8000
|
||||
# Production: wss://api.yourdomain.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.yourdomain.com
|
||||
@@ -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&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} />
|
||||
|
||||
{/* Post detail — shown when something is selected, else hint */}
|
||||
{selectedPost
|
||||
? <PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
|
||||
: <SelectHint />
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
+18
-19
@@ -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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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, 1–50×. 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'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>
|
||||
|
||||
{/* Link to Trades page for bot config */}
|
||||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
||||
<div className="row between" style={{ alignItems: 'center' }}>
|
||||
<div>
|
||||
{loadErr && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 12, border: '1px solid var(--down)', color: 'var(--down)', fontSize: 13 }}>
|
||||
{loadErr}
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Bot & 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>
|
||||
<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>
|
||||
)}
|
||||
{section === 'bot' && <BotSettings connected={isConnected} subscribed={isSubscribed} wallet={address} initial={settings} />}
|
||||
{section === 'exchange' && <ExchangeSettings subscribed={isSubscribed} />}
|
||||
{section === 'account' && <AccountSettings address={address} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+672
-65
@@ -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 (0–100)</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 & 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,38 +688,23 @@ 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&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>
|
||||
</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&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>
|
||||
{/* 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&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>
|
||||
|
||||
<div className="row between" style={{ margin: '20px 0 14px' }}>
|
||||
<div className="row gap-s">
|
||||
{/* 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)}>
|
||||
@@ -110,7 +720,6 @@ export default function TradesPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ textAlign:'center', padding:60, color:'var(--ink-3)' }}>Loading…</div>}
|
||||
|
||||
@@ -119,13 +728,8 @@ export default function TradesPage() {
|
||||
<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&L</th>
|
||||
<th>Asset</th><th>Side</th><th>Entry</th><th>Exit</th>
|
||||
<th>Hold</th><th>Trigger post</th><th>P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -133,8 +737,8 @@ export default function TradesPage() {
|
||||
<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>
|
||||
@@ -147,24 +751,27 @@ export default function TradesPage() {
|
||||
<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)}…
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
)}
|
||||
) : <span style={{ fontSize:12, color:'var(--ink-4)' }}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
<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 ${roiPct >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>{fmtPct(roiPct)}</span>
|
||||
<span className={`delta ${roi>=0?'up':'down'}`} style={{ fontSize:11, opacity:0.7 }}>{fmtPct(roi)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+1559
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import Script from 'next/script'
|
||||
import './[locale]/globals.css'
|
||||
|
||||
const siteTitle = 'Trump Alpha'
|
||||
const siteDescription = 'Track Trump social posts, AI-scored BTC and ETH signals, and auto-trader performance in one live dashboard.'
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: siteUrl ? new URL(siteUrl) : undefined,
|
||||
title: {
|
||||
default: `${siteTitle} | Trade Trump's Truth Social posts, automatically`,
|
||||
template: `%s | ${siteTitle}`,
|
||||
},
|
||||
description: siteDescription,
|
||||
applicationName: siteTitle,
|
||||
keywords: [
|
||||
'Trump Alpha',
|
||||
'TrumpSignal',
|
||||
'crypto signals',
|
||||
'BTC trading',
|
||||
'ETH trading',
|
||||
'AI trading dashboard',
|
||||
'Trump social sentiment',
|
||||
'Hyperliquid bot',
|
||||
],
|
||||
openGraph: {
|
||||
title: `${siteTitle} | Trade Trump's Truth Social posts, automatically`,
|
||||
description: siteDescription,
|
||||
siteName: siteTitle,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${siteTitle} | Trade Trump's Truth Social posts, automatically`,
|
||||
description: siteDescription,
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0f0d0a',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<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;800&family=Geist+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{/* Umami analytics — loaded after interactive so it never blocks render */}
|
||||
<Script
|
||||
src="https://stats.bitnews.day/script.js"
|
||||
data-website-id="708f06cc-3bd0-4b49-8e81-69289b7f1b42"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import './landing.css'
|
||||
|
||||
/* Scroll-reveal hook */
|
||||
function useReveal() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const root = ref.current
|
||||
if (!root) return
|
||||
const els = root.querySelectorAll<HTMLElement>('.lp-reveal')
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('in')
|
||||
io.unobserve(e.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' },
|
||||
)
|
||||
els.forEach((el) => io.observe(el))
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
|
||||
/* Ticking clock for the terminal header (looks alive) */
|
||||
function useClock() {
|
||||
const [t, setT] = useState<string>('')
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const d = new Date()
|
||||
setT(d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC')
|
||||
}
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
return t
|
||||
}
|
||||
|
||||
export default function LandingPage() {
|
||||
const rootRef = useReveal()
|
||||
const clock = useClock()
|
||||
|
||||
return (
|
||||
<div className="lp-root" ref={rootRef}>
|
||||
<div className="lp-mesh" aria-hidden />
|
||||
<div className="lp-grid" aria-hidden />
|
||||
|
||||
<nav className="lp-nav">
|
||||
<div className="lp-nav-inner">
|
||||
<Link href="/" className="lp-logo">
|
||||
<span className="lp-logo-dot" />
|
||||
Trump Alpha
|
||||
</Link>
|
||||
<Link href="/en" className="lp-nav-cta">
|
||||
Launch Dashboard
|
||||
<span className="lp-arrow">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="lp-wrap">
|
||||
{/* ---------- HERO ---------- */}
|
||||
<section className="lp-hero">
|
||||
<div className="lp-live-badge">
|
||||
<span className="lp-live-dot" />
|
||||
Reading @realDonaldTrump on Truth Social · right now
|
||||
</div>
|
||||
|
||||
<h1 className="lp-h1">
|
||||
Trump posts at 3am.<br />
|
||||
The bot trades at
|
||||
<span className="strike">
|
||||
<span className="grad">3:00:02</span>
|
||||
</span>
|
||||
.
|
||||
</h1>
|
||||
|
||||
<p className="lp-sub">
|
||||
Every Truth Social post is scraped, scored by AI, and turned into a
|
||||
Hyperliquid position — in under three seconds. You sleep. It doesn’t.
|
||||
</p>
|
||||
|
||||
<div className="lp-ctas">
|
||||
<Link href="/en" className="lp-btn lp-btn-primary">
|
||||
Launch Dashboard
|
||||
<span className="lp-arrow">→</span>
|
||||
</Link>
|
||||
<a href="#engine" className="lp-btn lp-btn-secondary">
|
||||
Watch the engine
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Ticker strip */}
|
||||
<div className="lp-ticker" aria-hidden>
|
||||
<div className="lp-ticker-track">
|
||||
{[0, 1].map((k) => (
|
||||
<div key={k} style={{ display: 'flex', gap: 48 }}>
|
||||
<TickerItem asset="BTC" kind="buy" text="Post scored 82 · AUTO-LONG fired" />
|
||||
<TickerItem asset="ETH" kind="hold" text="Below user confidence floor · skipped" />
|
||||
<TickerItem asset="BTC" kind="sell" text="Signal SHORT · TP/SL armed" />
|
||||
<TickerItem asset="ETH" kind="buy" text="Post scored 74 · AUTO-LONG fired" />
|
||||
<TickerItem asset="BTC" kind="hold" text="Outside active window · standing by" />
|
||||
<TickerItem asset="ETH" kind="sell" text="Signal SHORT · position opened" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- THE ENGINE (terminal block) ---------- */}
|
||||
<section id="engine" className="lp-section" style={{ paddingTop: 60 }}>
|
||||
<div className="lp-reveal" style={{ maxWidth: 640 }}>
|
||||
<div className="lp-eyebrow">The engine, running</div>
|
||||
<h2 className="lp-h2">
|
||||
No mock-ups. No dashboards in a marketing deck.
|
||||
</h2>
|
||||
<p className="lp-lead">
|
||||
This is what the signal engine actually does, every time Trump posts.
|
||||
Scrape. Score. Decide. Execute.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="lp-term lp-reveal">
|
||||
<div className="lp-term-head">
|
||||
<span className="dot" />
|
||||
<span className="meta-live">LIVE</span>
|
||||
<span className="sep">│</span>
|
||||
<span>trumpsignal.engine</span>
|
||||
<span className="sep">│</span>
|
||||
<span>src=<span className="meta-amber">truthsocial.com/@realDonaldTrump</span></span>
|
||||
<span className="spacer" />
|
||||
<span suppressHydrationWarning>{clock}</span>
|
||||
</div>
|
||||
|
||||
<div className="lp-term-body">
|
||||
<TermRow
|
||||
isNew
|
||||
ts="−02m"
|
||||
text="BITCOIN is the FUTURE of money. America will LEAD the world in crypto. BIG things coming very soon. ENJOY!"
|
||||
verdict={{ cls: 'LONG', asset: 'BTC', conf: 82, action: 'AUTO-LONG · 3× isolated · $50' }}
|
||||
/>
|
||||
<TermRow
|
||||
ts="−14m"
|
||||
text="Massive new TARIFFS on China coming — they have RIPPED OFF the USA for decades. No more!"
|
||||
verdict={{ cls: 'SHORT', asset: 'BTC', conf: 71, action: 'AUTO-SHORT · 3× isolated · $50' }}
|
||||
/>
|
||||
<TermRow
|
||||
ts="−38m"
|
||||
text="Crooked Joe and the Radical Left Lunatics are destroying our beautiful country. SAD!"
|
||||
verdict={{ cls: 'NOISE', skip: 'off-topic · below confidence floor' }}
|
||||
/>
|
||||
<TermRow
|
||||
ts="−01h"
|
||||
text="The Fed must CUT rates NOW. The economy cannot afford their stupidity any longer!"
|
||||
verdict={{ cls: 'LONG', asset: 'BTC', conf: 64, action: 'AUTO-LONG · 3× isolated · $50' }}
|
||||
/>
|
||||
<TermRow
|
||||
ts="−02h"
|
||||
text="Crypto is the FUTURE. No More War on Crypto! America FIRST!"
|
||||
verdict={{ cls: 'LONG', asset: 'BTC', conf: 78, action: 'skipped · daily budget cap hit' }}
|
||||
soft
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lp-term-foot">
|
||||
<span>
|
||||
tail -f /var/log/engine <span className="lp-term-cursor" />
|
||||
</span>
|
||||
<span className="tail">posts analysed: 1,247 · uptime 99.8%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat strip */}
|
||||
<div className="lp-stats lp-reveal">
|
||||
<Stat value="<3" suffix="s" label="Post → position" sub="scrape · score · sign · send" />
|
||||
<Stat value="24/7" label="Always on" sub="unless you say otherwise" />
|
||||
<Stat value="$0" label="Fee from us" sub="you pay Hyperliquid, not me" />
|
||||
<Stat value="6" label="Safety layers" sub="TP · SL · hold · budget · window · isolation" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="lp-reveal"
|
||||
style={{
|
||||
marginTop: 18, fontSize: 12, color: 'var(--lp-ink-4)',
|
||||
textAlign: 'center', fontFamily: 'Geist Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Posts shown are examples in Trump’s style — your actual feed is live from the dashboard.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---------- PRESS / HEADLINES ---------- */}
|
||||
<section className="lp-section" style={{ paddingTop: 80 }}>
|
||||
<div className="lp-reveal" style={{ maxWidth: 680 }}>
|
||||
<div className="lp-eyebrow">Headlines you can Google</div>
|
||||
<h2 className="lp-h2">
|
||||
The market already trades every Trump post.
|
||||
</h2>
|
||||
<p className="lp-lead">
|
||||
Big wallets have been scoring eight-figure moves on his Truth Social
|
||||
activity for two years. This bot won’t give you their edge —
|
||||
it just gives you their speed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lp-press">
|
||||
{/* Big card — the $6.8M trader story */}
|
||||
<a
|
||||
className="lp-news lp-news-big lp-reveal"
|
||||
href="https://www.newsbreak.com/raw-story-2096750/4286876592634-that-s-the-maga-playbook-crypto-trade-made-moments-before-trump-post-raises-eyebrows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="lp-news-big-figure">
|
||||
<span className="tag">verified · on-chain</span>
|
||||
<div className="num">+$6.8M</div>
|
||||
<div className="subnum">50× leverage · same-day close</div>
|
||||
</div>
|
||||
<div className="lp-news-big-body">
|
||||
<div className="lp-news-head">
|
||||
<span className="lp-news-outlet">Raw Story · NewsBreak</span>
|
||||
<span className="lp-news-date">Mar 2025</span>
|
||||
</div>
|
||||
<h3 className="lp-news-headline">
|
||||
Anonymous whale placed a 50× leveraged BTC/ETH bet minutes before
|
||||
Trump’s crypto-reserve post — cashed out the same day.
|
||||
</h3>
|
||||
</div>
|
||||
<span className="lp-news-arrow">↗</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="lp-news lp-reveal"
|
||||
href="https://www.cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="lp-news-arrow">↗</span>
|
||||
<div className="lp-news-head">
|
||||
<span className="lp-news-outlet">CNBC</span>
|
||||
<span className="lp-news-date">Mar 2 2025</span>
|
||||
</div>
|
||||
<h3 className="lp-news-headline">
|
||||
Trump’s Truth Social post confirming a Strategic Crypto
|
||||
Reserve sent Bitcoin from $84k to $91k overnight.
|
||||
</h3>
|
||||
<div className="lp-news-stat">
|
||||
<span className="n up">+8.2%</span>
|
||||
<span className="l">BTC · 24 hours<br />one Truth Social post</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="lp-news lp-reveal"
|
||||
href="https://www.aljazeera.com/news/2025/3/4/trump-announces-us-crypto-reserve-what-it-is-and-why-it-matters"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="lp-news-arrow">↗</span>
|
||||
<div className="lp-news-head">
|
||||
<span className="lp-news-outlet">Al Jazeera</span>
|
||||
<span className="lp-news-date">Mar 4 2025</span>
|
||||
</div>
|
||||
<h3 className="lp-news-headline">
|
||||
Ethereum spiked more than 10% alongside Bitcoin as the reserve
|
||||
announcement rippled across every major alt.
|
||||
</h3>
|
||||
<div className="lp-news-stat">
|
||||
<span className="n up">+10%+</span>
|
||||
<span className="l">ETH · same window<br />BTC, SOL, XRP followed</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="lp-news lp-reveal"
|
||||
href="https://www.coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="lp-news-arrow">↗</span>
|
||||
<div className="lp-news-head">
|
||||
<span className="lp-news-outlet">CoinDesk</span>
|
||||
<span className="lp-news-date">Apr 20 2026</span>
|
||||
</div>
|
||||
<h3 className="lp-news-headline">
|
||||
Five separate times a single Trump statement moved BTC within
|
||||
minutes — and why it keeps happening.
|
||||
</h3>
|
||||
<div className="lp-news-stat">
|
||||
<span className="n">5×</span>
|
||||
<span className="l">documented cases<br />BTC moved in minutes</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="lp-news lp-reveal"
|
||||
href="https://time.com/7263869/donald-trump-crypto-reserve-summit-bitcoin/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="lp-news-arrow">↗</span>
|
||||
<div className="lp-news-head">
|
||||
<span className="lp-news-outlet">TIME</span>
|
||||
<span className="lp-news-date">Mar 2025</span>
|
||||
</div>
|
||||
<h3 className="lp-news-headline">
|
||||
Senator Schiff calls for an SEC investigation into possible
|
||||
insider trading around Trump’s market-moving posts.
|
||||
</h3>
|
||||
<div className="lp-news-stat">
|
||||
<span className="n">SEC</span>
|
||||
<span className="l">investigation requested<br />by a U.S. senator</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="lp-press-note lp-reveal">
|
||||
Not our claims — their headlines. Click any card to read the source.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---------- WHY IT WORKS ---------- */}
|
||||
<section className="lp-section">
|
||||
<div className="lp-reveal" style={{ maxWidth: 640 }}>
|
||||
<div className="lp-eyebrow">Why bother</div>
|
||||
<h2 className="lp-h2">
|
||||
Trump moves markets. You can’t outrun him manually.
|
||||
</h2>
|
||||
<p className="lp-lead">
|
||||
He posts at any hour, in any mood. By the time you see it, the move
|
||||
is half over. The bot doesn’t blink. That’s the whole pitch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lp-why">
|
||||
<WhyCard
|
||||
n="01"
|
||||
t="Your rules, not ours"
|
||||
d="Your leverage, your position size, your TP/SL, your daily cap, your active hours. The bot only does what your config says."
|
||||
/>
|
||||
<WhyCard
|
||||
n="02"
|
||||
t="Isolated margin, always"
|
||||
d="Every open uses Hyperliquid isolated margin. A bad trade can’t drain your other positions or your whole account."
|
||||
/>
|
||||
<WhyCard
|
||||
n="03"
|
||||
t="Nothing held overnight"
|
||||
d="Every position force-closes after 60 minutes. No waking up to a liquidation from a post you missed."
|
||||
/>
|
||||
<WhyCard
|
||||
n="04"
|
||||
t="Your keys stay yours"
|
||||
d="Non-custodial. The Hyperliquid API key can open and close — it cannot withdraw. We never see your MetaMask."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------- FINAL CTA ---------- */}
|
||||
<section className="lp-cta-final lp-reveal">
|
||||
<h2 className="lp-h2" style={{ margin: '0 auto 24px', maxWidth: 780 }}>
|
||||
Stop refreshing Truth Social.<br />
|
||||
<span className="grad" style={{ fontWeight: 700 }}>
|
||||
Go do literally anything else.
|
||||
</span>
|
||||
</h2>
|
||||
<p className="lp-lead" style={{ margin: '0 auto 40px', textAlign: 'center' }}>
|
||||
2 minutes to set up. Free. Bring your own Hyperliquid account. If you
|
||||
don’t like it, disconnect — nothing to cancel.
|
||||
</p>
|
||||
<div className="lp-ctas">
|
||||
<Link href="/en" className="lp-btn lp-btn-primary">
|
||||
Launch Dashboard
|
||||
<span className="lp-arrow">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="lp-foot">
|
||||
<p className="lp-disclaimer">
|
||||
TrumpSignal is an experimental tool. Crypto perp trading is extremely
|
||||
high-risk; you can lose more than you deposit. Past signals are not
|
||||
predictions of future results. Not affiliated with Truth Social, Trump
|
||||
Media & Technology Group, or Donald J. Trump. Trade at your own risk.
|
||||
</p>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<span>© {new Date().getFullYear()} TrumpSignal</span>
|
||||
<Link href="/en/privacy">Privacy</Link>
|
||||
<Link href="/en/terms">Terms</Link>
|
||||
<Link href="/en/contact">Contact</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Terminal row ---------- */
|
||||
function TermRow({
|
||||
isNew,
|
||||
ts,
|
||||
text,
|
||||
verdict,
|
||||
soft,
|
||||
}: {
|
||||
isNew?: boolean
|
||||
ts: string
|
||||
text: string
|
||||
verdict:
|
||||
| { cls: 'LONG' | 'SHORT'; asset: string; conf: number; action: string }
|
||||
| { cls: 'NOISE'; skip: string }
|
||||
soft?: boolean
|
||||
}) {
|
||||
const cls = verdict.cls
|
||||
const valClass = cls === 'LONG' ? 'long' : cls === 'SHORT' ? 'short' : 'skip'
|
||||
return (
|
||||
<div className={`lp-term-row${isNew ? ' new' : ''}`}>
|
||||
<div className="lp-term-meta">
|
||||
<span className="handle">@realDonaldTrump</span>
|
||||
<span>·</span>
|
||||
<span className="ts">{ts}</span>
|
||||
<span>·</span>
|
||||
<span>truthsocial</span>
|
||||
</div>
|
||||
<p className="lp-term-quote">{text}</p>
|
||||
<div className="lp-term-verdict">
|
||||
<span className="seg">
|
||||
<span className="k">cls</span>
|
||||
<span className={`v ${valClass}`}>{cls}</span>
|
||||
</span>
|
||||
{'asset' in verdict && (
|
||||
<>
|
||||
<span className="seg">
|
||||
<span className="k">asset</span>
|
||||
<span className="v amber">{verdict.asset}</span>
|
||||
</span>
|
||||
<span className="seg">
|
||||
<span className="k">conf</span>
|
||||
<span className="bar">
|
||||
<span
|
||||
className="bar-fill"
|
||||
style={{ width: `${verdict.conf}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="v">{verdict.conf}</span>
|
||||
</span>
|
||||
<span className="seg">
|
||||
<span className="k">action</span>
|
||||
<span className={`v ${soft ? '' : 'amber'}`}>{verdict.action}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{'skip' in verdict && (
|
||||
<span className="seg">
|
||||
<span className="k">reason</span>
|
||||
<span className="v skip">{verdict.skip}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Small components ---------- */
|
||||
function TickerItem({ asset, kind, text }: { asset: string; kind: 'buy' | 'sell' | 'hold'; text: string }) {
|
||||
return (
|
||||
<div className="lp-ticker-item">
|
||||
<span className={`tag ${kind}`}>{kind.toUpperCase()}</span>
|
||||
<span style={{ color: 'var(--lp-ink-2)' }}>{asset}</span>
|
||||
<span>·</span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ value, suffix, label, sub }: { value: string; suffix?: string; label: string; sub?: string }) {
|
||||
return (
|
||||
<div className="lp-stat">
|
||||
<div className="lp-stat-val">
|
||||
{value}
|
||||
{suffix && <span className="suffix">{suffix}</span>}
|
||||
</div>
|
||||
<div className="lp-stat-lbl">{label}</div>
|
||||
{sub && <div className="lp-stat-sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WhyCard({ n, t, d }: { n: string; t: string; d: string }) {
|
||||
return (
|
||||
<div className="lp-why-card lp-reveal">
|
||||
<div className="num">{n}</div>
|
||||
<h4>{t}</h4>
|
||||
<p>{d}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
if (!siteUrl) {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
},
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
if (!siteUrl && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('NEXT_PUBLIC_SITE_URL must be set in production builds (used by sitemap.xml)')
|
||||
}
|
||||
const baseUrl = (siteUrl || 'http://localhost:3001').replace(/\/$/, '')
|
||||
const routes = ['', '/posts', '/trades', '/analytics', '/settings', '/privacy', '/terms', '/contact']
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const now = new Date()
|
||||
|
||||
return routes.map((route) => ({
|
||||
url: `${baseUrl}/en${route}`,
|
||||
lastModified: now,
|
||||
changeFrequency: route === '' ? 'hourly' : 'daily',
|
||||
priority: route === '' ? 1 : 0.7,
|
||||
}))
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { setHlApiKey, subscribe } from '@/lib/api'
|
||||
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
|
||||
// Action names must match backend/app/api/{user,subscribe}.py
|
||||
@@ -25,8 +25,9 @@ function fmtHold(s: number) {
|
||||
}
|
||||
|
||||
export default function BotPanel({ performance }: Props) {
|
||||
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore()
|
||||
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setBotReadiness, setHlApiKeySet, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
@@ -35,6 +36,33 @@ export default function BotPanel({ performance }: Props) {
|
||||
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
|
||||
const [subError, setSubError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
return
|
||||
}
|
||||
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, setHlApiKeySet, setSubscribed, setBotReadiness])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
if (!address) return
|
||||
setSubError('')
|
||||
@@ -48,7 +76,7 @@ export default function BotPanel({ performance }: Props) {
|
||||
})
|
||||
setSubState('saving')
|
||||
await subscribe(env)
|
||||
setSubscribed(true)
|
||||
await refreshUserState(address)
|
||||
setSubState('idle')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
@@ -76,7 +104,8 @@ export default function BotPanel({ performance }: Props) {
|
||||
})
|
||||
setSaveState('saving')
|
||||
const res = await setHlApiKey(env, trimmed)
|
||||
setHlApiKeySet(true, res.masked_key)
|
||||
const pub = await refreshUserState(address)
|
||||
setHlApiKeySet(pub.hl_api_key_set, res.masked_key)
|
||||
setApiKey('')
|
||||
setSaveState('success')
|
||||
} catch (err: unknown) {
|
||||
@@ -96,13 +125,18 @@ export default function BotPanel({ performance }: Props) {
|
||||
: saveState === 'success' ? '✓ Saved'
|
||||
: 'Save key'
|
||||
|
||||
function handleConnectWallet() {
|
||||
const connector = connectors[0]
|
||||
if (connector) connect({ connector })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bot status card — dark design */}
|
||||
<div className="bot-status">
|
||||
<div className="bot-head">
|
||||
<h3>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: isSubscribed ? 'var(--amber)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: botReadiness === 'ready' ? 'var(--amber)' : hlApiKeySet ? 'var(--up)' : isSubscribed ? 'var(--up)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
|
||||
Auto-trader
|
||||
</h3>
|
||||
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
|
||||
@@ -112,7 +146,7 @@ export default function BotPanel({ performance }: Props) {
|
||||
<div className="bot-stat">
|
||||
<div className="k">Net P&L</div>
|
||||
<div className="v amber">
|
||||
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
|
||||
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString('en-US', { maximumFractionDigits: 0 }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
@@ -134,7 +168,7 @@ export default function BotPanel({ performance }: Props) {
|
||||
<div className="bot-cta">
|
||||
{!isConnected && (
|
||||
<button className="btn amber" style={{ width: '100%' }}
|
||||
onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
|
||||
onClick={handleConnectWallet}>
|
||||
Connect wallet
|
||||
</button>
|
||||
)}
|
||||
@@ -155,12 +189,12 @@ export default function BotPanel({ performance }: Props) {
|
||||
)}
|
||||
{isConnected && isSubscribed && !hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
|
||||
↓ Paste your Hyperliquid API key below to activate
|
||||
↓ Paste your Hyperliquid API key below to finish setup
|
||||
</div>
|
||||
)}
|
||||
{isConnected && isSubscribed && hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
|
||||
✓ Bot active · waiting for next signal
|
||||
✓ Setup saved · verification still depends on backend
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,10 @@ interface ChartPanelProps {
|
||||
candles?: Candle[]
|
||||
externalSelectedId?: number | null
|
||||
onSelectPost?: (id: number | null) => void
|
||||
onSelectDayPosts?: (posts: TrumpPost[]) => void
|
||||
}
|
||||
|
||||
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
|
||||
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost, onSelectDayPosts }: ChartPanelProps) {
|
||||
const { timeframe } = useDashboardStore()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<unknown>(null)
|
||||
@@ -25,6 +26,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
timeframeRef.current = timeframe
|
||||
const onSelectRef = useRef(onSelectPost)
|
||||
onSelectRef.current = onSelectPost
|
||||
const onSelectDayPostsRef = useRef(onSelectDayPosts)
|
||||
onSelectDayPostsRef.current = onSelectDayPosts
|
||||
|
||||
// Detect current theme for chart colors
|
||||
function getChartColors() {
|
||||
@@ -41,6 +44,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || typeof window === 'undefined') return
|
||||
let destroyed = false
|
||||
let ro: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
|
||||
if (destroyed || !containerRef.current) return
|
||||
@@ -100,38 +105,57 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
||||
return Math.floor(pt / bucketSecs) * bucketSecs === clickBucket
|
||||
})
|
||||
.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0))
|
||||
|
||||
if (inBucket.length === 0) {
|
||||
onSelectRef.current?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
const currentIdx = inBucket.findIndex((p) => p.id === selectedPostIdRef.current)
|
||||
if (currentIdx >= 0) {
|
||||
const nextIdx = currentIdx + 1
|
||||
if (nextIdx >= inBucket.length) {
|
||||
onSelectRef.current?.(null)
|
||||
} else {
|
||||
onSelectRef.current?.(inBucket[nextIdx].id)
|
||||
// Multiple posts in this candle bucket → show all of them in the right rail
|
||||
if (inBucket.length > 1 && onSelectDayPostsRef.current) {
|
||||
const sorted = [...inBucket].sort(
|
||||
(a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
||||
)
|
||||
onSelectDayPostsRef.current(sorted)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
||||
// Single post → show detail directly
|
||||
onSelectRef.current?.(inBucket[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
ro = new ResizeObserver(() => {
|
||||
if (containerRef.current && !destroyed) {
|
||||
chart.applyOptions({ width: containerRef.current.clientWidth })
|
||||
}
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
|
||||
return () => { ro.disconnect() }
|
||||
themeObserver = new MutationObserver(() => {
|
||||
const colors = getChartColors()
|
||||
chart.applyOptions({
|
||||
layout: {
|
||||
background: { color: colors.background },
|
||||
textColor: colors.textColor,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: colors.gridColor },
|
||||
horzLines: { color: colors.gridColor },
|
||||
},
|
||||
rightPriceScale: { borderColor: colors.borderColor },
|
||||
timeScale: { borderColor: colors.borderColor },
|
||||
})
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
ro?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
fittedRef.current = false
|
||||
if (chartRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
@@ -169,7 +193,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
return t >= minTime && t <= maxTime
|
||||
})
|
||||
|
||||
if (visible.length > 0) {
|
||||
const bucketByTf: Record<string, number> = {
|
||||
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
|
||||
}
|
||||
@@ -210,7 +233,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
// @ts-expect-error lightweight-charts type
|
||||
series.setMarkers(markers)
|
||||
}
|
||||
|
||||
if (!fittedRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { TrumpPost } from '@/types'
|
||||
|
||||
function fmtPct(n: number | null | undefined) {
|
||||
if (n == null || isNaN(n)) return '—'
|
||||
if (n == null || isNaN(n)) return '—' // null = window not yet closed
|
||||
const s = n.toFixed(2) + '%'
|
||||
return n >= 0 ? '+' + s : s
|
||||
}
|
||||
|
||||
function fmtImpactPct(v: number | null | undefined) {
|
||||
if (v == null || isNaN(v)) return '—'
|
||||
const s = Math.abs(v).toFixed(2) + '%'
|
||||
return v >= 0 ? '+' + s : '-' + s
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000)
|
||||
@@ -18,8 +25,32 @@ function timeAgo(iso: string) {
|
||||
return Math.floor(h / 24) + 'd'
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: string }) {
|
||||
if (source === 'x') return <div className="src-ico x">𝕏</div>
|
||||
/**
|
||||
* Hydration-safe wrapper for relative time. Returns an empty placeholder on
|
||||
* SSR / first client render, then the real relative time after mount. Prevents
|
||||
* the SSR/CSR "5m vs 6m" mismatch error.
|
||||
*/
|
||||
function TimeAgo({ iso, suffix = '' }: { iso: string; suffix?: string }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
if (!mounted) return <span suppressHydrationWarning>…</span>
|
||||
return <span>{timeAgo(iso)}{suffix}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydration-safe absolute date formatter. Uses fixed 'en-US' locale + options
|
||||
* so the server/client outputs match once mounted; renders a placeholder before
|
||||
* mount to avoid timezone mismatches.
|
||||
*/
|
||||
function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatOptions }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
if (!mounted) return <span suppressHydrationWarning>…</span>
|
||||
return <span>{new Date(iso).toLocaleString('en-US', opts)}</span>
|
||||
}
|
||||
|
||||
function SourceIcon({ source: _source }: { source: string }) {
|
||||
// Truth Social only — no X/Twitter support.
|
||||
return <div className="src-ico truth">T</div>
|
||||
}
|
||||
|
||||
@@ -35,29 +66,45 @@ interface PostRowProps {
|
||||
}
|
||||
|
||||
export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const impact = post.price_impact
|
||||
|
||||
function handleClick() {
|
||||
if (!onClick) setExpanded(e => !e)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`post-row ${selected ? 'selected' : ''}`} onClick={onClick}>
|
||||
<div
|
||||
className={`post-row ${selected ? 'selected' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* ── main row ── */}
|
||||
<div className="post-row-main">
|
||||
<SourceIcon source={post.source} />
|
||||
<div className="post-body">
|
||||
<div className="meta">
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(post.published_at)} ago</span>
|
||||
<TimeAgo iso={post.published_at} suffix=" ago" />
|
||||
<span>·</span>
|
||||
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
|
||||
{post.sentiment}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text">{post.text.slice(0, 180)}{post.text.length > 180 ? '…' : ''}</p>
|
||||
<p className="text" style={expanded ? { display: 'block', overflow: 'visible', WebkitLineClamp: 'unset' } : {}}>
|
||||
{expanded ? post.text : (post.text.slice(0, 180) + (post.text.length > 180 ? '…' : ''))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="post-aside">
|
||||
<SignalPill signal={post.signal} />
|
||||
<div className="impact-mini">
|
||||
{impact ? (
|
||||
<>
|
||||
<span className="tf">1h</span>
|
||||
<span className={`delta ${(impact.m1h ?? 0) >= 0 ? 'up' : 'down'}`}>{fmtPct(impact.m1h)}</span>
|
||||
<span className="tf">1h peak</span>
|
||||
<span className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}>
|
||||
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="tf">no data</span>
|
||||
@@ -69,7 +116,78 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── expanded detail ── */}
|
||||
{expanded && (
|
||||
<div
|
||||
className="post-row-detail"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* AI confidence bar */}
|
||||
<div>
|
||||
<div className="ai-metric-head">
|
||||
<span>AI confidence</span>
|
||||
<strong>{post.ai_confidence}%</strong>
|
||||
</div>
|
||||
<div className="confidence-bar">
|
||||
<div style={{ width: post.ai_confidence + '%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI reasoning */}
|
||||
{post.ai_reasoning && (
|
||||
<div>
|
||||
<div className="ai-reasoning-label">AI reasoning</div>
|
||||
<div className="ai-reasoning-card">
|
||||
{post.ai_reasoning}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price impact — peak move in signal direction per window */}
|
||||
{impact && (
|
||||
<div>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>Peak move · {impact.asset}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{(['m5', 'm15', 'm1h'] as const).map(key => {
|
||||
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
|
||||
const v = impact[key] // null = window still open
|
||||
const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
|
||||
const pending = v == null
|
||||
return (
|
||||
<div key={key} style={{
|
||||
padding: 10,
|
||||
background: 'var(--bg-sunk)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: `1px solid ${pending ? 'var(--amber)' : 'var(--line)'}`,
|
||||
textAlign: 'center',
|
||||
opacity: pending ? 0.75 : 1,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
|
||||
<div
|
||||
className={pending ? '' : `delta ${v >= 0 ? 'up' : 'down'}`}
|
||||
style={{ fontSize: 14, fontWeight: 600, color: pending ? 'var(--ink-3)' : undefined }}
|
||||
>
|
||||
{pending ? '…' : fmtImpactPct(v)}
|
||||
</div>
|
||||
{!pending && correct != null && (
|
||||
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
|
||||
{correct ? '✓' : '✗'}
|
||||
</div>
|
||||
)}
|
||||
{pending && (
|
||||
<div style={{ fontSize: 9, marginTop: 4, color: 'var(--amber)' }}>live</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SignalPill, SourceIcon, fmtPct, timeAgo }
|
||||
export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }
|
||||
|
||||
+26
-20
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
|
||||
function BrandMark() {
|
||||
return <span className="brand-mark">α</span>
|
||||
@@ -46,10 +45,12 @@ function ThemeToggle() {
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect } = useConnect()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
useEffect(() => { setWalletMenuOpen(false) }, [pathname, address])
|
||||
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
const path = '/' + pathname.split('/').slice(2).join('/')
|
||||
@@ -98,45 +99,50 @@ export default function Navbar() {
|
||||
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="wallet-chip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const menu = (e.currentTarget.nextElementSibling as HTMLElement | null)
|
||||
if (menu) menu.style.display = menu.style.display === 'block' ? 'none' : 'block'
|
||||
}}
|
||||
onClick={() => setWalletMenuOpen(open => !open)}
|
||||
onBlur={(e) => {
|
||||
const menu = e.currentTarget.nextElementSibling as HTMLElement | null
|
||||
setTimeout(() => { if (menu) menu.style.display = 'none' }, 150)
|
||||
if (!e.currentTarget.parentElement?.contains(e.relatedTarget as Node | null)) {
|
||||
setWalletMenuOpen(false)
|
||||
}
|
||||
}}
|
||||
aria-expanded={walletMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="ava" />
|
||||
<span className="mono">{shortAddr}</span>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: 'none', position: 'absolute', right: 0, top: 'calc(100% + 6px)',
|
||||
background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 'var(--r-sm)',
|
||||
minWidth: 180, padding: 6, zIndex: 1000, boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<div className={`wallet-menu ${walletMenuOpen ? 'open' : ''}`} role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (address) navigator.clipboard?.writeText(address)
|
||||
setWalletMenuOpen(false)
|
||||
}}
|
||||
style={{ width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'left', borderRadius: 6, color: 'var(--ink)' }}
|
||||
>
|
||||
Copy address
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(e) => { e.preventDefault(); disconnect() }}
|
||||
style={{ width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'left', borderRadius: 6, color: 'var(--down)' }}
|
||||
role="menuitem"
|
||||
className="danger"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
disconnect()
|
||||
setWalletMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="connect-btn lg" onClick={() => connect({ connector: injected() })}>
|
||||
<button
|
||||
className="connect-btn lg"
|
||||
onClick={() => {
|
||||
const connector = connectors[0]
|
||||
if (connector) connect({ connector })
|
||||
}}
|
||||
>
|
||||
Connect wallet
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
|
||||
export const locales = ['en', 'ar', 'pt-BR', 'zh', 'ja'] as const
|
||||
// English-only. Other locales were listed historically but have no message
|
||||
// files, which caused browsers with non-English Accept-Language to be routed
|
||||
// to a missing bundle. Keep in sync with messages/*.json.
|
||||
export const locales = ['en'] as const
|
||||
export type Locale = (typeof locales)[number]
|
||||
export const defaultLocale: Locale = 'en'
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface UserSettings {
|
||||
take_profit_pct: number | null
|
||||
stop_loss_pct: number | null
|
||||
min_confidence: number
|
||||
daily_budget_usd: number | null
|
||||
active_from: string | null // ISO UTC
|
||||
active_until: string | null // ISO UTC
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
|
||||
+23
-1
@@ -55,6 +55,25 @@ export interface SignedEnvelope {
|
||||
*/
|
||||
const VIEW_TTL_MS = 4 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Read-only: returns a cached, not-yet-expired view envelope if one exists.
|
||||
* Never signs — never triggers a wallet popup. Returns null if no fresh cache.
|
||||
*/
|
||||
export function getCachedViewEnvelope(action: string, wallet: string): SignedEnvelope | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const cacheKey = `ts:view:${action}:${wallet.toLowerCase()}`
|
||||
const raw = sessionStorage.getItem(cacheKey)
|
||||
if (!raw) return null
|
||||
try {
|
||||
const env = JSON.parse(raw) as SignedEnvelope
|
||||
if (Date.now() - env.timestamp < VIEW_TTL_MS) return env
|
||||
} catch (err) {
|
||||
console.warn('[signedRequest] corrupt cached envelope, ignoring', err)
|
||||
sessionStorage.removeItem(cacheKey)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getOrCreateViewEnvelope(params: {
|
||||
action: string
|
||||
wallet: string
|
||||
@@ -68,7 +87,10 @@ export async function getOrCreateViewEnvelope(params: {
|
||||
try {
|
||||
const env = JSON.parse(raw) as SignedEnvelope
|
||||
if (Date.now() - env.timestamp < VIEW_TTL_MS) return env
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.warn('[signedRequest] corrupt cached envelope, ignoring', err)
|
||||
sessionStorage.removeItem(cacheKey)
|
||||
}
|
||||
}
|
||||
const env = await signRequest({
|
||||
action: params.action,
|
||||
|
||||
+10
-1
@@ -16,9 +16,12 @@ interface Handlers {
|
||||
export function usePriceSocket(handlers: Handlers) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const handlersRef = useRef(handlers)
|
||||
const unmountedRef = useRef(false)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
handlersRef.current = handlers
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmountedRef.current) return
|
||||
const ws = new WebSocket(`${WS_URL}/ws/prices`)
|
||||
wsRef.current = ws
|
||||
|
||||
@@ -36,7 +39,10 @@ export function usePriceSocket(handlers: Handlers) {
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connect, 3000)
|
||||
// Don't schedule a reconnect if the component already unmounted —
|
||||
// otherwise we leak a socket per navigation forever.
|
||||
if (unmountedRef.current) return
|
||||
reconnectTimerRef.current = setTimeout(connect, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -45,8 +51,11 @@ export function usePriceSocket(handlers: Handlers) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
unmountedRef.current = false
|
||||
connect()
|
||||
return () => {
|
||||
unmountedRef.current = true
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [connect])
|
||||
|
||||
+4
-3
@@ -4,10 +4,11 @@ import { locales, defaultLocale } from './i18n'
|
||||
export default createMiddleware({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'always',
|
||||
})
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)' ,
|
||||
],
|
||||
// Only match locale-prefixed routes. `/` is served by `app/page.tsx`
|
||||
// (the landing page) and must not be intercepted by the i18n middleware.
|
||||
matcher: ['/(en)/:path*'],
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ const withNextIntl = createNextIntlPlugin('./i18n.ts')
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
webpack: (config) => {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
'@react-native-async-storage/async-storage': false,
|
||||
'pino-pretty': false,
|
||||
}
|
||||
return config
|
||||
},
|
||||
}
|
||||
|
||||
export default withNextIntl(nextConfig)
|
||||
|
||||
+10
-1
@@ -6,6 +6,7 @@ interface DashboardState {
|
||||
timeframe: '5m' | '15m' | '1H' | '4H' | '1D' | '1W'
|
||||
walletAddress: string | null
|
||||
isSubscribed: boolean
|
||||
botReadiness: 'unknown' | 'saved' | 'verified' | 'ready'
|
||||
hlApiKeySet: boolean // true if user already has a key saved in backend
|
||||
hlApiKeyMasked: string | null // e.g. "...a1b2c3" shown after successful save
|
||||
livePrices: { BTC: number | null; ETH: number | null }
|
||||
@@ -14,6 +15,7 @@ interface DashboardState {
|
||||
setTimeframe: (tf: '5m' | '15m' | '1H' | '4H' | '1D' | '1W') => void
|
||||
setWallet: (address: string | null) => void
|
||||
setSubscribed: (subscribed: boolean) => void
|
||||
setBotReadiness: (state: DashboardState['botReadiness']) => void
|
||||
setHlApiKeySet: (set: boolean, masked?: string) => void
|
||||
setLivePrice: (asset: 'BTC' | 'ETH', price: number) => void
|
||||
}
|
||||
@@ -24,6 +26,7 @@ export const useDashboardStore = create<DashboardState>((set) => ({
|
||||
timeframe: '4H',
|
||||
walletAddress: null,
|
||||
isSubscribed: false,
|
||||
botReadiness: 'unknown',
|
||||
hlApiKeySet: false,
|
||||
hlApiKeyMasked: null,
|
||||
livePrices: { BTC: null, ETH: null },
|
||||
@@ -32,8 +35,14 @@ export const useDashboardStore = create<DashboardState>((set) => ({
|
||||
setTimeframe: (timeframe) => set({ timeframe }),
|
||||
setWallet: (walletAddress) => set({ walletAddress }),
|
||||
setSubscribed: (isSubscribed) => set({ isSubscribed }),
|
||||
setBotReadiness: (botReadiness) => set({ botReadiness }),
|
||||
setHlApiKeySet: (keySet, masked) =>
|
||||
set({ hlApiKeySet: keySet, hlApiKeyMasked: masked ?? null }),
|
||||
set((s) => ({
|
||||
hlApiKeySet: keySet,
|
||||
// Preserve existing mask when called without one (e.g. the /public poll
|
||||
// only returns a boolean). Only explicitly clear when keySet=false.
|
||||
hlApiKeyMasked: !keySet ? null : (masked !== undefined ? masked : s.hlApiKeyMasked),
|
||||
})),
|
||||
setLivePrice: (asset, price) =>
|
||||
set((s) => ({ livePrices: { ...s.livePrices, [asset]: price } })),
|
||||
}))
|
||||
|
||||
+3
-3
@@ -12,9 +12,9 @@ export interface TrumpPost {
|
||||
relevant: boolean
|
||||
price_impact: {
|
||||
asset: 'BTC' | 'ETH'
|
||||
m5: number
|
||||
m15: number
|
||||
m1h: number
|
||||
m5: number | null // null = window still open (live rolling peak pending)
|
||||
m15: number | null
|
||||
m1h: number | null
|
||||
price_at_post: number
|
||||
correct_m5: boolean | null
|
||||
correct_m15: boolean | null
|
||||
|
||||
Reference in New Issue
Block a user