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' 'use client'
import { useState, useEffect } from 'react' 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 { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData' import { usePriceSocket } from '@/lib/useRealtimeData'
import { getPrices } from '@/lib/api' import { getPrices, getUserPublic } from '@/lib/api'
import KpiRow from '@/components/dashboard/KpiRow'
import ChartPanel from '@/components/dashboard/ChartPanel' import ChartPanel from '@/components/dashboard/ChartPanel'
import BotPanel from '@/components/dashboard/BotPanel' import BotPanel from '@/components/dashboard/BotPanel'
import TradesTable from '@/components/trades/TradesTable' import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards'
interface Props { interface Props {
initialPosts: TrumpPost[] initialPosts: TrumpPost[]
initialPerformance?: BotPerformance initialPerformance?: BotPerformance
initialTrades: BotTrade[]
} }
export default function DashboardClient({ initialPosts, initialPerformance, initialTrades }: Props) { // ── Inline post detail panel shown in the right rail ──────────────────────────
const { asset, timeframe, setLivePrice } = useDashboardStore() 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 [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [candles, setCandles] = useState<Candle[]>([]) 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({ usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price), 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(() => { useEffect(() => {
setCandles([]) setCandles([])
setCandlesLoading(true)
getPrices(asset, timeframe) getPrices(asset, timeframe)
.then(setCandles) .then(setCandles)
.catch(() => {}) .catch(() => {})
.finally(() => setCandlesLoading(false))
}, [asset, timeframe]) }, [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 ( return (
<div className="pt-14"> <div className="page wide">
<div className="max-w-[1400px] mx-auto px-6 py-6 flex flex-col gap-4"> <div className="page-head">
{/* KPI 行 */} <div>
<KpiRow performance={initialPerformance} postsCount={posts.length} /> <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 面板 */} {/* KPI Row */}
<div className="flex gap-4 items-start w-full"> <div className="kpi-row">
<div className="flex-1 min-w-0 relative"> <div className="kpi">
<ChartPanel posts={posts} candles={candles} /> <div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div>
{candlesLoading && ( <div className="value">{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-[10px] pointer-events-none"> <div className="foot">
<span className="text-[#555555] text-sm">Loading...</span> <span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)}</span>
<span>{timeframe}</span>
</div> </div>
)}
</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} /> <BotPanel performance={initialPerformance} />
</div>
</div>
{/* 交易记录 */} {/* Post detail — shown when something is selected, else hint */}
<TradesTable trades={initialTrades} /> {selectedPost
? <PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
: <SelectHint />
}
</div>
</div> </div>
</div> </div>
) )
+94 -7
View File
@@ -1,14 +1,101 @@
import { getTranslations } from 'next-intl/server' 'use client'
export default async function AnalyticsPage() { import { useState, useEffect } from 'react'
const t = await getTranslations('analytics') 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 ( return (
<div className="max-w-[1400px] mx-auto px-6 py-6"> <div className="page">
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center"> <div className="page-head">
<h1 className="text-[22px] font-medium text-white mb-3">{t('title')}</h1> <div>
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p> <h1 className="page-title">Analytics</h1>
<p className="page-sub">Deep dive into {period} of signals and trades.</p>
</div> </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> </div>
) )
} }
+895 -14
View File
@@ -1,22 +1,903 @@
@tailwind base; /* ============================================================
@tailwind components; Trump Alpha — Design System
@tailwind utilities; ============================================================ */
* { :root {
box-sizing: border-box; /* 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 { html[data-theme="dark"] {
background-color: #000000; --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 */ html[data-theme="dark"] .src-ico.x { background: oklch(92% 0.005 85); color: oklch(15% 0.008 85); }
button:focus-visible {
outline: 1px solid #f97316; html[data-theme="dark"] .bot-status {
outline-offset: 2px; 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 { html[data-theme="dark"] .kpi.accent {
outline: 1px solid #222222; background: linear-gradient(135deg, oklch(28% 0.05 75), oklch(24% 0.07 70));
outline-offset: 0;
} }
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 type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { NextIntlClientProvider } from 'next-intl' import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server' import { getMessages } from 'next-intl/server'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { locales } from '@/i18n' import { locales } from '@/i18n'
import Navbar from '@/components/nav/Navbar'
import Providers from './Providers' import Providers from './Providers'
import Navbar from '@/components/nav/Navbar'
import './globals.css' import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = { 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.', 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 ( return (
<html lang={locale}> <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}> <NextIntlClientProvider messages={messages}>
<Providers> <Providers>
<Navbar locale={locale} /> <Navbar />
<main className="pt-14">{children}</main> <main>{children}</main>
</Providers> </Providers>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </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' import DashboardClient from './DashboardClient'
export const revalidate = 30 export const revalidate = 30
export default async function OverviewPage() { export default async function OverviewPage() {
const [posts, performance, trades] = await Promise.allSettled([ const [posts, performance] = await Promise.allSettled([
getPosts(500, 1), getPosts(500, 1),
getPerformance(), getPerformance(),
getTrades(20, 1),
]) ])
return ( return (
<DashboardClient <DashboardClient
initialPosts={posts.status === 'fulfilled' ? posts.value : []} initialPosts={posts.status === 'fulfilled' ? posts.value : []}
initialPerformance={performance.status === 'fulfilled' ? performance.value : undefined} 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' 'use client'
import { getTranslations } from 'next-intl/server'
export default async function PostsPage() { import { useState, useEffect } from 'react'
const t = await getTranslations('posts') 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 ( return (
<div className="max-w-[1400px] mx-auto px-6 py-6"> <div className="page">
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center"> <div className="page-head">
<h1 className="text-[22px] font-medium text-white mb-3">{t('title')}</h1> <div>
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p> <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>
</div> </div>
) )
+311 -17
View File
@@ -1,31 +1,325 @@
'use client' 'use client'
import { useTranslations } from 'next-intl' import { useEffect, useState } from 'react'
import { useAccount } from 'wagmi' import Link from 'next/link'
import { useConnectModal } from '@rainbow-me/rainbowkit' 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 ACTION_SET_SETTINGS = 'set_settings'
const t = useTranslations('settings') const ACTION_VIEW_USER = 'view_user'
const { isConnected } = useAccount() const DEFAULT_SETTINGS: UserSettings = {
const { openConnectModal } = useConnectModal() 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 ( return (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center"> <div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
<p className="text-[14px] text-[#555555] mb-4">{t('connectRequired')}</p> <div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-sunk)', margin: '0 auto 12px', display: 'grid', placeItems: 'center' }}>
<button <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>
onClick={openConnectModal} </div>
className="bg-[#f97316] text-black font-medium text-[13px] rounded-lg px-6 py-2.5 hover:bg-[#fb923c] transition-colors" {label}
> </div>
Connect wallet )
}
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> </button>
</div> </div>
) )
} }
if (!subscribed) {
return ( return (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center"> <div className="card" style={{ padding: 32 }}>
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p> <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> </div>
) )
} }
+8 -6
View File
@@ -1,12 +1,14 @@
import { getTranslations } from 'next-intl/server'
import SettingsClient from './SettingsClient' import SettingsClient from './SettingsClient'
export default async function SettingsPage() { export default function SettingsPage() {
const t = await getTranslations('settings')
return ( return (
<div className="max-w-[1400px] mx-auto px-6 py-6"> <div className="page">
<h1 className="text-[22px] font-medium text-white mb-5">{t('title')}</h1> <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 /> <SettingsClient />
</div> </div>
) )
+172 -10
View File
@@ -1,17 +1,179 @@
import { getTranslations } from 'next-intl/server' 'use client'
import TradesTable from '@/components/trades/TradesTable'
import { getTrades } from '@/lib/api'
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() { function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
const t = await getTranslations('trades') const { decimals = 2, sign = false } = opts
const trades = await getTrades(100, 1).catch(() => []) 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 ( return (
<div className="max-w-[1400px] mx-auto px-6 py-6 pt-20"> <div className="page">
<h1 className="text-[22px] font-medium text-white mb-5">{t('title')}</h1> <div className="page-head">
<TradesTable trades={trades} /> <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> </div>
) )
} }
+208 -88
View File
@@ -1,124 +1,244 @@
'use client' 'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react' import { useState } from 'react'
import { useAccount } from 'wagmi' import { useAccount, useSignMessage } from 'wagmi'
import { useConnectModal } from '@rainbow-me/rainbowkit'
import type { BotPerformance } from '@/types' import type { BotPerformance } from '@/types'
import { useDashboardStore } from '@/store/dashboard' 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 = { // Action names must match backend/app/api/{user,subscribe}.py
period_days: 30, const ACTION_SET_API_KEY = 'set_hl_api_key'
total_trades: 89, const ACTION_SUBSCRIBE = 'subscribe'
win_rate: 0.73,
net_pnl_usd: 12840, interface Props {
avg_hold_seconds: 14 * 60, performance?: BotPerformance | null
max_drawdown_pct: 8.2,
} }
interface BotPanelProps { type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
performance?: BotPerformance
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) { export default function BotPanel({ performance }: Props) {
const t = useTranslations('bot') const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore()
const { isSubscribed, setSubscribed } = useDashboardStore()
const { address, isConnected } = useAccount() const { address, isConnected } = useAccount()
const { openConnectModal } = useConnectModal() const { signMessageAsync } = useSignMessage()
const [apiKey, setApiKey] = useState('')
const stats = [ const [apiKey, setApiKey] = useState('')
{ label: t('winRate'), value: formatPct(performance.win_rate * 100 - 100 + performance.win_rate * 100), display: `${Math.round(performance.win_rate * 100)}%` }, const [saveState, setSaveState] = useState<SaveState>('idle')
{ label: t('netPnl'), value: `+$${performance.net_pnl_usd.toLocaleString()}`, positive: true }, const [errorMsg, setErrorMsg] = useState('')
{ label: t('totalTrades'), value: String(performance.total_trades) }, const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
{ label: t('avgHold'), value: formatHold(performance.avg_hold_seconds) }, 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 ( return (
<div className="w-[300px] shrink-0 flex flex-col gap-3"> <>
{/* Performance card */} {/* Bot status card — dark design */}
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5"> <div className="bot-status">
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-4"> <div className="bot-head">
{t('title')} · {t('period')} <h3>
</p> <span style={{ width: 8, height: 8, borderRadius: 999, background: isSubscribed ? 'var(--amber)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
<div className="grid grid-cols-2 gap-3"> Auto-trader
{stats.map((stat) => ( </h3>
<div key={stat.label}> <span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
<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>
</div> </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>
</div> </div>
{/* Divider */} <div className="bot-cta">
<div className="h-px bg-[#141414]" />
{/* Conditional bottom section */}
{!isConnected && ( {!isConnected && (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3"> <button className="btn amber" style={{ width: '100%' }}
<p className="text-[14px] font-medium text-white">{t('connectCta')}</p> onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
<p className="text-[12px] text-[#555555] leading-relaxed">{t('connectDesc')}</p> Connect wallet
<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> </button>
</div>
)} )}
{isConnected && !isSubscribed && ( {isConnected && !isSubscribed && (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3"> <div style={{ width: '100%' }}>
<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>
<button <button
onClick={() => setSubscribed(true)} className="btn amber"
className="w-full bg-[#f97316] text-black font-medium text-[13px] rounded-lg py-2.5 hover:bg-[#fb923c] transition-colors" 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> </button>
</div> </div>
)} )}
{isConnected && isSubscribed && ( {(!hlApiKeySet || apiKey) && (
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5 flex flex-col gap-3"> <>
<div className="flex items-center justify-between"> <div className="field" style={{ marginBottom: 12 }}>
<p className="text-[13px] font-medium text-white">{t('settingsTitle')}</p> <label>API wallet private key</label>
<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>
<input <input
value={apiKey} value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => {
placeholder={t('apiKeyPlaceholder')} setApiKey(e.target.value)
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]" 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> </div>
<button className="w-full bg-[#141414] text-white border border-[#1a1a1a] text-[13px] rounded-lg py-2 hover:bg-[#0d0d0d] transition-colors"> </div>
{t('saveKey')}
{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> </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> </div>
)}
</>
) )
} }
+100 -95
View File
@@ -3,56 +3,64 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { TrumpPost, Candle } from '@/types' import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard' import { useDashboardStore } from '@/store/dashboard'
import Pill from '@/components/ui/Pill'
import PostCards, { MOCK_POSTS } from './PostCards'
import ExpandDetail from './ExpandDetail'
interface ChartPanelProps { interface ChartPanelProps {
posts?: TrumpPost[] posts?: TrumpPost[]
candles?: Candle[] candles?: Candle[]
externalSelectedId?: number | null
onSelectPost?: (id: number | null) => void
} }
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) { export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore() const { timeframe } = useDashboardStore()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<unknown>(null) const chartRef = useRef<unknown>(null)
const seriesRef = useRef<unknown>(null) const seriesRef = useRef<unknown>(null)
const fittedRef = useRef(false) const fittedRef = useRef(false)
// Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing
const postsRef = useRef(posts) const postsRef = useRef(posts)
postsRef.current = posts postsRef.current = posts
const selectedPostIdRef = useRef(selectedPostId) const selectedPostIdRef = useRef(externalSelectedId)
selectedPostIdRef.current = selectedPostId selectedPostIdRef.current = externalSelectedId
const timeframeRef = useRef(timeframe)
timeframeRef.current = timeframe
const onSelectRef = useRef(onSelectPost)
onSelectRef.current = onSelectPost
const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH'] // Detect current theme for chart colors
const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W'] function getChartColors() {
const isDark = document.documentElement.dataset.theme === 'dark'
const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null return {
background: isDark ? '#121212' : '#ffffff',
textColor: isDark ? '#666666' : '#888888',
gridColor: isDark ? '#1e1e1e' : '#f0ede8',
borderColor: isDark ? '#2a2a2a' : '#e8e4de',
}
}
// Create chart once on mount // Create chart once on mount
useEffect(() => { useEffect(() => {
if (!containerRef.current || typeof window === 'undefined') return if (!containerRef.current || typeof window === 'undefined') return
let destroyed = false let destroyed = false
import('lightweight-charts').then(({ createChart, CrosshairMode }) => { import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
if (destroyed || !containerRef.current) return if (destroyed || !containerRef.current) return
const colors = getChartColors()
const chart = createChart(containerRef.current, { const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth, width: containerRef.current.clientWidth,
height: 360, height: 360,
layout: { layout: {
background: { color: '#050505' }, background: { color: colors.background },
textColor: '#555555', textColor: colors.textColor,
}, },
grid: { grid: {
vertLines: { color: '#111111' }, vertLines: { color: colors.gridColor },
horzLines: { color: '#111111' }, horzLines: { color: colors.gridColor },
}, },
crosshair: { mode: CrosshairMode.Normal }, crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: '#1a1a1a' }, rightPriceScale: { borderColor: colors.borderColor },
timeScale: { timeScale: {
borderColor: '#1a1a1a', borderColor: colors.borderColor,
timeVisible: true, timeVisible: true,
rightOffset: 5, rightOffset: 5,
barSpacing: 10, barSpacing: 10,
@@ -61,47 +69,54 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
handleScale: true, handleScale: true,
}) })
// @ts-expect-error lightweight-charts type
chartRef.current = chart chartRef.current = chart
const series = chart.addCandlestickSeries({ const series = chart.addCandlestickSeries({
upColor: '#4ade80', upColor: '#26a69a',
downColor: '#ef4444', downColor: '#ef5350',
borderUpColor: '#4ade80', borderUpColor: '#26a69a',
borderDownColor: '#ef4444', borderDownColor: '#ef5350',
wickUpColor: '#4ade80', wickUpColor: '#26a69a',
wickDownColor: '#ef4444', wickDownColor: '#ef5350',
}) })
// @ts-expect-error lightweight-charts type
seriesRef.current = series seriesRef.current = series
// Click on chart: find nearest post marker within 2-bar tolerance // eslint-disable-next-line @typescript-eslint/no-explicit-any
chart.subscribeClick((param: { time?: number | string }) => { chart.subscribeClick((param: any) => {
if (!param.time) return if (!param.time) return
const clickTime = typeof param.time === 'number' ? param.time : 0 const clickTime = typeof param.time === 'number' ? param.time : 0
if (!clickTime) return if (!clickTime) return
const allPosts = postsRef.current const bucketByTf: Record<string, number> = {
let closest: TrumpPost | null = null '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
let closestDiff = Infinity }
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600
const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs
for (const p of allPosts) { const inBucket = postsRef.current
if (!p.published_at) continue .filter((p) => {
if (!p.published_at) return false
const pt = Math.floor(new Date(p.published_at).getTime() / 1000) const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const diff = Math.abs(pt - clickTime) return Math.floor(pt / bucketSecs) * bucketSecs === clickBucket
if (diff < closestDiff) { })
closestDiff = diff .sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0))
closest = p
} if (inBucket.length === 0) {
onSelectRef.current?.(null)
return
} }
// Only select if click is within 2 hours of a post (7200 seconds) const currentIdx = inBucket.findIndex((p) => p.id === selectedPostIdRef.current)
if (closest && closestDiff <= 7200) { if (currentIdx >= 0) {
const newId = closest.id === selectedPostIdRef.current ? null : closest.id const nextIdx = currentIdx + 1
setSelectedPost(newId) if (nextIdx >= inBucket.length) {
onSelectRef.current?.(null)
} else { } 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) ro.observe(containerRef.current)
return () => { return () => { ro.disconnect() }
ro.disconnect()
}
}) })
return () => { return () => {
@@ -130,7 +143,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Update candles + markers whenever data changes // Update candles + markers
useEffect(() => { useEffect(() => {
const series = seriesRef.current const series = seriesRef.current
const chart = chartRef.current const chart = chartRef.current
@@ -147,7 +160,6 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
close: c.close, close: c.close,
}))) })))
// Show markers for all posts within visible time range
const minTime = sorted[0].time const minTime = sorted[0].time
const maxTime = sorted[sorted.length - 1].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) { if (visible.length > 0) {
const markers = [...visible] const bucketByTf: Record<string, number> = {
.sort((a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime()) '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
.map((p) => ({ }
time: Math.floor(new Date(p.published_at).getTime() / 1000) as number, 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, position: 'aboveBar' as const,
color: p.id === selectedPostId color: signalColor,
? '#fb923c'
: p.sentiment === 'bearish'
? '#ef4444'
: '#f97316',
shape: 'circle' as const, shape: 'circle' as const,
text: '', text: count > 1 ? String(count) : '',
size: p.id === selectedPostId ? 2 : 1, size: isSelected ? 2 : count > 1 ? 1.5 : 1,
})) }
})
// @ts-expect-error lightweight-charts type // @ts-expect-error lightweight-charts type
series.setMarkers(markers) series.setMarkers(markers)
} }
@@ -181,45 +217,14 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
chart.timeScale().fitContent() chart.timeScale().fitContent()
fittedRef.current = true fittedRef.current = true
} }
}, [candles, posts, selectedPostId]) }, [candles, posts, externalSelectedId])
// Reset fit flag on timeframe/asset switch useEffect(() => { fittedRef.current = false }, [timeframe])
useEffect(() => {
fittedRef.current = false
}, [asset, timeframe])
return ( 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 <div
ref={containerRef} ref={containerRef}
className="w-full rounded-lg overflow-hidden cursor-crosshair" style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair' }}
style={{ height: 360, background: '#050505' }}
/> />
{/* 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' 'use client'
import { useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import type { TrumpPost } from '@/types' 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[] = [ function fmtPct(n: number | null | undefined) {
{ if (n == null || isNaN(n)) return '—'
id: 1, const s = n.toFixed(2) + '%'
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!', return n >= 0 ? '+' + s : s
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 timeAgo(iso: string): string { function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime() const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60000) const m = Math.floor(diff / 60000)
if (mins < 60) return `${mins}m ago` if (m < 1) return 'just now'
const hours = Math.floor(mins / 60) if (m < 60) return m + 'm'
if (hours < 24) return `${hours}h ago` const h = Math.floor(m / 60)
return `${Math.floor(hours / 24)}d ago` if (h < 24) return h + 'h'
return Math.floor(h / 24) + 'd'
} }
export default function PostCards({ posts = MOCK_POSTS }: PostCardsProps) { function SourceIcon({ source }: { source: string }) {
const t = useTranslations('common') if (source === 'x') return <div className="src-ico x">𝕏</div>
const { selectedPostId, setSelectedPost } = useDashboardStore() return <div className="src-ico truth">T</div>
const selectedRef = useRef<HTMLDivElement>(null) }
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll selected card into view function SignalPill({ signal }: { signal: string | null }) {
useEffect(() => { if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
if (selectedRef.current && scrollRef.current) { return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }) }
}
}, [selectedPostId])
return ( interface PostRowProps {
<div className="mt-2"> post: TrumpPost
<div className="flex items-center justify-between mb-2"> selected?: boolean
<span className="text-[11px] uppercase tracking-wider text-[#555555]"> onClick?: () => void
Posts · {posts.length} }
</span>
{selectedPostId && (
<button
onClick={() => setSelectedPost(null)}
className="text-[11px] text-[#f97316] hover:text-[#fb923c] transition-colors"
>
clear selection ×
</button>
)}
</div>
{/* Horizontally scrollable row */} export default function PostRow({ post, selected, onClick }: PostRowProps) {
<div
ref={scrollRef}
className="flex gap-3 overflow-x-auto pb-2"
style={{ scrollbarWidth: 'none' }}
>
{posts.map((post) => {
const isSelected = selectedPostId === post.id
const impact = post.price_impact const impact = post.price_impact
return ( return (
<div <div className={`post-row ${selected ? 'selected' : ''}`} onClick={onClick}>
key={post.id} <SourceIcon source={post.source} />
ref={isSelected ? selectedRef : null} <div className="post-body">
onClick={() => setSelectedPost(isSelected ? null : post.id)} <div className="meta">
className={`shrink-0 w-[200px] bg-[#0a0a0a] border rounded-[10px] p-3 cursor-pointer transition-colors hover:bg-[#0d0d0d] ${ <span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span>
isSelected ? 'border-[#f97316]' : 'border-[#141414]' <span>·</span>
}`} <span>{timeAgo(post.published_at)} ago</span>
> <span>·</span>
{/* Header */} <span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
<div className="flex items-center justify-between mb-2"> {post.sentiment}
<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
</span> </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>
) <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>
</div> </div>
) )
} }
export { MOCK_POSTS } export { SignalPill, SourceIcon, fmtPct, timeAgo }
+119 -50
View File
@@ -1,74 +1,143 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useAccount } from 'wagmi' import { injected } from 'wagmi/connectors'
import { useConnectModal } from '@rainbow-me/rainbowkit'
import { shortenAddress } from '@/lib/utils'
const navItems = [ function BrandMark() {
{ key: 'overview', href: '' }, return <span className="brand-mark">α</span>
{ key: 'posts', href: '/posts' },
{ key: 'trades', href: '/trades' },
{ key: 'analytics', href: '/analytics' },
] as const
interface NavbarProps {
locale: string
} }
export default function Navbar({ locale }: NavbarProps) { function ThemeToggle() {
const t = useTranslations('nav') const [theme, setTheme] = useState<'light' | 'dark'>('light')
const pathname = usePathname()
const { address, isConnected } = useAccount()
const { openConnectModal } = useConnectModal()
function isActive(href: string) { useEffect(() => {
const full = `/${locale}${href}` const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
if (href === '') return pathname === `/${locale}` || pathname === `/${locale}/` const t = stored || 'light'
return pathname.startsWith(full) 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 ( 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"> <button className="icon-btn theme-toggle" onClick={toggle}>
{/* Logo */} {theme === 'dark' ? (
<Link href={`/${locale}`} className="flex items-center gap-2 mr-10 shrink-0"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<span className="text-[#f97316] font-bold text-lg leading-none">TS</span> <circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
<span className="text-white font-medium text-sm">TrumpSignal</span> <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"/>
</Link> </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 */} export default function Navbar() {
<div className="flex items-center gap-6 flex-1"> const pathname = usePathname()
{navItems.map((item) => ( 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 <Link
key={item.key} key={t.key}
href={`/${locale}${item.href}`} href={`/${locale}${t.key === '/' ? '' : t.key}`}
className={`text-[13px] transition-colors ${ className={`nav-tab ${isActive(t.key) ? 'active' : ''}`}
isActive(item.href) ? 'text-[#f97316]' : 'text-[#555555] hover:text-white'
}`}
> >
{t(item.key)} {t.label}
</Link> </Link>
))} ))}
</div> </div>
{/* Right side */} <div className="nav-spacer" />
<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>
{isConnected && address ? ( <div className="nav-right">
<button className="text-[13px] bg-[#141414] text-white border border-[#1a1a1a] rounded-full px-3 py-1"> <ThemeToggle />
{shortenAddress(address)} {!mounted ? (
</button> <button className="connect-btn lg" suppressHydrationWarning>Connect wallet</button>
) : ( ) : isConnected && shortAddr ? (
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
<button <button
onClick={openConnectModal} className="wallet-chip"
className="text-[13px] bg-[#f97316] text-black font-medium rounded-full px-4 py-1.5 hover:bg-[#fb923c] transition-colors" 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> </button>
)} )}
</div> </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 { TrumpPost, Candle, BotTrade, BotPerformance } from '@/types'
import type { SignedEnvelope } from './signedRequest'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' 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' }, headers: { 'Content-Type': 'application/json' },
...init, ...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> return res.json() as Promise<T>
} }
@@ -31,20 +39,70 @@ export async function getPerformance(): Promise<BotPerformance> {
return fetchJson<BotPerformance>(`/performance`) return fetchJson<BotPerformance>(`/performance`)
} }
export async function subscribe( export async function subscribe(env: SignedEnvelope): Promise<{ status: string; wallet: string }> {
wallet: string, return fetchJson<{ status: string; wallet: string }>(`/subscribe`, {
signature: string,
): Promise<{ success: boolean; message: string }> {
return fetchJson<{ success: boolean; message: string }>(`/subscribe`, {
method: 'POST', 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( export async function getUser(
wallet: string, wallet: string,
): Promise<{ wallet_address: string; active: boolean; subscribed_at: string | null; hl_api_key_set: boolean; trades: BotTrade[] }> { env: SignedEnvelope,
return fetchJson<{ wallet_address: string; active: boolean; subscribed_at: string | null; hl_api_key_set: boolean; trades: BotTrade[] }>( ): Promise<UserData> {
`/user/${wallet}`, 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) { } else if (msg.type === 'new_post' && handlersRef.current.onNewPost) {
handlersRef.current.onNewPost(msg.post) handlersRef.current.onNewPost(msg.post)
} }
} catch { } catch (err) {
// ignore malformed messages console.warn('[WS] malformed message:', e.data, err)
} }
} }
+7
View File
@@ -6,12 +6,15 @@ interface DashboardState {
timeframe: '5m' | '15m' | '1H' | '4H' | '1D' | '1W' timeframe: '5m' | '15m' | '1H' | '4H' | '1D' | '1W'
walletAddress: string | null walletAddress: string | null
isSubscribed: boolean 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 } livePrices: { BTC: number | null; ETH: number | null }
setSelectedPost: (id: number | null) => void setSelectedPost: (id: number | null) => void
setAsset: (asset: 'BTC' | 'ETH') => void setAsset: (asset: 'BTC' | 'ETH') => void
setTimeframe: (tf: '5m' | '15m' | '1H' | '4H' | '1D' | '1W') => void setTimeframe: (tf: '5m' | '15m' | '1H' | '4H' | '1D' | '1W') => void
setWallet: (address: string | null) => void setWallet: (address: string | null) => void
setSubscribed: (subscribed: boolean) => void setSubscribed: (subscribed: boolean) => void
setHlApiKeySet: (set: boolean, masked?: string) => void
setLivePrice: (asset: 'BTC' | 'ETH', price: number) => void setLivePrice: (asset: 'BTC' | 'ETH', price: number) => void
} }
@@ -21,12 +24,16 @@ export const useDashboardStore = create<DashboardState>((set) => ({
timeframe: '4H', timeframe: '4H',
walletAddress: null, walletAddress: null,
isSubscribed: false, isSubscribed: false,
hlApiKeySet: false,
hlApiKeyMasked: null,
livePrices: { BTC: null, ETH: null }, livePrices: { BTC: null, ETH: null },
setSelectedPost: (id) => set({ selectedPostId: id }), setSelectedPost: (id) => set({ selectedPostId: id }),
setAsset: (asset) => set({ asset }), setAsset: (asset) => set({ asset }),
setTimeframe: (timeframe) => set({ timeframe }), setTimeframe: (timeframe) => set({ timeframe }),
setWallet: (walletAddress) => set({ walletAddress }), setWallet: (walletAddress) => set({ walletAddress }),
setSubscribed: (isSubscribed) => set({ isSubscribed }), setSubscribed: (isSubscribed) => set({ isSubscribed }),
setHlApiKeySet: (keySet, masked) =>
set({ hlApiKeySet: keySet, hlApiKeyMasked: masked ?? null }),
setLivePrice: (asset, price) => setLivePrice: (asset, price) =>
set((s) => ({ livePrices: { ...s.livePrices, [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 signal: 'buy' | 'sell' | 'short' | 'hold' | null
ai_confidence: number ai_confidence: number
ai_reasoning: string | null ai_reasoning: string | null
prefilter_reason: 'rt_only' | 'url_only' | 'empty' | 'parse_error' | 'api_error' | null
analysis_version: string | null
relevant: boolean relevant: boolean
price_impact: { price_impact: {
asset: 'BTC' | 'ETH' asset: 'BTC' | 'ETH'
@@ -14,6 +16,9 @@ export interface TrumpPost {
m15: number m15: number
m1h: number m1h: number
price_at_post: number price_at_post: number
correct_m5: boolean | null
correct_m15: boolean | null
correct_m1h: boolean | null
} | null } | null
} }