'use client' import { useEffect, useMemo, useRef, 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' import Pagination from '@/components/ui/Pagination' /** * 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', reduce: '#f59e0b', 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', reduce: 'Reduce', bullish: 'Bullish', bearish: 'Bearish', mention: 'Mention', } return labels[action] } function windowLabel(days: number, isZh: boolean) { if (days === 1) return 'Today' return `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', reduce: 'Reducing', 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 === 'blog') return 'Blog' 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 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, days, }: { onTickerClick: (ticker: string) => void activeTicker?: string | null isZh: boolean initialDigest?: KolDigest | null days: number }) { const [data, setData] = useState(initialDigest) const [loading, setLoading] = useState(initialDigest === null) const [err, setErr] = useState('') useEffect(() => { if (initialDigest === null || days !== (initialDigest?.window_days ?? 30)) { 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 : ('Failed to load digest'))) .finally(() => setLoading(false)) }, [days, initialDigest, isZh]) return (
{/* Compact meta line — no header label needed, context is obvious */} {data && !loading && (
{data.post_count} posts · {data.ticker_count} assets
)} {loading && (
Loading…
)} {err && (
{err}
)} {!loading && !err && data && data.tickers.length === 0 && (
No repeat mentions in this window.
)} {!loading && !err && data && data.tickers.length > 0 && (
{data.tickers.map(t => onTickerClick(t.ticker)} />)}
)}
) } function DigestTickerChip({ 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' ? 'Long' : t.side === 'short' ? 'Short' : 'Mention' const actionLabel = t.dominant_action === 'buy' ? 'BUY' : t.dominant_action === 'sell' ? 'SELL' : sideLabel return ( ) } const CHANGE_COLOR: Record = { new_position: '#16a34a', increased: '#22c55e', decreased: '#f59e0b', closed: '#dc2626', } function WalletCheckWidget({ isZh, dateLocale, initialDigest = null, initialChanges = null, initialItems = null, tickerFilter = null, days, }: { isZh: boolean dateLocale: string days: number initialDigest?: KolDigest | null initialChanges?: KolHoldingChange[] | null initialItems?: KolDivergence[] | null tickerFilter?: string | null }) { const [digest, setDigest] = useState(initialDigest) const [changes, setChanges] = useState(initialChanges ?? []) const [items, setItems] = useState(initialItems ?? []) const [loading, setLoading] = useState( initialDigest === null || initialChanges === null || initialItems === null, ) // `days` now comes from the parent — no local state needed. // genRef lets in-flight fetches detect they were superseded by a newer // days/filter change before writing back to state. const walletCheckGenRef = useRef(0) useEffect(() => { const gen = ++walletCheckGenRef.current setLoading(true) Promise.all([ swrFetch( `kol-digest-${days}`, 15 * 60_000, () => getKolDigest(days), fresh => { if (gen === walletCheckGenRef.current) setDigest(fresh) }, ), swrFetch( `kol-changes-${days}`, 30 * 60_000, () => getKolChanges({ days }), fresh => { if (gen === walletCheckGenRef.current) setChanges(fresh.changes) }, ), swrFetch( `kol-divergence-all-${days}`, 30 * 60_000, () => getKolDivergence({ days }), fresh => { if (gen === walletCheckGenRef.current) setItems(fresh.items) }, ), ]) .then(([nextDigest, nextChanges, nextItems]) => { if (gen !== walletCheckGenRef.current) return setDigest(nextDigest) setChanges(nextChanges.changes) setItems(nextItems.items) }) .catch(() => { if (gen !== walletCheckGenRef.current) return setDigest(null) setChanges([]) setItems([]) }) .finally(() => { if (gen === walletCheckGenRef.current) setLoading(false) }) }, [days]) 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 (
{/* Compact summary — no uppercase header, no redundant label */} {!loading && rows.length > 0 && (
{totalAligned > 0 && ✓ {totalAligned} aligned} {totalMismatch > 0 && ⚠ {totalMismatch} mismatch} {totalAligned === 0 && totalMismatch === 0 && 'Wallet evidence pending'}
)} {!loading && rows.length === 0 && (
No wallet overlap for this window — try a wider range.
)} {!loading && rows.length > 0 && (
{rows.map(row => (
{row.ticker} {row.verdict.label}
{row.talkLine}
{row.speakers}
{row.conviction}
{/* No "WALLET EVIDENCE" label — context is clear from layout */}
{row.latestMatch ? `@${row.latestMatch.handle} ${chainActionLabel(row.latestMatch.onchain_action, isZh).toLowerCase()}` : row.latestChange ? compactChangeCopy(row.latestChange) : 'No tracked move yet'}
{/* Drop boilerplate "Wallet action supports/disagrees the public pitch." — the verdict badge already says Aligned / Mismatch. Just show the size. */} {row.latestMatch?.usd_after ? `Size ${formatShortUsd(row.latestMatch.usd_after)}` : row.verdict.label === 'No wallet proof' ? 'No tracked wallet move in this window' : ''}
{(row.divergenceCount > 0 || row.alignmentCount > 0) && (
{row.alignmentCount > 0 && ( {row.alignmentCount} alignment )} {row.divergenceCount > 0 && ( {row.divergenceCount} divergence )}
)}
{row.latestMatch ? new Date(row.latestMatch.onchain_at).toLocaleDateString(dateLocale) : row.latestChange ? new Date(row.latestChange.detected_at).toLocaleDateString(dateLocale) : 'No date'}
))}
)}
) } 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 || post.summary || (post.source === 'twitter' ? '(Tweet)' : '(Untitled)')}

