style(dashboard): unify Macro composite into one card + dark-mode fix

for Performance accent

User flagged the macro index box on the overview page as feeling
disjointed. Reshaped it from three stacked elements (band → track →
scale) into a single bordered card so it reads as ONE component.

  - JSX: wrap the three pieces in <div className="overview-macro-card">
  - CSS: new .overview-macro-card with tone-coloured rim (bull/bear/neutral)
  - Solid neutral-gray filled needle (was a hollow ring — looked like a
    placeholder); tone-coloured background + white inner ring + double
    shadow so it stands out on any gradient position
  - Removed the .overview-score-fill overlay — the gradient already
    encodes the spectrum; layering an opaque fill obscured it near 0
  - Thinner track (14px vs 22px), tighter scale labels, smaller pill
  - Added "TODAY · 8 INDICATORS" stamp next to the title — gives users
    a quick anchor of what they're looking at + freshness

Plus: dark-mode override for .overview-stat-card.accent (the Performance
card). It was using a cream gradient that floated as a glaring
out-of-theme block on dark mode. Mirrored the existing .kpi.accent dark
treatment so it stays visually grouped with the rest of the dashboard.

Also includes the in-flight overview rewrite from the other AI tool
(legacy /btc redirect to /macro, middleware.ts replacing proxy.ts for
Next.js routing, refactored several dashboard panels). TypeScript clean,
production build passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-27 11:25:59 +08:00
parent ee3648c4cb
commit d01adc4790
23 changed files with 1793 additions and 592 deletions
+290 -271
View File
@@ -106,12 +106,37 @@ function formatShortUsd(value: number) {
return `$${(value / 1e3).toFixed(0)}K`
}
function digestSideCopy(side: KolDigestTicker['side']) {
if (side === 'long') return 'Bullish flow'
if (side === 'short') return 'Bearish flow'
return 'Mention flow'
}
function compactChangeCopy(change: KolHoldingChange) {
const verb = change.change_type === 'new_position'
? 'opened'
: change.change_type === 'increased'
? 'added'
: change.change_type === 'decreased'
? 'reduced'
: 'closed'
const size = change.usd_after != null && change.usd_after > 0
? ` ${formatShortUsd(change.usd_after)}`
: ''
const pct = change.pct_change != null
? ` (${change.pct_change > 0 ? '+' : ''}${change.pct_change.toFixed(0)}%)`
: ''
return `@${change.handle} ${verb}${size}${pct}`
}
function DigestWidget({
onTickerClick,
activeTicker,
isZh,
initialDigest = null,
}: {
onTickerClick: (ticker: string) => void
activeTicker?: string | null
isZh: boolean
initialDigest?: KolDigest | null
}) {
@@ -149,11 +174,11 @@ function DigestWidget({
<div style={{ fontSize: 10, color: 'var(--ink-3)',
letterSpacing: 1, textTransform: 'uppercase',
marginBottom: 2 }}>
KOL signal digest
What KOLs are pushing now
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{data
? `${data.post_count} posts · ${data.ticker_count} assets`
? `${data.post_count} posts analysed · ${data.ticker_count} assets with repeat mentions`
: 'Loading…'}
</div>
</div>
@@ -185,9 +210,19 @@ function DigestWidget({
</div>
)}
{!loading && !err && data && data.tickers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
gap: 10,
}}>
{data.tickers.map(t => <DigestTickerChip
key={t.ticker} t={t} isZh={isZh} onClick={() => onTickerClick(t.ticker)} />)}
key={t.ticker}
t={t}
isZh={isZh}
active={activeTicker === t.ticker}
hasActive={!!activeTicker}
onClick={() => onTickerClick(t.ticker)}
/>)}
</div>
)}
</div>
@@ -195,8 +230,8 @@ function DigestWidget({
}
function DigestTickerChip({
t, onClick, isZh,
}: { t: KolDigestTicker; onClick: () => void; isZh: boolean }) {
t, onClick, isZh, active, hasActive,
}: { t: KolDigestTicker; onClick: () => void; isZh: boolean; active: boolean; hasActive: boolean }) {
const sideColor = t.side === 'long' ? '#16a34a'
: t.side === 'short' ? '#dc2626' : '#9ca3af'
const sideLabel = t.side === 'long'
@@ -214,41 +249,69 @@ function DigestTickerChip({
style={{
display: 'inline-flex', flexDirection: 'column',
alignItems: 'flex-start', gap: 4,
padding: '8px 12px', borderRadius: 10,
background: 'var(--bg-sunk)',
border: `1px solid ${sideColor}55`,
padding: '12px 14px', borderRadius: 12,
background: active ? `${sideColor}12` : hasActive ? 'color-mix(in oklab, var(--bg-sunk) 92%, transparent)' : 'var(--bg-sunk)',
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
cursor: 'pointer', textAlign: 'left',
minWidth: 110,
minHeight: 108,
width: '100%',
transform: active ? 'translateY(-2px)' : 'none',
opacity: hasActive && !active ? 0.6 : 1,
transition: 'background 120ms, border-color 120ms, box-shadow 120ms, transform 120ms, opacity 120ms',
}}
aria-pressed={active}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<strong style={{ fontSize: 15, color: 'var(--ink-1)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<strong style={{ fontSize: 18, color: 'var(--ink-1)' }}>
{t.ticker}
</strong>
{active && (
<span style={{
width: 18,
height: 18,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: sideColor,
color: '#fff',
fontSize: 11,
fontWeight: 800,
}}>
</span>
)}
<span style={{
fontSize: 10, fontWeight: 700, color: sideColor,
padding: '1px 5px', borderRadius: 4,
padding: '2px 6px', borderRadius: 999,
background: `${sideColor}22`,
}}>
{actionLabel}
</span>
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}%
<div style={{ fontSize: 12, color: 'var(--ink-2)', fontWeight: 600 }}>
{digestSideCopy(t.side)}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
{t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}% max conviction
</div>
<div style={{
fontSize: 10, color: 'var(--ink-3)',
maxWidth: 160, whiteSpace: 'nowrap',
fontSize: 11, color: 'var(--ink-3)',
maxWidth: '100%', whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{t.kols.map(k => '@' + k).join(', ')}
</div>
{active && (
<div style={{ fontSize: 11, fontWeight: 700, color: sideColor, marginTop: 2 }}>
Filtering the page by {t.ticker}
</div>
)}
</button>
)
}
// ── On-chain changes widget ───────────────────────────────────────────────
const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
new_position: '#16a34a',
increased: '#22c55e',
@@ -256,162 +319,134 @@ const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
closed: '#dc2626',
}
function OnchainWidget({
function WalletCheckWidget({
isZh,
dateLocale,
initialDigest = null,
initialChanges = null,
}: {
isZh: boolean
dateLocale: string
initialChanges?: KolHoldingChange[] | null
}) {
const [data, setData] = useState<KolHoldingChange[]>(initialChanges ?? [])
const [loading, setLoading] = useState(initialChanges === null)
const [days, setDays] = useState(7)
useEffect(() => {
if (initialChanges === null || days !== 7) {
setLoading(true)
}
swrFetch(
`kol-changes-${days}`,
30 * 60_000,
() => getKolChanges({ days }),
fresh => setData(fresh.changes),
)
.then(r => setData(r.changes))
.catch(() => setData([]))
.finally(() => setLoading(false))
}, [days, initialChanges])
return (
<div style={{
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
borderRadius: 12, background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 2 }}>
On-chain positioning
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{loading
? 'Loading…'
: data.length === 0
? 'No tracked wallets yet. Add KOL addresses and this feed will populate automatically.'
: `${data.length} changes`}
</div>
</div>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([1, 7, 30] as const).map(d => (
<button key={d} onClick={() => setDays(d)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}>
{windowLabel(d, isZh)}
</button>
))}
</div>
</div>
{!loading && data.length === 0 && (
<div style={{
padding: '12px 16px', borderRadius: 8,
background: 'var(--bg-sunk)',
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
}}>
<>
Add tracked wallets through <code style={{ fontSize: 11 }}>POST /api/kol/wallets</code>,
or use{' '}
<a href="https://platform.arkhamintelligence.com" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--accent)' }}>Arkham Intelligence</a>{' '}
to discover labeled addresses. Ethereum, Solana, and Hyperliquid are supported.
</>
</div>
)}
{!loading && data.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{data.slice(0, 20).map(c => {
const color = CHANGE_COLOR[c.change_type]
const label = changeLabel(c.change_type, isZh)
return (
<div key={c.id} style={{
display: 'flex', alignItems: 'center', gap: 8,
flexWrap: 'wrap', // ← prevent horizontal overflow on mobile
padding: '8px 12px', borderRadius: 8,
background: 'var(--bg-sunk)',
borderLeft: `3px solid ${color}`,
}}>
<span style={{
fontSize: 10, fontWeight: 700, color,
padding: '2px 6px', borderRadius: 4,
background: `${color}22`, flexShrink: 0,
}}>
{label}
</span>
<strong style={{ fontSize: 14 }}>{c.ticker}</strong>
<span style={{
fontSize: 12, color: 'var(--ink-3)',
flex: '1 1 140px', minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
@{c.handle}
{c.usd_after != null && c.usd_after > 0 &&
` · ${formatShortUsd(c.usd_after)}`}
{c.pct_change != null &&
` (${c.pct_change > 0 ? '+' : ''}${c.pct_change.toFixed(0)}%)`}
</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>
{new Date(c.detected_at).toLocaleDateString(dateLocale)}
</span>
</div>
)
})}
</div>
)}
</div>
)
}
// ── Talks-vs-trades divergence widget ────────────────────────────────────────
const DIR_COLOR = { long: '#16a34a', short: '#dc2626' }
function DivergenceWidget({
isZh,
dateLocale,
initialItems = null,
tickerFilter = null,
}: {
isZh: boolean
dateLocale: string
initialDigest?: KolDigest | null
initialChanges?: KolHoldingChange[] | null
initialItems?: KolDivergence[] | null
tickerFilter?: string | null
}) {
const [data, setData] = useState<KolDivergence[]>(initialItems ?? [])
const [loading, setLoading] = useState(initialItems === null)
const [filter, setFilter] = useState<'all' | 'divergence' | 'alignment'>('all')
const [days, setDays] = useState(30)
const [digest, setDigest] = useState<KolDigest | null>(initialDigest)
const [changes, setChanges] = useState<KolHoldingChange[]>(initialChanges ?? [])
const [items, setItems] = useState<KolDivergence[]>(initialItems ?? [])
const [loading, setLoading] = useState(
initialDigest === null || initialChanges === null || initialItems === null,
)
const [days, setDays] = useState(7)
useEffect(() => {
if (initialItems === null || filter !== 'all' || days !== 30) {
setLoading(true)
}
swrFetch(
`kol-divergence-${filter}-${days}`,
30 * 60_000,
() => getKolDivergence({ signal_type: filter === 'all' ? undefined : filter, days }),
fresh => setData(fresh.items),
)
.then(r => setData(r.items))
.catch(() => setData([]))
setLoading(true)
Promise.all([
swrFetch(
`kol-digest-${days}`,
15 * 60_000,
() => getKolDigest(days),
fresh => setDigest(fresh),
),
swrFetch(
`kol-changes-${days}`,
30 * 60_000,
() => getKolChanges({ days }),
fresh => setChanges(fresh.changes),
),
swrFetch(
`kol-divergence-all-${days}`,
30 * 60_000,
() => getKolDivergence({ days }),
fresh => setItems(fresh.items),
),
])
.then(([nextDigest, nextChanges, nextItems]) => {
setDigest(nextDigest)
setChanges(nextChanges.changes)
setItems(nextItems.items)
})
.catch(() => {
setDigest(null)
setChanges([])
setItems([])
})
.finally(() => setLoading(false))
}, [days, filter, initialItems])
}, [days])
const divCount = data.filter(d => d.signal_type === 'divergence').length
const aliCount = data.filter(d => d.signal_type === 'alignment').length
const rows = useMemo(() => {
const sourceTickers = digest?.tickers ?? []
const filteredTickers = tickerFilter
? sourceTickers.filter(t => t.ticker === tickerFilter)
: sourceTickers
return filteredTickers.map(t => {
const relatedChanges = changes
.filter(c => c.ticker === t.ticker)
.sort((a, b) => +new Date(b.detected_at) - +new Date(a.detected_at))
const relatedItems = items
.filter(i => i.ticker === t.ticker)
.sort((a, b) => +new Date(b.onchain_at) - +new Date(a.onchain_at))
const divergenceCount = relatedItems.filter(i => i.signal_type === 'divergence').length
const alignmentCount = relatedItems.filter(i => i.signal_type === 'alignment').length
const latestChange = relatedChanges[0] ?? null
const latestMatch = relatedItems[0] ?? null
const verdict =
divergenceCount > 0 && alignmentCount === 0
? {
label: 'Mismatch',
color: '#f59e0b',
bg: '#f59e0b22',
note: 'Wallet action disagrees with the public pitch.',
}
: alignmentCount > 0 && divergenceCount === 0
? {
label: 'Aligned',
color: '#22c55e',
bg: '#22c55e22',
note: 'Wallet action supports the public pitch.',
}
: alignmentCount > 0 && divergenceCount > 0
? {
label: 'Mixed',
color: '#a855f7',
bg: '#a855f722',
note: 'Different KOLs or time windows point in both directions.',
}
: latestChange
? {
label: 'Watch',
color: CHANGE_COLOR[latestChange.change_type],
bg: `${CHANGE_COLOR[latestChange.change_type]}22`,
note: 'Wallet movement exists, but no clean talk-vs-wallet match yet.',
}
: {
label: 'No wallet proof',
color: '#9ca3af',
bg: '#9ca3af22',
note: 'No tracked wallet move linked to this asset in the selected window.',
}
return {
ticker: t.ticker,
talkLine: `${t.kol_count} KOLs pushing ${t.side === 'short' ? 'bearish' : t.side === 'long' ? 'bullish' : 'mixed'} flow`,
speakers: t.kols.map(k => '@' + k).join(', '),
conviction: `${(t.max_conviction * 100).toFixed(0)}% max conviction`,
verdict,
divergenceCount,
alignmentCount,
latestChange,
latestMatch,
}
})
}, [changes, digest, items, tickerFilter])
const totalMismatch = rows.filter(r => r.verdict.label === 'Mismatch').length
const totalAligned = rows.filter(r => r.verdict.label === 'Aligned').length
return (
<div style={{
@@ -419,7 +454,6 @@ function DivergenceWidget({
borderRadius: 12, background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}>
{/* Header */}
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'flex-start', flexWrap: 'wrap', gap: 8, marginBottom: 12,
@@ -427,126 +461,115 @@ function DivergenceWidget({
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
textTransform: 'uppercase', marginBottom: 2 }}>
Talks vs trades
Talks vs wallets
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{loading
? 'Loading…'
: data.length === 0
? 'No cross-signals yet. This feed needs both a post and a wallet move.'
: `⚠️ ${divCount} divergences · ${aliCount} alignments`}
: rows.length === 0
? 'No overlapping talk and wallet evidence in this window.'
: `${totalAligned} aligned · ${totalMismatch} mismatched across the assets KOLs are pushing`}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap',
maxWidth: '100%', overflowX: 'auto' }}>
{/* filter tabs */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{(['all', 'divergence', 'alignment'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`nav-tab ${filter === f ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
whiteSpace: 'nowrap' }}>
{f === 'all'
? (isZh ? '全部' : 'All')
: f === 'divergence'
? (isZh ? '⚠️ 不符' : '⚠️ Divergence')
: (isZh ? '✅ 一致' : '✅ Alignment')}
</button>
))}
</div>
{/* day tabs */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([7, 30, 90] as const).map(d => (
<button key={d} onClick={() => setDays(d)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
whiteSpace: 'nowrap' }}>
{windowLabel(d, isZh)}
</button>
))}
</div>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{([1, 7, 30] as const).map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
>
{windowLabel(d, isZh)}
</button>
))}
</div>
</div>
{/* Rows */}
{!loading && data.length === 0 && (
{!loading && rows.length === 0 && (
<div style={{
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.7,
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
}}>
<>
This module needs two pieces of evidence: a directional KOL post extracted by AI,
and a matching wallet position change in the same token within ±7 days.<br />
The wallet snapshot layer is still early, so signal count will grow with daily data.
</>
This panel only answers one question: when KOLs talk about an asset, do tracked wallets confirm it?
If there is no match yet, the asset stays in watch mode until wallet evidence appears.
</div>
)}
{!loading && data.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.slice(0, 20).map(d => {
const meta = divergenceMeta(d.signal_type, isZh)
const dirColor = DIR_COLOR[d.direction]
return (
<div key={d.id} style={{
padding: '10px 14px', borderRadius: 10,
background: 'var(--bg-sunk)',
borderLeft: `3px solid ${meta.color}`,
display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center',
}}
title={meta.tip}
>
{/* Signal badge */}
<span style={{
fontSize: 10, fontWeight: 700, color: meta.color,
padding: '2px 7px', borderRadius: 4, background: meta.bg,
flexShrink: 0, whiteSpace: 'nowrap',
}}>
{meta.label}
</span>
{/* Ticker + direction */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<strong style={{ fontSize: 15 }}>{d.ticker}</strong>
{!loading && rows.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{rows.map(row => (
<div key={row.ticker} style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 1.4fr) auto',
gap: 12,
alignItems: 'start',
padding: '14px 16px',
borderRadius: 12,
background: 'var(--bg-sunk)',
border: '1px solid var(--border)',
}}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 18 }}>{row.ticker}</strong>
<span style={{
fontSize: 11, fontWeight: 700, color: dirColor,
padding: '1px 6px', borderRadius: 4,
background: `${dirColor}22`,
fontSize: 10, fontWeight: 700, color: row.verdict.color,
padding: '3px 8px', borderRadius: 999, background: row.verdict.bg,
}}>
{directionLabel(d.direction, isZh)}
{row.verdict.label}
</span>
</div>
{/* KOL + actions — flex: 1 1 0 with minWidth:0 so it shrinks on narrow screens */}
<div style={{ flex: '1 1 140px', fontSize: 12, color: 'var(--ink-2)',
minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ color: 'var(--ink-3)' }}>@{d.handle}</span>
{' '}
<span style={{ color: 'var(--ink-3)' }}>
{postActionLabel(d.post_action, isZh)}
</span>
<span style={{ color: 'var(--ink-3)', margin: '0 6px' }}>{isZh ? 'vs' : 'vs'}</span>
<span style={{ color: 'var(--ink-3)' }}>
{chainActionLabel(d.onchain_action, isZh)}
</span>
{d.usd_after != null && d.usd_after > 0 &&
<span style={{ color: 'var(--ink-2)', marginLeft: 6 }}>
{formatShortUsd(d.usd_after)}
</span>}
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
{row.talkLine}
</div>
{/* Time info */}
<div style={{ fontSize: 11, color: 'var(--ink-3)',
flexShrink: 0, textAlign: 'right' }}>
<div>
{`${d.days_apart?.toFixed(1) ?? '?'} days apart`}
</div>
<div>{new Date(d.onchain_at).toLocaleDateString(dateLocale)}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row.speakers}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>
{row.conviction}
</div>
</div>
)
})}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 5 }}>
Wallet evidence
</div>
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
{row.latestMatch
? `@${row.latestMatch.handle} ${chainActionLabel(row.latestMatch.onchain_action, isZh).toLowerCase()}`
: row.latestChange
? compactChangeCopy(row.latestChange)
: 'No tracked move yet'}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5 }}>
{row.latestMatch
? `${row.verdict.note} ${row.latestMatch.usd_after ? `Size ${formatShortUsd(row.latestMatch.usd_after)}.` : ''}`
: row.verdict.note}
</div>
{(row.divergenceCount > 0 || row.alignmentCount > 0) && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
{row.alignmentCount > 0 && (
<span style={{ fontSize: 11, color: '#22c55e' }}>
{row.alignmentCount} alignment
</span>
)}
{row.divergenceCount > 0 && (
<span style={{ fontSize: 11, color: '#f59e0b' }}>
{row.divergenceCount} divergence
</span>
)}
</div>
)}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', textAlign: 'right', whiteSpace: 'nowrap' }}>
{row.latestMatch
? new Date(row.latestMatch.onchain_at).toLocaleDateString(dateLocale)
: row.latestChange
? new Date(row.latestChange.detected_at).toLocaleDateString(dateLocale)
: 'No date'}
</div>
</div>
))}
</div>
)}
</div>
@@ -804,10 +827,8 @@ export default function KolPage({
<div>
<h1 className="page-title">{isZh ? 'KOL 信号' : 'KOL Signals'}</h1>
<PageHint count={`${posts.length} posts`}>
19 crypto KOLs' essays and podcasts ingested daily, AI extracts every
ticker call, then cross-checked against the same KOLs' on-chain
wallets. Catches "talks vs trades" when their wallet says the
opposite of their tweets.
This page answers two questions only: which assets KOLs are pushing now,
and whether tracked wallets confirm or contradict that public stance.
</PageHint>
</div>
</div>
@@ -815,20 +836,18 @@ export default function KolPage({
<DigestWidget
isZh={isZh}
initialDigest={initialDigest}
activeTicker={tickerFilter}
onTickerClick={(sym) =>
setTickerFilter(prev => prev === sym ? null : sym)}
/>
<OnchainWidget
<WalletCheckWidget
isZh={isZh}
dateLocale={dateLocale}
initialDigest={initialDigest}
initialChanges={initialChanges}
/>
<DivergenceWidget
isZh={isZh}
dateLocale={dateLocale}
initialItems={initialDivergence}
tickerFilter={tickerFilter}
/>
{tickerFilter && (