Files
trumpsignal-frontend/app/[locale]/DashboardClient.tsx
T
k d72323b1c6 Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
Big-picture changes since 01be8e7:

New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.

KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.

BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.

Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.

SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.

WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.

OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.

i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.

Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.

PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.

OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.

Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.

PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.

Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).

Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.

SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:53:27 +08:00

543 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData'
import { getPerformance, getPrices, getUserPublic } from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
import ChartPanel from '@/components/dashboard/ChartPanel'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
import SignalMonitor from '@/components/dashboard/SignalMonitor'
import OpenPositions from '@/components/positions/OpenPositions'
interface Props {
initialPosts: TrumpPost[]
}
// ── 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 iso={post.published_at} suffix=" ago" /> · <LocalDateTime iso={post.published_at} /></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="ai-metric-head">
<span>AI confidence</span>
<strong>{post.ai_confidence}%</strong>
</div>
<div className="confidence-bar" style={{ marginBottom: 16 }}>
<div style={{ width: post.ai_confidence + '%' }} />
</div>
{/* AI reasoning */}
{post.ai_reasoning && (
<>
<div className="ai-reasoning-label">AI reasoning</div>
<div className="ai-reasoning-card scroll" style={{ marginBottom: 16 }}>
{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 or a post below to see details
</p>
</div>
)
}
// ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts }: Props) {
const intlLocale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const params = useParams()
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([])
const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
useEffect(() => {
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
setPerformance(undefined)
return
}
// Clear account-scoped bot state immediately when the connected wallet
// changes so a previous wallet's status never leaks into the next one.
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
getUserPublic(address.toLowerCase())
.then((user) => {
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
})
.catch(() => {})
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
useEffect(() => {
if (!isConnected || !address) {
setPerformance(undefined)
return
}
let cancelled = false
;(async () => {
try {
const env = getCachedViewEnvelope('view_performance', address)
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
const data = await getPerformance(address, env)
if (!cancelled) setPerformance(data)
} catch {
if (!cancelled) setPerformance(undefined)
}
})()
return () => { cancelled = true }
}, [address, isConnected, signMessageAsync])
usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price),
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
})
useEffect(() => {
setCandles([])
setChartErr('')
getPrices(asset, timeframe)
.then(c => { setCandles(c); setChartErr('') })
.catch(e => setChartErr(e instanceof Error ? e.message : (isZh ? '价格数据加载失败' : 'Failed to load price data')))
}, [asset, timeframe, chartReload, isZh])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
// Pinned BTC bottom-reversal alert: the rarest, highest-conviction signal.
// Surface the most recent btc_bottom_reversal post fired in the last 21
// days right at the top of the overview so it's never missed.
const btcReversalAlert = (() => {
const cutoff = Date.now() - 21 * 24 * 3600 * 1000
const hits = posts
.filter(p => (p.source || '') === 'btc_bottom_reversal'
&& new Date(p.published_at).getTime() >= cutoff)
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
return hits[0] ?? null
})()
// Show actionable signals first (buy/short), then most recent hold/neutral.
// Cap at 8 total so the list doesn't get too long.
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4)
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
const recentPosts = [...actionable, ...recentOthers].slice(0, 8)
const lastCandle = candles[candles.length - 1]
// Live price beats the most recent candle close — the candle is only
// refreshed when the user changes asset/timeframe, so without WS the number
// is frozen at page load.
const livePrice = livePrices[asset]
const displayPrice = livePrice ?? lastCandle?.close ?? null
// 24-hour change. Picking by candle index is wrong (200×4H = 33 days).
// Find the candle whose `time` is closest to (now 24h) and use its open.
const now = lastCandle ? lastCandle.time : Math.floor(Date.now() / 1000)
const target24h = now - 24 * 3600
let baseline24h: typeof lastCandle | undefined
if (candles.length) {
baseline24h = candles[0]
for (const c of candles) {
if (c.time <= target24h) baseline24h = c
else break
}
}
const priceChange =
displayPrice != null && baseline24h
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
: 0
const totalPosts = posts.length
const todayKey = new Date().toISOString().slice(0, 10)
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const winRate = performance?.win_rate ?? 0
const netPnl = performance?.net_pnl_usd ?? 0
const hasPriceData = candles.length > 0
const hasPerformanceData = Boolean(performance)
return (
<div className="page wide">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
<p className="page-sub">
{`${actionablePosts} actionable signals · Auto-trader ${botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · ${hasPriceData ? 'Live market context' : 'Waiting for market data'}`}
</p>
</div>
<div className="row gap-s">
<span className="chip"><span className="live-dot" />Live feed</span>
<span className="chip">{`${totalPosts} posts tracked`}</span>
</div>
</div>
{/* Pinned BTC bottom-reversal alert — the rarest / highest-conviction
signal. Always sits ABOVE everything so it's never missed. */}
{btcReversalAlert && (
<a
href={`/${locale}/btc`}
style={{
display: 'block', textDecoration: 'none',
border: '1px solid color-mix(in oklab, var(--up) 45%, var(--line))',
background: 'var(--up-soft, rgba(22,163,74,0.10))',
borderRadius: 12, padding: '14px 18px', marginBottom: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: '.04em',
color: 'var(--up)', textTransform: 'uppercase' }}>
BTC bottom-reversal signal
</span>
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px',
borderRadius: 999, background: 'var(--up)', color: '#fff' }}>
conf {Math.round(btcReversalAlert.ai_confidence)}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{timeAgo(btcReversalAlert.published_at)}
</span>
<span style={{ marginLeft: 'auto', fontSize: 12, fontWeight: 700,
color: 'var(--up)' }}>
Open BTC signal
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 6,
lineHeight: 1.45, overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{btcReversalAlert.text}
</div>
</a>
)}
{/* Open positions — what's on the book right now. Renders only when
a subscribed wallet is connected, so guests see the normal feed. */}
<OpenPositions />
{/* 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">{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}</div>
<div className="foot">
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span>
<span>24h</span>
</div>
</div>
<div className="kpi">
<div className="label">Signals today</div>
<div className="value">{signalsToday}</div>
<div className="foot"><span>{`${actionablePosts} actionable total`}</span></div>
</div>
<div className="kpi accent">
<div className="label">30d Net P&L</div>
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div>
</div>
<div className="kpi">
<div className="label">Win rate</div>
<div className="value">{performance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{hasPerformanceData ? `${performance?.total_trades ?? 0} trades` : 'Connect wallet to load your trade history'}</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 }}>
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
</div>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</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>
{chartErr && (
<div style={{ padding: '8px 12px', margin: '0 0 8px', fontSize: 12,
color: 'var(--down)', background: 'var(--down-soft, rgba(220,38,38,.08))',
borderRadius: 8 }}>
{asset} {timeframe} chart failed to load {chartErr}{' '}
<button className="btn ghost" style={{ fontSize: 11, padding: '3px 10px', marginLeft: 6 }}
onClick={() => setChartReload(n => n + 1)}>Retry</button>
</div>
)}
<div className="chart-wrap">
<ChartPanel
posts={posts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={(id) => {
setSelectedDayPosts(null)
setSelectedPostId(id)
}}
onSelectDayPosts={(dayPosts) => {
setSelectedDayPosts(dayPosts)
setSelectedPostId(null)
}}
/>
</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">
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
{`${totalPosts} tracked`}
</span>
</div>
<div className="post-stream">
{recentPosts.map(p => (
<PostRow key={p.id} post={p} />
))}
</div>
</div>
</div>
{/* Right rail */}
<div className="rail">
{/* Day-view: all posts from clicked day */}
{selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}>
<div className="row between" style={{ marginBottom: 14 }}>
<div>
<div className="tiny">{selectedDayPosts.length} posts · this candle</div>
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 2 }}>
<LocalDateTime iso={selectedDayPosts[0].published_at} opts={{ month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }} />
</div>
</div>
<button className="icon-btn" onClick={() => { setSelectedDayPosts(null); setSelectedPostId(null) }}
style={{ width:26, height:26, borderRadius:'50%' }}>
<svg width="10" height="10" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
{selectedDayPosts.map(p => {
const expanded = selectedPostId === p.id
const impact = p.price_impact
function fmtImpactPct(v: number | null | undefined) {
if (v == null || isNaN(v)) return '—'
const s = Math.abs(v).toFixed(2) + '%'
return v >= 0 ? '+' + s : '-' + s
}
return (
<div key={p.id}
style={{ border:`1px solid ${expanded?'var(--amber)':'var(--line)'}`,
borderRadius:'var(--r-sm)', overflow:'hidden', transition:'border-color 0.15s' }}>
{/* collapsed row — always visible */}
<div onClick={() => setSelectedPostId(expanded ? null : p.id)}
style={{ padding:12, cursor:'pointer',
background: expanded ? 'var(--bg-sunk)' : 'transparent' }}>
<div className="row between" style={{ marginBottom:6 }}>
<div className="row gap-s">
<SignalPill signal={p.signal} />
<span className={`chip ${p.sentiment === 'bullish' ? 'up' : p.sentiment === 'bearish' ? 'down' : 'neutral'}`}
style={{ fontSize:10 }}>{p.sentiment}</span>
</div>
<span style={{ fontSize:11, color:'var(--ink-3)' }}><TimeAgo iso={p.published_at} /></span>
</div>
<p style={{ fontSize:12, lineHeight:1.5, margin:'0 0 8px', color:'var(--ink-2)',
...(expanded ? {} : {
display:'-webkit-box', WebkitLineClamp: 3,
WebkitBoxOrient:'vertical', overflow:'hidden'
}) }}>
{p.text}
</p>
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
<div style={{ flex:1, height:3, background:'var(--line)', borderRadius:2, overflow:'hidden' }}>
<div style={{ width:p.ai_confidence+'%', height:'100%', background:'var(--amber)', borderRadius:2 }} />
</div>
<span style={{ fontSize:10, color:'var(--ink-3)', whiteSpace:'nowrap' }}>{p.ai_confidence}% conf</span>
</div>
</div>
{/* expanded detail */}
{expanded && (
<div style={{ padding:'0 12px 12px', borderTop:'1px solid var(--line)' }}>
{p.ai_reasoning && (
<>
<div className="ai-reasoning-label" style={{ margin:'10px 0 8px' }}>AI reasoning</div>
<div className="ai-reasoning-card" style={{ padding: '14px 15px', fontSize: 14, lineHeight: 1.7 }}>
{p.ai_reasoning}
</div>
</>
)}
{impact && (
<>
<div className="tiny" style={{ margin:'10px 0 6px' }}>Price impact · {impact.asset}</div>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6 }}>
{(['m5','m15','m1h'] as const).map((key) => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
const v = impact[key]
const correct = impact[`correct_${key}` as 'correct_m5'|'correct_m15'|'correct_m1h']
return (
<div key={key} style={{ padding:8, background:'var(--bg-sunk)',
borderRadius:'var(--r-sm)', border:'1px solid var(--line)', textAlign:'center' }}>
<div style={{ fontSize:10, color:'var(--ink-3)', marginBottom:3 }}>{label}</div>
<div className={`delta ${(v??0)>=0?'up':'down'}`} style={{ fontSize:13, fontWeight:600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize:10, marginTop:3, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)}
</div>
)
})}
</div>
</div>
) : selectedPost ? (
<PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
) : (
<SelectHint />
)}
{/* Breakout signal monitor */}
<SignalMonitor />
</div>
</div>
</div>
)
}