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