Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes

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>
This commit is contained in:
k
2026-05-25 00:53:27 +08:00
parent 01be8e790b
commit d72323b1c6
67 changed files with 11735 additions and 2530 deletions
+331
View File
@@ -0,0 +1,331 @@
'use client'
import { useState, useMemo } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade, TrumpPost } from '@/types'
// ── Formatters ────────────────────────────────────────────────────────────────
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
const { decimals = 2, sign = false } = opts
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
if (n < 0) return '-$' + s
if (sign && n > 0) return '+$' + s
return '$' + s
}
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
interface Props {
trades: BotTrade[]
posts: TrumpPost[]
loading: boolean
}
const ASSETS = ['all', 'BTC', 'ETH', 'SOL'] as const
const SIDES = ['all', 'long', 'short'] as const
export default function TradeTable({ trades, posts, loading }: 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')
const [sideFilter, setSideFilter] = useState('all')
const [sourceFilter, setSourceFilter] = useState('all')
const [hidePaper, setHidePaper] = useState(false)
// Distinct sources present in the loaded trade set — drives the filter UI.
// Includes 'unknown' as a bucket for trades whose trigger post was deleted.
const sources = useMemo(() => {
const set = new Set<string>()
for (const t of trades) set.add(t.trigger_source || 'unknown')
return Array.from(set).sort()
}, [trades])
// 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.
const perSource = useMemo(() => {
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
for (const t of trades) {
const k = t.trigger_source || 'unknown'
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
acc[k].trades += 1
if (t.pnl_usd != null) {
acc[k].pnl += t.pnl_usd
if (t.pnl_usd > 0) acc[k].wins += 1
}
if (t.is_paper) acc[k].paper += 1
}
return acc
}, [trades])
const filtered = trades.filter(t => {
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
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
})
// Exclude externally-closed trades (pnl_usd null) from aggregates.
const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined)
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)
: 0
return (
<>
{/* ── Per-source breakdown (the "which module makes money" view) ── */}
{sources.length > 1 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
}}>
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 10,
}}>
{sources.map(src => {
const s = perSource[src]
const winRate = s.trades ? (s.wins / s.trades) * 100 : 0
const tone = s.pnl > 0 ? 'up' : s.pnl < 0 ? 'down' : 'idle'
const bg = tone === 'up' ? 'var(--up-soft)'
: tone === 'down' ? 'var(--down-soft)'
: 'var(--bg-sunk)'
const fg = tone === 'up' ? 'var(--up)'
: tone === 'down' ? 'var(--down)'
: 'var(--ink-2)'
return (
<button
key={src}
onClick={() => setSourceFilter(sourceFilter === src ? 'all' : src)}
style={{
textAlign: 'left', cursor: 'pointer',
background: bg, borderRadius: 8, padding: '12px 14px',
border: `1px solid ${sourceFilter === src ? fg : 'transparent'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{src}
{s.paper > 0 && (
<span style={{
fontSize: 9, marginLeft: 6, padding: '1px 5px',
borderRadius: 3, background: 'rgba(245,158,11,0.15)',
color: '#f59e0b',
}}>
{s.paper}P
</span>
)}
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: fg, fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(s.pnl, { sign: true, decimals: 0 })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2 }}>
{isZh ? `${s.trades} 笔交易 · ${winRate.toFixed(0)}% 胜率` : `${s.trades} trades · ${winRate.toFixed(0)}% win`}
</div>
</button>
)
})}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && (
<button
onClick={() => setSourceFilter('all')}
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
border: '1px solid var(--line)', borderRadius: 4,
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
>
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
</button>
)}
</div>
</div>
)}
{/* KPI row */}
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
<div className="kpi">
<div className="label">{isZh ? '总交易数' : 'Total trades'}</div>
<div className="value">{filtered.length}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '胜率' : 'Win rate'}</div>
<div className="value">{priced.length ? ((wins / priced.length) * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{isZh ? `${wins} 赢 · ${losses}` : `${wins}W · ${losses}L`}</span></div>
</div>
<div className="kpi accent">
<div className="label">{isZh ? '净盈亏' : 'Net P&L'}</div>
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '平均持仓' : 'Avg hold'}</div>
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
</div>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
<div className="nav-tabs">
{ASSETS.map(a => (
<button
key={a}
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
onClick={() => setAssetFilter(a)}
>
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
</button>
))}
</div>
<div className="nav-tabs">
{SIDES.map(s => (
<button
key={s}
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
onClick={() => setSideFilter(s)}
>
{s === 'all' ? (isZh ? '全部方向' : 'All') : s === 'long' ? (isZh ? '做多' : 'Long') : (isZh ? '做空' : 'Short')}
</button>
))}
</div>
{/* Hide-paper toggle: paper trades inflate volume but aren't real $ — */}
{/* let users blend them out before reading the KPI row. */}
<label style={{
display: 'flex', alignItems: 'center', gap: 6, fontSize: 12,
color: 'var(--ink-3)', cursor: 'pointer', marginLeft: 'auto',
}}>
<input
type="checkbox"
checked={hidePaper}
onChange={e => setHidePaper(e.target.checked)}
/>
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
</label>
</div>
{/* Loading state */}
{loading && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>{isZh ? '加载中…' : 'Loading…'}</div>
)}
{/* Table */}
{!loading && (
<div className="card flush" style={{ overflow: 'hidden' }}>
<table className="table">
<thead>
<tr>
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th>
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发内容' : 'Trigger'}</th>
<th>P&amp;L</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
{isZh ? '没有符合条件的交易。' : 'No trades found'}
</td>
</tr>
)}
{filtered.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)
: 0
const src = t.trigger_source || 'unknown'
return (
<tr key={t.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
padding: '2px 7px', borderRadius: 4,
background: 'var(--bg-sunk)',
}}>
{src}
</span>
{t.is_paper && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
}}>
PAPER
</span>
)}
</div>
</td>
<td>
<div className="row gap-s">
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
<span style={{ fontWeight: 500 }}>{t.asset}</span>
</div>
</td>
<td>
<span className={`side-pill ${t.side}`}>
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
</span>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<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 ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{tp.text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-end' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span
style={{ fontSize: 12, color: 'var(--ink-4)' }}
title={isZh ? '在 Hyperliquid 外部平仓,PnL 未记录' : 'Closed externally on Hyperliquid — PnL not recorded'}
>
n/a
</span>
) : (
<>
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
{fmtMoney(t.pnl_usd, { sign: true })}
</span>
<span className={`delta ${roi >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>
{fmtPct(roi)}
</span>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)
}