d72323b1c6
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>
967 lines
35 KiB
TypeScript
967 lines
35 KiB
TypeScript
'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'
|
|
|
|
/**
|
|
* 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<KolTicker['action'], string> = {
|
|
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<KolTicker['action'], string> = {
|
|
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<KolHoldingChange['change_type'], string> = {
|
|
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<string, string> = {
|
|
buy: 'BUY',
|
|
sell: 'SELL',
|
|
bullish: 'Bullish post',
|
|
bearish: 'Bearish post',
|
|
}
|
|
return labels[action] ?? action
|
|
}
|
|
|
|
function chainActionLabel(action: string, isZh: boolean) {
|
|
const labels: Record<string, string> = {
|
|
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<number>(7)
|
|
const [data, setData] = useState<KolDigest | null>(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 (
|
|
<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 }}>
|
|
KOL signal digest
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
|
{data
|
|
? `${data.post_count} posts · ${data.ticker_count} assets`
|
|
: 'Loading…'}
|
|
</div>
|
|
</div>
|
|
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
|
{WINDOW_OPTIONS.map(daysOption => (
|
|
<button
|
|
key={daysOption}
|
|
onClick={() => setDays(daysOption)}
|
|
className={`nav-tab ${days === daysOption ? 'active' : ''}`}
|
|
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
|
|
>
|
|
{windowLabel(daysOption, isZh)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{loading && (
|
|
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
|
|
fontSize: 12 }}>Loading…</div>
|
|
)}
|
|
{err && (
|
|
<div style={{ padding: 12, color: '#dc2626', fontSize: 12 }}>{err}</div>
|
|
)}
|
|
{!loading && !err && data && data.tickers.length === 0 && (
|
|
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
|
|
fontSize: 13 }}>
|
|
No actionable calls in this window. Try a longer range.
|
|
</div>
|
|
)}
|
|
{!loading && !err && data && data.tickers.length > 0 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
{data.tickers.map(t => <DigestTickerChip
|
|
key={t.ticker} t={t} isZh={isZh} onClick={() => onTickerClick(t.ticker)} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
onClick={onClick}
|
|
title={`${t.kols.map(k => '@' + k).join(', ')} · ${t.post_count} signals`}
|
|
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`,
|
|
cursor: 'pointer', textAlign: 'left',
|
|
minWidth: 110,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<strong style={{ fontSize: 15, color: 'var(--ink-1)' }}>
|
|
{t.ticker}
|
|
</strong>
|
|
<span style={{
|
|
fontSize: 10, fontWeight: 700, color: sideColor,
|
|
padding: '1px 5px', borderRadius: 4,
|
|
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>
|
|
<div style={{
|
|
fontSize: 10, color: 'var(--ink-3)',
|
|
maxWidth: 160, whiteSpace: 'nowrap',
|
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
}}>
|
|
{t.kols.map(k => '@' + k).join(', ')}
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ── On-chain changes widget ───────────────────────────────────────────────
|
|
|
|
const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
|
|
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<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,
|
|
}: {
|
|
isZh: boolean
|
|
dateLocale: string
|
|
initialItems?: KolDivergence[] | 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)
|
|
|
|
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 (
|
|
<div style={{
|
|
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
|
|
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,
|
|
}}>
|
|
<div>
|
|
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
|
|
textTransform: 'uppercase', marginBottom: 2 }}>
|
|
Talks vs trades
|
|
</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`}
|
|
</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>
|
|
</div>
|
|
|
|
{/* Rows */}
|
|
{!loading && data.length === 0 && (
|
|
<div style={{
|
|
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
|
|
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.7,
|
|
}}>
|
|
<>
|
|
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.
|
|
</>
|
|
</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>
|
|
<span style={{
|
|
fontSize: 11, fontWeight: 700, color: dirColor,
|
|
padding: '1px 6px', borderRadius: 4,
|
|
background: `${dirColor}22`,
|
|
}}>
|
|
{directionLabel(d.direction, isZh)}
|
|
</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>
|
|
|
|
{/* 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>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
|
|
if (!tickers.length) {
|
|
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}>—</span>
|
|
}
|
|
return (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
{tickers.map((t, i) => (
|
|
<span
|
|
key={`${t.ticker}-${i}`}
|
|
title={t.quote}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
padding: '2px 8px', borderRadius: 999,
|
|
fontSize: 11, fontWeight: 600,
|
|
background: 'var(--bg-sunk)',
|
|
border: `1px solid ${ACTION_COLOR[t.action]}33`,
|
|
color: ACTION_COLOR[t.action],
|
|
}}
|
|
>
|
|
{t.ticker}
|
|
<span style={{ opacity: 0.7, fontWeight: 500 }}>
|
|
{actionLabel(t.action, isZh)}
|
|
</span>
|
|
<span style={{ opacity: 0.55, fontWeight: 500 }}>
|
|
{(t.conviction * 100).toFixed(0)}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 = (
|
|
<div
|
|
onClick={onClose}
|
|
style={{
|
|
position: 'fixed', inset: 0,
|
|
background: 'rgba(0,0,0,0.82)',
|
|
backdropFilter: 'blur(4px)',
|
|
WebkitBackdropFilter: 'blur(4px)',
|
|
zIndex: 9999, display: 'flex', alignItems: 'flex-start',
|
|
justifyContent: 'center',
|
|
padding: 'clamp(8px, 4vw, 40px) clamp(8px, 3vw, 20px)',
|
|
overflowY: 'auto',
|
|
}}
|
|
>
|
|
<div
|
|
onClick={e => 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: 标题 + 关闭 + 查看原帖 ── */}
|
|
<div style={{
|
|
display: 'flex', justifyContent: 'space-between',
|
|
alignItems: 'flex-start', gap: 12, marginBottom: 12,
|
|
}}>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 6 }}>
|
|
@{post.kol_handle} · {postSourceLabel} · {new Date(post.published_at).toLocaleString(dateLocale)}
|
|
</div>
|
|
<h2 style={{
|
|
margin: 0, fontSize: 'clamp(16px, 4.5vw, 22px)',
|
|
lineHeight: 1.3, wordBreak: 'break-word',
|
|
}}>
|
|
{post.title || (isZh ? '(无标题)' : '(Untitled)')}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* 右上角按钮组 */}
|
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'center' }}>
|
|
{post.url && (
|
|
<a
|
|
href={post.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
padding: '5px 12px', borderRadius: 6,
|
|
background: 'var(--amber)', color: '#000',
|
|
fontSize: 12, fontWeight: 700, textDecoration: 'none',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{isZh ? `查看原帖 ${postSourceLabel}` : `Open original ${postSourceLabel}`} ↗
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
style={{
|
|
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
|
|
borderRadius: 6, padding: '5px 10px', cursor: 'pointer',
|
|
color: 'var(--ink-2)', fontSize: 14, lineHeight: 1,
|
|
}}
|
|
>✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── AI 摘要 ── */}
|
|
{post.summary && (
|
|
<div style={{
|
|
margin: '12px 0', padding: 12, borderRadius: 8,
|
|
background: 'var(--bg-sunk)', borderLeft: '3px solid var(--amber)',
|
|
fontSize: 14, lineHeight: 1.6,
|
|
}}>
|
|
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4,
|
|
letterSpacing: 1, textTransform: 'uppercase' }}>
|
|
{isZh ? 'AI 摘要' : 'AI summary'}
|
|
</div>
|
|
{post.summary}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 喊单标的 ── */}
|
|
{post.tickers.length > 0 && (
|
|
<div style={{ margin: '12px 0' }}>
|
|
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 6,
|
|
letterSpacing: 1, textTransform: 'uppercase' }}>
|
|
{isZh ? '提取标的' : 'Extracted assets'}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{post.tickers.map((t, i) => (
|
|
<div key={i} style={{
|
|
padding: 10, borderRadius: 8,
|
|
background: 'var(--bg-sunk)',
|
|
borderLeft: `3px solid ${ACTION_COLOR[t.action]}`,
|
|
}}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
|
<strong style={{ fontSize: 14 }}>{t.ticker}</strong>
|
|
<span style={{ color: ACTION_COLOR[t.action], fontSize: 12, fontWeight: 600 }}>
|
|
{actionLabel(t.action, isZh)}
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
|
{isZh ? '信心分' : 'Conviction'} {(t.conviction * 100).toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--ink-2)',
|
|
fontStyle: 'italic', lineHeight: 1.5 }}>
|
|
“{t.quote}”
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 原文 ── */}
|
|
<div style={{
|
|
marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--line)',
|
|
}}>
|
|
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 8,
|
|
letterSpacing: 1, textTransform: 'uppercase' }}>
|
|
{isZh ? '原文摘录' : 'Source excerpt'}
|
|
</div>
|
|
<div style={{
|
|
whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7,
|
|
color: 'var(--ink-2)', maxHeight: '40vh', overflowY: 'auto',
|
|
padding: 12, borderRadius: 8, background: 'var(--bg-sunk)',
|
|
wordBreak: 'break-word',
|
|
}}>
|
|
{post.raw_text}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
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<KolPostSummary[]>(initialPosts ?? [])
|
|
const [loading, setLoading] = useState(initialPosts === null)
|
|
const [err, setErr] = useState('')
|
|
const [openPost, setOpenPost] = useState<KolPostDetail | null>(null)
|
|
const [handleFilter, setHandleFilter] = useState<string>('all')
|
|
const [tickerFilter, setTickerFilter] = useState<string | null>(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 (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1 className="page-title">{isZh ? 'KOL 信号' : 'KOL Signals'}</h1>
|
|
<p className="page-sub">
|
|
{`Track long-form KOL theses and extracted asset calls · ${posts.length} posts`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5, marginBottom: 16 }}>
|
|
This module ingests long-form KOL writing and show notes, extracts explicit asset views with AI, then cross-checks those views against wallet behavior. Open any row to inspect the original text and supporting quote.
|
|
</div>
|
|
|
|
<DigestWidget
|
|
isZh={isZh}
|
|
initialDigest={initialDigest}
|
|
onTickerClick={(sym) =>
|
|
setTickerFilter(prev => prev === sym ? null : sym)}
|
|
/>
|
|
|
|
<OnchainWidget
|
|
isZh={isZh}
|
|
dateLocale={dateLocale}
|
|
initialChanges={initialChanges}
|
|
/>
|
|
|
|
<DivergenceWidget
|
|
isZh={isZh}
|
|
dateLocale={dateLocale}
|
|
initialItems={initialDivergence}
|
|
/>
|
|
|
|
{tickerFilter && (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
marginBottom: 12, fontSize: 12,
|
|
}}>
|
|
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '当前筛选标的:' : 'Filtered asset:'}</span>
|
|
<strong>{tickerFilter}</strong>
|
|
<button
|
|
onClick={() => setTickerFilter(null)}
|
|
style={{
|
|
background: 'var(--bg-sunk)', border: '1px solid var(--border)',
|
|
borderRadius: 4, padding: '2px 8px',
|
|
cursor: 'pointer', fontSize: 11, color: 'var(--ink-2)',
|
|
}}
|
|
>{isZh ? '清除 ✕' : 'Clear ✕'}</button>
|
|
</div>
|
|
)}
|
|
|
|
{handles.length > 2 && (
|
|
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', marginBottom: 12 }}>
|
|
{handles.map(h => (
|
|
<button
|
|
key={h}
|
|
onClick={() => setHandleFilter(h)}
|
|
className={`nav-tab ${handleFilter === h ? 'active' : ''}`}
|
|
style={{ border: 'none', cursor: 'pointer' }}
|
|
>
|
|
{h === 'all' ? (isZh ? `全部 (${posts.length})` : `All (${posts.length})`) : `@${h}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{loading && <KolPostsSkeleton />}
|
|
{err && <div style={{ padding: 20, color: '#dc2626' }}>{isZh ? `错误:${err}` : `Error: ${err}`}</div>}
|
|
|
|
{!loading && !err && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{filtered.length === 0 && (
|
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
|
|
{isZh
|
|
? '当前没有数据,等待下一轮抓取任务(每天 01:15 UTC)。'
|
|
: 'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
|
|
</div>
|
|
)}
|
|
{filtered.map(p => (
|
|
<div
|
|
key={p.id}
|
|
onClick={() => 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)')}
|
|
>
|
|
{/* 顶部:作者 + 右侧操作区 */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between',
|
|
alignItems: 'center', gap: 8, marginBottom: 6,
|
|
flexWrap: 'wrap' }}>
|
|
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
|
@{p.kol_handle} · {sourceLabel(p.source, isZh)} · {new Date(p.published_at).toLocaleDateString(dateLocale)}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
|
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
|
{p.analyzed_at
|
|
? (isZh ? '✓ 已分析' : '✓ Analyzed')
|
|
: (isZh ? '⏳ 待分析' : '⏳ Pending')}
|
|
</span>
|
|
{p.url && (
|
|
<a
|
|
href={p.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={e => 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',
|
|
}}
|
|
>
|
|
{isZh ? '原帖 ↗' : 'Source ↗'}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6,
|
|
wordBreak: 'break-word' }}>
|
|
{p.title || (isZh ? '(无标题)' : '(Untitled)')}
|
|
</div>
|
|
{p.summary && (
|
|
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.5,
|
|
marginBottom: 8 }}>
|
|
{p.summary}
|
|
</div>
|
|
)}
|
|
<TickerChips tickers={p.tickers} isZh={isZh} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{openPost && <PostDetail post={openPost} onClose={() => setOpenPost(null)} isZh={isZh} dateLocale={dateLocale} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KolPostsSkeleton() {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="skeleton-card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<div className="skeleton sk-line-sm" style={{ width: 160 }} />
|
|
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
|
|
</div>
|
|
<div className="skeleton sk-title sk-w-3q" />
|
|
<div className="skeleton sk-line sk-w-full" />
|
|
<div className="skeleton sk-line" style={{ width: '85%' }} />
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
|
|
<div className="skeleton sk-line-sm" style={{ width: 48 }} />
|
|
<div className="skeleton sk-line-sm" style={{ width: 48 }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|