This commit is contained in:
k
2026-04-21 19:32:53 +08:00
parent 1747fc489f
commit 83e5892ddf
25 changed files with 2582 additions and 916 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "trumpsignal",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3001
}
]
}
+279 -28
View File
@@ -1,64 +1,315 @@
'use client'
import { useState, useEffect } from 'react'
import type { TrumpPost, BotTrade, BotPerformance, Candle } from '@/types'
import { useAccount } from 'wagmi'
import type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData'
import { getPrices } from '@/lib/api'
import KpiRow from '@/components/dashboard/KpiRow'
import { getPrices, getUserPublic } from '@/lib/api'
import ChartPanel from '@/components/dashboard/ChartPanel'
import BotPanel from '@/components/dashboard/BotPanel'
import TradesTable from '@/components/trades/TradesTable'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards'
interface Props {
initialPosts: TrumpPost[]
initialPerformance?: BotPerformance
initialTrades: BotTrade[]
}
export default function DashboardClient({ initialPosts, initialPerformance, initialTrades }: Props) {
const { asset, timeframe, setLivePrice } = useDashboardStore()
// ── Inline post detail panel shown in the right rail ──────────────────────────
function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) {
const impact = post.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 className="card" style={{ padding: 20 }}>
{/* header */}
<div className="row between" style={{ marginBottom: 14 }}>
<span className="tiny">Signal detail</span>
<button
className="icon-btn"
onClick={onClose}
style={{ width: 26, height: 26, borderRadius: '50%' }}
title="Close"
>
<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>
{/* source + time */}
<div className="row gap-s" style={{ marginBottom: 12 }}>
<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>
</div>
{/* post text */}
<p style={{ fontSize: 14, lineHeight: 1.55, margin: '0 0 16px', color: 'var(--ink)' }}>{post.text}</p>
{/* signal + sentiment */}
<div className="row gap-s" style={{ marginBottom: 16 }}>
<SignalPill signal={post.signal} />
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}>
{post.sentiment}
</span>
</div>
{/* AI confidence */}
<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: 600 }}>{post.ai_confidence}%</span>
</div>
<div className="confidence-bar" style={{ marginBottom: 16 }}>
<div style={{ width: post.ai_confidence + '%' }} />
</div>
{/* 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',
}}>
{post.ai_reasoning}
</div>
</>
)}
{/* Price impact grid */}
{impact && (
<>
<div className="tiny" style={{ marginBottom: 10 }}>Price impact · {impact.asset}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{([['m5', '5m'], ['m15', '15m'], ['m1h', '1h']] as const).map(([key, label]) => {
const v = impact[key]
const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
return (
<div key={key} style={{
padding: 10,
background: 'var(--bg-sunk)',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--line)',
textAlign: 'center',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
<div className={`delta ${(v ?? 0) >= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)
}
// ── Empty state for when nothing is selected ──────────────────────────────────
function SelectHint() {
return (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--ink-3)' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" style={{ margin: '0 auto 12px', display: 'block', opacity: 0.4 }}>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
Click any marker on the chart<br/>or a post below to see details
</p>
</div>
)
}
// ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setHlApiKeySet, isSubscribed } = 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 [candlesLoading, setCandlesLoading] = useState(false)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
useEffect(() => {
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
return
}
getUserPublic(address.toLowerCase())
.then((user) => {
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
})
.catch(() => {})
}, [address, isConnected, setSubscribed, setHlApiKeySet])
usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price),
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 50)),
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
})
useEffect(() => {
setCandles([])
setCandlesLoading(true)
getPrices(asset, timeframe)
.then(setCandles)
.catch(() => {})
.finally(() => setCandlesLoading(false))
}, [asset, timeframe])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
const recentPosts = posts.slice(0, 8)
const lastCandle = candles[candles.length - 1]
const firstCandle = candles[0]
const priceChange = lastCandle && firstCandle
? ((lastCandle.close - firstCandle.open) / firstCandle.open) * 100
: 0
const totalPosts = posts.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
return (
<div className="pt-14">
<div className="max-w-[1400px] mx-auto px-6 py-6 flex flex-col gap-4">
{/* KPI 行 */}
<KpiRow performance={initialPerformance} postsCount={posts.length} />
<div className="page wide">
<div className="page-head">
<div>
<h1 className="page-title">Signal monitor</h1>
<p className="page-sub">
{actionablePosts} actionable signals · Auto-trader {isSubscribed ? 'running' : 'standby'} · Model v4-selective
</p>
</div>
<div className="row gap-s">
<span className="chip"><span className="live-dot" />Live feed</span>
<span className="chip">{totalPosts} posts tracked</span>
</div>
</div>
{/* 主内容区:图表 + Bot 面板 */}
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0 relative">
<ChartPanel posts={posts} candles={candles} />
{candlesLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-[10px] pointer-events-none">
<span className="text-[#555555] text-sm">Loading...</span>
{/* KPI Row */}
<div className="kpi-row">
<div className="kpi">
<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>{timeframe}</span>
</div>
)}
</div>
<div className="w-[280px] shrink-0">
<div className="kpi">
<div className="label">Signals today</div>
<div className="value">{actionablePosts}</div>
<div className="foot"><span>Actionable posts</span></div>
</div>
<div className="kpi accent">
<div className="label">30d Net P&amp;L</div>
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>Bot performance</span></div>
</div>
<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>
</div>
<div className="dash-grid">
{/* Left: Chart + signal stream */}
<div className="stack gap-l">
<div className="card" style={{ padding: 24 }}>
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
<div>
<div className="tiny">Price · {asset}</div>
<div className="row gap-m" style={{ marginTop: 6 }}>
<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>
</div>
</div>
<div className="stack gap-s" style={{ alignItems: 'flex-end' }}>
<div className="asset-switch">
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
<span className="asset-dot btc" /> BTC
</button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div>
</div>
</div>
<div className="chart-wrap">
<ChartPanel
posts={posts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={setSelectedPostId}
/>
</div>
<div className="chart-footnote">
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
<div style={{ marginLeft: 'auto' }}>Click marker to see details </div>
</div>
</div>
{/* Recent signals list */}
<div>
<div className="section-title">
<h2>Recent signals</h2>
<span className="hint">Showing {recentPosts.length} of {totalPosts}</span>
</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)}
/>
))}
</div>
</div>
</div>
{/* Right rail: bot stats + post detail */}
<div className="rail">
<BotPanel performance={initialPerformance} />
</div>
</div>
{/* 交易记录 */}
<TradesTable trades={initialTrades} />
{/* Post detail — shown when something is selected, else hint */}
{selectedPost
? <PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
: <SelectHint />
}
</div>
</div>
</div>
)
+94 -7
View File
@@ -1,14 +1,101 @@
import { getTranslations } from 'next-intl/server'
'use client'
export default async function AnalyticsPage() {
const t = await getTranslations('analytics')
import { useState, useEffect } from 'react'
import { getPerformance, getTrades } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types'
function fmtMoney(n: number) {
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
const s = abs.toLocaleString('en-US', { maximumFractionDigits: 0 })
if (n < 0) return '-$' + s
if (n > 0) return '+$' + s
return '$' + s
}
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
export default function AnalyticsPage() {
const [perf, setPerf] = useState<BotPerformance | null>(null)
const [trades, setTrades] = useState<BotTrade[]>([])
const [period, setPeriod] = useState('30d')
useEffect(() => {
Promise.all([
getPerformance().catch(() => null),
getTrades(200, 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 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: '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 },
]
return (
<div className="max-w-[1400px] mx-auto px-6 py-6">
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
<h1 className="text-[22px] font-medium text-white mb-3">{t('title')}</h1>
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Analytics</h1>
<p className="page-sub">Deep dive into {period} of signals and trades.</p>
</div>
<div className="nav-tabs">
{['7d', '30d', '90d', 'All'].map(p => (
<button key={p} className={`nav-tab ${period === p ? 'active' : ''}`} onClick={() => setPeriod(p)}>{p}</button>
))}
</div>
</div>
{/* Summary card */}
<div className="card" style={{ padding: 28, marginBottom: 20 }}>
<div className="row between" style={{ alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<div className="tiny">Performance · {period}</div>
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
{perf ? fmtMoney(perf.net_pnl_usd) : '—'}
</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>
</div>
</div>
</div>
</div>
{/* Metric grid */}
<div className="metric-grid" style={{ marginBottom: 20 }}>
{metrics.map(m => (
<div key={m.k} className="card" style={{ padding: 20 }}>
<div className="tiny" style={{ marginBottom: 8 }}>{m.k}</div>
<div className={`mono ${m.up ? 'delta up' : m.down ? 'delta down' : ''}`} style={{ fontSize: 24, fontWeight: 500, letterSpacing: '-0.01em' }}>{m.v}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{m.sub}</div>
</div>
))}
</div>
{/* No data message */}
{!perf && !trades.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>
</div>
)}
</div>
)
}
+895 -14
View File
@@ -1,22 +1,903 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================================
Trump Alpha — Design System
============================================================ */
* {
box-sizing: border-box;
:root {
/* Surfaces — warm off-whites */
--bg: oklch(99% 0.004 85);
--bg-sunk: oklch(97.8% 0.006 85);
--surface: #ffffff;
--surface-2: oklch(97.2% 0.006 85);
--surface-3: oklch(94% 0.008 85);
/* Text */
--ink: oklch(18% 0.008 85);
--ink-2: oklch(38% 0.008 85);
--ink-3: oklch(55% 0.008 85);
--ink-4: oklch(70% 0.006 85);
/* Borders */
--line: oklch(93% 0.008 85);
--line-2: oklch(88% 0.01 85);
/* Amber accent */
--amber: oklch(78% 0.17 75);
--amber-ink: oklch(45% 0.16 70);
--amber-soft: oklch(96% 0.05 85);
--amber-ring: oklch(88% 0.12 80);
/* Signal colors */
--up: oklch(62% 0.17 148);
--up-soft: oklch(95% 0.05 148);
--down: oklch(58% 0.22 25);
--down-soft: oklch(95% 0.05 25);
--violet: oklch(55% 0.17 280);
--violet-soft: oklch(96% 0.04 280);
/* Radii */
--r-xs: 8px;
--r-sm: 10px;
--r-md: 14px;
--r-lg: 20px;
--r-xl: 28px;
--r-pill: 999px;
/* Shadow */
--shadow-1: 0 1px 2px rgba(20, 18, 14, 0.04), 0 1px 1px rgba(20, 18, 14, 0.02);
--shadow-2: 0 4px 16px rgba(20, 18, 14, 0.06), 0 1px 2px rgba(20, 18, 14, 0.03);
--shadow-3: 0 12px 40px rgba(20, 18, 14, 0.08), 0 2px 4px rgba(20, 18, 14, 0.04);
--sans: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
}
body {
background-color: #000000;
html[data-theme="dark"] {
--bg: oklch(15% 0.008 85);
--bg-sunk: oklch(12% 0.008 85);
--surface: oklch(18% 0.008 85);
--surface-2: oklch(21% 0.008 85);
--surface-3: oklch(25% 0.01 85);
--ink: oklch(97% 0.005 85);
--ink-2: oklch(82% 0.006 85);
--ink-3: oklch(62% 0.006 85);
--ink-4: oklch(45% 0.006 85);
--line: oklch(24% 0.008 85);
--line-2: oklch(30% 0.01 85);
--amber: oklch(78% 0.17 75);
--amber-ink: oklch(82% 0.16 75);
--amber-soft: oklch(25% 0.05 75);
--amber-ring: oklch(38% 0.12 75);
--up: oklch(70% 0.18 148);
--up-soft: oklch(24% 0.06 148);
--down: oklch(68% 0.22 25);
--down-soft: oklch(24% 0.08 25);
--violet: oklch(70% 0.17 280);
--violet-soft: oklch(24% 0.06 280);
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-2: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5);
color-scheme: dark;
}
/* Remove default focus ring and apply custom */
button:focus-visible {
outline: 1px solid #f97316;
outline-offset: 2px;
html[data-theme="dark"] .src-ico.x { background: oklch(92% 0.005 85); color: oklch(15% 0.008 85); }
html[data-theme="dark"] .bot-status {
background: linear-gradient(160deg, oklch(28% 0.015 85) 0%, oklch(20% 0.01 85) 100%);
border: 1px solid oklch(30% 0.01 85);
}
input:focus-visible {
outline: 1px solid #222222;
outline-offset: 0;
html[data-theme="dark"] .kpi.accent {
background: linear-gradient(135deg, oklch(28% 0.05 75), oklch(24% 0.07 70));
}
html[data-theme="dark"] .landing-nav {
background: oklch(15% 0.008 85 / 0.8);
}
html[data-theme="dark"] .src-ico.truth {
background: oklch(30% 0.08 25); color: oklch(80% 0.15 25);
}
html[data-theme="dark"] .post-row.selected {
background: oklch(22% 0.03 75);
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'ss01', 'cv11';
transition: background 200ms, color 200ms;
}
* { box-sizing: border-box; }
button { font-family: inherit; cursor: pointer; border: 0; background: transparent; color: inherit; }
input, textarea { font-family: inherit; }
a { color: inherit; text-decoration: none; }
.mono { font-family: var(--mono); font-feature-settings: 'tnum', 'zero'; }
.tnum { font-variant-numeric: tabular-nums; }
/* ============================================================
App scaffold
============================================================ */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---------- Top navbar ---------- */
.nav {
height: 64px;
padding: 0 28px;
display: flex;
align-items: center;
gap: 32px;
background: var(--bg);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 40;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 15px;
letter-spacing: -0.01em;
}
.brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--ink);
color: var(--bg);
display: grid;
place-items: center;
font-family: var(--mono);
font-size: 15px;
font-weight: 500;
position: relative;
}
.brand-mark::after {
content: '';
position: absolute;
right: -3px;
bottom: -3px;
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--amber);
border: 2px solid var(--bg);
}
.nav-tabs {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: var(--bg-sunk);
border: 1px solid var(--line);
border-radius: var(--r-pill);
}
.nav-tab {
padding: 7px 14px;
border-radius: var(--r-pill);
font-size: 13px;
font-weight: 500;
color: var(--ink-3);
transition: color 120ms, background 120ms;
}
.nav-tab:hover { color: var(--ink); }
.nav-tab.active {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-1);
}
.nav-spacer { flex: 1; }
.nav-right {
display: flex;
align-items: center;
gap: 10px;
}
.icon-btn {
width: 36px;
height: 36px;
border-radius: var(--r-pill);
background: var(--bg-sunk);
border: 1px solid var(--line);
display: grid;
place-items: center;
color: var(--ink-2);
transition: background 120ms, color 120ms;
}
.icon-btn:hover { background: var(--surface-3); color: var(--ink); }
.connect-btn {
padding: 12px 22px;
border-radius: var(--r-pill);
background: var(--ink);
color: var(--bg);
font-size: 14px;
font-weight: 600;
letter-spacing: -0.005em;
transition: background 120ms, transform 120ms, box-shadow 120ms;
box-shadow: var(--shadow-1);
}
.connect-btn:hover { background: oklch(30% 0.01 85); box-shadow: var(--shadow-2); }
.connect-btn:active { transform: scale(0.98); }
.connect-btn.lg { padding: 13px 26px; font-size: 14px; }
html[data-theme="dark"] .connect-btn { background: var(--amber); color: oklch(20% 0.04 75); }
html[data-theme="dark"] .connect-btn:hover { background: oklch(82% 0.17 75); }
.theme-toggle { width: 40px; height: 40px; }
html[data-theme="dark"] .icon-btn { background: var(--surface-2); border-color: var(--line); }
html[data-theme="dark"] .icon-btn:hover { background: var(--surface-3); }
html[data-theme="dark"] .nav-tab.active { background: var(--surface-2); }
html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2); }
.wallet-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 6px;
border-radius: var(--r-pill);
background: var(--bg-sunk);
border: 1px solid var(--line);
font-size: 13px;
}
.wallet-chip .ava {
width: 24px;
height: 24px;
border-radius: 999px;
background: linear-gradient(135deg, var(--amber), oklch(65% 0.2 35));
}
/* ============================================================
Page shell
============================================================ */
.page {
max-width: 1360px;
margin: 0 auto;
padding: 32px 28px 80px;
width: 100%;
}
.page.wide { max-width: 1600px; }
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 28px;
gap: 24px;
}
.page-title {
font-size: 34px;
letter-spacing: -0.02em;
font-weight: 600;
line-height: 1.05;
margin: 0;
}
.page-sub {
color: var(--ink-3);
font-size: 14px;
margin-top: 6px;
margin-bottom: 0;
}
/* ============================================================
Cards + primitives
============================================================ */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 20px;
}
.card.flush { padding: 0; }
.card.soft { background: var(--bg-sunk); }
.card.raise { box-shadow: var(--shadow-1); }
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.section-title h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.section-title .hint {
font-size: 12px;
color: var(--ink-3);
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--r-pill);
background: var(--bg-sunk);
border: 1px solid var(--line);
font-size: 12px;
color: var(--ink-2);
font-weight: 500;
}
.chip .dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
.chip.up { color: oklch(40% 0.16 148); background: var(--up-soft); border-color: oklch(85% 0.08 148); }
.chip.down { color: oklch(44% 0.2 25); background: var(--down-soft); border-color: oklch(87% 0.08 25); }
.chip.amber { color: var(--amber-ink); background: var(--amber-soft); border-color: var(--amber-ring); }
.chip.violet { color: oklch(38% 0.15 280); background: var(--violet-soft); border-color: oklch(86% 0.08 280); }
.chip.neutral { color: var(--ink-2); }
.live-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--up);
position: relative;
display: inline-block;
}
.live-dot::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 999px;
background: var(--up);
opacity: 0.3;
animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
0% { transform: scale(0.6); opacity: 0.5; }
100% { transform: scale(2.2); opacity: 0; }
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border-radius: var(--r-pill);
font-size: 13px;
font-weight: 500;
transition: background 120ms, transform 120ms;
border: 1px solid transparent;
}
.btn.primary { background: var(--ink); color: var(--bg); }
.btn.primary:hover { background: oklch(30% 0.01 85); }
.btn.amber {
background: var(--amber);
color: oklch(22% 0.04 75);
font-weight: 600;
}
.btn.amber:hover { background: oklch(82% 0.17 75); }
.btn.ghost { background: var(--bg-sunk); color: var(--ink); border-color: var(--line); }
.btn.ghost:hover { background: var(--surface-3); }
.btn.lg { padding: 14px 22px; font-size: 15px; }
.btn:active { transform: scale(0.98); }
.delta { font-family: var(--mono); font-weight: 500; }
.delta.up { color: oklch(42% 0.16 148); }
.delta.down { color: oklch(46% 0.2 25); }
/* ============================================================
Dashboard specific
============================================================ */
.dash-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 20px;
align-items: start;
}
.hero-value {
font-size: 44px;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1;
font-family: var(--mono);
font-feature-settings: 'tnum';
}
.hero-value .cents { color: var(--ink-3); font-size: 0.6em; }
.asset-switch {
display: inline-flex;
padding: 4px;
background: var(--bg-sunk);
border: 1px solid var(--line);
border-radius: var(--r-pill);
gap: 2px;
}
.asset-switch button {
padding: 7px 14px;
border-radius: var(--r-pill);
font-size: 13px;
font-weight: 500;
color: var(--ink-3);
display: flex;
align-items: center;
gap: 6px;
}
.asset-switch button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-1);
}
.asset-dot {
width: 14px;
height: 14px;
border-radius: 999px;
}
.asset-dot.btc { background: linear-gradient(135deg, #f7931a, #f2a93b); }
.asset-dot.eth { background: linear-gradient(135deg, #627eea, #8fa2ff); }
.tf-bar {
display: inline-flex;
gap: 2px;
padding: 3px;
background: var(--bg-sunk);
border: 1px solid var(--line);
border-radius: var(--r-pill);
}
.tf-bar button {
padding: 5px 10px;
border-radius: var(--r-pill);
font-size: 12px;
font-weight: 500;
color: var(--ink-3);
font-family: var(--mono);
}
.tf-bar button.on { background: var(--ink); color: var(--bg); }
.chart-wrap {
margin-top: 8px;
position: relative;
height: 360px;
}
.chart-footnote {
display: flex;
gap: 18px;
font-size: 12px;
color: var(--ink-3);
border-top: 1px dashed var(--line);
padding-top: 14px;
margin-top: 4px;
}
.chart-footnote .item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
/* KPI tiles */
.kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.kpi {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
overflow: hidden;
}
.kpi .label {
font-size: 12px;
color: var(--ink-3);
display: flex;
align-items: center;
gap: 6px;
}
.kpi .value {
font-size: 22px;
font-weight: 500;
letter-spacing: -0.01em;
font-family: var(--mono);
}
.kpi .foot {
font-size: 12px;
color: var(--ink-3);
display: flex;
align-items: center;
gap: 6px;
}
.kpi.accent {
background: linear-gradient(135deg, oklch(97% 0.04 85), oklch(95% 0.07 80));
border-color: var(--amber-ring);
}
.kpi.accent .label { color: var(--amber-ink); }
/* Right rail */
.rail { display: flex; flex-direction: column; gap: 20px; }
.signal-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px;
}
.signal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.signal-head h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.latest-post {
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 14px;
background: var(--bg-sunk);
}
.latest-post .meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--ink-3);
margin-bottom: 10px;
}
.latest-post .text {
font-size: 13px;
line-height: 1.55;
color: var(--ink);
margin: 0 0 12px;
}
.latest-post .divider {
height: 1px;
background: var(--line);
margin: 12px 0;
}
.latest-post .lp-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.latest-post .lp-row + .lp-row { margin-top: 6px; }
.latest-post .lp-row .k { color: var(--ink-3); }
.confidence-bar {
height: 6px;
background: var(--surface-3);
border-radius: 999px;
overflow: hidden;
position: relative;
margin-top: 6px;
}
.confidence-bar > div {
height: 100%;
background: linear-gradient(90deg, var(--amber) 0%, oklch(68% 0.2 45) 100%);
border-radius: 999px;
transition: width 600ms ease-out;
}
/* Bot status */
.bot-status {
background: linear-gradient(160deg, oklch(22% 0.01 85) 0%, oklch(14% 0.01 85) 100%);
color: oklch(96% 0.005 85);
border-radius: var(--r-lg);
padding: 22px;
position: relative;
overflow: hidden;
}
.bot-status::after {
content: '';
position: absolute;
right: -40px;
top: -40px;
width: 180px;
height: 180px;
background: radial-gradient(circle, oklch(75% 0.17 75 / 0.4) 0%, transparent 70%);
pointer-events: none;
}
.bot-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.bot-head h3 { margin: 0; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.bot-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 20px;
position: relative;
z-index: 1;
}
.bot-stat .k {
font-size: 11px;
color: oklch(70% 0.01 85);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.bot-stat .v {
font-size: 20px;
font-family: var(--mono);
font-weight: 500;
letter-spacing: -0.01em;
}
.bot-stat .v.up { color: oklch(75% 0.17 148); }
.bot-stat .v.amber { color: var(--amber); }
.bot-cta {
margin-top: 18px;
display: flex;
gap: 8px;
position: relative;
z-index: 1;
}
.bot-cta .btn { flex: 1; }
/* Post list (stream) */
.post-stream {
display: flex;
flex-direction: column;
gap: 10px;
}
.post-row {
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); }
.src-ico {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
font-family: var(--mono);
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.src-ico.x { background: #111; color: #fff; }
.src-ico.truth { background: oklch(94% 0.05 25); color: oklch(45% 0.2 25); }
.post-body .meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: var(--ink-3);
}
.post-body .text {
font-size: 14px;
line-height: 1.5;
color: var(--ink);
margin: 0 0 10px;
}
.post-aside {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
min-width: 120px;
}
/* Signal pill */
.sig {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: var(--r-pill);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.sig.buy { background: var(--up-soft); color: oklch(38% 0.16 148); }
.sig.sell, .sig.short { background: var(--down-soft); color: oklch(42% 0.2 25); }
.sig.hold { background: var(--bg-sunk); color: var(--ink-2); }
.impact-mini {
display: flex;
gap: 8px;
align-items: center;
font-family: var(--mono);
font-size: 12px;
}
.impact-mini .tf { color: var(--ink-4); font-size: 11px; }
/* Trades table */
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
}
.table th {
text-align: left;
font-weight: 500;
color: var(--ink-3);
padding: 14px 18px;
border-bottom: 1px solid var(--line);
font-size: 12px;
background: var(--bg-sunk);
}
.table th:first-child { border-top-left-radius: var(--r-md); }
.table th:last-child { border-top-right-radius: var(--r-md); text-align: right; }
.table td {
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.table td:last-child { text-align: right; }
.table tr:last-child td { border-bottom: 0; }
.table tr:hover td { background: var(--bg-sunk); }
.side-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: var(--r-pill);
font-size: 12px;
font-weight: 600;
}
.side-pill.long { background: var(--up-soft); color: oklch(38% 0.16 148); }
.side-pill.short { background: var(--down-soft); color: oklch(42% 0.2 25); }
/* Settings */
.settings-grid {
display: grid;
grid-template-columns: 260px 1fr;
gap: 32px;
align-items: start;
}
.settings-side {
position: sticky;
top: 84px;
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-side button {
text-align: left;
padding: 10px 14px;
border-radius: var(--r-sm);
font-size: 13px;
color: var(--ink-2);
font-weight: 500;
}
.settings-side button.on { background: var(--bg-sunk); color: var(--ink); }
.settings-side button:hover { background: var(--bg-sunk); color: var(--ink); }
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 20px;
}
.field label {
font-size: 13px;
font-weight: 500;
}
.field .hint {
font-size: 12px;
color: var(--ink-3);
}
.field input, .field select, .field textarea {
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--r-sm);
padding: 11px 14px;
font-size: 14px;
outline: none;
transition: border-color 120ms, box-shadow 120ms;
color: var(--ink);
}
.field input:focus, .field textarea:focus {
border-color: var(--amber-ring);
box-shadow: 0 0 0 3px oklch(88% 0.12 80 / 0.4);
}
.switch {
--w: 40px;
width: var(--w);
height: 24px;
border-radius: 999px;
background: var(--surface-3);
position: relative;
cursor: pointer;
transition: background 150ms;
border: 1px solid var(--line);
flex-shrink: 0;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--surface);
box-shadow: var(--shadow-1);
transition: transform 180ms;
}
.switch.on {
background: var(--ink);
border-color: var(--ink);
}
.switch.on::after { transform: translateX(16px); }
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.setting-row:last-child { border-bottom: 0; }
.setting-row .desc { font-size: 12px; color: var(--ink-3); margin-top: 4px; max-width: 440px; line-height: 1.5; }
/* Analytics */
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Misc utilities */
.stack { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; }
.between { justify-content: space-between; }
.gap-s { gap: 8px; }
.gap-m { gap: 14px; }
.gap-l { gap: 20px; }
.grow { flex: 1; }
.mono-num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.muted { color: var(--ink-3); }
.tiny { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); font-weight: 500; }
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 999px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: oklch(80% 0.01 85); }
+13 -8
View File
@@ -1,17 +1,14 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { locales } from '@/i18n'
import Navbar from '@/components/nav/Navbar'
import Providers from './Providers'
import Navbar from '@/components/nav/Navbar'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'TrumpSignal — AI-powered crypto trading signals',
title: 'Trump Alpha — AI signal trading',
description: 'Trade crypto based on Trump social media signals with AI confidence scoring.',
}
@@ -29,11 +26,19 @@ export default async function LocaleLayout({ children, params: { locale } }: Lay
return (
<html lang={locale}>
<body className={`${inter.className} bg-[#000000] min-h-screen text-white`}>
<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 locale={locale} />
<main className="pt-14">{children}</main>
<Navbar />
<main>{children}</main>
</Providers>
</NextIntlClientProvider>
</body>
+2 -4
View File
@@ -1,20 +1,18 @@
import { getPosts, getPerformance, getTrades } from '@/lib/api'
import { getPosts, getPerformance } from '@/lib/api'
import DashboardClient from './DashboardClient'
export const revalidate = 30
export default async function OverviewPage() {
const [posts, performance, trades] = await Promise.allSettled([
const [posts, performance] = await Promise.allSettled([
getPosts(500, 1),
getPerformance(),
getTrades(20, 1),
])
return (
<DashboardClient
initialPosts={posts.status === 'fulfilled' ? posts.value : []}
initialPerformance={performance.status === 'fulfilled' ? performance.value : undefined}
initialTrades={trades.status === 'fulfilled' ? trades.value : []}
/>
)
}
+118 -8
View File
@@ -1,14 +1,124 @@
import { useTranslations } from 'next-intl'
import { getTranslations } from 'next-intl/server'
'use client'
export default async function PostsPage() {
const t = await getTranslations('posts')
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>
)
}
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))
}, [])
const filtered = posts.filter(p => {
if (filter !== 'all' && p.sentiment !== filter) return false
return true
})
const counts = {
all: posts.length,
bullish: posts.filter(p => p.sentiment === 'bullish').length,
bearish: posts.filter(p => p.sentiment === 'bearish').length,
neutral: posts.filter(p => p.sentiment === 'neutral').length,
}
const selectedPost = posts.find(p => p.id === selected)
return (
<div className="max-w-[1400px] mx-auto px-6 py-6">
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
<h1 className="text-[22px] font-medium text-white mb-3">{t('title')}</h1>
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Signals feed</h1>
<p className="page-sub">Every post we tracked, every prediction we made. {posts.length} posts in the last 30 days.</p>
</div>
<span className="chip"><span className="live-dot" />Streaming</span>
</div>
<div className="row between" style={{ marginBottom: 18 }}>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{(['all', 'bullish', 'bearish', 'neutral'] as const).map(f => (
<button key={f} className={`nav-tab ${filter === f ? 'active' : ''}`} onClick={() => setFilter(f)}>
{f.charAt(0).toUpperCase() + f.slice(1)} <span style={{ color: 'var(--ink-4)', marginLeft: 4 }}>{counts[f]}</span>
</button>
))}
</div>
</div>
{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' }}>
<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)} />
))}
</div>
{selected && selectedPost && <PostDetail post={selectedPost} onClose={() => setSelected(null)} />}
</div>
</div>
)
+311 -17
View File
@@ -1,31 +1,325 @@
'use client'
import { useTranslations } from 'next-intl'
import { useAccount } from 'wagmi'
import { useConnectModal } from '@rainbow-me/rainbowkit'
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'
export default function SettingsClient() {
const t = useTranslations('settings')
const { isConnected } = useAccount()
const { openConnectModal } = useConnectModal()
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,
}
if (!isConnected) {
function LockedCard({ label }: { label: string }) {
return (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
<p className="text-[14px] text-[#555555] mb-4">{t('connectRequired')}</p>
<button
onClick={openConnectModal}
className="bg-[#f97316] text-black font-medium text-[13px] rounded-lg px-6 py-2.5 hover:bg-[#fb923c] transition-colors"
>
Connect wallet
<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="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
<div className="card" style={{ padding: 32 }}>
<div className="row between" style={{ marginBottom: 20 }}>
<div>
<h3 style={{ margin: '0 0 4px', fontSize: 18 }}>Subscribe to unlock the bot</h3>
<p style={{ color: 'var(--ink-3)', fontSize: 13, margin: 0 }}>Get automated execution, real-time alerts, and advanced configuration.</p>
</div>
<span className="chip amber">14-day free trial</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
{['Auto-execute trades on your exchange', 'SMS + Telegram alerts within 2s', 'Custom confidence + position-size rules', 'Backtest any strategy on 2y of data'].map(f => (
<div key={f} className="row gap-s" style={{ padding: 12, background: 'var(--bg-sunk)', borderRadius: 10, fontSize: 13 }}>
<span style={{ color: 'var(--up)' }}></span>{f}
</div>
))}
</div>
<Link href="/" className="btn amber lg" style={{ textAlign: 'center' }}>
Activate on dashboard
</Link>
</div>
)
}
return <BotSettingsForm wallet={wallet!} initial={initial} />
}
function BotSettingsForm({ wallet, initial }: { wallet: string; initial: UserSettings }) {
const { signMessageAsync } = useSignMessage()
const [s, setS] = useState<UserSettings>(initial)
const [useTp, setUseTp] = useState<boolean>(initial.take_profit_pct != null)
const [useSl, setUseSl] = useState<boolean>(initial.stop_loss_pct != null)
const [state, setState] = useState<'idle' | 'signing' | 'saving' | 'ok' | 'err'>('idle')
const [err, setErr] = useState('')
// Re-sync when initial changes (after /user fetch resolves)
useEffect(() => {
setS(initial)
setUseTp(initial.take_profit_pct != null)
setUseSl(initial.stop_loss_pct != null)
}, [initial])
const dirty = JSON.stringify(s) !== JSON.stringify(initial) ||
useTp !== (initial.take_profit_pct != null) ||
useSl !== (initial.stop_loss_pct != null)
async function save() {
setErr('')
try {
const payload: UserSettings = {
...s,
take_profit_pct: useTp ? s.take_profit_pct ?? 2 : null,
stop_loss_pct: useSl ? s.stop_loss_pct ?? 1.5 : null,
}
setState('signing')
const env = await signRequest({
action: ACTION_SET_SETTINGS,
wallet,
body: payload,
signMessageAsync,
})
setState('saving')
await setUserSettings(env, payload)
setS(payload)
setState('ok')
setTimeout(() => setState('idle'), 2000)
} catch (e: unknown) {
const m = e instanceof Error ? e.message : 'Save failed'
setErr(m.includes('User rejected') || m.includes('denied') ? 'Signature cancelled' : m.slice(0, 140))
setState('err')
}
}
const label = state === 'signing' ? 'Waiting for signature…' : state === 'saving' ? 'Saving…' : state === 'ok' ? '✓ Saved' : 'Save changes'
return (
<div>
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="section-title"><h2>Execution</h2></div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Position size (USD)</div>
<div className="desc" style={{ marginBottom: 8 }}>Fixed notional per trade. Margin = size / leverage.</div>
<input
type="number" min={5} max={10000} step={5}
value={s.position_size_usd}
onChange={e => setS({ ...s, position_size_usd: Math.max(5, +e.target.value || 0) })}
style={{ width: 140 }}
/>
</div>
<div className="tiny mono" style={{ color: 'var(--ink-3)' }}>margin ${(s.position_size_usd / s.leverage).toFixed(2)}</div>
</div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Leverage</div>
<div className="desc" style={{ marginBottom: 8 }}>Isolated margin, 150×. Higher = bigger PnL swings.</div>
<div className="row gap-m">
<input type="range" min={1} max={50} value={s.leverage}
onChange={e => setS({ ...s, leverage: +e.target.value })}
style={{ flex: 1, accentColor: 'var(--amber)' }} />
<div className="mono" style={{ minWidth: 60, fontSize: 16, fontWeight: 500 }}>{s.leverage}×</div>
</div>
</div>
</div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Minimum AI confidence</div>
<div className="desc" style={{ marginBottom: 8 }}>Platform floor is 80%. Raise to be pickier; can&apos;t lower.</div>
<div className="row gap-m">
<input type="range" min={80} max={100} value={s.min_confidence}
onChange={e => setS({ ...s, min_confidence: +e.target.value })}
style={{ flex: 1, accentColor: 'var(--amber)' }} />
<div className="mono" style={{ minWidth: 60, fontSize: 16, fontWeight: 500 }}>{s.min_confidence}%</div>
</div>
</div>
</div>
</div>
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="section-title"><h2>Risk management</h2></div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div className="row gap-s" style={{ marginBottom: 4 }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>Take profit</div>
<label style={{ fontSize: 12, color: 'var(--ink-3)', cursor: 'pointer' }}>
<input type="checkbox" checked={useTp} onChange={e => setUseTp(e.target.checked)} style={{ marginRight: 6 }} />
enable
</label>
</div>
<div className="desc" style={{ marginBottom: 8 }}>Auto-close when price moves your way by this %.</div>
<div className="row gap-s" style={{ opacity: useTp ? 1 : 0.45 }}>
<input type="number" min={0.1} max={50} step={0.1}
value={s.take_profit_pct ?? 2}
disabled={!useTp}
onChange={e => setS({ ...s, take_profit_pct: +e.target.value })}
style={{ width: 100 }} />
<span className="muted">% gain</span>
</div>
</div>
</div>
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
<div style={{ flex: 1 }}>
<div className="row gap-s" style={{ marginBottom: 4 }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>Stop loss</div>
<label style={{ fontSize: 12, color: 'var(--ink-3)', cursor: 'pointer' }}>
<input type="checkbox" checked={useSl} onChange={e => setUseSl(e.target.checked)} style={{ marginRight: 6 }} />
enable
</label>
</div>
<div className="desc" style={{ marginBottom: 8 }}>Auto-close when price moves against you by this %.</div>
<div className="row gap-s" style={{ opacity: useSl ? 1 : 0.45 }}>
<input type="number" min={0.1} max={50} step={0.1}
value={s.stop_loss_pct ?? 1.5}
disabled={!useSl}
onChange={e => setS({ ...s, stop_loss_pct: +e.target.value })}
style={{ width: 100 }} />
<span className="muted">% loss</span>
</div>
</div>
</div>
</div>
<div className="row gap-s" style={{ alignItems: 'center' }}>
<button
className={`btn ${state === 'ok' ? 'ghost' : 'amber'} lg`}
onClick={save}
disabled={!dirty || state === 'signing' || state === 'saving'}
>
{label}
</button>
{!dirty && state === 'idle' && <span className="tiny" style={{ color: 'var(--ink-3)' }}>No changes to save</span>}
{state === 'err' && <span className="tiny" style={{ color: 'var(--down)' }}>{err}</span>}
</div>
</div>
)
}
function ExchangeSettings({ subscribed }: { subscribed: boolean }) {
if (!subscribed) return <LockedCard label="Subscribe to add Hyperliquid API key" />
return <BotPanel />
}
function AccountSettings({ address }: { address?: string }) {
return (
<div className="card" style={{ padding: 24 }}>
<div className="section-title"><h2>Account</h2></div>
<div className="field">
<label>Wallet</label>
<input disabled value={address ? `${address.slice(0, 8)}${address.slice(-6)}` : 'Not connected'} />
</div>
</div>
)
}
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' },
]
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>
<div>
{loadErr && (
<div className="card" style={{ padding: 12, marginBottom: 12, border: '1px solid var(--down)', color: 'var(--down)', fontSize: 13 }}>
{loadErr}
</div>
)}
{section === 'bot' && <BotSettings connected={isConnected} subscribed={isSubscribed} wallet={address} initial={settings} />}
{section === 'exchange' && <ExchangeSettings subscribed={isSubscribed} />}
{section === 'account' && <AccountSettings address={address} />}
</div>
</div>
)
}
+8 -6
View File
@@ -1,12 +1,14 @@
import { getTranslations } from 'next-intl/server'
import SettingsClient from './SettingsClient'
export default async function SettingsPage() {
const t = await getTranslations('settings')
export default function SettingsPage() {
return (
<div className="max-w-[1400px] mx-auto px-6 py-6">
<h1 className="text-[22px] font-medium text-white mb-5">{t('title')}</h1>
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-sub">Configure your bot, alerts, and account.</p>
</div>
</div>
<SettingsClient />
</div>
)
+172 -10
View File
@@ -1,17 +1,179 @@
import { getTranslations } from 'next-intl/server'
import TradesTable from '@/components/trades/TradesTable'
import { getTrades } from '@/lib/api'
'use client'
export const revalidate = 10
import { useState, useEffect } from 'react'
import type { BotTrade, TrumpPost } from '@/types'
import { getTrades, getPosts } from '@/lib/api'
export default async function TradesPage() {
const t = await getTranslations('trades')
const trades = await getTrades(100, 1).catch(() => [])
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
const { decimals = 2, sign = false } = opts
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
if (n < 0) return '-$' + s
if (sign && n > 0) return '+$' + s
return '$' + s
}
function fmtPct(n: number) {
const s = n.toFixed(2) + '%'
return n >= 0 ? '+' + s : s
}
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
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>
}
export default function TradesPage() {
const [trades, setTrades] = useState<BotTrade[]>([])
const [posts, setPosts] = useState<TrumpPost[]>([])
const [loading, setLoading] = useState(true)
const [assetFilter, setAssetFilter] = useState('all')
const [sideFilter, setSideFilter] = useState('all')
useEffect(() => {
Promise.all([
getTrades(200, 1).catch(() => []),
getPosts(500, 1).catch(() => []),
]).then(([t, p]) => {
setTrades(t)
setPosts(p)
}).finally(() => setLoading(false))
}, [])
const filtered = trades.filter(t => {
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
return true
})
const totalPnl = filtered.reduce((s, t) => s + t.pnl_usd, 0)
const wins = filtered.filter(t => t.pnl_usd > 0).length
const avgHold = filtered.length > 0 ? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length) : 0
return (
<div className="max-w-[1400px] mx-auto px-6 py-6 pt-20">
<h1 className="text-[22px] font-medium text-white mb-5">{t('title')}</h1>
<TradesTable trades={trades} />
<div className="page">
<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>
</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&amp;L</div>
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
<div className="foot"><span>All assets</span></div>
</div>
<div className="kpi">
<div className="label">Avg hold</div>
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
<div className="foot"><span>Per trade</span></div>
</div>
</div>
<div className="row between" style={{ margin: '20px 0 14px' }}>
<div className="row gap-s">
<div className="nav-tabs">
{(['all', 'BTC', 'ETH'] as const).map(a => (
<button key={a} className={`nav-tab ${assetFilter === a ? 'active' : ''}`} onClick={() => setAssetFilter(a)}>
{a === 'all' ? 'All assets' : a}
</button>
))}
</div>
<div className="nav-tabs">
{(['all', 'long', 'short'] as const).map(s => (
<button key={s} className={`nav-tab ${sideFilter === s ? 'active' : ''}`} onClick={() => setSideFilter(s)}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
</div>
</div>
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading</div>}
{!loading && (
<div className="card flush" style={{ overflow: 'hidden' }}>
<table className="table">
<thead>
<tr>
<th>Asset</th>
<th>Side</th>
<th>Entry</th>
<th>Exit</th>
<th>Hold</th>
<th>Trigger</th>
<th>P&amp;L</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<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)
return (
<tr key={t.id}>
<td>
<div className="row gap-s">
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
<span style={{ fontWeight: 500 }}>{t.asset}</span>
</div>
</td>
<td><span className={`side-pill ${t.side}`}>{t.side === 'long' ? '↗ LONG' : '↘ SHORT'}</span></td>
<td className="mono">${t.entry_price.toLocaleString()}</td>
<td className="mono">${t.exit_price.toLocaleString()}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 280 }}>
{triggerPost ? (
<div className="row gap-s" style={{ alignItems: 'center' }}>
<SourceIcon source={triggerPost.source} />
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{triggerPost.text.slice(0, 50)}
</span>
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-end' }}>
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
{fmtMoney(t.pnl_usd, { sign: true })}
</span>
<span className={`delta ${roiPct >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>{fmtPct(roiPct)}</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
+208 -88
View File
@@ -1,124 +1,244 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { useAccount } from 'wagmi'
import { useConnectModal } from '@rainbow-me/rainbowkit'
import { useAccount, useSignMessage } from 'wagmi'
import type { BotPerformance } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { formatPct, formatHold } from '@/lib/utils'
import { setHlApiKey, subscribe } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
const MOCK_PERFORMANCE: BotPerformance = {
period_days: 30,
total_trades: 89,
win_rate: 0.73,
net_pnl_usd: 12840,
avg_hold_seconds: 14 * 60,
max_drawdown_pct: 8.2,
// Action names must match backend/app/api/{user,subscribe}.py
const ACTION_SET_API_KEY = 'set_hl_api_key'
const ACTION_SUBSCRIBE = 'subscribe'
interface Props {
performance?: BotPerformance | null
}
interface BotPanelProps {
performance?: BotPerformance
type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
export default function BotPanel({ performance = MOCK_PERFORMANCE }: BotPanelProps) {
const t = useTranslations('bot')
const { isSubscribed, setSubscribed } = useDashboardStore()
export default function BotPanel({ performance }: Props) {
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore()
const { address, isConnected } = useAccount()
const { openConnectModal } = useConnectModal()
const [apiKey, setApiKey] = useState('')
const { signMessageAsync } = useSignMessage()
const stats = [
{ label: t('winRate'), value: formatPct(performance.win_rate * 100 - 100 + performance.win_rate * 100), display: `${Math.round(performance.win_rate * 100)}%` },
{ label: t('netPnl'), value: `+$${performance.net_pnl_usd.toLocaleString()}`, positive: true },
{ label: t('totalTrades'), value: String(performance.total_trades) },
{ label: t('avgHold'), value: formatHold(performance.avg_hold_seconds) },
]
const [apiKey, setApiKey] = useState('')
const [saveState, setSaveState] = useState<SaveState>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
const [subError, setSubError] = useState('')
async function handleSubscribe() {
if (!address) return
setSubError('')
try {
setSubState('signing')
const env = await signRequest({
action: ACTION_SUBSCRIBE,
wallet: address,
body: null,
signMessageAsync,
})
setSubState('saving')
await subscribe(env)
setSubscribed(true)
setSubState('idle')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
setSubError(msg.includes('User rejected') || msg.includes('denied') ? 'Signature cancelled' : msg.slice(0, 120))
setSubState('error')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) {
setErrorMsg('Key must start with 0x and be 66 characters')
setSaveState('error')
return
}
setErrorMsg('')
try {
setSaveState('signing')
const trimmed = apiKey.trim()
const env = await signRequest({
action: ACTION_SET_API_KEY,
wallet: address,
body: { api_key: trimmed },
signMessageAsync,
})
setSaveState('saving')
const res = await setHlApiKey(env, trimmed)
setHlApiKeySet(true, res.masked_key)
setApiKey('')
setSaveState('success')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
if (msg.includes('User rejected') || msg.includes('denied')) {
setErrorMsg('Signature cancelled')
} else {
setErrorMsg(msg.slice(0, 120))
}
setSaveState('error')
}
}
const saveLabel =
saveState === 'signing' ? 'Waiting for signature…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'success' ? '✓ Saved'
: 'Save key'
return (
<div className="w-[300px] shrink-0 flex flex-col gap-3">
{/* Performance card */}
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5">
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-4">
{t('title')} · {t('period')}
</p>
<div className="grid grid-cols-2 gap-3">
{stats.map((stat) => (
<div key={stat.label}>
<p className="text-[10px] text-[#555555] mb-0.5">{stat.label}</p>
<p className={`text-[16px] font-medium ${stat.positive ? 'text-[#4ade80]' : 'text-white'}`}>
{stat.display ?? stat.value}
</p>
<>
{/* 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' }} />
Auto-trader
</h3>
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
</div>
))}
<div className="bot-stats">
<div className="bot-stat">
<div className="k">Net P&amp;L</div>
<div className="v amber">
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
</div>
</div>
<div className="bot-stat">
<div className="k">Win rate</div>
<div className="v up">
{performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'}
</div>
</div>
<div className="bot-stat">
<div className="k">Trades</div>
<div className="v">{performance?.total_trades ?? '—'}</div>
</div>
<div className="bot-stat">
<div className="k">Avg hold</div>
<div className="v">{performance ? fmtHold(performance.avg_hold_seconds) : '—'}</div>
</div>
</div>
{/* Divider */}
<div className="h-px bg-[#141414]" />
{/* Conditional bottom section */}
<div className="bot-cta">
{!isConnected && (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
<p className="text-[14px] font-medium text-white">{t('connectCta')}</p>
<p className="text-[12px] text-[#555555] leading-relaxed">{t('connectDesc')}</p>
<button
onClick={openConnectModal}
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors"
>
{t('connectWalletFree')}
<button className="btn amber" style={{ width: '100%' }}
onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
Connect wallet
</button>
</div>
)}
{isConnected && !isSubscribed && (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-[#333333]">{t('settingsTitle')}</p>
<span className="text-[10px] text-[#333333] border border-[#1e1e1e] rounded-full px-2 py-0.5">
Locked
</span>
</div>
{/* Disabled API key input */}
<div className="opacity-40">
<label className="text-[11px] text-[#555555] block mb-1">{t('apiKeyLabel')}</label>
<input
disabled
placeholder={t('apiKeyPlaceholder')}
className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-[#333333] placeholder-[#222222] cursor-not-allowed"
/>
</div>
<div style={{ width: '100%' }}>
<button
onClick={() => setSubscribed(true)}
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors"
className="btn amber"
style={{ width: '100%' }}
onClick={handleSubscribe}
disabled={subState === 'signing' || subState === 'saving'}
>
{t('subscribeBtn')}
{subState === 'signing' ? 'Waiting for signature…' : subState === 'saving' ? 'Activating…' : 'Start trading'}
</button>
{subState === 'error' && subError && (
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{subError}</p>
)}
</div>
)}
{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
</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
</div>
)}
</div>
</div>
{/* HL API Key card — only when subscribed */}
{isConnected && isSubscribed && (
<div className="card" style={{ padding: 20 }}>
<div className="section-title">
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
{hlApiKeySet && <span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 500 }}> Connected</span>}
</div>
{hlApiKeySet && !apiKey && (
<div className="row between" style={{ padding: '10px 12px', background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)', border: '1px solid var(--line)', marginBottom: 12 }}>
<span style={{ fontSize: 12, color: 'var(--up)', fontFamily: 'var(--mono)' }}>
{hlApiKeyMasked ?? '···'}
</span>
<button
style={{ fontSize: 11, color: 'var(--ink-3)' }}
onClick={() => { setSaveState('idle'); setApiKey(' ') }}
>
Update
</button>
</div>
)}
{isConnected && isSubscribed && (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-white">{t('settingsTitle')}</p>
<span className="text-[10px] text-[#4ade80] border border-[#1a4a2a] bg-[#0d2e1a] rounded-full px-2 py-0.5">
{t('activeStatus')}
</span>
</div>
<div>
<label className="text-[11px] text-[#555555] block mb-1">{t('apiKeyLabel')}</label>
{(!hlApiKeySet || apiKey) && (
<>
<div className="field" style={{ marginBottom: 12 }}>
<label>API wallet private key</label>
<input
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('apiKeyPlaceholder')}
className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-white placeholder-[#333333] focus:outline-none focus:border-[#222222]"
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={(e) => {
setApiKey(e.target.value)
if (saveState === 'error' || saveState === 'success') setSaveState('idle')
}}
placeholder="0x…"
/>
<div className="hint">
From{' '}
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber-ink)' }}>
app.hyperliquid.xyz/API
</a>
</div>
<button className="w-full bg-[#141414] text-white border border-[#1a1a1a] text-[13px] rounded-lg py-2 hover:bg-[#0d0d0d] transition-colors">
{t('saveKey')}
</div>
{saveState === 'error' && errorMsg && (
<p style={{ fontSize: 11, color: 'var(--down)', marginBottom: 8 }}>{errorMsg}</p>
)}
<button
className={`btn ${saveState === 'success' ? 'ghost' : 'amber'}`}
style={{ width: '100%' }}
onClick={handleSaveKey}
disabled={saveState === 'signing' || saveState === 'saving' || !apiKey.trim()}
>
{saveLabel}
</button>
</div>
</>
)}
{!hlApiKeySet && (
<details style={{ marginTop: 12 }}>
<summary style={{ fontSize: 11, color: 'var(--ink-3)', cursor: 'pointer' }}>
How to get your API key
</summary>
<ol style={{ marginTop: 8, fontSize: 11, color: 'var(--ink-3)', paddingLeft: 16, lineHeight: 1.7 }}>
<li>Deposit USDC at app.hyperliquid.xyz</li>
<li>Go to app.hyperliquid.xyz/API</li>
<li>Click <strong>Generate API Wallet</strong></li>
<li>Sign with MetaMask (no gas)</li>
<li>Copy the private key paste above</li>
</ol>
</details>
)}
</div>
)}
</>
)
}
+100 -95
View File
@@ -3,56 +3,64 @@
import { useEffect, useRef } from 'react'
import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import Pill from '@/components/ui/Pill'
import PostCards, { MOCK_POSTS } from './PostCards'
import ExpandDetail from './ExpandDetail'
interface ChartPanelProps {
posts?: TrumpPost[]
candles?: Candle[]
externalSelectedId?: number | null
onSelectPost?: (id: number | null) => void
}
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore()
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
const { timeframe } = useDashboardStore()
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<unknown>(null)
const seriesRef = useRef<unknown>(null)
const fittedRef = useRef(false)
// Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing
const postsRef = useRef(posts)
postsRef.current = posts
const selectedPostIdRef = useRef(selectedPostId)
selectedPostIdRef.current = selectedPostId
const selectedPostIdRef = useRef(externalSelectedId)
selectedPostIdRef.current = externalSelectedId
const timeframeRef = useRef(timeframe)
timeframeRef.current = timeframe
const onSelectRef = useRef(onSelectPost)
onSelectRef.current = onSelectPost
const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH']
const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W']
const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null
// Detect current theme for chart colors
function getChartColors() {
const isDark = document.documentElement.dataset.theme === 'dark'
return {
background: isDark ? '#121212' : '#ffffff',
textColor: isDark ? '#666666' : '#888888',
gridColor: isDark ? '#1e1e1e' : '#f0ede8',
borderColor: isDark ? '#2a2a2a' : '#e8e4de',
}
}
// Create chart once on mount
useEffect(() => {
if (!containerRef.current || typeof window === 'undefined') return
let destroyed = false
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
if (destroyed || !containerRef.current) return
const colors = getChartColors()
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 360,
layout: {
background: { color: '#050505' },
textColor: '#555555',
background: { color: colors.background },
textColor: colors.textColor,
},
grid: {
vertLines: { color: '#111111' },
horzLines: { color: '#111111' },
vertLines: { color: colors.gridColor },
horzLines: { color: colors.gridColor },
},
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: '#1a1a1a' },
rightPriceScale: { borderColor: colors.borderColor },
timeScale: {
borderColor: '#1a1a1a',
borderColor: colors.borderColor,
timeVisible: true,
rightOffset: 5,
barSpacing: 10,
@@ -61,47 +69,54 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
handleScale: true,
})
// @ts-expect-error lightweight-charts type
chartRef.current = chart
const series = chart.addCandlestickSeries({
upColor: '#4ade80',
downColor: '#ef4444',
borderUpColor: '#4ade80',
borderDownColor: '#ef4444',
wickUpColor: '#4ade80',
wickDownColor: '#ef4444',
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
})
// @ts-expect-error lightweight-charts type
seriesRef.current = series
// Click on chart: find nearest post marker within 2-bar tolerance
chart.subscribeClick((param: { time?: number | string }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chart.subscribeClick((param: any) => {
if (!param.time) return
const clickTime = typeof param.time === 'number' ? param.time : 0
if (!clickTime) return
const allPosts = postsRef.current
let closest: TrumpPost | null = null
let closestDiff = Infinity
const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
}
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600
const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs
for (const p of allPosts) {
if (!p.published_at) continue
const inBucket = postsRef.current
.filter((p) => {
if (!p.published_at) return false
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const diff = Math.abs(pt - clickTime)
if (diff < closestDiff) {
closestDiff = diff
closest = p
}
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
}
// Only select if click is within 2 hours of a post (7200 seconds)
if (closest && closestDiff <= 7200) {
const newId = closest.id === selectedPostIdRef.current ? null : closest.id
setSelectedPost(newId)
const currentIdx = inBucket.findIndex((p) => p.id === selectedPostIdRef.current)
if (currentIdx >= 0) {
const nextIdx = currentIdx + 1
if (nextIdx >= inBucket.length) {
onSelectRef.current?.(null)
} else {
setSelectedPost(null)
onSelectRef.current?.(inBucket[nextIdx].id)
}
} else {
onSelectRef.current?.(inBucket[0].id)
}
})
@@ -112,9 +127,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
})
ro.observe(containerRef.current)
return () => {
ro.disconnect()
}
return () => { ro.disconnect() }
})
return () => {
@@ -130,7 +143,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Update candles + markers whenever data changes
// Update candles + markers
useEffect(() => {
const series = seriesRef.current
const chart = chartRef.current
@@ -147,7 +160,6 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
close: c.close,
})))
// Show markers for all posts within visible time range
const minTime = sorted[0].time
const maxTime = sorted[sorted.length - 1].time
@@ -158,20 +170,44 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
})
if (visible.length > 0) {
const markers = [...visible]
.sort((a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime())
.map((p) => ({
time: Math.floor(new Date(p.published_at).getTime() / 1000) as number,
const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
}
const candleSpacing = sorted.length > 1 ? sorted[1].time - sorted[0].time : 300
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
const bucketMap = new Map<number, typeof visible>()
for (const p of visible) {
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
bucketMap.get(bucket)!.push(p)
}
bucketMap.forEach((ps) => ps.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0)))
const markers = Array.from(bucketMap.entries())
.sort(([a], [b]) => a - b)
.map(([bucketTime, bPosts]) => {
const isSelected = bPosts.some((p) => p.id === externalSelectedId)
const best = bPosts[0]
const count = bPosts.length
const signalColor = isSelected
? '#f59e0b'
: best.signal === 'short' || best.signal === 'sell'
? '#ef5350'
: best.signal === 'buy'
? '#26a69a'
: '#aaaaaa'
return {
time: bucketTime as number,
position: 'aboveBar' as const,
color: p.id === selectedPostId
? '#fb923c'
: p.sentiment === 'bearish'
? '#ef4444'
: '#f97316',
color: signalColor,
shape: 'circle' as const,
text: '',
size: p.id === selectedPostId ? 2 : 1,
}))
text: count > 1 ? String(count) : '',
size: isSelected ? 2 : count > 1 ? 1.5 : 1,
}
})
// @ts-expect-error lightweight-charts type
series.setMarkers(markers)
}
@@ -181,45 +217,14 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
chart.timeScale().fitContent()
fittedRef.current = true
}
}, [candles, posts, selectedPostId])
}, [candles, posts, externalSelectedId])
// Reset fit flag on timeframe/asset switch
useEffect(() => {
fittedRef.current = false
}, [asset, timeframe])
useEffect(() => { fittedRef.current = false }, [timeframe])
return (
<div className="flex-1 min-w-0 bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-4 flex flex-col gap-3">
{/* Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{assets.map((a) => (
<Pill key={a} active={asset === a} onClick={() => setAsset(a)}>
{a}
</Pill>
))}
</div>
<div className="flex items-center gap-2">
{timeframes.map((tf) => (
<Pill key={tf} active={timeframe === tf} onClick={() => setTimeframe(tf as '4H' | '1D' | '1W')}>
{tf}
</Pill>
))}
</div>
</div>
{/* Chart */}
<div
ref={containerRef}
className="w-full rounded-lg overflow-hidden cursor-crosshair"
style={{ height: 360, background: '#050505' }}
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair' }}
/>
{/* Selected post detail — shown immediately below chart */}
<ExpandDetail post={selectedPost} />
{/* Post cards list */}
<PostCards posts={posts} />
</div>
)
}
-87
View File
@@ -1,87 +0,0 @@
'use client'
import type { TrumpPost } from '@/types'
import Badge from '@/components/ui/Badge'
import { formatPct } from '@/lib/utils'
interface ExpandDetailProps {
post: TrumpPost | null
}
export default function ExpandDetail({ post }: ExpandDetailProps) {
return (
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
post ? 'max-h-[300px] opacity-100 mt-3' : 'max-h-0 opacity-0'
}`}
>
{post && (
<div className="bg-[#0a0a0a] border border-[#f97316] rounded-[10px] p-5">
<div className="flex items-start gap-4">
{/* Post content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<Badge variant={post.source} />
<Badge variant={post.sentiment} />
<span className="text-[11px] text-[#555555]">
{new Date(post.published_at).toLocaleString()}
</span>
</div>
<p className="text-[13px] text-[#e2e8f0] leading-relaxed">{post.text}</p>
</div>
{/* Stats */}
<div className="shrink-0 w-[260px]">
{/* AI Confidence */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
AI Confidence
</span>
<span className="text-[13px] font-medium text-[#818cf8]">
{post.ai_confidence}%
</span>
</div>
<div className="h-1.5 bg-[#141414] rounded-full">
<div
className="h-full bg-[#818cf8] rounded-full transition-all duration-500"
style={{ width: `${post.ai_confidence}%` }}
/>
</div>
</div>
{/* Price impact */}
{post.price_impact ? (
<div>
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
Price Impact ({post.price_impact.asset})
</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: '5m', value: post.price_impact.m5 },
{ label: '15m', value: post.price_impact.m15 },
{ label: '1h', value: post.price_impact.m1h },
].map((item) => (
<div key={item.label} className="bg-[#050505] border border-[#141414] rounded-lg p-2 text-center">
<p className="text-[10px] text-[#555555] mb-1">{item.label}</p>
<p
className={`text-[13px] font-medium ${
item.value >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
}`}
>
{formatPct(item.value)}
</p>
</div>
))}
</div>
</div>
) : (
<p className="text-[12px] text-[#333333]">No significant price impact detected.</p>
)}
</div>
</div>
</div>
)}
</div>
)
}
-77
View File
@@ -1,77 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import type { BotPerformance } from '@/types'
import { formatPrice, formatPct } from '@/lib/utils'
import { useDashboardStore } from '@/store/dashboard'
interface KpiRowProps {
performance?: BotPerformance
postsCount?: number
}
interface StatCard {
labelKey: string
value: string
change?: string
changePositive?: boolean
}
export default function KpiRow({ performance, postsCount }: KpiRowProps) {
const t = useTranslations('kpi')
const { livePrices } = useDashboardStore()
const stats: StatCard[] = [
{
labelKey: 'btcPrice',
value: formatPrice(livePrices.BTC ?? 94230),
change: livePrices.BTC ? 'live' : '+1.4%',
changePositive: true,
},
{
labelKey: 'ethPrice',
value: formatPrice(livePrices.ETH ?? 1847),
change: livePrices.ETH ? 'live' : '-0.8%',
changePositive: false,
},
{
labelKey: 'postsTracked',
value: postsCount != null ? postsCount.toLocaleString() : '1,247',
change: '+12 today',
changePositive: true,
},
{
labelKey: 'avgMove',
value: performance
? formatPct(performance.net_pnl_usd > 0 ? 2.3 : -2.3)
: formatPct(2.3),
change: 'after Trump posts',
changePositive: true,
},
]
return (
<div className="grid grid-cols-4 gap-4">
{stats.map((stat) => (
<div
key={stat.labelKey}
className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5"
>
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
{t(stat.labelKey as 'btcPrice' | 'ethPrice' | 'postsTracked' | 'avgMove')}
</p>
<p className="text-[22px] font-medium text-white leading-none mb-1.5">{stat.value}</p>
{stat.change && (
<p
className={`text-[12px] ${
stat.changePositive ? 'text-[#4ade80]' : 'text-[#ef4444]'
}`}
>
{stat.change}
</p>
)}
</div>
))}
</div>
)
}
+52 -158
View File
@@ -1,181 +1,75 @@
'use client'
import { useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import type { TrumpPost } from '@/types'
import Badge from '@/components/ui/Badge'
import { useDashboardStore } from '@/store/dashboard'
import { formatPct } from '@/lib/utils'
const MOCK_POSTS: TrumpPost[] = [
{
id: 1,
text: 'BITCOIN IS THE FUTURE OF MONEY! We will make America the crypto capital of the world. BIG things coming very soon. The dollar will be STRONGER than ever with Bitcoin as our reserve!',
source: 'truth',
published_at: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
sentiment: 'bullish',
ai_confidence: 91,
relevant: true,
price_impact: {
asset: 'BTC',
m5: 1.2,
m15: 2.8,
m1h: 3.4,
price_at_post: 92800,
},
},
{
id: 2,
text: 'Ethereum and all these so-called "smart contracts" are nothing but a scam by the radical left globalists. Very bad for America. We need REAL money, not fake computer tricks!',
source: 'x',
published_at: new Date(Date.now() - 1000 * 60 * 47).toISOString(),
sentiment: 'bearish',
ai_confidence: 84,
relevant: true,
price_impact: {
asset: 'ETH',
m5: -1.9,
m15: -3.1,
m1h: -4.2,
price_at_post: 1920,
},
},
{
id: 3,
text: "Met with many great business leaders today. Discussed the future of America's economy. Jobs are coming back FAST. Nobody builds like Trump!",
source: 'truth',
published_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
sentiment: 'neutral',
ai_confidence: 42,
relevant: false,
price_impact: null,
},
{
id: 4,
text: 'Crypto regulations will be ELIMINATED under my watch. No more Biden weaponization of the SEC against Bitcoin holders. We will have the most CRYPTO FRIENDLY government in history!',
source: 'truth',
published_at: new Date(Date.now() - 1000 * 60 * 210).toISOString(),
sentiment: 'bullish',
ai_confidence: 88,
relevant: true,
price_impact: {
asset: 'BTC',
m5: 0.9,
m15: 2.1,
m1h: 2.7,
price_at_post: 91500,
},
},
]
interface PostCardsProps {
posts?: TrumpPost[]
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—'
const s = n.toFixed(2) + '%'
return n >= 0 ? '+' + s : s
}
function timeAgo(iso: string): string {
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
const m = Math.floor(diff / 60000)
if (m < 1) return 'just now'
if (m < 60) return m + 'm'
const h = Math.floor(m / 60)
if (h < 24) return h + 'h'
return Math.floor(h / 24) + 'd'
}
export default function PostCards({ posts = MOCK_POSTS }: PostCardsProps) {
const t = useTranslations('common')
const { selectedPostId, setSelectedPost } = useDashboardStore()
const selectedRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
function SourceIcon({ source }: { source: string }) {
if (source === 'x') return <div className="src-ico x">𝕏</div>
return <div className="src-ico truth">T</div>
}
// Auto-scroll selected card into view
useEffect(() => {
if (selectedRef.current && scrollRef.current) {
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
}
}, [selectedPostId])
function SignalPill({ signal }: { signal: string | null }) {
if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
}
return (
<div className="mt-2">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
Posts · {posts.length}
</span>
{selectedPostId && (
<button
onClick={() => setSelectedPost(null)}
className="text-[11px] text-[#f97316] hover:text-[#fb923c] transition-colors"
>
clear selection ×
</button>
)}
</div>
interface PostRowProps {
post: TrumpPost
selected?: boolean
onClick?: () => void
}
{/* Horizontally scrollable row */}
<div
ref={scrollRef}
className="flex gap-3 overflow-x-auto pb-2"
style={{ scrollbarWidth: 'none' }}
>
{posts.map((post) => {
const isSelected = selectedPostId === post.id
export default function PostRow({ post, selected, onClick }: PostRowProps) {
const impact = post.price_impact
return (
<div
key={post.id}
ref={isSelected ? selectedRef : null}
onClick={() => setSelectedPost(isSelected ? null : post.id)}
className={`shrink-0 w-[200px] bg-[#0a0a0a] border rounded-[10px] p-3 cursor-pointer transition-colors hover:bg-[#0d0d0d] ${
isSelected ? 'border-[#f97316]' : 'border-[#141414]'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
<Badge variant={post.source} />
<Badge variant={post.sentiment} />
</div>
<span className="text-[10px] text-[#555555]">{timeAgo(post.published_at)}</span>
</div>
{/* Text */}
<p className="text-[11px] text-[#e2e8f0] leading-relaxed mb-2 line-clamp-3">
{post.text.slice(0, 90)}
{post.text.length > 90 ? '…' : ''}
</p>
{/* Confidence bar */}
<div className="mb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[#555555]">AI</span>
<span className="text-[10px] text-[#818cf8]">{post.ai_confidence}%</span>
</div>
<div className="h-[2px] bg-[#141414] rounded-full">
<div
className="h-full bg-[#818cf8] rounded-full"
style={{ width: `${post.ai_confidence}%` }}
/>
</div>
</div>
{/* Price impact */}
{impact ? (
<span
className={`text-[11px] font-medium ${
impact.m1h >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
}`}
>
{formatPct(impact.m1h)} 1h
<div className={`post-row ${selected ? 'selected' : ''}`} onClick={onClick}>
<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>
<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>
</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="text-[10px] text-[#333333]">{t('neutral')}</span>
<span className="tf">no data</span>
)}
</div>
)
})}
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
<span>AI</span>
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>{post.ai_confidence}%</span>
</div>
</div>
</div>
)
}
export { MOCK_POSTS }
export { SignalPill, SourceIcon, fmtPct, timeAgo }
+119 -50
View File
@@ -1,74 +1,143 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useAccount } from 'wagmi'
import { useConnectModal } from '@rainbow-me/rainbowkit'
import { shortenAddress } from '@/lib/utils'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
const navItems = [
{ key: 'overview', href: '' },
{ key: 'posts', href: '/posts' },
{ key: 'trades', href: '/trades' },
{ key: 'analytics', href: '/analytics' },
] as const
interface NavbarProps {
locale: string
function BrandMark() {
return <span className="brand-mark">α</span>
}
export default function Navbar({ locale }: NavbarProps) {
const t = useTranslations('nav')
const pathname = usePathname()
const { address, isConnected } = useAccount()
const { openConnectModal } = useConnectModal()
function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
function isActive(href: string) {
const full = `/${locale}${href}`
if (href === '') return pathname === `/${locale}` || pathname === `/${locale}/`
return pathname.startsWith(full)
useEffect(() => {
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
const t = stored || 'light'
setTheme(t)
document.documentElement.dataset.theme = t
}, [])
function toggle() {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
localStorage.setItem('theme', next)
document.documentElement.dataset.theme = next
}
return (
<nav className="fixed top-0 left-0 right-0 z-50 h-14 bg-[#000000] border-b border-[#141414] flex items-center px-6">
{/* Logo */}
<Link href={`/${locale}`} className="flex items-center gap-2 mr-10 shrink-0">
<span className="text-[#f97316] font-bold text-lg leading-none">TS</span>
<span className="text-white font-medium text-sm">TrumpSignal</span>
</Link>
<button className="icon-btn theme-toggle" onClick={toggle}>
{theme === 'dark' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
</svg>
)}
</button>
)
}
{/* Center nav */}
<div className="flex items-center gap-6 flex-1">
{navItems.map((item) => (
export default function Navbar() {
const pathname = usePathname()
const { address, isConnected } = useAccount()
const { connect } = useConnect()
const { disconnect } = useDisconnect()
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const locale = pathname.split('/')[1] || 'en'
const path = '/' + pathname.split('/').slice(2).join('/')
const tabs = [
{ key: '/', label: 'Overview' },
{ key: '/posts', label: 'Signals' },
{ key: '/trades', label: 'Trades' },
{ key: '/analytics', label: 'Analytics' },
{ key: '/settings', label: 'Settings' },
]
function isActive(key: string) {
if (key === '/') return path === '/' || path === '//'
return path.startsWith(key)
}
const shortAddr = address ? `${address.slice(0, 6)}${address.slice(-4)}` : null
return (
<nav className="nav">
<div className="brand">
<BrandMark />
<span>Trump Alpha</span>
</div>
<div className="nav-tabs">
{tabs.map(t => (
<Link
key={item.key}
href={`/${locale}${item.href}`}
className={`text-[13px] transition-colors ${
isActive(item.href) ? 'text-[#f97316]' : 'text-[#555555] hover:text-white'
}`}
key={t.key}
href={`/${locale}${t.key === '/' ? '' : t.key}`}
className={`nav-tab ${isActive(t.key) ? 'active' : ''}`}
>
{t(item.key)}
{t.label}
</Link>
))}
</div>
{/* Right side */}
<div className="flex items-center gap-3 shrink-0">
<button className="text-[13px] text-[#555555] hover:text-white border border-[#141414] rounded-full px-3 py-1 transition-colors">
{t('language')}
</button>
<div className="nav-spacer" />
{isConnected && address ? (
<button className="text-[13px] bg-[#141414] text-white border border-[#1a1a1a] rounded-full px-3 py-1">
{shortenAddress(address)}
</button>
) : (
<div className="nav-right">
<ThemeToggle />
{!mounted ? (
<button className="connect-btn lg" suppressHydrationWarning>Connect wallet</button>
) : isConnected && shortAddr ? (
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
<button
onClick={openConnectModal}
className="text-[13px] bg-[#f97316] text-black font-medium rounded-full px-4 py-1.5 hover:bg-[#fb923c] transition-colors"
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'
}}
onBlur={(e) => {
const menu = e.currentTarget.nextElementSibling as HTMLElement | null
setTimeout(() => { if (menu) menu.style.display = 'none' }, 150)
}}
>
{t('connectWallet')}
<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)',
}}
>
<button
onMouseDown={(e) => {
e.preventDefault()
if (address) navigator.clipboard?.writeText(address)
}}
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)' }}
>
Disconnect
</button>
</div>
</div>
) : (
<button className="connect-btn lg" onClick={() => connect({ connector: injected() })}>
Connect wallet
</button>
)}
</div>
-147
View File
@@ -1,147 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import type { BotTrade } from '@/types'
import Badge from '@/components/ui/Badge'
import { formatPrice, formatHold } from '@/lib/utils'
const MOCK_TRADES: BotTrade[] = [
{
id: 1,
asset: 'BTC',
side: 'long',
entry_price: 92800,
exit_price: 95100,
pnl_usd: 2300,
hold_seconds: 52 * 60,
trigger_post_id: 1,
opened_at: new Date(Date.now() - 1000 * 60 * 80).toISOString(),
closed_at: new Date(Date.now() - 1000 * 60 * 28).toISOString(),
},
{
id: 2,
asset: 'ETH',
side: 'short',
entry_price: 1920,
exit_price: 1847,
pnl_usd: 1460,
hold_seconds: 34 * 60,
trigger_post_id: 2,
opened_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
closed_at: new Date(Date.now() - 1000 * 60 * 86).toISOString(),
},
{
id: 3,
asset: 'BTC',
side: 'long',
entry_price: 91500,
exit_price: 90200,
pnl_usd: -1300,
hold_seconds: 22 * 60,
trigger_post_id: 4,
opened_at: new Date(Date.now() - 1000 * 60 * 240).toISOString(),
closed_at: new Date(Date.now() - 1000 * 60 * 218).toISOString(),
},
{
id: 4,
asset: 'ETH',
side: 'long',
entry_price: 1780,
exit_price: 1840,
pnl_usd: 900,
hold_seconds: 8 * 60,
trigger_post_id: 1,
opened_at: new Date(Date.now() - 1000 * 60 * 360).toISOString(),
closed_at: new Date(Date.now() - 1000 * 60 * 352).toISOString(),
},
{
id: 5,
asset: 'BTC',
side: 'short',
entry_price: 93400,
exit_price: 91800,
pnl_usd: 3200,
hold_seconds: 18 * 60,
trigger_post_id: 2,
opened_at: new Date(Date.now() - 1000 * 60 * 500).toISOString(),
closed_at: new Date(Date.now() - 1000 * 60 * 482).toISOString(),
},
]
interface TradesTableProps {
trades?: BotTrade[]
}
export default function TradesTable({ trades = MOCK_TRADES }: TradesTableProps) {
const t = useTranslations('trades')
return (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] overflow-hidden">
<div className="px-5 py-4 border-b border-[#141414]">
<p className="text-[13px] font-medium text-white">{t('title')}</p>
</div>
<table className="w-full">
<thead>
<tr className="bg-[#050505]">
{[
t('asset'),
t('side'),
t('entry'),
t('exit'),
t('pnl'),
t('hold'),
t('trigger'),
].map((col) => (
<th
key={col}
className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-[#555555] font-medium"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{trades.map((trade) => (
<tr
key={trade.id}
className="border-b border-[#141414] hover:bg-[#0d0d0d] transition-colors"
>
<td className="px-5 py-3.5">
<span className="text-[13px] font-medium text-white">{trade.asset}</span>
</td>
<td className="px-5 py-3.5">
<Badge variant={trade.side} />
</td>
<td className="px-5 py-3.5">
<span className="text-[13px] text-[#e2e8f0]">{formatPrice(trade.entry_price)}</span>
</td>
<td className="px-5 py-3.5">
<span className="text-[13px] text-[#e2e8f0]">{formatPrice(trade.exit_price)}</span>
</td>
<td className="px-5 py-3.5">
<span
className={`text-[13px] font-medium ${
trade.pnl_usd >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
}`}
>
{trade.pnl_usd >= 0 ? '+' : ''}${trade.pnl_usd.toLocaleString()}
</span>
</td>
<td className="px-5 py-3.5">
<span className="text-[13px] text-[#555555]">{formatHold(trade.hold_seconds)}</span>
</td>
<td className="px-5 py-3.5">
<span className="text-[12px] text-[#818cf8] hover:text-[#a5b4fc] cursor-pointer">
#{trade.trigger_post_id}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export { MOCK_TRADES }
-36
View File
@@ -1,36 +0,0 @@
type BadgeVariant = 'bullish' | 'bearish' | 'neutral' | 'long' | 'short' | 'x' | 'truth'
interface BadgeProps {
variant: BadgeVariant
children?: React.ReactNode
}
const variantStyles: Record<BadgeVariant, string> = {
bullish: 'bg-[#0d2e1a] text-[#4ade80] border border-[#1a4a2a]',
bearish: 'bg-[#2e0d0d] text-[#ef4444] border border-[#4a1a1a]',
neutral: 'bg-[#141414] text-[#555555] border border-[#1e1e1e]',
long: 'bg-[#0d2e1a] text-[#4ade80] border border-[#1a4a2a]',
short: 'bg-[#2e0d0d] text-[#ef4444] border border-[#4a1a1a]',
x: 'bg-[#141414] text-white border border-[#222222]',
truth: 'bg-[#1a1a2e] text-[#818cf8] border border-[#2a2a4a]',
}
const variantLabels: Record<BadgeVariant, string> = {
bullish: 'Bullish',
bearish: 'Bearish',
neutral: 'Neutral',
long: 'Long',
short: 'Short',
x: 'X',
truth: 'Truth',
}
export default function Badge({ variant, children }: BadgeProps) {
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${variantStyles[variant]}`}
>
{children ?? variantLabels[variant]}
</span>
)
}
-16
View File
@@ -1,16 +0,0 @@
import React from 'react'
interface CardProps {
children: React.ReactNode
className?: string
}
export default function Card({ children, className = '' }: CardProps) {
return (
<div
className={`bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 ${className}`}
>
{children}
</div>
)
}
-23
View File
@@ -1,23 +0,0 @@
'use client'
interface PillProps {
active: boolean
onClick: () => void
children: React.ReactNode
}
export default function Pill({ active, onClick, children }: PillProps) {
return (
<button
onClick={onClick}
className={
`rounded-full px-3 py-1 text-xs transition-colors ` +
(active
? 'bg-[#141414] text-white border border-[#222222]'
: 'text-[#555555] border border-[#141414] hover:text-white')
}
>
{children}
</button>
)
}
+68 -10
View File
@@ -1,4 +1,5 @@
import type { TrumpPost, Candle, BotTrade, BotPerformance } from '@/types'
import type { SignedEnvelope } from './signedRequest'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
@@ -7,7 +8,14 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`)
if (!res.ok) {
let detail = `API error ${res.status}`
try {
const j = await res.json()
if (j.detail) detail = `${res.status}: ${j.detail}`
} catch {}
throw new Error(detail)
}
return res.json() as Promise<T>
}
@@ -31,20 +39,70 @@ export async function getPerformance(): Promise<BotPerformance> {
return fetchJson<BotPerformance>(`/performance`)
}
export async function subscribe(
wallet: string,
signature: string,
): Promise<{ success: boolean; message: string }> {
return fetchJson<{ success: boolean; message: string }>(`/subscribe`, {
export async function subscribe(env: SignedEnvelope): Promise<{ status: string; wallet: string }> {
return fetchJson<{ status: string; wallet: string }>(`/subscribe`, {
method: 'POST',
body: JSON.stringify({ wallet, signature }),
body: JSON.stringify(env),
})
}
export interface UserSettings {
leverage: number
position_size_usd: number
take_profit_pct: number | null
stop_loss_pct: number | null
min_confidence: number
}
export interface UserData {
wallet_address: string
active: boolean
subscribed_at: string | null
hl_api_key_set: boolean
hl_api_key_masked: string | null
trades: BotTrade[]
settings: UserSettings
}
export async function setUserSettings(
env: SignedEnvelope,
settings: UserSettings,
): Promise<UserSettings> {
return fetchJson<UserSettings>(`/user/${env.wallet}/settings`, {
method: 'PUT',
body: JSON.stringify({ ...env, settings }),
})
}
export interface UserPublic {
wallet_address: string
active: boolean
hl_api_key_set: boolean
}
/** No-auth boolean state. Safe to call on every page load. */
export async function getUserPublic(wallet: string): Promise<UserPublic> {
return fetchJson<UserPublic>(`/user/${wallet.toLowerCase()}/public`)
}
/** Full user data (trades, settings). Requires signed envelope. */
export async function getUser(
wallet: string,
): Promise<{ wallet_address: string; active: boolean; subscribed_at: string | null; hl_api_key_set: boolean; trades: BotTrade[] }> {
return fetchJson<{ wallet_address: string; active: boolean; subscribed_at: string | null; hl_api_key_set: boolean; trades: BotTrade[] }>(
`/user/${wallet}`,
env: SignedEnvelope,
): Promise<UserData> {
const qs = `ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}?${qs}`)
}
export async function setHlApiKey(
env: SignedEnvelope,
api_key: string,
): Promise<{ status: string; masked_key: string }> {
return fetchJson<{ status: string; masked_key: string }>(
`/user/${env.wallet}/hl-api-key`,
{
method: 'PUT',
body: JSON.stringify({ ...env, api_key }),
},
)
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Signed-request helper. Must match backend/app/services/signed_request.py exactly.
*
* Message format:
* TrumpSignal · {ACTION}
* wallet: {wallet_lowercase}
* timestamp: {ms}
* body: {sha256_hex or "-"}
*/
async function sha256Hex(text: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/** Canonical JSON: sorted keys, no whitespace. Must match Python's json.dumps(..., sort_keys, separators). */
function canonicalJson(v: unknown): string {
if (v === null || typeof v !== 'object') return JSON.stringify(v)
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
const keys = Object.keys(v as object).sort()
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson((v as Record<string, unknown>)[k])).join(',') + '}'
}
export async function bodyHash(body: unknown | null): Promise<string> {
if (body === null || body === undefined) return '-'
return sha256Hex(canonicalJson(body))
}
export async function buildSignMessage(
action: string,
wallet: string,
timestampMs: number,
body: unknown | null,
): Promise<string> {
const h = await bodyHash(body)
return `TrumpSignal · ${action}\nwallet: ${wallet.toLowerCase()}\ntimestamp: ${timestampMs}\nbody: ${h}`
}
export interface SignedEnvelope {
wallet: string
timestamp: number
signature: string
}
/**
* Produce {timestamp, signature} for a given action + optional body.
* Caller supplies wagmi's `signMessageAsync`.
*/
/**
* Get-or-create a cached "view" envelope for a wallet. Reused across page loads
* within a 4-minute window (server accepts 5-min skew). Backend's replay-guard
* is disabled for the view action so the same sig can be used multiple times.
*/
const VIEW_TTL_MS = 4 * 60 * 1000
export async function getOrCreateViewEnvelope(params: {
action: string
wallet: string
signMessageAsync: (args: { message: string }) => Promise<string>
}): Promise<SignedEnvelope> {
if (typeof window === 'undefined') throw new Error('view envelope requires browser')
const wallet = params.wallet.toLowerCase()
const cacheKey = `ts:view:${params.action}:${wallet}`
const raw = sessionStorage.getItem(cacheKey)
if (raw) {
try {
const env = JSON.parse(raw) as SignedEnvelope
if (Date.now() - env.timestamp < VIEW_TTL_MS) return env
} catch {}
}
const env = await signRequest({
action: params.action,
wallet,
body: null,
signMessageAsync: params.signMessageAsync,
})
sessionStorage.setItem(cacheKey, JSON.stringify(env))
return env
}
export async function signRequest(params: {
action: string
wallet: string
body: unknown | null
signMessageAsync: (args: { message: string }) => Promise<string>
}): Promise<SignedEnvelope> {
const timestamp = Date.now()
const message = await buildSignMessage(params.action, params.wallet, timestamp, params.body)
const signature = await params.signMessageAsync({ message })
return { wallet: params.wallet.toLowerCase(), timestamp, signature }
}
+2 -2
View File
@@ -30,8 +30,8 @@ export function usePriceSocket(handlers: Handlers) {
} else if (msg.type === 'new_post' && handlersRef.current.onNewPost) {
handlersRef.current.onNewPost(msg.post)
}
} catch {
// ignore malformed messages
} catch (err) {
console.warn('[WS] malformed message:', e.data, err)
}
}
+7
View File
@@ -6,12 +6,15 @@ interface DashboardState {
timeframe: '5m' | '15m' | '1H' | '4H' | '1D' | '1W'
walletAddress: string | null
isSubscribed: boolean
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 }
setSelectedPost: (id: number | null) => void
setAsset: (asset: 'BTC' | 'ETH') => void
setTimeframe: (tf: '5m' | '15m' | '1H' | '4H' | '1D' | '1W') => void
setWallet: (address: string | null) => void
setSubscribed: (subscribed: boolean) => void
setHlApiKeySet: (set: boolean, masked?: string) => void
setLivePrice: (asset: 'BTC' | 'ETH', price: number) => void
}
@@ -21,12 +24,16 @@ export const useDashboardStore = create<DashboardState>((set) => ({
timeframe: '4H',
walletAddress: null,
isSubscribed: false,
hlApiKeySet: false,
hlApiKeyMasked: null,
livePrices: { BTC: null, ETH: null },
setSelectedPost: (id) => set({ selectedPostId: id }),
setAsset: (asset) => set({ asset }),
setTimeframe: (timeframe) => set({ timeframe }),
setWallet: (walletAddress) => set({ walletAddress }),
setSubscribed: (isSubscribed) => set({ isSubscribed }),
setHlApiKeySet: (keySet, masked) =>
set({ hlApiKeySet: keySet, hlApiKeyMasked: masked ?? null }),
setLivePrice: (asset, price) =>
set((s) => ({ livePrices: { ...s.livePrices, [asset]: price } })),
}))
+5
View File
@@ -7,6 +7,8 @@ export interface TrumpPost {
signal: 'buy' | 'sell' | 'short' | 'hold' | null
ai_confidence: number
ai_reasoning: string | null
prefilter_reason: 'rt_only' | 'url_only' | 'empty' | 'parse_error' | 'api_error' | null
analysis_version: string | null
relevant: boolean
price_impact: {
asset: 'BTC' | 'ETH'
@@ -14,6 +16,9 @@ export interface TrumpPost {
m15: number
m1h: number
price_at_post: number
correct_m5: boolean | null
correct_m15: boolean | null
correct_m1h: boolean | null
} | null
}