This commit is contained in:
k
2026-04-21 19:32:53 +08:00
parent 1747fc489f
commit 83e5892ddf
25 changed files with 2582 additions and 916 deletions
+220 -100
View File
@@ -1,124 +1,244 @@
'use client'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { useAccount } from 'wagmi'
import { useConnectModal } from '@rainbow-me/rainbowkit'
import { useAccount, useSignMessage } from 'wagmi'
import type { BotPerformance } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { formatPct, formatHold } from '@/lib/utils'
import { setHlApiKey, subscribe } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
const MOCK_PERFORMANCE: BotPerformance = {
period_days: 30,
total_trades: 89,
win_rate: 0.73,
net_pnl_usd: 12840,
avg_hold_seconds: 14 * 60,
max_drawdown_pct: 8.2,
// Action names must match backend/app/api/{user,subscribe}.py
const ACTION_SET_API_KEY = 'set_hl_api_key'
const ACTION_SUBSCRIBE = 'subscribe'
interface Props {
performance?: BotPerformance | null
}
interface BotPanelProps {
performance?: BotPerformance
type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
export default function BotPanel({ performance = MOCK_PERFORMANCE }: BotPanelProps) {
const t = useTranslations('bot')
const { isSubscribed, setSubscribed } = useDashboardStore()
export default function BotPanel({ performance }: Props) {
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore()
const { address, isConnected } = useAccount()
const { openConnectModal } = useConnectModal()
const [apiKey, setApiKey] = useState('')
const { signMessageAsync } = useSignMessage()
const stats = [
{ label: t('winRate'), value: formatPct(performance.win_rate * 100 - 100 + performance.win_rate * 100), display: `${Math.round(performance.win_rate * 100)}%` },
{ label: t('netPnl'), value: `+$${performance.net_pnl_usd.toLocaleString()}`, positive: true },
{ label: t('totalTrades'), value: String(performance.total_trades) },
{ label: t('avgHold'), value: formatHold(performance.avg_hold_seconds) },
]
const [apiKey, setApiKey] = useState('')
const [saveState, setSaveState] = useState<SaveState>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
const [subError, setSubError] = useState('')
async function handleSubscribe() {
if (!address) return
setSubError('')
try {
setSubState('signing')
const env = await signRequest({
action: ACTION_SUBSCRIBE,
wallet: address,
body: null,
signMessageAsync,
})
setSubState('saving')
await subscribe(env)
setSubscribed(true)
setSubState('idle')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
setSubError(msg.includes('User rejected') || msg.includes('denied') ? 'Signature cancelled' : msg.slice(0, 120))
setSubState('error')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) {
setErrorMsg('Key must start with 0x and be 66 characters')
setSaveState('error')
return
}
setErrorMsg('')
try {
setSaveState('signing')
const trimmed = apiKey.trim()
const env = await signRequest({
action: ACTION_SET_API_KEY,
wallet: address,
body: { api_key: trimmed },
signMessageAsync,
})
setSaveState('saving')
const res = await setHlApiKey(env, trimmed)
setHlApiKeySet(true, res.masked_key)
setApiKey('')
setSaveState('success')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
if (msg.includes('User rejected') || msg.includes('denied')) {
setErrorMsg('Signature cancelled')
} else {
setErrorMsg(msg.slice(0, 120))
}
setSaveState('error')
}
}
const saveLabel =
saveState === 'signing' ? 'Waiting for signature…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'success' ? '✓ Saved'
: 'Save key'
return (
<div className="w-[300px] shrink-0 flex flex-col gap-3">
{/* Performance card */}
<div className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5">
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-4">
{t('title')} · {t('period')}
</p>
<div className="grid grid-cols-2 gap-3">
{stats.map((stat) => (
<div key={stat.label}>
<p className="text-[10px] text-[#555555] mb-0.5">{stat.label}</p>
<p className={`text-[16px] font-medium ${stat.positive ? 'text-[#4ade80]' : 'text-white'}`}>
{stat.display ?? stat.value}
</p>
<>
{/* Bot status card — dark design */}
<div className="bot-status">
<div className="bot-head">
<h3>
<span style={{ width: 8, height: 8, borderRadius: 999, background: isSubscribed ? 'var(--amber)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
Auto-trader
</h3>
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
</div>
<div className="bot-stats">
<div className="bot-stat">
<div className="k">Net P&amp;L</div>
<div className="v amber">
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
</div>
))}
</div>
<div className="bot-stat">
<div className="k">Win rate</div>
<div className="v up">
{performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'}
</div>
</div>
<div className="bot-stat">
<div className="k">Trades</div>
<div className="v">{performance?.total_trades ?? '—'}</div>
</div>
<div className="bot-stat">
<div className="k">Avg hold</div>
<div className="v">{performance ? fmtHold(performance.avg_hold_seconds) : '—'}</div>
</div>
</div>
<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
View File
@@ -3,56 +3,64 @@
import { useEffect, useRef } from 'react'
import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import Pill from '@/components/ui/Pill'
import PostCards, { MOCK_POSTS } from './PostCards'
import ExpandDetail from './ExpandDetail'
interface ChartPanelProps {
posts?: TrumpPost[]
candles?: Candle[]
externalSelectedId?: number | null
onSelectPost?: (id: number | null) => void
}
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore()
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
const { timeframe } = useDashboardStore()
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<unknown>(null)
const seriesRef = useRef<unknown>(null)
const fittedRef = useRef(false)
// Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing
const postsRef = useRef(posts)
postsRef.current = posts
const selectedPostIdRef = useRef(selectedPostId)
selectedPostIdRef.current = selectedPostId
const selectedPostIdRef = useRef(externalSelectedId)
selectedPostIdRef.current = externalSelectedId
const timeframeRef = useRef(timeframe)
timeframeRef.current = timeframe
const onSelectRef = useRef(onSelectPost)
onSelectRef.current = onSelectPost
const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH']
const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W']
const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null
// Detect current theme for chart colors
function getChartColors() {
const isDark = document.documentElement.dataset.theme === 'dark'
return {
background: isDark ? '#121212' : '#ffffff',
textColor: isDark ? '#666666' : '#888888',
gridColor: isDark ? '#1e1e1e' : '#f0ede8',
borderColor: isDark ? '#2a2a2a' : '#e8e4de',
}
}
// Create chart once on mount
useEffect(() => {
if (!containerRef.current || typeof window === 'undefined') return
let destroyed = false
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
if (destroyed || !containerRef.current) return
const colors = getChartColors()
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 360,
layout: {
background: { color: '#050505' },
textColor: '#555555',
background: { color: colors.background },
textColor: colors.textColor,
},
grid: {
vertLines: { color: '#111111' },
horzLines: { color: '#111111' },
vertLines: { color: colors.gridColor },
horzLines: { color: colors.gridColor },
},
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: '#1a1a1a' },
rightPriceScale: { borderColor: colors.borderColor },
timeScale: {
borderColor: '#1a1a1a',
borderColor: colors.borderColor,
timeVisible: true,
rightOffset: 5,
barSpacing: 10,
@@ -61,47 +69,54 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
handleScale: true,
})
// @ts-expect-error lightweight-charts type
chartRef.current = chart
const series = chart.addCandlestickSeries({
upColor: '#4ade80',
downColor: '#ef4444',
borderUpColor: '#4ade80',
borderDownColor: '#ef4444',
wickUpColor: '#4ade80',
wickDownColor: '#ef4444',
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
})
// @ts-expect-error lightweight-charts type
seriesRef.current = series
// Click on chart: find nearest post marker within 2-bar tolerance
chart.subscribeClick((param: { time?: number | string }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chart.subscribeClick((param: any) => {
if (!param.time) return
const clickTime = typeof param.time === 'number' ? param.time : 0
if (!clickTime) return
const allPosts = postsRef.current
let closest: TrumpPost | null = null
let closestDiff = Infinity
const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
}
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600
const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs
for (const p of allPosts) {
if (!p.published_at) continue
const 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' }}
/>
)
}
-87
View File
@@ -1,87 +0,0 @@
'use client'
import type { TrumpPost } from '@/types'
import Badge from '@/components/ui/Badge'
import { formatPct } from '@/lib/utils'
interface ExpandDetailProps {
post: TrumpPost | null
}
export default function ExpandDetail({ post }: ExpandDetailProps) {
return (
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
post ? 'max-h-[300px] opacity-100 mt-3' : 'max-h-0 opacity-0'
}`}
>
{post && (
<div className="bg-[#0a0a0a] border border-[#f97316] rounded-[10px] p-5">
<div className="flex items-start gap-4">
{/* Post content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<Badge variant={post.source} />
<Badge variant={post.sentiment} />
<span className="text-[11px] text-[#555555]">
{new Date(post.published_at).toLocaleString()}
</span>
</div>
<p className="text-[13px] text-[#e2e8f0] leading-relaxed">{post.text}</p>
</div>
{/* Stats */}
<div className="shrink-0 w-[260px]">
{/* AI Confidence */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] uppercase tracking-wider text-[#555555]">
AI Confidence
</span>
<span className="text-[13px] font-medium text-[#818cf8]">
{post.ai_confidence}%
</span>
</div>
<div className="h-1.5 bg-[#141414] rounded-full">
<div
className="h-full bg-[#818cf8] rounded-full transition-all duration-500"
style={{ width: `${post.ai_confidence}%` }}
/>
</div>
</div>
{/* Price impact */}
{post.price_impact ? (
<div>
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
Price Impact ({post.price_impact.asset})
</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: '5m', value: post.price_impact.m5 },
{ label: '15m', value: post.price_impact.m15 },
{ label: '1h', value: post.price_impact.m1h },
].map((item) => (
<div key={item.label} className="bg-[#050505] border border-[#141414] rounded-lg p-2 text-center">
<p className="text-[10px] text-[#555555] mb-1">{item.label}</p>
<p
className={`text-[13px] font-medium ${
item.value >= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]'
}`}
>
{formatPct(item.value)}
</p>
</div>
))}
</div>
</div>
) : (
<p className="text-[12px] text-[#333333]">No significant price impact detected.</p>
)}
</div>
</div>
</div>
)}
</div>
)
}
-77
View File
@@ -1,77 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import type { BotPerformance } from '@/types'
import { formatPrice, formatPct } from '@/lib/utils'
import { useDashboardStore } from '@/store/dashboard'
interface KpiRowProps {
performance?: BotPerformance
postsCount?: number
}
interface StatCard {
labelKey: string
value: string
change?: string
changePositive?: boolean
}
export default function KpiRow({ performance, postsCount }: KpiRowProps) {
const t = useTranslations('kpi')
const { livePrices } = useDashboardStore()
const stats: StatCard[] = [
{
labelKey: 'btcPrice',
value: formatPrice(livePrices.BTC ?? 94230),
change: livePrices.BTC ? 'live' : '+1.4%',
changePositive: true,
},
{
labelKey: 'ethPrice',
value: formatPrice(livePrices.ETH ?? 1847),
change: livePrices.ETH ? 'live' : '-0.8%',
changePositive: false,
},
{
labelKey: 'postsTracked',
value: postsCount != null ? postsCount.toLocaleString() : '1,247',
change: '+12 today',
changePositive: true,
},
{
labelKey: 'avgMove',
value: performance
? formatPct(performance.net_pnl_usd > 0 ? 2.3 : -2.3)
: formatPct(2.3),
change: 'after Trump posts',
changePositive: true,
},
]
return (
<div className="grid grid-cols-4 gap-4">
{stats.map((stat) => (
<div
key={stat.labelKey}
className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-5"
>
<p className="text-[11px] uppercase tracking-wider text-[#555555] mb-2">
{t(stat.labelKey as 'btcPrice' | 'ethPrice' | 'postsTracked' | 'avgMove')}
</p>
<p className="text-[22px] font-medium text-white leading-none mb-1.5">{stat.value}</p>
{stat.change && (
<p
className={`text-[12px] ${
stat.changePositive ? 'text-[#4ade80]' : 'text-[#ef4444]'
}`}
>
{stat.change}
</p>
)}
</div>
))}
</div>
)
}
+57 -163
View File
@@ -1,181 +1,75 @@
'use client'
import { useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import type { TrumpPost } from '@/types'
import Badge from '@/components/ui/Badge'
import { useDashboardStore } from '@/store/dashboard'
import { formatPct } from '@/lib/utils'
const MOCK_POSTS: TrumpPost[] = [
{
id: 1,
text: 'BITCOIN IS THE FUTURE OF MONEY! We will make America the crypto capital of the world. BIG things coming very soon. The dollar will be STRONGER than ever with Bitcoin as our reserve!',
source: 'truth',
published_at: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
sentiment: 'bullish',
ai_confidence: 91,
relevant: true,
price_impact: {
asset: 'BTC',
m5: 1.2,
m15: 2.8,
m1h: 3.4,
price_at_post: 92800,
},
},
{
id: 2,
text: 'Ethereum and all these so-called "smart contracts" are nothing but a scam by the radical left globalists. Very bad for America. We need REAL money, not fake computer tricks!',
source: 'x',
published_at: new Date(Date.now() - 1000 * 60 * 47).toISOString(),
sentiment: 'bearish',
ai_confidence: 84,
relevant: true,
price_impact: {
asset: 'ETH',
m5: -1.9,
m15: -3.1,
m1h: -4.2,
price_at_post: 1920,
},
},
{
id: 3,
text: "Met with many great business leaders today. Discussed the future of America's economy. Jobs are coming back FAST. Nobody builds like Trump!",
source: 'truth',
published_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(),
sentiment: 'neutral',
ai_confidence: 42,
relevant: false,
price_impact: null,
},
{
id: 4,
text: 'Crypto regulations will be ELIMINATED under my watch. No more Biden weaponization of the SEC against Bitcoin holders. We will have the most CRYPTO FRIENDLY government in history!',
source: 'truth',
published_at: new Date(Date.now() - 1000 * 60 * 210).toISOString(),
sentiment: 'bullish',
ai_confidence: 88,
relevant: true,
price_impact: {
asset: 'BTC',
m5: 0.9,
m15: 2.1,
m1h: 2.7,
price_at_post: 91500,
},
},
]
interface PostCardsProps {
posts?: TrumpPost[]
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—'
const s = n.toFixed(2) + '%'
return n >= 0 ? '+' + s : s
}
function timeAgo(iso: string): string {
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
const m = Math.floor(diff / 60000)
if (m < 1) return 'just now'
if (m < 60) return m + 'm'
const h = Math.floor(m / 60)
if (h < 24) return h + 'h'
return Math.floor(h / 24) + 'd'
}
export default function PostCards({ posts = MOCK_POSTS }: PostCardsProps) {
const t = useTranslations('common')
const { selectedPostId, setSelectedPost } = useDashboardStore()
const selectedRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
function SourceIcon({ source }: { source: string }) {
if (source === 'x') return <div className="src-ico x">𝕏</div>
return <div className="src-ico truth">T</div>
}
// Auto-scroll selected card into view
useEffect(() => {
if (selectedRef.current && scrollRef.current) {
selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
}
}, [selectedPostId])
function SignalPill({ signal }: { signal: string | null }) {
if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
}
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 }