'use client' import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { useLocale } from 'next-intl' import type { KolPostSummary, KolPostDetail, KolTicker, KolDigest, KolDigestTicker, KolHoldingChange, KolDivergence, } from '@/types' import { getKolPosts, getKolPost, getKolDigest, getKolChanges, getKolDivergence } from '@/lib/api' import { swrFetch } from '@/lib/cache' import PageHint from '@/components/ui/PageHint' /** * KOL feed — independent module. Lists analyzed posts from tracked KOLs * (Substack today, Twitter later). Click a row to open the full post body * + structured ticker calls inline. */ const ACTION_COLOR: Record = { buy: '#16a34a', bullish: '#16a34a', sell: '#dc2626', bearish: '#dc2626', mention: '#9ca3af', } const WINDOW_OPTIONS = [1, 7, 30, 90] as const function actionLabel(action: KolTicker['action'], isZh: boolean) { const labels: Record = { buy: 'BUY', sell: 'SELL', bullish: 'Bullish', bearish: 'Bearish', mention: 'Mention', } return labels[action] } function windowLabel(days: number, isZh: boolean) { if (days === 1) return isZh ? '今日' : 'Today' return isZh ? `近 ${days} 日` : `Last ${days}d` } function changeLabel(changeType: KolHoldingChange['change_type'], isZh: boolean) { const labels: Record = { new_position: 'New', increased: 'Added', decreased: 'Reduced', closed: 'Closed', } return labels[changeType] } function divergenceMeta(signalType: KolDivergence['signal_type'], isZh: boolean) { return signalType === 'divergence' ? { label: '⚠️ Divergence', color: '#f59e0b', bg: '#f59e0b22', tip: 'Public stance and wallet action disagree. Follow the wallet, not the words.', } : { label: '✅ Alignment', color: '#22c55e', bg: '#22c55e22', tip: 'Public stance and wallet action align, which increases confidence.', } } function directionLabel(direction: KolDivergence['direction'], isZh: boolean) { if (direction === 'long') return 'Long ▲' return 'Short ▼' } function postActionLabel(action: string, isZh: boolean) { const labels: Record = { buy: 'BUY', sell: 'SELL', bullish: 'Bullish post', bearish: 'Bearish post', } return labels[action] ?? action } function chainActionLabel(action: string, isZh: boolean) { const labels: Record = { new_position: 'Opened on-chain', increased: 'Added on-chain', decreased: 'Reduced on-chain', closed: 'Closed on-chain', } return labels[action] ?? action } function sourceLabel(source: string, isZh: boolean) { if (source === 'substack') return 'Substack' if (source === 'podcast') return 'Podcast' if (source === 'twitter') return 'X' return source } function formatShortUsd(value: number) { if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M` return `$${(value / 1e3).toFixed(0)}K` } function DigestWidget({ onTickerClick, isZh, initialDigest = null, }: { onTickerClick: (ticker: string) => void isZh: boolean initialDigest?: KolDigest | null }) { const [days, setDays] = useState(7) const [data, setData] = useState(initialDigest) const [loading, setLoading] = useState(initialDigest === null) const [err, setErr] = useState('') useEffect(() => { if (initialDigest === null || days !== (initialDigest?.window_days ?? 7)) { setLoading(true) } swrFetch( `kol-digest-${days}`, 15 * 60_000, () => getKolDigest(days), fresh => setData(fresh), ) .then(d => { setData(d); setErr('') }) .catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load digest'))) .finally(() => setLoading(false)) }, [days, initialDigest, isZh]) return (
KOL signal digest
{data ? `${data.post_count} posts · ${data.ticker_count} assets` : 'Loading…'}
{WINDOW_OPTIONS.map(daysOption => ( ))}
{loading && (
Loading…
)} {err && (
{err}
)} {!loading && !err && data && data.tickers.length === 0 && (
No actionable calls in this window. Try a longer range.
)} {!loading && !err && data && data.tickers.length > 0 && (
{data.tickers.map(t => onTickerClick(t.ticker)} />)}
)}
) } function DigestTickerChip({ t, onClick, isZh, }: { t: KolDigestTicker; onClick: () => void; isZh: boolean }) { const sideColor = t.side === 'long' ? '#16a34a' : t.side === 'short' ? '#dc2626' : '#9ca3af' const sideLabel = t.side === 'long' ? 'Long' : t.side === 'short' ? 'Short' : 'Mention' const actionLabel = t.dominant_action === 'buy' ? 'BUY' : t.dominant_action === 'sell' ? 'SELL' : sideLabel return ( ) } // ── On-chain changes widget ─────────────────────────────────────────────── const CHANGE_COLOR: Record = { new_position: '#16a34a', increased: '#22c55e', decreased: '#f59e0b', closed: '#dc2626', } function OnchainWidget({ isZh, dateLocale, initialChanges = null, }: { isZh: boolean dateLocale: string initialChanges?: KolHoldingChange[] | null }) { const [data, setData] = useState(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 (
On-chain positioning
{loading ? 'Loading…' : data.length === 0 ? 'No tracked wallets yet. Add KOL addresses and this feed will populate automatically.' : `${data.length} changes`}
{([1, 7, 30] as const).map(d => ( ))}
{!loading && data.length === 0 && (
<> Add tracked wallets through POST /api/kol/wallets, or use{' '} Arkham Intelligence{' '} to discover labeled addresses. Ethereum, Solana, and Hyperliquid are supported.
)} {!loading && data.length > 0 && (
{data.slice(0, 20).map(c => { const color = CHANGE_COLOR[c.change_type] const label = changeLabel(c.change_type, isZh) return (
{label} {c.ticker} @{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)}%)`} {new Date(c.detected_at).toLocaleDateString(dateLocale)}
) })}
)}
) } // ── Talks-vs-trades divergence widget ──────────────────────────────────────── const DIR_COLOR = { long: '#16a34a', short: '#dc2626' } function DivergenceWidget({ isZh, dateLocale, initialItems = null, }: { isZh: boolean dateLocale: string initialItems?: KolDivergence[] | null }) { const [data, setData] = useState(initialItems ?? []) const [loading, setLoading] = useState(initialItems === null) const [filter, setFilter] = useState<'all' | 'divergence' | 'alignment'>('all') const [days, setDays] = useState(30) 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([])) .finally(() => setLoading(false)) }, [days, filter, initialItems]) const divCount = data.filter(d => d.signal_type === 'divergence').length const aliCount = data.filter(d => d.signal_type === 'alignment').length return (
{/* Header */}
Talks vs trades
{loading ? 'Loading…' : data.length === 0 ? 'No cross-signals yet. This feed needs both a post and a wallet move.' : `⚠️ ${divCount} divergences · ✅ ${aliCount} alignments`}
{/* filter tabs */}
{(['all', 'divergence', 'alignment'] as const).map(f => ( ))}
{/* day tabs */}
{([7, 30, 90] as const).map(d => ( ))}
{/* Rows */} {!loading && data.length === 0 && (
<> 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.
The wallet snapshot layer is still early, so signal count will grow with daily data.
)} {!loading && data.length > 0 && (
{data.slice(0, 20).map(d => { const meta = divergenceMeta(d.signal_type, isZh) const dirColor = DIR_COLOR[d.direction] return (
{/* Signal badge */} {meta.label} {/* Ticker + direction */}
{d.ticker} {directionLabel(d.direction, isZh)}
{/* KOL + actions — flex: 1 1 0 with minWidth:0 so it shrinks on narrow screens */}
@{d.handle} {' '} {postActionLabel(d.post_action, isZh)} {isZh ? 'vs' : 'vs'} {chainActionLabel(d.onchain_action, isZh)} {d.usd_after != null && d.usd_after > 0 && {formatShortUsd(d.usd_after)} }
{/* Time info */}
{`${d.days_apart?.toFixed(1) ?? '?'} days apart`}
{new Date(d.onchain_at).toLocaleDateString(dateLocale)}
) })}
)}
) } function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) { if (!tickers.length) { return } return (
{tickers.map((t, i) => ( {t.ticker} {actionLabel(t.action, isZh)} {(t.conviction * 100).toFixed(0)} ))}
) } function PostDetail({ post, onClose, isZh, dateLocale, }: { post: KolPostDetail; onClose: () => void; isZh: boolean; dateLocale: string }) { // Lock body scroll while open useEffect(() => { const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = prev } }, []) const postSourceLabel = sourceLabel(post.source, isZh) const node = (
e.stopPropagation()} style={{ background: 'var(--bg)', borderRadius: 14, maxWidth: 820, width: '100%', padding: 'clamp(16px, 4vw, 28px)', border: '1px solid var(--line)', boxShadow: '0 24px 80px rgba(0,0,0,0.6)', }} > {/* ── Header: 标题 + 关闭 + 查看原帖 ── */}
@{post.kol_handle} · {postSourceLabel} · {new Date(post.published_at).toLocaleString(dateLocale)}

