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
+3 -3
View File
@@ -457,7 +457,7 @@ export default function MacroPanel() {
label="Altcoin Season"
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 30 days. < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason."
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason."
summary={altSay}
activeIndex={altActive}
thresholds={[
@@ -466,8 +466,8 @@ export default function MacroPanel() {
{ label: '6075 alt strength', tone: 'up' },
{ label: '≥ 75 altseason', tone: 'up' },
]}
chartHref="https://www.coinglass.com/en/pro/i/alt-coin-season"
chartLabel="CoinGlass"
chartHref="https://www.blockchaincenter.net/altcoin-season-index/"
chartLabel="BlockchainCenter"
/>
<MetricCard
rank={3}
-296
View File
@@ -1,296 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import type { BotPerformance } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
// Action names must match backend/app/api/{user,subscribe}.py
const ACTION_SET_API_KEY = 'set_hl_api_key'
const ACTION_SUBSCRIBE = 'subscribe'
interface Props {
performance?: BotPerformance | null
}
type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
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'
}
export default function BotPanel({ performance }: Props) {
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setBotReadiness, setHlApiKeySet, setSubscribed } = useDashboardStore()
const { address, isConnected } = useAccount()
const { connectAsync, connectors } = useConnect()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [apiKey, setApiKey] = useState('')
const [saveState, setSaveState] = useState<SaveState>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
const [subError, setSubError] = useState('')
const [connectError, setConnectError] = useState('')
useEffect(() => { setMounted(true) }, [])
useEffect(() => {
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
return
}
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
getUserPublic(address.toLowerCase())
.then((user) => {
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
})
.catch(() => {})
}, [address, isConnected, setHlApiKeySet, setSubscribed, setBotReadiness])
async function refreshUserState(wallet: string) {
const pub = await getUserPublic(wallet.toLowerCase())
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
return pub
}
async function handleSubscribe() {
if (!address) return
setSubError('')
try {
setSubState('signing')
const env = await signRequest({
action: ACTION_SUBSCRIBE,
wallet: address,
body: null,
signMessageAsync,
})
setSubState('saving')
await subscribe(env)
await refreshUserState(address)
setSubState('idle')
} catch (err: unknown) {
setSubError(walletErrorLabel(err, 'Signature cancelled', 120))
setSubState('error')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) {
setErrorMsg('Key must start with 0x and be 66 characters')
setSaveState('error')
return
}
setErrorMsg('')
try {
setSaveState('signing')
const trimmed = apiKey.trim()
const env = await signRequest({
action: ACTION_SET_API_KEY,
wallet: address,
body: { api_key: trimmed },
signMessageAsync,
})
setSaveState('saving')
const res = await setHlApiKey(env, trimmed)
const pub = await refreshUserState(address)
setHlApiKeySet(pub.hl_api_key_set, res.masked_key)
setApiKey('')
setSaveState('success')
} catch (err: unknown) {
if (isUserRejection(err)) {
setErrorMsg('Signature cancelled')
} else {
setErrorMsg(walletErrorLabel(err, 'Signature cancelled', 120))
}
setSaveState('error')
}
}
const saveLabel =
saveState === 'signing' ? 'Waiting for signature…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'success' ? '✓ Saved'
: 'Save key'
async function handleConnectWallet() {
setConnectError('')
try {
const connector = await getFirstReadyConnector(connectors)
if (!connector) {
setConnectError('No wallet connector is available right now.')
return
}
await connectAsync({ connector })
} catch (err: unknown) {
setConnectError(walletConnectErrorLabel(err))
}
}
return (
<>
{/* Bot status card — dark design */}
<div className="bot-status">
<div className="bot-head">
<h3>
<span style={{ width: 8, height: 8, borderRadius: 999, background: botReadiness === 'ready' ? 'var(--amber)' : hlApiKeySet ? 'var(--up)' : isSubscribed ? 'var(--up)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
Auto-trader
</h3>
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
</div>
<div className="bot-stats">
<div className="bot-stat">
<div className="k">Net P&amp;L</div>
<div className="v amber">
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString('en-US', { maximumFractionDigits: 0 }) : '—'}
</div>
</div>
<div className="bot-stat">
<div className="k">Win rate</div>
<div className="v up">
{performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'}
</div>
</div>
<div className="bot-stat">
<div className="k">Trades</div>
<div className="v">{performance?.total_trades ?? '—'}</div>
</div>
<div className="bot-stat">
<div className="k">Avg hold</div>
<div className="v">{performance ? fmtHold(performance.avg_hold_seconds) : '—'}</div>
</div>
</div>
<div className="bot-cta">
{(!mounted || !isConnected) && (
<>
<button className="btn amber" style={{ width: '100%' }}
onClick={() => { void handleConnectWallet() }}>
Connect wallet
</button>
{connectError && (
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{connectError}</p>
)}
</>
)}
{mounted && isConnected && !isSubscribed && (
<div style={{ width: '100%' }}>
<button
className="btn amber"
style={{ width: '100%' }}
onClick={handleSubscribe}
disabled={subState === 'signing' || subState === 'saving'}
>
{subState === 'signing' ? 'Waiting for signature…' : subState === 'saving' ? 'Activating…' : 'Start trading'}
</button>
{subState === 'error' && subError && (
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{subError}</p>
)}
</div>
)}
{mounted && isConnected && isSubscribed && !hlApiKeySet && (
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
Paste your Hyperliquid API key below to finish setup
</div>
)}
{mounted && isConnected && isSubscribed && hlApiKeySet && (
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
Setup saved · verification still depends on backend
</div>
)}
</div>
</div>
{/* HL API Key card — only when subscribed */}
{mounted && isConnected && isSubscribed && (
<div className="card" style={{ padding: 20 }}>
<div className="section-title">
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
{hlApiKeySet && <span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 500 }}> Connected</span>}
</div>
{hlApiKeySet && !apiKey && (
<div className="row between" style={{ padding: '10px 12px', background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)', border: '1px solid var(--line)', marginBottom: 12 }}>
<span style={{ fontSize: 12, color: 'var(--up)', fontFamily: 'var(--mono)' }}>
{hlApiKeyMasked ?? '···'}
</span>
<button
style={{ fontSize: 11, color: 'var(--ink-3)' }}
onClick={() => { setSaveState('idle'); setApiKey(' ') }}
>
Update
</button>
</div>
)}
{(!hlApiKeySet || apiKey) && (
<>
<div className="field" style={{ marginBottom: 12 }}>
<label>API wallet private key</label>
<input
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={(e) => {
setApiKey(e.target.value)
if (saveState === 'error' || saveState === 'success') setSaveState('idle')
}}
placeholder="0x…"
/>
<div className="hint">
From{' '}
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber-ink)' }}>
app.hyperliquid.xyz/API
</a>
</div>
</div>
{saveState === 'error' && errorMsg && (
<p style={{ fontSize: 11, color: 'var(--down)', marginBottom: 8 }}>{errorMsg}</p>
)}
<button
className={`btn ${saveState === 'success' ? 'ghost' : 'amber'}`}
style={{ width: '100%' }}
onClick={handleSaveKey}
disabled={saveState === 'signing' || saveState === 'saving' || !apiKey.trim()}
>
{saveLabel}
</button>
</>
)}
{!hlApiKeySet && (
<details style={{ marginTop: 12 }}>
<summary style={{ fontSize: 11, color: 'var(--ink-3)', cursor: 'pointer' }}>
How to get your API key
</summary>
<ol style={{ marginTop: 8, fontSize: 11, color: 'var(--ink-3)', paddingLeft: 16, lineHeight: 1.7 }}>
<li>Deposit USDC at app.hyperliquid.xyz</li>
<li>Go to app.hyperliquid.xyz/API</li>
<li>Click <strong>Generate API Wallet</strong></li>
<li>Sign with MetaMask (no gas)</li>
<li>Copy the private key paste above</li>
</ol>
</details>
)}
</div>
)}
</>
)
}
+1 -1
View File
@@ -417,7 +417,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
}
}, [candles, posts, externalSelectedId, asset])
useEffect(() => { fittedRef.current = false }, [timeframe])
useEffect(() => { fittedRef.current = false }, [timeframe, asset])
// Live-tick the rightmost candle so the chart feels alive between REST polls.
// lightweight-charts' `series.update()` either appends a new bar (newer time)
+20 -16
View File
@@ -66,16 +66,18 @@ function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatO
// When adding a new scanner source, register it here too — otherwise it
// falls through to the generic "first letter" fallback which has no title
// or accent colour.
const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string }> = {
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social' },
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner' },
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC · Macro Bottom Reversal' },
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC · Funding Rate Reversal' },
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL · Talks vs Trades Divergence' },
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert' },
manual: { glyph: '', cls: 'manual', title: 'Manual entry' },
export const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string; label: string }> = {
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social', label: '@realDonaldTrump' },
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner', label: 'Breakout scanner' },
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner', label: 'Breakout scanner' },
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner', label: 'Reversal scanner' },
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC Macro Bottom scanner', label: 'BTC Macro Bottom' },
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC Funding Rate Reversal scanner', label: 'Funding Reversal' },
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL Talks-vs-Trades Divergence', label: 'KOL Divergence' },
sma_reclaim: { glyph: '', cls: 'breakout', title: 'SMA reclaim scanner', label: 'SMA Reclaim' },
rsi_reversal: { glyph: '', cls: 'reversal', title: 'RSI reversal scanner', label: 'RSI Reversal' },
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert', label: 'Whale alert' },
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry', label: 'Manual' },
}
function SourceIcon({ source }: { source: string }) {
@@ -119,7 +121,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
return (
<div
className={`post-row ${selected ? 'selected' : ''} ${aiScored ? '' : 'noise'}`}
className={`post-row ${selected ? 'selected' : ''} ${aiScored ? '' : 'noise'} ${post.signal === 'buy' ? 'signal-buy' : post.signal === 'short' ? 'signal-short' : ''}`}
onClick={handleClick}
>
{/* ── main row ── */}
@@ -130,7 +132,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
{/* Author label depends on source — non-Trump signals come from
technical scanners or external modules, not @realDonaldTrump. */}
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
{post.source === 'truth' ? '@realDonaldTrump' : post.source}
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
</span>
<span>·</span>
<TimeAgo iso={post.published_at} suffix=" ago" />
@@ -186,7 +188,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
<div className="impact-mini">
{impact ? (
<>
<span className="tf">1h peak</span>
<span className="tf">1h move</span>
<span className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}>
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
</span>
@@ -246,7 +248,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
</span>
{post.expected_move_pct != null && (
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{`AI expects ~${post.expected_move_pct}% in 1h`}
{`model projection ~${post.expected_move_pct}% · 1h`}
</span>
)}
{post.category && (
@@ -260,7 +262,9 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
{/* AI reasoning */}
{post.ai_reasoning && (
<div>
<div className="ai-reasoning-label">AI reasoning</div>
<div className="ai-reasoning-label">
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
</div>
<div className="ai-reasoning-card">
{post.ai_reasoning}
</div>
@@ -270,7 +274,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
{/* Price impact — peak move in signal direction per window */}
{impact && (
<div>
<div className="tiny" style={{ marginBottom: 8 }}>{`Peak move · ${impact.asset}`}</div>
<div className="tiny" style={{ marginBottom: 8 }}>{`Price moved · ${impact.asset} · after signal`}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{(['m5', 'm15', 'm1h'] as const).map(key => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
+18 -99
View File
@@ -1,7 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useState, useEffect } from 'react'
import { useWsSubscribe } from '@/lib/wsContext'
const API_BASE = '/api/proxy/api'
@@ -30,82 +29,22 @@ function timeAgo(iso: string) {
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 ────────────────────────────────────────────────────────────
// Display-only: toggle is operator-only (requires X-Ingest-Key) so the
// on/off switch is intentionally absent from the user-facing UI.
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)
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))
.then(d => { setEnabledState(d.enabled) })
.catch(() => {})
fetch(`${API_BASE}/signal/history?limit=20`)
.then(r => r.json())
@@ -128,22 +67,6 @@ export default function SignalMonitor() {
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('↑')
@@ -160,20 +83,16 @@ export default function SignalMonitor() {
{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"}
{/* Enabled status dot — display-only; toggle is operator-only (X-Ingest-Key) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: enabled ? '#22c55e' : 'var(--ink-3)' }}>
<div style={{
width: 7, height: 7, borderRadius: '50%',
background: enabled ? '#22c55e' : 'var(--ink-4)',
boxShadow: enabled ? '0 0 0 2px rgba(34,197,94,0.25)' : 'none',
}} />
{enabled ? (isZh ? '监控中' : 'Active') : (isZh ? '已暂停' : 'Paused')}
</div>
)}
</div>
{/* Status row: BTC trend + last scan */}
<div style={{
@@ -208,7 +127,7 @@ export default function SignalMonitor() {
}}>
{enabled
? (isZh ? '· 正在等待信号…' : '· Watching for signals…')
: (isZh ? 打开开关后开始监控' : Enable the toggle to start watching')}
: (isZh ? 暂无历史信号' : No signals yet')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+23 -2
View File
@@ -6,6 +6,8 @@ import { usePathname } from 'next/navigation'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useTranslations } from 'next-intl'
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
import { needsMobileWallet } from '@/lib/mobileWallet'
import MobileWalletSheet from '@/components/wallet/MobileWalletSheet'
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
// import LanguageSwitch from './LanguageSwitch'
@@ -31,7 +33,12 @@ function ThemeToggle() {
}
return (
<button className="icon-btn theme-toggle" onClick={toggle}>
<button
className="icon-btn theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
@@ -56,6 +63,7 @@ export default function Navbar() {
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [connectError, setConnectError] = useState('')
const [mobileSheetOpen, setMobileSheetOpen] = useState(false)
async function copyAddress(addr: string) {
let ok = false
@@ -89,10 +97,20 @@ export default function Navbar() {
async function handleConnectWallet() {
setConnectError('')
// On mobile without an injected provider, show the wallet deep-link sheet
// instead of throwing "No wallet found". This lets users open the dApp
// inside MetaMask / Trust / Coinbase without a confusing error.
if (needsMobileWallet()) {
setMobileSheetOpen(true)
return
}
try {
const connector = await getFirstReadyConnector(connectors)
if (!connector) {
setConnectError('No wallet connector is available right now.')
// Desktop with no extension — give a helpful hint instead of raw error.
setConnectError('No wallet extension found. Install MetaMask or another browser wallet.')
return
}
await connectAsync({ connector })
@@ -124,6 +142,8 @@ export default function Navbar() {
const shortAddr = address ? `${address.slice(0, 6)}${address.slice(-4)}` : null
return (
<>
<MobileWalletSheet open={mobileSheetOpen} onClose={() => setMobileSheetOpen(false)} />
<nav className="nav">
<Link href={`/${locale}`} className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
<BrandMark />
@@ -204,5 +224,6 @@ export default function Navbar() {
</div>
</div>
</nav>
</>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
/**
* Ticker — horizontal scrolling price tape under the navbar, the way a crypto
* terminal shows a running market strip. Driven by the shared WebSocket price
* feed (no new socket). Each asset cell flashes green/red on a price change and
* shows the live last price.
*
* Behaviour notes:
* - The strip is duplicated once and translated -50% in a CSS marquee so the
* loop is seamless (`ticker-marquee` keyframe in globals.css).
* - prefers-reduced-motion disables the scroll (CSS), so the tape becomes a
* static, readable row instead of moving.
* - The animation is paused on hover so a user can read a specific price.
* - Assets with no tick yet render "—" rather than a stale zero.
*/
import { useRef, useState } from 'react'
import { useWsSubscribe } from '@/lib/wsContext'
// Assets the backend streams (see binance ASSET_MAP + hl_price_feed). Order is
// the display order in the tape.
const ASSETS = ['BTC', 'ETH', 'SOL', 'BNB', 'DOGE', 'LINK', 'AAVE', 'TRUMP', 'HYPE'] as const
interface Cell {
price: number | null
dir: 'up' | 'down' | null
}
function fmtPrice(p: number): string {
if (p >= 1000) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 0 })
if (p >= 1) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 2 })
return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 4 })
}
export default function Ticker() {
const [cells, setCells] = useState<Record<string, Cell>>({})
// Per-asset flash-clear timers so a fast stream doesn't leak setTimeouts.
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
useWsSubscribe('price', (msg) => {
const m = msg as { asset?: string; price?: number }
if (!m.asset || typeof m.price !== 'number') return
const asset = m.asset.toUpperCase()
if (!ASSETS.includes(asset as (typeof ASSETS)[number])) return
setCells((prev) => {
const old = prev[asset]
const dir: Cell['dir'] =
old?.price != null && m.price !== old.price
? (m.price! > old.price ? 'up' : 'down')
: old?.dir ?? null
return { ...prev, [asset]: { price: m.price!, dir } }
})
// Clear the flash after 600ms.
clearTimeout(timers.current[asset])
timers.current[asset] = setTimeout(() => {
setCells((prev) => (prev[asset] ? { ...prev, [asset]: { ...prev[asset], dir: null } } : prev))
}, 600)
})
const strip = ASSETS.map((a) => {
const c = cells[a]
return (
<span key={a} className={`ticker-cell ${c?.dir ? `tick-flash-${c.dir}` : ''}`}>
<span className="ticker-sym">{a}</span>
<span className="ticker-px">{c?.price != null ? fmtPrice(c.price) : '—'}</span>
<span className={`ticker-arrow ${c?.dir ?? ''}`} aria-hidden>
{c?.dir === 'up' ? '▲' : c?.dir === 'down' ? '▼' : ''}
</span>
</span>
)
})
return (
<div className="ticker" role="region" aria-label="Live market prices">
<div className="ticker-track">
{strip}
{/* duplicate for seamless loop */}
<span aria-hidden style={{ display: 'contents' }}>{strip}</span>
</div>
</div>
)
}
+73 -21
View File
@@ -12,6 +12,7 @@
*/
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
@@ -22,7 +23,7 @@ import {
type OpenPosition,
type TodayStats,
} from '@/lib/api'
import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest'
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest, type SignedEnvelope } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
const POLL_MS = 15_000
@@ -85,7 +86,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
</span>
{/* Entry → Current */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry mark</div>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry / current price</div>
<div>
{fmtMoney(p.entry_price)}
<span style={{ color: 'var(--ink-4)' }}> </span>
@@ -102,10 +103,10 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
{((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>
<span style={{ color: 'var(--up)' }}>{`↑ scaled in ×${p.addon_steps} `}</span>
)}
{(p.derisk_steps ?? 0) > 0 && (
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
<span style={{ color: 'var(--down)' }}>{`↓ trimmed ×${p.derisk_steps}`}</span>
)}
</div>
)}
@@ -114,8 +115,8 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
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.')}
? (isZh ? '加仓已开启:趋势确认后自动追加仓。点击关闭。' : 'Scale-in ON — bot adds to this position when trend is confirmed. Click to turn off.')
: (isZh ? '加仓已关闭:仅持有并做保护性仓。' : 'Scale-in OFF — hold + protective trim only. Click to allow adding.')}
style={{
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
@@ -124,7 +125,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
}}
>
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
{p.grow_mode ? '↑ Scale-in ON' : 'Scale-in OFF'}
</button>
</div>
{/* Hold time */}
@@ -141,7 +142,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
</div>
{p.realized_usd != null && p.realized_usd !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
locked in {fmtMoney(p.realized_usd, { sign: true })}
banked {fmtMoney(p.realized_usd, { sign: true })} from partial close
</div>
)}
</div>
@@ -172,6 +173,7 @@ export default function OpenPositions() {
const [today, setToday] = useState<TodayStats | null>(null)
const [err, setErr] = useState<string | null>(null)
const [needsUnlock, setNeedsUnlock] = useState(false)
const [unlocking, setUnlocking] = useState(false)
// Close-confirmation modal state
const [closing, setClosing] = useState<OpenPosition | null>(null)
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
@@ -212,10 +214,11 @@ export default function OpenPositions() {
}
useEffect(() => {
if (!isConnected || !address) {
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
return
}
// B39: wipe stale data from the previous wallet immediately — don't wait
// for the async fetch to complete before the UI shows a clean slate.
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
if (!isConnected || !address) return
let cancelled = false
// Only reuse a cached envelope here. Open positions should never trigger
@@ -253,6 +256,31 @@ export default function OpenPositions() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected])
// In-page unlock: mint a view_user envelope (one signature) right here and
// load positions immediately — no detour to the Settings page. view_user is
// a superset accepted by /positions/open and /positions/today.
async function handleUnlock() {
if (!address || unlocking) return
setUnlocking(true); setErr(null)
try {
const env = await getOrCreateViewEnvelope({
action: 'view_user', wallet: address, signMessageAsync,
})
const [p, t] = await Promise.all([
getOpenPositions(address, env),
getTodayStats(address, env),
])
setPositions(p.positions); setToday(t); setNeedsUnlock(false); setErr(null)
} catch (e: unknown) {
// User-cancelled signature is benign — keep the unlock CTA visible.
if (!isUserRejection(e)) {
setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '解锁失败' : 'unlock failed'))
}
} finally {
setUnlocking(false)
}
}
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
async function confirmCloseTrade() {
if (!address || !closing) return
@@ -293,15 +321,39 @@ export default function OpenPositions() {
Open positions
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
Sign in once to view open positions
Sign in to see open positions
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
Go to the <strong>Settings page</strong> and click <em>Sign in to view your settings</em>. After signing, your open positions will appear here automatically.
Sign once on this device to unlock your positions (valid for a few
minutes, no transaction, no gas).
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, flexWrap: 'wrap' }}>
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px' }}
disabled={unlocking} onClick={handleUnlock}>
{unlocking ? 'Waiting for signature…' : 'Unlock positions'}
</button>
<Link href={`/${locale}/settings`} style={{ fontSize: 12, color: 'var(--ink-3)', textDecoration: 'none' }}>
or go to Settings
</Link>
</div>
{err && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}> {err}</div>
)}
</div>
)
}
if (positions === null && today === null) return null
// When the first load fails, positions and today are still null but err is set.
// Without this guard the component returns null (blank), swallowing the error.
if (positions === null && today === null) {
if (err) {
return (
<div className="card" style={{ padding: '12px 16px', marginBottom: 16, fontSize: 12, color: 'var(--down)' }}>
Could not load positions {err}
</div>
)
}
return null
}
const totalUnrealized = (positions ?? []).reduce(
(s, p) => s + (p.unrealized_usd ?? 0), 0,
@@ -363,7 +415,7 @@ export default function OpenPositions() {
</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 })} locked in on open trades`}
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked from partial closes`}
</div>
)}
</div>
@@ -447,8 +499,8 @@ export default function OpenPositions() {
</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.')}
? (isZh ? '这是模拟交易,平仓只做本地记录,不会动用真实资金。' : 'Paper trade — simulated close, no real funds involved.')
: (isZh ? '立即按市价在 Hyperliquid 上平仓。盈亏将立即结算,无法撤销。' : 'Closes at market price on Hyperliquid right now. The profit or loss becomes final and cannot be undone.')}
</div>
<div style={{
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
@@ -458,17 +510,17 @@ export default function OpenPositions() {
<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 style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Current price'}</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 ? '已锁定(降风险' : 'Locked in (de-risked)'}</span>
<span>{isZh ? '已落袋(之前减仓' : 'Already banked'}</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>{isZh ? '平仓后将结算' : 'You\'ll get if you close now'}</span>
<span style={{
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
+59 -54
View File
@@ -1,6 +1,6 @@
'use client'
import Link from 'next/link'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
@@ -25,8 +25,8 @@ import { confirmSign } from '@/components/wallet/SignConfirmSheet'
*/
const SYS = {
trump: { idx: '', name: 'Trump', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
btc: { idx: '', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
trump: { idx: '', name: 'Trump Signal', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
btc: { idx: '', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
} as const
const ROW: React.CSSProperties = {
@@ -49,9 +49,18 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
useEffect(() => { setMounted(true) }, [])
// Generation counter: each address change increments it so any in-flight
// refresh from the previous address can detect it's stale.
// `snap !== address` inside a useCallback is a stale-closure trap — both
// refer to the same closed-over value so the check is always false.
const genRef = useRef(0)
useEffect(() => { genRef.current++ }, [address])
const refresh = useCallback(async () => {
if (!address) { setPub(null); return }
const gen = genRef.current
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
if (gen !== genRef.current) return // wallet changed while in-flight
setPub(p)
}, [address])
@@ -71,6 +80,14 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
if (busy) return
if (!address) { setErr(isZh ? '请先连接右上角的钱包。' : 'Connect your wallet first (top-right).'); return }
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
// B51: live users without an HL API key cannot actually execute trades —
// block the toggle so the ON state never silently becomes a no-op.
if (on && !paper && !pub?.hl_api_key_set) {
setErr(isZh
? '未绑定 Hyperliquid API Key。请先在设置页保存 API KeyAuto-Trade 才能真实下单。'
: 'No Hyperliquid API key saved. Add your HL API key on the Settings page before enabling Auto-Trade.')
return
}
if (autoOn === on) return
// Claim the busy slot BEFORE awaiting confirmSign — otherwise a rapid
@@ -141,38 +158,34 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
// auto-open and (b) be a confusing second copy of the SAME global switch
// shown on the Trump page. Show the adopt-only flow instead.
if (system === 'btc') {
const linkStyle: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 12px',
borderRadius: 999, border: '1px solid var(--line)', background: 'var(--surface)',
fontSize: 12, color: 'var(--ink)', textDecoration: 'none', fontWeight: 700,
boxShadow: 'var(--shadow-1)',
}
// Compact inline strip — signal fires → you open → bot manages.
// No big card; the data panel is the main content. Settings link stays
// for users who want to configure, but the explanation is one line.
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
<div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.idx} {s.name} manage-only
</div>
</div>
<div style={{ padding: '16px', fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6 }}>
<p style={{ margin: '0 0 10px' }}>
<strong>Macro Vibes does not auto-open trades.</strong> When a bottom-reversal
signal fires you get a Telegram alert. You open the position yourself on
Hyperliquid, then hand it to the bot with <code>/adopt</code> the bot then
manages the exit (staged stop ladder, de-risk, pyramid, peak-trail).
</p>
<p style={{ margin: '0 0 12px', color: 'var(--ink-4)', fontSize: 12 }}>
The Auto-Trade switch on the Trump page controls Trump (System&nbsp;1)
auto-opens only it has no effect on Macro Vibes.
</p>
<Link href={settingsHref} style={linkStyle}>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)' }}>Settings</span>
<span>{settingsLabel}</span>
<span aria-hidden="true"></span>
</Link>
</div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, flexWrap: 'wrap',
padding: '9px 14px', marginBottom: 14,
borderRadius: 8,
background: s.soft,
borderLeft: `3px solid ${s.accent}`,
}}>
<span style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.4 }}>
<strong style={{ color: s.accent }}>You open · bot manages exit</strong>
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
Signal Telegram open on Hyperliquid <code style={{ fontSize: 11 }}>/adopt</code>
</span>
</span>
<Link
href={settingsHref}
style={{
fontSize: 11, color: s.accent, textDecoration: 'none',
fontWeight: 700, flexShrink: 0,
opacity: 0.8,
}}
>
{settingsLabel} settings
</Link>
</div>
)
}
@@ -248,11 +261,18 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
netMsg = isZh
? 'Auto-Trade 当前为关闭: Trump 信号仍会显示,但不会自动开仓。'
: 'Auto-Trade is OFF — Trump signals are shown in the feed but NOT traded.'
} else if (pub?.trump_enabled === false) {
// Auto-Trade is ON but the Trump (System-1) gate is OFF, so the backend
// skips every Trump signal (bot_engine: trump_enabled OFF → not traded).
// Without this branch the UI falsely promised "next signal auto-opens".
netMsg = isZh
? 'Auto-Trade 已开启,但 Trump 系统①未启用: 在设置页打开 Trump 开关后才会自动开仓。'
: 'Auto-Trade is ON, but the Trump system is disabled — enable Trump in Settings before signals will trade.'
} else {
netOk = true
netMsg = isZh
? `Auto-Trade 已开启(Trump 系统①): 下一条合格 Trump 信号会自动开出${paper ? '模拟' : '真实'}仓位。`
: `Auto-Trade ON (Trump / System 1) — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
: `Auto-Trade ON — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
}
const Pill = ({ active, onClick, children, tone }: {
@@ -278,7 +298,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
<div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.idx} {s.name} {isZh ? '控制面板' : '— control'}
{s.name} {isZh ? '控制面板' : '— Auto-Trade'}
</div>
</div>
@@ -288,25 +308,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
<div>
<div style={{ fontSize: 14, fontWeight: 700 }}>
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? Trump 系统①' : TRUMP (System 1)'}</span>
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? 事件交易' : event-driven'}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3, maxWidth: 480, lineHeight: 1.5 }}>
{isZh ? (
<>
<strong> Trump </strong>
Trump
Macro Vibes Macro manage-only
</>
) : (
<>
<strong>Controls Trump (System 1) auto-opens.</strong> OFF: signals
scanned &amp; shown in the feed, nothing traded. ON: a qualifying
Trump signal auto-opens a trade. Stop-loss / staged de-risk always
protect open positions either way. (Macro Vibes is manage-only and
never auto-opens see its page.)
</>
)}
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3 }}>
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
+32 -15
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
@@ -36,19 +36,36 @@ export default function TelegramCard() {
useEffect(() => { setMounted(true) }, [])
// Generation counter guards against stale-closure trap: `snap !== address`
// inside useCallback compares two closed-over copies of the same value and
// is always false. genRef is a mutable ref readable from any closure.
const genRef = useRef(0)
useEffect(() => { genRef.current++ }, [address])
const refresh = useCallback(async () => {
if (!address) {
setLoading(false)
setStatus(null)
return
}
if (!address) { setLoading(false); setStatus(null); return }
const gen = genRef.current
setLoading(true)
try {
const s = await getTelegramStatus(address.toLowerCase())
const { getCachedViewEnvelope } = await import('@/lib/signedRequest')
const cached = getCachedViewEnvelope('view_user', address)
const s = await getTelegramStatus(address.toLowerCase(), cached ?? undefined)
if (gen !== genRef.current) return // wallet changed while in-flight
setStatus(s); setErr('')
} catch (e) {
if (gen !== genRef.current) return
setErr(e instanceof Error ? e.message : 'load failed')
} finally { setLoading(false) }
} finally {
if (gen === genRef.current) setLoading(false)
}
}, [address])
// B32: clear stale status immediately when wallet changes so the previous
// wallet's binding info never flickers in before the new fetch resolves.
useEffect(() => {
setStatus(null)
setCode(null)
setErr('')
}, [address])
useEffect(() => { refresh() }, [refresh])
@@ -112,7 +129,7 @@ export default function TelegramCard() {
Telegram alerts
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6 }}>
Connect your wallet first. After that, you can link Telegram for alert delivery and Pro wallet-bound notifications.
You can get free Telegram alerts without a wallet just open the bot. Connect a wallet here to also get alerts tailored to your own positions.
</div>
</div>
)
@@ -180,8 +197,8 @@ export default function TelegramCard() {
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 2 }}>
{status.bound
? (isZh ? '打开机器人调整偏好(/trump /btc /funding /kol /conf /quiet' : 'Open the bot to adjust preferences (/trump /btc /funding /kol /conf /quiet)')
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and send /start — no account needed')}
? (isZh ? '机器人里可开关各类提醒、设置免打扰时段' : 'Open the bot to choose which alerts you get and set quiet hours')
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and tap Start — no account needed')}
</div>
</div>
</div>
@@ -197,11 +214,11 @@ export default function TelegramCard() {
</span>
{status.wallet_address ? (
<>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<button className="btn ghost" disabled={busy} onClick={handleUnbind}
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}>
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
@@ -209,7 +226,7 @@ export default function TelegramCard() {
</>
) : (
<>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
</>
)}
+295 -65
View File
@@ -6,9 +6,11 @@ import { useAccount, useConnect, useSignMessage } from 'wagmi'
import {
getUserPublic,
getUser,
getTelegramStatus,
setUserSettings,
setHlApiKey,
setManualWindow,
setAutoTrade,
subscribe,
type UserSettings,
} from '@/lib/api'
@@ -30,18 +32,27 @@ const DEFAULT_SETTINGS: UserSettings = {
trump_enabled: false, macro_enabled: false,
}
// The backend treats active_from/active_until as a DAILY RECURRING time window
// (it extracts only .time() and compares with the current UTC time). Storing a
// full datetime-local value was misleading — the date part was silently ignored
// so users thought they were setting a date range when they were setting a
// time-of-day schedule. We now use type="time" and store as a fixed-epoch ISO
// so the backend's .time() extraction gives the right HH:MM:SS.
function isoToLocalInput(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
// Return just HH:MM for the time input
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
}
function localInputToIso(v: string): string | null {
if (!v) return null
const d = new Date(v)
if (isNaN(d.getTime())) return null
return d.toISOString()
// v is HH:MM from <input type="time">. Store as 2000-01-01T{HH:MM}:00Z so
// the backend's .time() extraction returns the correct UTC time.
const [hh, mm] = v.split(':')
if (!hh || !mm) return null
return `2000-01-01T${hh.padStart(2,'0')}:${mm.padStart(2,'0')}:00Z`
}
export default function BotConfigPanel() {
@@ -51,7 +62,7 @@ export default function BotConfigPanel() {
const { signMessageAsync } = useSignMessage()
const {
isSubscribed, hlApiKeySet, hlApiKeyMasked,
setSubscribed, setBotReadiness, setHlApiKeySet,
setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode: setPaperModeStore,
} = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
@@ -75,24 +86,66 @@ export default function BotConfigPanel() {
const [mwErr, setMwErr] = useState('')
const [mwTick, setMwTick] = useState(0)
const [paperMode, setPaperMode] = useState(false)
const [autoTrade, setAutoTrade_] = useState(false)
const [atState, setAtState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [showAdvanced, setShowAdvanced] = useState(false)
const [mounted, setMounted] = useState(false)
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
const [loadErr, setLoadErr] = useState('')
const [connectErr, setConnectErr] = useState('')
// Telegram binding state. Macro Vibes (System-2) is manage-only via the
// Telegram /adopt command, so a Macro-only user who hasn't bound Telegram
// cannot actually hand any position to the bot — readiness must reflect that.
// The unauthenticated status call returns only { bound } (no chat_id), which
// is all we need here.
const [tgBound, setTgBound] = useState<boolean | null>(null)
useEffect(() => { setMounted(true) }, [])
useEffect(() => {
if (!address) { setTgBound(null); return }
let cancelled = false
getTelegramStatus(address.toLowerCase())
.then(s => { if (!cancelled) setTgBound(!!s.bound) })
.catch(() => { if (!cancelled) setTgBound(null) })
return () => { cancelled = true }
}, [address])
// B33: when the connected wallet changes, immediately wipe all private
// settings so the previous wallet's config never leaks into the new one.
useEffect(() => {
setSettings(DEFAULT_SETTINGS)
setPaperMode(false)
setAutoTrade_(false)
setTpConfigured(false)
setSlConfigured(false)
setUseBudget(false)
setUseSchedule(false)
setFromLocal('')
setUntilLocal('')
setManualUntil(null)
setApiKey('')
setDirty(false)
setSaveState('idle'); setSaveErr('')
setKeyState('idle'); setKeyErr('')
setSubState('idle'); setSubErr('')
setLoadState('idle'); setLoadErr('')
setConnectErr('')
}, [address])
function applyUserPayload(u: {
active: boolean
hl_api_key_set: boolean
hl_api_key_masked: string | null
paper_mode?: boolean
auto_trade?: boolean
settings: UserSettings
manual_window_until?: string | null
}) {
setSubscribed(u.active)
setPaperMode(!!u.paper_mode)
setPaperModeStore(!!u.paper_mode) // sync to global store for B40/B41
setAutoTrade_(!!u.auto_trade)
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
setManualUntil(u.manual_window_until ?? null)
if (u.settings) {
@@ -126,6 +179,7 @@ export default function BotConfigPanel() {
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
setAutoTrade_(!!pub.auto_trade)
if (!pub.active) return
const cached = getCachedViewEnvelope('view_user', address)
if (!cached) return
@@ -141,11 +195,8 @@ export default function BotConfigPanel() {
if (!address) return
setLoadErr(''); setLoadState('loading')
try {
const ok = await confirmSign({
label: 'View account settings',
description: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification — no on-chain action is performed.',
})
if (!ok) { setLoadState('idle'); return }
// Read-only identity check — go straight to MetaMask, no pre-confirm sheet.
// getOrCreateViewEnvelope caches the result for 4 min so repeat visits are free.
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
const u = await getUser(address, env)
applyUserPayload(u)
@@ -161,9 +212,34 @@ export default function BotConfigPanel() {
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
setAutoTrade_(!!pub.auto_trade)
return pub
}
async function flipAutoTrade(on: boolean) {
if (!address || atState !== 'idle') return
const ok = await confirmSign({
label: on ? 'Enable Auto-Trade' : 'Disable Auto-Trade',
description: on
? 'Bot will open real positions on Hyperliquid when qualifying Trump signals fire. Stop-loss and de-risking remain active.'
: 'Bot stops opening new trades. Risk controls on existing positions keep running.',
danger: on,
})
if (!ok) return
setAtState('signing')
try {
const env = await signRequest({ action: 'set_auto_trade', wallet: address, body: { enabled: on }, signMessageAsync })
setAtState('saving')
const r = await setAutoTrade(env, on)
setAutoTrade_(r.auto_trade)
setAtState('idle')
} catch (e: unknown) {
console.error('set_auto_trade failed', e)
setAtState('err')
setTimeout(() => setAtState('idle'), 3000)
}
}
function updateSettings(patch: Partial<UserSettings>) {
setSettings(s => ({ ...s, ...patch }))
setDirty(true)
@@ -202,7 +278,7 @@ export default function BotConfigPanel() {
if (!address) return
const ok = await confirmSign({
label: 'Save trading settings',
description: 'Settings are saved to the server and apply to all new trades from this point forward. Open positions are not affected.',
description: 'Applied to all new trades going forward. Open positions are not affected.',
})
if (!ok) return
setSaveErr(''); setSaveState('signing')
@@ -213,7 +289,11 @@ export default function BotConfigPanel() {
schedFrom = localInputToIso(fromLocal)
schedUntil = localInputToIso(untilLocal)
if (!schedFrom || !schedUntil) { setSaveErr('Pick both a start and end time'); setSaveState('err'); return }
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) { setSaveErr('End must be after start'); setSaveState('err'); return }
// Cross-midnight windows (e.g. 22:0002:00) are VALID — the backend's
// _is_in_active_window treats af_t > au_t as a wrap-around window
// (now >= from OR now <= until). Only reject when start === end, which
// would describe a zero-length (or full-day-ambiguous) window.
if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end cant be the same time'); setSaveState('err'); return }
}
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
if (trumpOn) {
@@ -244,7 +324,7 @@ export default function BotConfigPanel() {
}
const ok = await confirmSign({
label: 'Link Hyperliquid API key',
description: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you.',
description: 'Stored encrypted on the server. Used by the bot to open and close positions only — no withdrawal access.',
danger: true,
})
if (!ok) return
@@ -304,7 +384,7 @@ export default function BotConfigPanel() {
if (!address) return
const ok = await confirmSign({
label: 'Switch to live trading',
description: 'Your subscription will change from paper mode to live. For your safety, Auto-Trade is turned OFF on this switch — you must re-enable it explicitly while live. No trade opens immediately; you still need to add a Hyperliquid API key first.',
description: 'Switches to live trading. Auto-Trade is turned OFF automatically — re-enable it explicitly. You still need a Hyperliquid API key before the bot can trade.',
danger: true,
})
if (!ok) return
@@ -360,10 +440,9 @@ export default function BotConfigPanel() {
if (!isSubscribed) {
return (
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your trading bot</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 24 }}>
The bot places trades on Hyperliquid with a trade-only API key it can never withdraw funds.
Start in paper mode to try it safely, or choose Live if you already have a Hyperliquid API key.
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your bot</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 24 }}>
Trade-only API key no withdrawals. Try Paper first, or Live if you already have a key.
</div>
{/* Paper / Live choice */}
@@ -421,8 +500,8 @@ export default function BotConfigPanel() {
return (
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Sign in to view your settings</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 20 }}>
Your settings are private and wallet-bound. Sign once to load them the permission is cached for 4 minutes so you won&apos;t be prompted again while you browse.
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 20 }}>
Settings are wallet-private. Sign once to load cached for 4 min.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button className="btn amber" style={{ padding: '10px 22px', fontSize: 13 }} onClick={handleLoadSettings} disabled={loadState === 'loading'}>
@@ -442,6 +521,13 @@ export default function BotConfigPanel() {
if (!trumpOn && !macroOn) missingItems.push('enable Trump Signal or Macro Vibes below')
if (trumpOn && !tpConfigured) missingItems.push('set a Trump Signal take-profit %')
if (trumpOn && !slConfigured) missingItems.push('set a Trump Signal stop-loss %')
// Macro Vibes is manage-only through the Telegram /adopt command. If Macro is
// the only enabled system and Telegram isn't bound, the bot can't manage any
// position — so "ready" would be a lie. (tgBound === null = status unknown /
// still loading: don't block on it.)
if (macroOn && !trumpOn && tgBound === false) {
missingItems.push('connect Telegram (Settings → Telegram) so the bot can manage Macro positions via /adopt')
}
const botReady = missingItems.length === 0
// Manual window countdown
@@ -461,6 +547,77 @@ export default function BotConfigPanel() {
return (
<div style={{ marginBottom: 28 }} id="bot-config-panel">
{/* ── Onboarding stepper ─────────────────────────────────────────────── */}
{(!isSubscribed || !(hlApiKeySet || paperMode) || !autoTrade) && (
<div className="card" style={{ padding: '14px 18px 16px', marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 14 }}>
Setup progress
</div>
{/* Steps row: steps are fixed-width, connectors flex-grow to fill */}
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: 14 }}>
{([
{ label: 'Subscribe', done: isSubscribed },
{ label: 'Add HL key', done: hlApiKeySet || paperMode },
{ label: 'Enable Auto-Trade', done: autoTrade },
] as const).map((step, i, arr) => {
const isActive = !step.done && (i === 0 || arr[i - 1].done)
const isLast = i === arr.length - 1
return (
<div key={step.label} style={{
display: 'flex', alignItems: 'flex-start',
flex: isLast ? '0 0 auto' : 1, // last step: natural width; others: expand via connector
minWidth: 0,
}}>
{/* Circle + label — fixed, never stretches */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--bg-sunk)',
color: step.done ? '#fff' : isActive ? '#000' : 'var(--ink-4)',
border: `2px solid ${step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--line)'}`,
boxSizing: 'border-box',
}}>
{step.done ? '✓' : i + 1}
</div>
<div style={{
fontSize: 10, lineHeight: 1.3,
textAlign: isLast ? 'right' : 'center',
fontWeight: isActive ? 600 : 400,
color: step.done ? 'var(--up)' : isActive ? 'var(--ink)' : 'var(--ink-4)',
whiteSpace: 'nowrap',
}}>
{step.label}
</div>
</div>
{/* Connector — takes all remaining space between this and next step */}
{!isLast && (
<div style={{
flex: 1, height: 2, marginTop: 11,
background: step.done ? 'var(--up)' : 'var(--line)',
transition: 'background 0.3s',
}} />
)}
</div>
)
})}
</div>
{/* Bottom progress bar */}
<div style={{ height: 3, borderRadius: 999, background: 'var(--bg-sunk)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 999,
background: 'var(--up)',
width: `${Math.round(([isSubscribed, hlApiKeySet || paperMode, autoTrade].filter(Boolean).length / 3) * 100)}%`,
transition: 'width .4s ease',
}} />
</div>
</div>
)}
{/* ── HL API Key ─────────────────────────────────────────────────────── */}
<div className="card" style={{ padding: '16px 20px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
@@ -481,7 +638,9 @@ export default function BotConfigPanel() {
? <span style={{ color: 'var(--up)' }}>📝 Paper mode no real money involved. Add an API key below to switch to live.</span>
: hlApiKeySet && !apiKey
? <><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only · cannot withdraw</span></>
: 'Generate at app.hyperliquid.xyz/API — paste the private key here. Never your MetaMask key.'}
: <>Don&apos;t have Hyperliquid?{' '}
<a href="https://app.hyperliquid.xyz" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber)', textDecoration: 'none' }}>Sign up free </a>
{' '}· Then go to API generate a trade-only key paste below.</>}
</div>
</div>
{/* Paper mode: show upgrade path instead of key input */}
@@ -526,10 +685,74 @@ export default function BotConfigPanel() {
))}
</div>
{keyState === 'err' && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8, paddingLeft: 50 }}>{keyErr}</div>}
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Run one small test trade to confirm the live path end-to-end.</div>}
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.</div>}
{!paperMode && !hlApiKeySet && (
<div style={{
marginTop: 10, paddingLeft: 50,
display: 'flex', alignItems: 'flex-start', gap: 6,
}}>
<span style={{ fontSize: 13, flexShrink: 0, marginTop: 1 }}></span>
<div style={{ fontSize: 11, color: 'var(--down)', lineHeight: 1.5 }}>
<strong>Do not paste your main wallet private key.</strong>{' '}
This field only accepts a Hyperliquid <em>trade-only API key</em> generate one at{' '}
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--down)', textDecorationColor: 'var(--down)' }}>
app.hyperliquid.xyz/API
</a>
{' '} &ldquo;Generate API wallet&rdquo;. A trade-only key cannot withdraw funds.
</div>
</div>
)}
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
</div>
{/* ── Auto-Trade master switch ───────────────────────────────────────── */}
{isSubscribed && (hlApiKeySet || paperMode) && (
<div className="card" style={{ padding: '14px 20px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>Auto-Trade</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4 }}>
{autoTrade
? 'ON — bot opens positions automatically when qualifying Trump signals fire.'
: 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
onClick={() => flipAutoTrade(true)}
disabled={atState !== 'idle'}
style={{
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
border: 'none',
background: autoTrade ? 'var(--up)' : 'var(--bg-sunk)',
color: autoTrade ? '#fff' : 'var(--ink-4)',
opacity: atState !== 'idle' ? 0.5 : 1,
}}
>ON</button>
<button
onClick={() => flipAutoTrade(false)}
disabled={atState !== 'idle'}
style={{
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
border: '1px solid var(--line)',
background: !autoTrade ? 'var(--ink)' : 'transparent',
color: !autoTrade ? 'var(--bg)' : 'var(--ink-4)',
opacity: atState !== 'idle' ? 0.5 : 1,
}}
>OFF</button>
</div>
</div>
{atState !== 'idle' && (
<div style={{ fontSize: 11, marginTop: 8, color: atState === 'err' ? 'var(--down)' : 'var(--ink-4)' }}>
{atState === 'signing' ? 'Waiting for wallet signature…'
: atState === 'saving' ? 'Saving…'
: 'Failed to update — please try again.'}
</div>
)}
</div>
)}
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
<div id="config-trump" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
{/* Header */}
@@ -560,7 +783,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Per-trade size
<span className="hint">Notional in USD margin ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
<span className="hint">How much to bet per trade. At {settings.leverage}×, HL holds ~${(settings.position_size_usd / settings.leverage).toFixed(0)} as collateral.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -575,7 +798,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Leverage
<span className="hint">Event-driven scalp use lower leverage if unsure</span>
<span className="hint">How aggressive the position is. Start low (23×) and increase once you've seen the bot trade live.</span>
</div>
<div className="form-row-control">
<div className="slider-field">
@@ -584,13 +807,18 @@ export default function BotConfigPanel() {
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
</div>
<span className="slider-readout">{settings.leverage}×</span>
{settings.leverage > 10 && (
<div className="settings-note warn" style={{ marginTop: 6 }}>
{settings.leverage}× is high for event-driven signals. A 1.5% stop-loss at {settings.leverage}× means the trade closes on a {(1.5 / settings.leverage).toFixed(1)}% price move against you. Consider 35× until you see how the bot performs live.
</div>
)}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Skip signals below this score (0 = take all, 100 = only the highest-conviction)</span>
<span className="hint">Filter out weak signals. Higher = fewer trades, but only the strongest ones.</span>
</div>
<div className="form-row-control">
<div className="slider-field">
@@ -607,7 +835,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when unrealised gain hits this target. Required.</span>
<span className="hint">Bot locks in profit when the position gains this much. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -623,7 +851,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when drawdown hits this limit. Required.</span>
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -655,8 +883,8 @@ export default function BotConfigPanel() {
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{macroOn
? 'You open a BTC long on Hyperliquid, then use /adopt in the Telegram bot to hand it to the bot for exit management.'
: 'Disabled — Macro Vibes alerts will not include bot management instructions.'}
? 'When a signal fires: open a BTC long on Hyperliquid type /adopt in the Telegram bot bot manages the exit for you.'
: 'Disabled Macro Vibes alerts sent without bot management. Enable + set up Telegram to activate.'}
</div>
</div>
<Switch on={macroOn} onChange={v => updateSettings({ macro_enabled: v })} tone="up" />
@@ -719,7 +947,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
BTC bottom leverage
<span className="hint">Separate from Trump leverage. The bot de-risks in stages before exchange liquidation.</span>
<span className="hint">Higher leverage = less room for BTC to drop before the bot starts reducing the position.</span>
</div>
<div className="form-row-control">
<div className="slider-field">
@@ -729,9 +957,9 @@ export default function BotConfigPanel() {
</div>
<span className="slider-readout">{lev}×</span>
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
At {lev}× it sheds near {(prot * 0.6).toFixed(0)}%, near {(prot * 0.8).toFixed(0)}%, fully out by {prot.toFixed(0)}%.
Exchange liquidation {liq.toFixed(0)}%.
{risky ? ' Above 2×, a normal correction can push you out early.' : ' Wide enough to survive a normal correction.'}
{risky
? `⚠️ At ${lev}×, BTC only needs to drop ${prot.toFixed(0)}% before the bot fully closes the position. A normal correction can trigger early exits — use lower leverage unless you're comfortable with that.`
: `At ${lev}×, the bot has room for a ${prot.toFixed(0)}% BTC drop before closing. It reduces the position gradually as the drawdown deepens, so you don't lose everything at once.`}
</div>
</div>
</div>
@@ -751,7 +979,7 @@ export default function BotConfigPanel() {
cursor: 'pointer', borderBottom: showAdvanced ? '1px solid var(--line)' : 'none',
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Advanced</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Risk limits &amp; schedule</span>
<span style={{
fontSize: 11, color: 'var(--ink-4)',
display: 'flex', alignItems: 'center', gap: 10,
@@ -770,7 +998,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Daily trading cap
<span className="hint">Optional bot stops opening new trades once total notional crosses this limit in a UTC day.</span>
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
@@ -792,7 +1020,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Trading schedule
<span className="hint">Bot only accepts signals inside this window. Times in your browser&apos;s local timezone. Leave off to trade anytime.</span>
<span className="hint">Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:0002:00) are allowed. Leave off for 24/7.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
<Switch on={useSchedule} onChange={v => { setUseSchedule(v); setDirty(true) }} />
@@ -800,46 +1028,48 @@ export default function BotConfigPanel() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="num-field">
<span className="prefix">From</span>
<input type="datetime-local" lang="en-US" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
<input type="time" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
<div className="num-field">
<span className="prefix">Until</span>
<input type="datetime-local" lang="en-US" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
<input type="time" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
</div>
</div>
)}
</div>
</div>
{/* Manual window */}
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Override window
<span className="hint">Open a timed override so the bot accepts signals for the next 124 hours useful for high-conviction catalysts like CPI or FOMC releases.</span>
{/* Override window — only relevant when a schedule is set */}
{useSchedule && (
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Override window
<span className="hint">Temporarily bypass your schedule. Useful when a high-conviction event (e.g. CPI, FOMC) happens outside your trading hours.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
{armed ? (
<>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}>● Override active — {remainingLabel} remaining</span>
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
{mwBusy ? 'Sign' : 'Cancel'}
</button>
</>
) : (
[4, 12, 24].map(h => (
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
{mwBusy && mwState === 'signing' ? 'Sign' : `+${h}h`}
</button>
))
)}
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
</div>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
{armed ? (
<>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}> Override active {remainingLabel} remaining</span>
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
{mwBusy ? 'Sign…' : 'Cancel override'}
</button>
</>
) : (
[1, 4, 24].map(h => (
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
{mwBusy && mwState === 'signing' ? 'Sign…' : `Override ${h}h`}
</button>
))
)}
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
</div>
</div>
)}
</div>
)}
+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>
+48
View File
@@ -0,0 +1,48 @@
'use client'
/**
* AnimatedNumber — flashes green/red when its value changes, the way a live
* trading terminal ticks. Pure presentational: it renders `display` (already
* formatted) but watches `value` (the raw number) to decide flash direction.
*
* The flash is a CSS class toggled for ~600ms via a keyframe (`tick-flash-up` /
* `tick-flash-down` in globals.css). Respects prefers-reduced-motion (the CSS
* keyframes are disabled there, so this degrades to a plain number).
*/
import { useEffect, useRef, useState } from 'react'
interface Props {
/** Raw numeric value — drives the flash direction when it changes. */
value: number | null | undefined
/** Pre-formatted string to actually show (e.g. "$72,140"). */
display: string
/** Extra className(s) for the wrapper. */
className?: string
style?: React.CSSProperties
}
export default function AnimatedNumber({ value, display, className = '', style }: Props) {
const prev = useRef<number | null | undefined>(value)
const [dir, setDir] = useState<'up' | 'down' | null>(null)
useEffect(() => {
const p = prev.current
if (value != null && p != null && value !== p) {
setDir(value > p ? 'up' : 'down')
const id = setTimeout(() => setDir(null), 600)
prev.current = value
return () => clearTimeout(id)
}
prev.current = value
}, [value])
return (
<span
className={`tick-num ${dir ? `tick-flash-${dir}` : ''} ${className}`}
style={style}
>
{display}
</span>
)
}
+2 -2
View File
@@ -93,7 +93,7 @@ function PageBtn({ num, active, onClick }: { num: number; active: boolean; onCli
const [hover, setHover] = React.useState(false)
const bg = active
? 'var(--brand, #f5a524)'
? 'var(--amber)'
: hover
? 'var(--bg-sunk)'
: 'transparent'
@@ -105,7 +105,7 @@ function PageBtn({ num, active, onClick }: { num: number; active: boolean; onCli
: 'var(--ink-2)'
const border = active
? '1.5px solid var(--brand, #f5a524)'
? '1.5px solid var(--amber)'
: '1.5px solid var(--line)'
return (
+156
View File
@@ -0,0 +1,156 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useAccount } from 'wagmi'
import { useWsSubscribe } from '@/lib/wsContext'
interface TradeAlert {
id: number
event: 'execution_failed' | 'insufficient_balance' | 'budget_reached'
| 'reconcile_drift' | 'circuit_breaker_tripped'
asset?: string
reason?: string
balance_usd?: number
required_usd?: number
spent_usd?: number
cap_usd?: number
// reconcile_drift fields (from reconciler.py broadcast)
// orphan_hl is a list of {asset, trade_id} dicts, NOT a number
orphan_hl?: unknown[]
marked_closed?: number
ghost_positions?: unknown[]
// circuit_breaker_tripped fields (from circuit_breaker.py broadcast)
system?: string
cb_reason?: string
unlock_at?: string
}
// How long each alert stays visible before auto-dismissing.
const AUTO_DISMISS_MS = 10_000
function alertMessage(alert: TradeAlert): string {
switch (alert.event) {
case 'execution_failed':
return `Auto-trade failed${alert.asset ? ` on ${alert.asset}` : ''}${alert.reason ? `: ${alert.reason}` : ''}. Check your HL API key and account status.`
case 'insufficient_balance':
return `Insufficient balance to open${alert.asset ? ` ${alert.asset}` : ''}: account has $${alert.balance_usd?.toFixed(2) ?? '?'}, trade requires $${alert.required_usd?.toFixed(2) ?? '?'}. Top up your Hyperliquid account.`
case 'budget_reached':
return `Daily budget reached${alert.asset ? ` (${alert.asset})` : ''}: $${alert.spent_usd?.toFixed(0) ?? '?'} spent of $${alert.cap_usd?.toFixed(0) ?? '?'} cap. No more trades today.`
// B47: reconciler detects DB ↔ HL mismatch (ghost/orphan position)
case 'reconcile_drift': {
const parts = []
const orphanCount = (alert.orphan_hl as unknown[])?.length ?? 0
if (orphanCount > 0) parts.push(`${orphanCount} orphan(s) on HL`)
if (alert.marked_closed) parts.push(`${alert.marked_closed} auto-closed`)
if ((alert.ghost_positions as unknown[])?.length) parts.push(`${(alert.ghost_positions as unknown[]).length} ghost(s)`)
return `Position drift detected: ${parts.join(', ') || 'mismatch between DB and HL'}. Check your Hyperliquid account.`
}
// B53: circuit breaker fired — Auto-Trade suspended
case 'circuit_breaker_tripped':
return `Circuit breaker tripped${alert.system ? ` (${alert.system})` : ''}${alert.cb_reason ? `: ${alert.cb_reason}` : ''}. Auto-Trade suspended — turn it back ON on the Trump page to acknowledge and resume.`
default:
return 'Auto-trade event — check your account.'
}
}
let _seq = 0
export default function TradeAlertBanner() {
const { address } = useAccount()
const [alerts, setAlerts] = useState<TradeAlert[]>([])
const dismiss = useCallback((id: number) => {
setAlerts(prev => prev.filter(a => a.id !== id))
}, [])
// Auto-dismiss each alert after AUTO_DISMISS_MS.
// Effect re-runs whenever the alert list changes. For each alert we schedule
// a timeout; cleanup cancels all pending timers when the list updates or
// the component unmounts — prevents a dismissed-then-re-added alert from
// triggering a ghost dismiss.
useEffect(() => {
if (alerts.length === 0) return
const timers = alerts.map(a =>
window.setTimeout(() => dismiss(a.id), AUTO_DISMISS_MS)
)
return () => timers.forEach(clearTimeout)
}, [alerts, dismiss])
useWsSubscribe('trade_alert', useCallback((raw) => {
const msg = raw as { wallet?: string; event?: string } & Record<string, unknown>
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
const alert = { id: ++_seq, ...msg } as TradeAlert
setAlerts(prev => [alert, ...prev].slice(0, 3))
}, [address]))
// B47: reconcile_drift — DB↔HL position mismatch flagged by the reconciler.
useWsSubscribe('reconcile_drift', useCallback((raw) => {
const msg = raw as { wallet?: string } & Record<string, unknown>
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
const alert = { id: ++_seq, event: 'reconcile_drift' as const, ...msg } as TradeAlert
setAlerts(prev => [alert, ...prev].slice(0, 3))
}, [address]))
// B53: circuit_breaker_tripped — Auto-Trade suspended by the CB.
useWsSubscribe('circuit_breaker_tripped', useCallback((raw) => {
const msg = raw as { wallet?: string; reason?: string } & Record<string, unknown>
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
// Rename 'reason' → 'cb_reason' to avoid collision with the trade_alert 'reason' field.
const { reason: cb_reason, ...rest } = msg
const alert = { id: ++_seq, event: 'circuit_breaker_tripped' as const, cb_reason, ...rest } as TradeAlert
setAlerts(prev => [alert, ...prev].slice(0, 3))
}, [address]))
if (alerts.length === 0) return null
return (
<>
<style>{`
@keyframes tradeAlertIn {
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes tradeAlertOut {
from { opacity: 1; }
to { opacity: 0; }
}
`}</style>
<div style={{
position: 'fixed', top: 60, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column', gap: 8,
width: 'min(480px, calc(100vw - 32px))',
pointerEvents: 'none',
animation: 'tradeAlertIn 0.2s ease',
}}>
{alerts.map(alert => (
<div key={alert.id} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '12px 14px', borderRadius: 10,
background: 'var(--bg)',
border: '1px solid var(--down)',
boxShadow: '0 4px 20px rgba(0,0,0,.3)',
pointerEvents: 'auto',
}}>
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}></span>
<div style={{ flex: 1, fontSize: 12, lineHeight: 1.5, color: 'var(--ink)' }}>
<strong style={{ color: 'var(--down)', display: 'block', marginBottom: 2 }}>
Auto-trade blocked
</strong>
{alertMessage(alert)}
</div>
<button
onClick={() => dismiss(alert.id)}
style={{
flexShrink: 0, padding: 0, border: 'none', background: 'transparent',
cursor: 'pointer', color: 'var(--ink-4)', fontSize: 16, lineHeight: 1,
}}
aria-label="Dismiss"
>
×
</button>
</div>
))}
</div>
</>
)
}
+189
View File
@@ -0,0 +1,189 @@
'use client'
/**
* MobileWalletSheet — bottom-drawer shown on mobile when no injected wallet
* is detected. Offers deep links to common mobile wallets so the user can
* open MetaMask (or Trust / Coinbase / OKX) and land directly on this dApp.
*
* Rendered as a fixed overlay + slide-up panel. Backdrop tap dismisses it.
* The CSS animation class `mobile-sheet-enter` is defined in globals.css.
*
* Usage:
* <MobileWalletSheet open={open} onClose={() => setOpen(false)} />
*/
import { useEffect, useState } from 'react'
import { getWalletLinks } from '@/lib/mobileWallet'
import type { WalletLink } from '@/lib/mobileWallet'
interface Props {
open: boolean
onClose: () => void
}
export default function MobileWalletSheet({ open, onClose }: Props) {
const [links, setLinks] = useState<WalletLink[]>([])
// Build deep links client-side only (needs window.location.href).
useEffect(() => {
if (open) {
setLinks(getWalletLinks(window.location.href))
}
}, [open])
// Lock body scroll while sheet is open.
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}
}, [open])
// Escape key closes.
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
/* Backdrop */
<div
style={{
position: 'fixed', inset: 0, zIndex: 9000,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'flex-end',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
onClick={onClose}
aria-modal="true"
role="dialog"
aria-label="Connect a wallet"
>
{/* Sheet */}
<div
className="mobile-sheet-enter"
style={{
width: '100%',
background: 'var(--bg-elev)',
borderRadius: '20px 20px 0 0',
padding: '20px 20px 32px',
boxShadow: '0 -8px 40px rgba(0,0,0,0.18)',
maxHeight: '80vh',
overflowY: 'auto',
}}
onClick={e => e.stopPropagation()}
>
{/* Handle bar */}
<div style={{
width: 40, height: 4,
background: 'var(--line-2)',
borderRadius: 99,
margin: '0 auto 20px',
}} />
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 4 }}>
Connect a wallet
</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.5 }}>
Open the dApp inside your wallet's built-in browser to connect.
</div>
</div>
{/* Wallet list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{links.map(link => (
<a
key={link.name}
href={link.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '14px 16px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--line)',
background: 'var(--surface)',
textDecoration: 'none',
color: 'inherit',
transition: 'background 120ms, border-color 120ms',
}}
onTouchStart={e => {
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--bg-sunk)'
}}
onTouchEnd={e => {
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--surface)'
}}
>
{/* Icon */}
<div style={{
width: 44, height: 44,
borderRadius: 12,
background: link.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: link.abbr.length > 2 ? 12 : 20,
fontWeight: 700,
color: '#fff',
flexShrink: 0,
letterSpacing: '-0.01em',
}}>
{link.abbr}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{link.name}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{link.hint}</div>
</div>
{/* Arrow */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--ink-3)', flexShrink: 0 }}>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</a>
))}
</div>
{/* WalletConnect note */}
<div style={{
marginTop: 18,
padding: '12px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-sunk)',
fontSize: 12,
color: 'var(--ink-3)',
lineHeight: 1.55,
}}>
<strong style={{ color: 'var(--ink-2)' }}>Already in your wallet's browser?</strong>
{' '}Tap Cancel to dismiss, then use the built-in browser navigation to reload the page MetaMask should auto-detect the dApp.
</div>
{/* Close */}
<button
onClick={onClose}
style={{
marginTop: 14,
width: '100%',
padding: '14px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
fontSize: 14,
fontWeight: 500,
color: 'var(--ink-2)',
}}
>
Cancel
</button>
</div>
</div>
)
}