KOL count: 29 → 25 across marketing/SEO copy

Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists

Bundles other in-flight frontend work already in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
k
2026-06-09 22:55:27 +08:00
parent 9e0f6554cb
commit 4c3c8c6f87
57 changed files with 3464 additions and 1855 deletions
+62 -22
View File
@@ -1,8 +1,8 @@
'use client'
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade, TrumpPost } from '@/types'
import type { BotTrade } from '@/types'
import Pagination from '@/components/ui/Pagination'
// ── Formatters ────────────────────────────────────────────────────────────────
@@ -16,7 +16,8 @@ function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
return '$' + s
}
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
function fmtHold(s: number) {
function fmtHold(s: number | null | undefined) {
if (s == null) return '—'
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
@@ -24,18 +25,36 @@ function fmtHold(s: number) {
}
interface Props {
trades: BotTrade[]
posts: TrumpPost[]
loading: boolean
trades: BotTrade[]
loading: boolean
locked?: boolean // true = waiting for wallet signature, not "no trades"
}
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
const SIDES = ['all', 'long', 'short'] as const
// Human-readable labels for raw signal-source identifiers. Keeps the trade
// history readable for non-technical users (matches PostCards source labels).
const SOURCE_LABEL: Record<string, string> = {
truth: 'Trump',
btc_bottom_reversal: 'BTC Macro Bottom',
funding_reversal: 'Funding Reversal',
kol_divergence: 'KOL Divergence',
sma_reclaim: 'SMA Reclaim',
rsi_reversal: 'RSI Reversal',
breakout: 'Breakout',
adopted: 'Adopted',
manual: 'Manual',
unknown: 'Unknown',
}
function sourceLabel(src: string): string {
return SOURCE_LABEL[src?.toLowerCase()] ?? src
}
const TRADES_PER_PAGE = 25
export default function TradeTable({ trades, posts, loading }: Props) {
export default function TradeTable({ trades, loading, locked = false }: Props) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [assetFilter, setAssetFilter] = useState('all')
@@ -63,12 +82,31 @@ export default function TradeTable({ trades, posts, loading }: Props) {
return Array.from(set).sort()
}, [trades])
// Reset filters that no longer apply to the loaded trade set. On a wallet
// switch the trades prop changes but the local filter state persists, so a
// source/asset/side the previous wallet had could stay "stuck" — and when
// the new wallet has a single source the breakdown card (sources.length > 1)
// disappears, removing the only Clear affordance. Clamping here guarantees
// the user always sees their full new history.
useEffect(() => {
if (sourceFilter !== 'all' && !sources.includes(sourceFilter)) setSourceFilter('all')
if (assetFilter !== 'all' && !assets.includes(assetFilter)) setAssetFilter('all')
}, [sources, assets, sourceFilter, assetFilter])
// Per-source PnL aggregate — the critical view for "which module makes money".
// Computed BEFORE the asset/side filter so the source breakdown reflects the
// full universe, not whatever sub-filter is currently applied.
// Apply asset/side/paper filters for the source breakdown so the cards stay
// consistent with the KPI and table rows below. We intentionally do NOT apply
// sourceFilter here — filtering by source would trivially make one card 100%.
const filteredForSources = useMemo(() => trades.filter(t => {
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
if (hidePaper && t.is_paper) return false
return true
}), [trades, assetFilter, sideFilter, hidePaper])
const perSource = useMemo(() => {
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
for (const t of trades) {
for (const t of filteredForSources) {
const k = t.trigger_source || 'unknown'
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
acc[k].trades += 1
@@ -79,7 +117,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
if (t.is_paper) acc[k].paper += 1
}
return acc
}, [trades])
}, [filteredForSources])
const filtered = useMemo(() => trades.filter(t => {
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
@@ -98,8 +136,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length
const losses = priced.length - wins
const avgHold = filtered.length > 0
? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length)
const withHold = filtered.filter(t => t.hold_seconds !== null)
const avgHold = withHold.length > 0
? Math.round(withHold.reduce((s, t) => s + (t.hold_seconds ?? 0), 0) / withHold.length)
: 0
return (
@@ -111,7 +150,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
}}>
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
{isZh ? '按信号来源拆分盈亏' : 'Which signal makes money'}
</div>
<div style={{
display: 'grid',
@@ -142,7 +181,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{src}
{sourceLabel(src)}
{s.paper > 0 && (
<span style={{
fontSize: 9, marginLeft: 6, padding: '1px 5px',
@@ -171,7 +210,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
border: '1px solid var(--line)', borderRadius: 4,
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
>
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
{isZh ? `清除(当前为 “${sourceLabel(sourceFilter)}”)` : `Clear (showing “${sourceLabel(sourceFilter)}”)`}
</button>
)}
</div>
@@ -255,7 +294,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发内容' : 'Trigger'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
<th>P&amp;L</th>
</tr>
</thead>
@@ -263,12 +302,13 @@ export default function TradeTable({ trades, posts, loading }: Props) {
{filtered.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
{isZh ? '没有符合条件的交易。' : 'No trades found'}
{locked
? (isZh ? '签名解锁后即可查看交易历史。' : 'Sign to unlock your trade history above.')
: (isZh ? '没有符合条件的交易。' : 'No trades found')}
</td>
</tr>
)}
{pageRows.map(t => {
const tp = posts.find(p => p.id === t.trigger_post_id)
const roi =
t.entry_price && t.exit_price != null
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
@@ -283,7 +323,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
padding: '2px 7px', borderRadius: 4,
background: 'var(--bg-sunk)',
}}>
{src}
{sourceLabel(src)}
</span>
{t.is_paper && (
<span style={{
@@ -310,9 +350,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{tp ? (
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{tp.text.slice(0, 60)}
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>