{post.title || (isZh ? '(无标题)' : '(Untitled)')}

{/* 右上角按钮组 */}
{/* ── AI 摘要 ── */} {post.summary && (
{isZh ? 'AI 摘要' : 'AI summary'}
{post.summary}
)} {/* ── 喊单标的 ── */} {post.tickers.length > 0 && (
{isZh ? '提取标的' : 'Extracted assets'}
{post.tickers.map((t, i) => (
{t.ticker} {actionLabel(t.action, isZh)} {isZh ? '信心分' : 'Conviction'} {(t.conviction * 100).toFixed(0)}%
“{t.quote}”
))}
)} {/* ── 原文 ── */}
{isZh ? '原文摘录' : 'Source excerpt'}
{post.raw_text}
) if (typeof document === 'undefined') return null return createPortal(node, document.body) } interface KolPageProps { initialPosts?: KolPostSummary[] | null initialDigest?: KolDigest | null initialChanges?: KolHoldingChange[] | null initialDivergence?: KolDivergence[] | null } export default function KolPage({ initialPosts = null, initialDigest = null, initialChanges = null, initialDivergence = null, }: KolPageProps) { const locale = useLocale() const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json const dateLocale = isZh ? 'zh-CN' : 'en-US' const [posts, setPosts] = useState(initialPosts ?? []) const [loading, setLoading] = useState(initialPosts === null) const [err, setErr] = useState('') const [openPost, setOpenPost] = useState(null) const [handleFilter, setHandleFilter] = useState('all') const [tickerFilter, setTickerFilter] = useState(null) useEffect(() => { swrFetch( 'kol-posts-100', 15 * 60_000, // 15 min TTL — KOL feed is ingested daily () => getKolPosts({ limit: 100 }), fresh => setPosts(fresh.items), ) .then(r => { setPosts(r.items); setErr('') }) .catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load posts'))) .finally(() => setLoading(false)) }, [isZh]) const handles = useMemo(() => { const set = new Set(posts.map(p => p.kol_handle)) return ['all', ...Array.from(set)] }, [posts]) const filtered = useMemo(() => { let out = handleFilter === 'all' ? posts : posts.filter(p => p.kol_handle === handleFilter) if (tickerFilter) { out = out.filter(p => p.tickers.some(t => t.ticker === tickerFilter)) } return out }, [posts, handleFilter, tickerFilter]) async function openDetail(id: number) { try { const detail = await getKolPost(id) setOpenPost(detail) } catch (e) { setErr(e instanceof Error ? e.message : (isZh ? '详情加载失败' : 'Failed to load detail')) } } return (

{isZh ? 'KOL 信号' : 'KOL Signals'}

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.
setTickerFilter(prev => prev === sym ? null : sym)} /> {tickerFilter && (
{isZh ? '当前筛选标的:' : 'Filtered asset:'} {tickerFilter}
)} {handles.length > 2 && (
{handles.map(h => ( ))}
)} {loading && } {err &&
{isZh ? `错误:${err}` : `Error: ${err}`}
} {!loading && !err && (
{filtered.length === 0 && (
{isZh ? '当前没有数据,等待下一轮抓取任务(每天 01:15 UTC)。' : 'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
)} {filtered.map(p => (
openDetail(p.id)} style={{ padding: 14, borderRadius: 10, background: 'var(--bg-card)', border: '1px solid var(--border)', cursor: 'pointer', transition: 'border-color 0.15s', }} onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--accent)')} onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')} > {/* 顶部:作者 + 右侧操作区 */}
@{p.kol_handle} · {sourceLabel(p.source, isZh)} · {new Date(p.published_at).toLocaleDateString(dateLocale)}
{p.title || (isZh ? '(无标题)' : '(Untitled)')}
{p.summary && (
{p.summary}
)}
))}
)} {openPost && setOpenPost(null)} isZh={isZh} dateLocale={dateLocale} />}
) } function KolPostsSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
) }