Files
trumpsignal-frontend/components/dashboard/SignalMonitor.tsx
T
k d72323b1c6 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>
2026-05-25 00:53:27 +08:00

280 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useWsSubscribe } from '@/lib/wsContext'
const API_BASE = '/api/proxy/api'
interface SignalAlert {
type: 'funding_signal'
symbol: string
time: string
close: number
tbr: number
vol_mult: number
bb_pct: number
bb_upper: number
btc_trend: string
enabled: boolean
}
function symbolLabel(s: string) {
return s.replace('USDT', '')
}
function timeAgo(iso: string) {
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return `${Math.round(diff)}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
return `${Math.round(diff / 3600)}h ago`
}
// ── iOS-style toggle switch ───────────────────────────────────────────────────
function ToggleSwitch({ on, loading, onToggle }: {
on: boolean
loading: boolean
onToggle: () => void
}) {
return (
<button
onClick={onToggle}
disabled={loading}
title={on ? 'Click to disable' : 'Click to enable'}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'none',
border: 'none',
cursor: loading ? 'not-allowed' : 'pointer',
padding: 0,
opacity: loading ? 0.5 : 1,
}}
>
{/* Switch track */}
<div style={{
width: 44,
height: 26,
borderRadius: 13,
background: on ? '#22c55e' : 'var(--ink-4)',
position: 'relative',
transition: 'background 0.2s',
flexShrink: 0,
opacity: on ? 1 : 0.5,
}}>
{/* Thumb */}
<div style={{
width: 18,
height: 18,
borderRadius: '50%',
background: '#fff',
position: 'absolute',
top: 2,
left: on ? 20 : 2,
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
<span style={{
fontSize: 12,
fontWeight: 600,
color: on ? '#22c55e' : 'var(--ink-3)',
letterSpacing: '0.05em',
minWidth: 24,
}}>
{on ? 'ON' : 'OFF'}
</span>
</button>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function SignalMonitor() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [enabled, setEnabledState] = useState(false)
const [loading, setLoading] = useState(false)
const [backendOk, setBackendOk] = useState<boolean | null>(null)
const [signals, setSignals] = useState<SignalAlert[]>([])
const [btcTrend, setBtcTrend] = useState<string | null>(null)
const [lastScan, setLastScan] = useState<Date | null>(null)
// ── Load initial state ───────────────────────────────────────────────────
useEffect(() => {
fetch(`${API_BASE}/signal/status`)
.then(r => { if (!r.ok) throw new Error(); return r.json() })
.then(d => { setEnabledState(d.enabled); setBackendOk(true) })
.catch(() => setBackendOk(false))
fetch(`${API_BASE}/signal/history?limit=20`)
.then(r => r.json())
.then((list: SignalAlert[]) => {
if (!Array.isArray(list)) return
setSignals(list)
if (list.length > 0) {
setBtcTrend(list[0].btc_trend)
setLastScan(new Date(list[0].time))
}
})
.catch(() => {})
}, [])
// ── WebSocket listener (shared singleton connection via WsProvider) ─────────
useWsSubscribe('funding_signal', (msg) => {
const alert = msg as SignalAlert
setBtcTrend(alert.btc_trend)
setLastScan(new Date(alert.time))
setSignals(prev => [alert, ...prev].slice(0, 50))
})
// ── Toggle ───────────────────────────────────────────────────────────────
const toggle = useCallback(async () => {
setLoading(true)
try {
const next = !enabled
const r = await fetch(`${API_BASE}/signal/toggle?enabled=${next}`, { method: 'POST' })
if (!r.ok) throw new Error()
const d = await r.json()
setEnabledState(d.enabled)
setBackendOk(true)
} catch {
setBackendOk(false)
}
setLoading(false)
}, [enabled])
// ── Render ───────────────────────────────────────────────────────────────
const btcUp = btcTrend?.includes('↑')
return (
<div className="card" style={{ padding: 20, marginTop: 12 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 3 }}>
{isZh ? '突破监控' : 'Breakout Monitor'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'}
</div>
</div>
<ToggleSwitch on={enabled} loading={loading} onToggle={toggle} />
</div>
{/* Backend offline warning */}
{backendOk === false && (
<div style={{
fontSize: 11, color: '#f59e0b',
padding: '6px 10px', borderRadius: 6,
background: 'rgba(245,158,11,0.1)',
marginBottom: 12,
}}>
{isZh ? '后端离线,开关暂时不可用' : "Backend offline — toggle won't work"}
</div>
)}
{/* Status row: BTC trend + last scan */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
fontSize: 11, color: 'var(--ink-3)',
padding: '6px 10px', borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
marginBottom: 14,
}}>
<span>
BTC &nbsp;
{btcTrend ? (
<strong style={{ color: btcUp ? '#22c55e' : '#ef4444' }}>{btcTrend}</strong>
) : (
<span style={{ opacity: 0.5 }}></span>
)}
</span>
<span>
{lastScan
? (isZh ? `最近信号 ${timeAgo(lastScan.toISOString())}` : `Last signal ${timeAgo(lastScan.toISOString())}`)
: enabled ? (isZh ? '扫描中…' : 'Scanning…') : (isZh ? '已暂停' : 'Paused')
}
</span>
</div>
{/* Signal list */}
{signals.length === 0 ? (
<div style={{
fontSize: 13, color: 'var(--ink-3)',
textAlign: 'center', padding: '20px 0',
lineHeight: 1.5,
}}>
{enabled
? (isZh ? '· 正在等待信号…' : '· Watching for signals…')
: (isZh ? '· 打开开关后开始监控' : '· Enable the toggle to start watching')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signals.map((s) => (
<div key={`${s.symbol}-${s.time}`} style={{
padding: '11px 13px',
borderRadius: 10,
background: 'rgba(255,255,255,0.04)',
borderLeft: `3px solid ${s.enabled ? '#22c55e' : 'rgba(255,255,255,0.15)'}`,
}}>
{/* Top row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontWeight: 700, fontSize: 14,
fontFamily: 'var(--mono)',
color: 'var(--ink)',
}}>
{symbolLabel(s.symbol)}
</span>
{!s.enabled && (
<span style={{
fontSize: 9, padding: '2px 6px', borderRadius: 4,
background: 'rgba(255,255,255,0.08)',
color: 'var(--ink-3)',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
silent
</span>
)}
</div>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{timeAgo(s.time)}
</span>
</div>
{/* Metrics grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{[
{ label: isZh ? '价格' : 'Price', value: `$${s.close.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, color: 'var(--ink)' },
{ label: isZh ? '主动买入' : 'Taker Buy', value: `${(s.tbr * 100).toFixed(1)}%`, color: '#22c55e' },
{ label: isZh ? '成交量倍数' : 'Vol ×', value: `${s.vol_mult}×`, color: '#22c55e' },
].map(m => (
<div key={m.label} style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: 6, padding: '5px 8px',
}}>
<div style={{ fontSize: 9, color: 'var(--ink-3)', marginBottom: 2, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{m.label}
</div>
<div style={{ fontSize: 13, fontFamily: 'var(--mono)', fontWeight: 600, color: m.color }}>
{m.value}
</div>
</div>
))}
</div>
{/* Footer */}
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 8 }}>
{isZh ? `BB squeeze 处于第 ${s.bb_pct} 分位` : `BB squeeze ${s.bb_pct}th pct`} &nbsp;·&nbsp; {s.btc_trend}
</div>
</div>
))}
</div>
)}
</div>
)
}