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
+473
View File
@@ -0,0 +1,473 @@
'use client'
/**
* Open positions panel — what's currently on the book.
*
* The single most-asked question on any trading dashboard: "what do I hold
* right now?" Polls /api/positions/open + /api/positions/today every 15s.
* Compact enough to embed at the top of Dashboard AND Trades.
*
* Empty state is intentional: explicitly says "0 open" rather than hiding,
* so the user can confirm the bot isn't doing anything weird off-screen.
*/
import { useEffect, useState } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
getOpenPositions,
getTodayStats,
manualCloseTrade,
setTradeGrow,
type OpenPosition,
type TodayStats,
} from '@/lib/api'
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
const POLL_MS = 15_000
function fmtMoney(n: number | null | undefined, opts: { sign?: boolean } = {}) {
if (n == null || isNaN(n)) return '—'
const sign = opts.sign === true
const abs = Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n < 0) return '-$' + abs
if (sign && n > 0) return '+$' + abs
return '$' + abs
}
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—'
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%'
}
function fmtHold(min: number) {
if (min < 60) return `${min}m`
const h = Math.floor(min / 60), m = min % 60
if (h < 24) return `${h}h ${m}m`
return `${Math.floor(h / 24)}d ${h % 24}h`
}
function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
p: OpenPosition
onClose: (p: OpenPosition) => void
onToggleGrow: (p: OpenPosition) => void
growBusy: boolean
isZh: boolean
}) {
const pnlTone = (p.unrealized_pct ?? 0) > 0 ? 'up'
: (p.unrealized_pct ?? 0) < 0 ? 'down' : 'idle'
const pnlColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-3)'
return (
<div style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12, alignItems: 'center',
padding: '10px 14px',
borderTop: '1px solid var(--line)',
fontSize: 13,
}}>
{/* Asset + paper tag */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`asset-dot ${p.asset.toLowerCase()}`} />
<span style={{ fontWeight: 600 }}>{p.asset}</span>
{p.is_paper && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
}}>P</span>
)}
</div>
{/* Side */}
<span className={`side-pill ${p.side}`}>
{p.side === 'long'
? '↗ LONG'
: '↘ SHORT'}
</span>
{/* Entry → Current */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry mark</div>
<div>
{fmtMoney(p.entry_price)}
<span style={{ color: 'var(--ink-4)' }}> </span>
{p.current_price != null ? fmtMoney(p.current_price) : <span style={{ color: 'var(--ink-4)' }}>n/a</span>}
</div>
</div>
{/* Size + leverage (size = OPEN notional after de-risk) */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>open size · lev</div>
<div>
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'}
{p.leverage != null && <span style={{ color: 'var(--ink-4)' }}> · {p.leverage}×</span>}
</div>
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
<div style={{ fontSize: 9, marginTop: 2 }}>
{(p.addon_steps ?? 0) > 0 && (
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
)}
{(p.derisk_steps ?? 0) > 0 && (
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
)}
</div>
)}
{/* Per-trade Grow (pyramiding) switch */}
<button
onClick={() => onToggleGrow(p)}
disabled={growBusy}
title={p.grow_mode
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
style={{
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
border: '1px solid var(--line)',
background: p.grow_mode ? 'var(--up, #16a34a)' : 'transparent',
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
}}
>
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
</button>
</div>
{/* Hold time */}
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-2)' }}>
{fmtHold(p.hold_minutes)}
</div>
{/* Unrealized PnL */}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: pnlColor, fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(p.unrealized_usd, { sign: true })}
</div>
<div style={{ fontSize: 11, color: pnlColor, opacity: 0.85 }}>
{fmtPct(p.unrealized_pct)}
</div>
{p.realized_usd != null && p.realized_usd !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
banked {fmtMoney(p.realized_usd, { sign: true })}
</div>
)}
</div>
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
<button
onClick={() => onClose(p)}
className="btn ghost"
style={{
padding: '6px 10px', fontSize: 11, fontWeight: 600,
color: 'var(--down)',
border: '1px solid color-mix(in oklab, var(--down) 25%, var(--line))',
}}
title={isZh ? '立即平掉这个仓位' : 'Close this position immediately'}
>
{isZh ? '立即平仓' : 'Close now'}
</button>
</div>
)
}
export default function OpenPositions() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [positions, setPositions] = useState<OpenPosition[] | null>(null)
const [today, setToday] = useState<TodayStats | null>(null)
const [err, setErr] = useState<string | null>(null)
// Close-confirmation modal state
const [closing, setClosing] = useState<OpenPosition | null>(null)
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
useEffect(() => { setMounted(true) }, [])
const [closeErr, setCloseErr] = useState('')
const [growId, setGrowId] = useState<number | null>(null)
async function toggleGrow(p: OpenPosition) {
if (!address || growId !== null) return
const next = !p.grow_mode
setGrowId(p.trade_id)
try {
const env = await signRequest({
action: 'set_trade_grow', wallet: address,
body: { trade_id: p.trade_id, enabled: next }, signMessageAsync,
})
const r = await setTradeGrow(env, p.trade_id, next)
setPositions(prev => (prev ?? []).map(x =>
x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x))
} catch (e) {
if (isUserRejection(e)) {
setErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
} else {
setErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|| (isZh ? 'Grow 切换失败' : 'grow toggle failed'))
}
} finally { setGrowId(null) }
}
useEffect(() => {
if (!isConnected || !address) {
setPositions(null); setToday(null)
return
}
let cancelled = false
// First load uses getOrCreateViewEnvelope — may pop MetaMask exactly ONCE
// when the page is opened (user action). All subsequent polls reuse the
// cached envelope via getCachedViewEnvelope (no popup). When the cache
// expires (20 min), the polling tick silently skips until the user
// refocuses the tab or navigates back in — preventing the wallet from
// popping in the background while the user is doing something else.
async function load(envSource: 'first' | 'poll') {
const env = envSource === 'first'
? await getOrCreateViewEnvelope({
action: 'view_positions', wallet: address!, signMessageAsync,
})
: getCachedViewEnvelope('view_positions', address!)
if (!env) return // cache expired during polling — wait for user re-entry
try {
const [p, t] = await Promise.all([
getOpenPositions(address!, env),
getTodayStats(address!, env),
])
if (!cancelled) {
setPositions(p.positions); setToday(t); setErr(null)
}
} catch (e: unknown) {
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
}
}
load('first').catch(e => {
// Initial sign-rejection lands here. Show a soft error so the polling
// loop still starts (a subsequent in-cache sign elsewhere will revive it).
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
})
const id = setInterval(() => { void load('poll') }, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [address, isConnected, signMessageAsync, isZh])
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
async function confirmCloseTrade() {
if (!address || !closing) return
setCloseErr(''); setCloseState('signing')
try {
const env = await signRequest({
action: 'close_trade',
wallet: address,
body: { trade_id: closing.trade_id },
signMessageAsync,
})
setCloseState('closing')
await manualCloseTrade(env, closing.trade_id)
// Optimistically drop the row from the list; the next poll will confirm.
setPositions(prev => (prev ?? []).filter(x => x.trade_id !== closing.trade_id))
setClosing(null)
setCloseState('idle')
} catch (e: unknown) {
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
setCloseState('err')
}
}
// Return null both before mount (SSR) and when not connected — same output,
// no hydration mismatch.
if (!mounted || !isConnected) return null
if (positions === null && today === null) return null
const totalUnrealized = (positions ?? []).reduce(
(s, p) => s + (p.unrealized_usd ?? 0), 0,
)
const pnlTone = today
? (today.realized_pnl_usd > 0 ? 'up' : today.realized_pnl_usd < 0 ? 'down' : 'idle')
: 'idle'
const todayColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-2)'
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header strip — at-a-glance status */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 18px', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Open
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{today?.open_count ?? 0}
<span style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 6, fontWeight: 400 }}>
{`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
</span>
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Unrealized
</div>
<div style={{ fontSize: 18, fontWeight: 700,
color: totalUnrealized > 0 ? 'var(--up)' :
totalUnrealized < 0 ? 'var(--down)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(totalUnrealized, { sign: true })}
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Today realised
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: todayColor,
fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
</div>
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked on open`}
</div>
)}
</div>
</div>
{err && (
<span style={{ fontSize: 11, color: 'var(--down)' }}> {err}</span>
)}
</div>
{/* Position list (or empty state) */}
{positions && positions.length > 0 ? (
<div>
<div style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12,
padding: '8px 14px',
background: 'var(--bg-sunk)',
fontSize: 10, fontWeight: 600, letterSpacing: '0.05em',
color: 'var(--ink-3)', textTransform: 'uppercase',
}}>
<span>Asset</span><span>Side</span><span>Prices</span>
<span>Size</span><span>Hold</span>
<span style={{ textAlign: 'right' }}>Unrealized</span>
<span style={{ textAlign: 'right' }}>Action</span>
</div>
{positions.map(p => (
<PositionRow
key={p.trade_id}
p={p}
onClose={setClosing}
onToggleGrow={toggleGrow}
growBusy={growId === p.trade_id}
isZh={isZh}
/>
))}
</div>
) : (
<div style={{
borderTop: '1px solid var(--line)',
padding: '20px 18px',
fontSize: 13, color: 'var(--ink-3)', textAlign: 'center',
}}>
No open positions. The bot will appear here as soon as a signal triggers a trade.
</div>
)}
{/* ── Confirmation modal (P0.1 safety valve) ───────────────────
Two-step UX so the wallet popup isn't surprising. The user
presses "Close now", reads the impact summary, then confirms. */}
{closing && (
<div
onClick={() => closeState === 'idle' && setClosing(null)}
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
className="card"
style={{ padding: 24, maxWidth: 440, width: '100%' }}
>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
{isZh
? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}`
: `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
{closing.is_paper
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
</div>
<div style={{
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '开仓价' : 'Entry'}</span>
<span className="mono">{fmtMoney(closing.entry_price)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
</div>
{closing.realized_usd != null && closing.realized_usd !== 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
<span>{isZh ? '已锁定(降风险)' : 'Already banked (de-risk)'}</span>
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
<span style={{
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}>
{fmtMoney(closing.unrealized_usd, { sign: true })}
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: 11 }}>
({fmtPct(closing.unrealized_pct)})
</span>
</span>
</div>
</div>
{closeState === 'err' && (
<div style={{ fontSize: 12, color: 'var(--down)', marginBottom: 12 }}>{closeErr}</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setClosing(null)}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn ghost"
style={{ padding: '8px 16px', fontSize: 13 }}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
onClick={confirmCloseTrade}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn"
style={{
padding: '8px 18px', fontSize: 13, fontWeight: 600,
background: 'var(--down)', color: '#fff', border: 'none',
}}
>
{closeState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
: closeState === 'closing' ? (isZh ? '平仓中…' : 'Closing…')
: (isZh ? `立即平掉 ${closing.asset}` : `Close ${closing.asset} now`)}
</button>
</div>
</div>
</div>
)}
</div>
)
}