{/* 右上角按钮组 */}
{/* ── AI 摘要 ── */} {post.summary && (
{'AI summary'}
{post.summary}
)} {/* ── 喊单标的 ── */} {post.tickers.length > 0 && (
{'Extracted assets'}
{post.tickers.map((t, i) => (
{t.ticker} {actionLabel(t.action, isZh)} {'Conviction'} {(t.conviction * 100).toFixed(0)}%
“{t.quote}”
))}
)} {/* ── 原文 ── */}
{'Source excerpt'}
{post.raw_text}
) if (typeof document === 'undefined') return null return createPortal(node, document.body) } type SourceFilter = 'all' | 'substack' | 'blog' | 'podcast' | 'twitter' const KOL_SERVER_PAGE_SIZE = 50 interface KolPageProps { initialPosts?: KolPostSummary[] | null initialTotal?: number initialDigest?: KolDigest | null initialChanges?: KolHoldingChange[] | null initialDivergence?: KolDivergence[] | null } export default function KolPage({ initialPosts = null, initialTotal = 0, initialDigest = null, initialChanges = null, initialDivergence = null, }: KolPageProps) { const dateLocale = 'en-US' const isZh = false // i18n shelved — passed to helper fns that still take the param const [posts, setPosts] = useState(initialPosts ?? []) const [serverTotal, setServerTotal] = useState(initialTotal) const [loading, setLoading] = useState(initialPosts === null) const [err, setErr] = useState('') const [openPost, setOpenPost] = useState(null) const [sourceFilter, setSourceFilter] = useState('all') const [signalsOnly, setSignalsOnly] = useState(false) const [tickerFilter, setTickerFilter] = useState(null) const [serverPage, setServerPage] = useState(1) // Unified time window — controls both DigestWidget and WalletCheckWidget. // Default 30d, not 7d: KOL ingestion is daily and sparse, so a 7d window is // frequently empty (e.g. 7d=0 while 30d=196) and the first paint showed // "No data yet" for modules that actually have data. const [kolDays, setKolDays] = useState(30) const postsGenRef = useRef(0) useEffect(() => { const gen = ++postsGenRef.current setLoading(true) const src = sourceFilter === 'all' ? undefined : sourceFilter const cacheKey = `kol-posts-${sourceFilter}-${signalsOnly}-${serverPage}-${tickerFilter ?? ''}-${kolDays}` swrFetch( cacheKey, 15 * 60_000, // 15 min TTL — KOL feed is ingested daily () => getKolPosts({ limit: KOL_SERVER_PAGE_SIZE, page: serverPage, source: src, signalsOnly, ticker: tickerFilter ?? undefined, days: kolDays, }), fresh => { if (gen === postsGenRef.current) { setPosts(fresh.items); setServerTotal(fresh.total ?? 0) } }, ) .then(r => { if (gen !== postsGenRef.current) return setPosts(r.items); setServerTotal(r.total ?? 0); setErr('') }) .catch(e => { if (gen === postsGenRef.current) setErr(e instanceof Error ? e.message : ('Failed to load posts')) }) .finally(() => { if (gen === postsGenRef.current) setLoading(false) }) }, [sourceFilter, signalsOnly, serverPage, tickerFilter, kolDays]) // posts are already filtered server-side; no client-side re-filter needed const filtered = posts const totalServerPages = Math.max(1, Math.ceil(serverTotal / KOL_SERVER_PAGE_SIZE)) async function openDetail(id: number) { try { const detail = await getKolPost(id) setOpenPost(detail) } catch (e) { setErr(e instanceof Error ? e.message : ('Failed to load detail')) } } function changeSource(src: SourceFilter) { setSourceFilter(src) setServerPage(1) setTickerFilter(null) } return (

{'KOL Signals'}

Arthur Hayes, Delphi, Bankless, and 22 more — their public calls vs what their wallets actually do.
{/* Single time filter controlling both widgets */}
{WINDOW_OPTIONS.map(d => ( ))}
{/* Source filter — All / Substack / X + Signals only toggle */}
{(['all', 'substack', 'blog', 'podcast', 'twitter'] as SourceFilter[]).map(src => { const label = src === 'all' ? 'All' : src === 'substack' ? 'Substack' : src === 'blog' ? 'Blog' : src === 'podcast' ? 'Podcast' : 'X (Twitter)' const active = sourceFilter === src return ( ) })}
{ setTickerFilter(prev => prev === sym ? null : sym) setServerPage(1) }} /> {tickerFilter && (
{'Filtered asset:'} {tickerFilter}
)} {/* KOL handle filter removed — 25 handles as a horizontal tab bar overflows on every screen size and most users filter by asset (ticker), not by person. Handle is visible on each post card. */}
All posts · paginated feed
{loading && } {err &&
{`Error: ${err}`}
} {!loading && !err && (
{filtered.length === 0 && (
{'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(--surface)', border: '1px solid var(--line)', cursor: 'pointer', transition: 'border-color 0.15s', }} onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--amber)')} onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--line)')} > {/* 顶部:作者 + 右侧操作区 */}
@{p.kol_handle} · {sourceLabel(p.source, isZh)} · {new Date(p.published_at).toLocaleDateString(dateLocale)}
{p.tier === 'trade_signal' && ( SIGNAL )} {p.tier === 'directional' && ( VIEW )} {p.talks_vs_trades_flag && ( ⚠ FLIP )} {p.analyzed_at ? ('✓ Analyzed') : ('⏳ Pending')} {p.url && ( e.stopPropagation()} style={{ fontSize: 11, fontWeight: 600, color: 'var(--amber)', textDecoration: 'none', padding: '2px 8px', borderRadius: 4, border: '1px solid color-mix(in srgb, var(--amber) 35%, transparent)', whiteSpace: 'nowrap', }} > {'Source ↗'} )}
{p.title || p.summary || (p.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
{/* Only show summary as a separate line when there's a real title above it */} {p.title && p.summary && (
{p.summary}
)}
))}
)} {openPost && setOpenPost(null)} isZh={isZh} dateLocale={dateLocale} />}
) } function KolPostsSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
) }