Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
Big-picture changes since 01be8e7:
New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.
KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.
BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.
Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.
SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.
WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.
OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.
i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.
Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.
PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.
OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.
Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.
PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.
Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).
Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.
SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ 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'
|
||||
|
||||
// Action names must match backend/app/api/{user,subscribe}.py
|
||||
const ACTION_SET_API_KEY = 'set_hl_api_key'
|
||||
@@ -30,12 +31,15 @@ export default function BotPanel({ performance }: Props) {
|
||||
const { connect, 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('')
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setSubscribed(false)
|
||||
@@ -79,8 +83,7 @@ export default function BotPanel({ performance }: Props) {
|
||||
await refreshUserState(address)
|
||||
setSubState('idle')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
setSubError(msg.includes('User rejected') || msg.includes('denied') ? 'Signature cancelled' : msg.slice(0, 120))
|
||||
setSubError(walletErrorLabel(err, 'Signature cancelled', 120))
|
||||
setSubState('error')
|
||||
}
|
||||
}
|
||||
@@ -109,11 +112,10 @@ export default function BotPanel({ performance }: Props) {
|
||||
setApiKey('')
|
||||
setSaveState('success')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (msg.includes('User rejected') || msg.includes('denied')) {
|
||||
if (isUserRejection(err)) {
|
||||
setErrorMsg('Signature cancelled')
|
||||
} else {
|
||||
setErrorMsg(msg.slice(0, 120))
|
||||
setErrorMsg(walletErrorLabel(err, 'Signature cancelled', 120))
|
||||
}
|
||||
setSaveState('error')
|
||||
}
|
||||
@@ -166,13 +168,13 @@ export default function BotPanel({ performance }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="bot-cta">
|
||||
{!isConnected && (
|
||||
{(!mounted || !isConnected) && (
|
||||
<button className="btn amber" style={{ width: '100%' }}
|
||||
onClick={handleConnectWallet}>
|
||||
Connect wallet
|
||||
</button>
|
||||
)}
|
||||
{isConnected && !isSubscribed && (
|
||||
{mounted && isConnected && !isSubscribed && (
|
||||
<div style={{ width: '100%' }}>
|
||||
<button
|
||||
className="btn amber"
|
||||
@@ -187,12 +189,12 @@ export default function BotPanel({ performance }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isConnected && isSubscribed && !hlApiKeySet && (
|
||||
{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>
|
||||
)}
|
||||
{isConnected && isSubscribed && hlApiKeySet && (
|
||||
{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>
|
||||
@@ -201,7 +203,7 @@ export default function BotPanel({ performance }: Props) {
|
||||
</div>
|
||||
|
||||
{/* HL API Key card — only when subscribed */}
|
||||
{isConnected && isSubscribed && (
|
||||
{mounted && isConnected && isSubscribed && (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div className="section-title">
|
||||
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
|
||||
|
||||
@@ -87,7 +87,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
seriesRef.current = series
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
chart.subscribeClick((param: any) => {
|
||||
if (!param.time) return
|
||||
const clickTime = typeof param.time === 'number' ? param.time : 0
|
||||
@@ -164,7 +163,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
seriesRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Update candles + markers
|
||||
@@ -249,7 +247,6 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
// the last candle's bucket so the bar grows in place; high/low expand if
|
||||
// the live tick exceeds them.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const series = seriesRef.current as any
|
||||
if (!series) return
|
||||
const live = livePrices[asset]
|
||||
|
||||
@@ -49,13 +49,41 @@ function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatO
|
||||
return <span>{new Date(iso).toLocaleString('en-US', opts)}</span>
|
||||
}
|
||||
|
||||
function SourceIcon({ source: _source }: { source: string }) {
|
||||
// Truth Social only — no X/Twitter support.
|
||||
return <div className="src-ico truth">T</div>
|
||||
// Visual identity per signal source. The post stream now mixes Trump posts
|
||||
// with scanner-emitted technical signals — operators need to tell them
|
||||
// apart at a glance without reading the text.
|
||||
//
|
||||
// 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' },
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: string }) {
|
||||
const d = SOURCE_DISPLAY[source.toLowerCase()]
|
||||
if (d) {
|
||||
return <div className={`src-ico ${d.cls}`} title={d.title}>{d.glyph}</div>
|
||||
}
|
||||
// Unknown source — show first letter so user can still tell it apart.
|
||||
return <div className="src-ico external" title={`Source: ${source}`}>
|
||||
{source.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
}
|
||||
|
||||
function SignalPill({ signal }: { signal: string | null }) {
|
||||
if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
|
||||
if (signal === 'buy') return <span className="sig buy">BUY</span>
|
||||
if (signal === 'short') return <span className="sig short">SHORT</span>
|
||||
if (signal === 'sell') return <span className="sig sell">SELL</span>
|
||||
return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
|
||||
}
|
||||
|
||||
@@ -84,7 +112,11 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
<SourceIcon source={post.source} />
|
||||
<div className="post-body">
|
||||
<div className="meta">
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span>
|
||||
{/* 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}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<TimeAgo iso={post.published_at} suffix=" ago" />
|
||||
<span>·</span>
|
||||
@@ -98,6 +130,19 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
</div>
|
||||
<div className="post-aside">
|
||||
<SignalPill signal={post.signal} />
|
||||
{/* Target asset chip for actionable signals */}
|
||||
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, fontFamily: 'var(--mono)',
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
|
||||
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
{post.target_asset}
|
||||
{post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''}
|
||||
</span>
|
||||
)}
|
||||
<div className="impact-mini">
|
||||
{impact ? (
|
||||
<>
|
||||
@@ -134,6 +179,33 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade routing — target asset + expected move */}
|
||||
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
||||
<span className="tiny">Trade</span>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 700, fontFamily: 'var(--mono)',
|
||||
padding: '3px 8px', borderRadius: 5,
|
||||
background: post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
|
||||
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
|
||||
}}>
|
||||
{post.signal === 'buy'
|
||||
? `↑ LONG ${post.target_asset}`
|
||||
: `↓ SHORT ${post.target_asset}`}
|
||||
</span>
|
||||
{post.expected_move_pct != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{`AI expects ~${post.expected_move_pct}% in 1h`}
|
||||
</span>
|
||||
)}
|
||||
{post.category && (
|
||||
<span style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{post.category.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI reasoning */}
|
||||
{post.ai_reasoning && (
|
||||
<div>
|
||||
@@ -147,7 +219,7 @@ export default 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 }}>{`Peak move · ${impact.asset}`}</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'
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
const API_BASE = '/api/proxy/api'
|
||||
|
||||
interface SignalAlert {
|
||||
type: 'funding_signal'
|
||||
symbol: string
|
||||
time: string
|
||||
close: number
|
||||
tbr: number
|
||||
vol_mult: number
|
||||
bb_pct: number
|
||||
bb_upper: number
|
||||
btc_trend: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function symbolLabel(s: string) {
|
||||
return s.replace('USDT', '')
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
if (diff < 60) return `${Math.round(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
|
||||
return `${Math.round(diff / 3600)}h ago`
|
||||
}
|
||||
|
||||
// ── iOS-style toggle switch ───────────────────────────────────────────────────
|
||||
function ToggleSwitch({ on, loading, onToggle }: {
|
||||
on: boolean
|
||||
loading: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={loading}
|
||||
title={on ? 'Click to disable' : 'Click to enable'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
opacity: loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Switch track */}
|
||||
<div style={{
|
||||
width: 44,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
background: on ? '#22c55e' : 'var(--ink-4)',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
opacity: on ? 1 : 0.5,
|
||||
}}>
|
||||
{/* Thumb */}
|
||||
<div style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: on ? 20 : 2,
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: on ? '#22c55e' : 'var(--ink-3)',
|
||||
letterSpacing: '0.05em',
|
||||
minWidth: 24,
|
||||
}}>
|
||||
{on ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
export default function SignalMonitor() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [backendOk, setBackendOk] = useState<boolean | null>(null)
|
||||
const [signals, setSignals] = useState<SignalAlert[]>([])
|
||||
const [btcTrend, setBtcTrend] = useState<string | null>(null)
|
||||
const [lastScan, setLastScan] = useState<Date | null>(null)
|
||||
|
||||
// ── Load initial state ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/signal/status`)
|
||||
.then(r => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then(d => { setEnabledState(d.enabled); setBackendOk(true) })
|
||||
.catch(() => setBackendOk(false))
|
||||
|
||||
fetch(`${API_BASE}/signal/history?limit=20`)
|
||||
.then(r => r.json())
|
||||
.then((list: SignalAlert[]) => {
|
||||
if (!Array.isArray(list)) return
|
||||
setSignals(list)
|
||||
if (list.length > 0) {
|
||||
setBtcTrend(list[0].btc_trend)
|
||||
setLastScan(new Date(list[0].time))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// ── WebSocket listener (shared singleton connection via WsProvider) ─────────
|
||||
useWsSubscribe('funding_signal', (msg) => {
|
||||
const alert = msg as SignalAlert
|
||||
setBtcTrend(alert.btc_trend)
|
||||
setLastScan(new Date(alert.time))
|
||||
setSignals(prev => [alert, ...prev].slice(0, 50))
|
||||
})
|
||||
|
||||
// ── Toggle ───────────────────────────────────────────────────────────────
|
||||
const toggle = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const next = !enabled
|
||||
const r = await fetch(`${API_BASE}/signal/toggle?enabled=${next}`, { method: 'POST' })
|
||||
if (!r.ok) throw new Error()
|
||||
const d = await r.json()
|
||||
setEnabledState(d.enabled)
|
||||
setBackendOk(true)
|
||||
} catch {
|
||||
setBackendOk(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [enabled])
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
const btcUp = btcTrend?.includes('↑')
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginTop: 12 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 3 }}>
|
||||
{isZh ? '突破监控' : 'Breakout Monitor'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={enabled} loading={loading} onToggle={toggle} />
|
||||
</div>
|
||||
|
||||
{/* Backend offline warning */}
|
||||
{backendOk === false && (
|
||||
<div style={{
|
||||
fontSize: 11, color: '#f59e0b',
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
background: 'rgba(245,158,11,0.1)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
⚠️ {isZh ? '后端离线,开关暂时不可用' : "Backend offline — toggle won't work"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status row: BTC trend + last scan */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
fontSize: 11, color: 'var(--ink-3)',
|
||||
padding: '6px 10px', borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<span>
|
||||
BTC
|
||||
{btcTrend ? (
|
||||
<strong style={{ color: btcUp ? '#22c55e' : '#ef4444' }}>{btcTrend}</strong>
|
||||
) : (
|
||||
<span style={{ opacity: 0.5 }}>—</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{lastScan
|
||||
? (isZh ? `最近信号 ${timeAgo(lastScan.toISOString())}` : `Last signal ${timeAgo(lastScan.toISOString())}`)
|
||||
: enabled ? (isZh ? '扫描中…' : 'Scanning…') : (isZh ? '已暂停' : 'Paused')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal list */}
|
||||
{signals.length === 0 ? (
|
||||
<div style={{
|
||||
fontSize: 13, color: 'var(--ink-3)',
|
||||
textAlign: 'center', padding: '20px 0',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{enabled
|
||||
? (isZh ? '· 正在等待信号…' : '· Watching for signals…')
|
||||
: (isZh ? '· 打开开关后开始监控' : '· Enable the toggle to start watching')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{signals.map((s) => (
|
||||
<div key={`${s.symbol}-${s.time}`} style={{
|
||||
padding: '11px 13px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderLeft: `3px solid ${s.enabled ? '#22c55e' : 'rgba(255,255,255,0.15)'}`,
|
||||
}}>
|
||||
{/* Top row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
fontWeight: 700, fontSize: 14,
|
||||
fontFamily: 'var(--mono)',
|
||||
color: 'var(--ink)',
|
||||
}}>
|
||||
{symbolLabel(s.symbol)}
|
||||
</span>
|
||||
{!s.enabled && (
|
||||
<span style={{
|
||||
fontSize: 9, padding: '2px 6px', borderRadius: 4,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: 'var(--ink-3)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}>
|
||||
silent
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{timeAgo(s.time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metrics grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||
{[
|
||||
{ label: isZh ? '价格' : 'Price', value: `$${s.close.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, color: 'var(--ink)' },
|
||||
{ label: isZh ? '主动买入' : 'Taker Buy', value: `${(s.tbr * 100).toFixed(1)}%`, color: '#22c55e' },
|
||||
{ label: isZh ? '成交量倍数' : 'Vol ×', value: `${s.vol_mult}×`, color: '#22c55e' },
|
||||
].map(m => (
|
||||
<div key={m.label} style={{
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 6, padding: '5px 8px',
|
||||
}}>
|
||||
<div style={{ fontSize: 9, color: 'var(--ink-3)', marginBottom: 2, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{m.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontFamily: 'var(--mono)', fontWeight: 600, color: m.color }}>
|
||||
{m.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 8 }}>
|
||||
{isZh ? `BB squeeze 处于第 ${s.bb_pct} 分位` : `BB squeeze ${s.bb_pct}th pct`} · {s.btc_trend}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { defaultLocale, locales, type Locale } from '@/i18n'
|
||||
|
||||
function replaceLocale(pathname: string, nextLocale: Locale) {
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const current = segments[0]
|
||||
if (locales.includes(current as Locale)) {
|
||||
segments[0] = nextLocale
|
||||
} else {
|
||||
segments.unshift(nextLocale)
|
||||
}
|
||||
return '/' + segments.join('/')
|
||||
}
|
||||
|
||||
export default function LanguageSwitch() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const activeLocale = useMemo<Locale>(() => {
|
||||
const seg = pathname.split('/').filter(Boolean)[0]
|
||||
return locales.includes(seg as Locale) ? (seg as Locale) : defaultLocale
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Language switch"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-sunk)',
|
||||
border: '1px solid var(--line)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{([
|
||||
['en', 'EN'],
|
||||
['zh', '中文'],
|
||||
] as const).map(([locale, label]) => {
|
||||
const active = activeLocale === locale
|
||||
const nextPath = replaceLocale(pathname || `/${defaultLocale}`, locale)
|
||||
const qs = searchParams.toString()
|
||||
const href = qs ? `${nextPath}?${qs}` : nextPath
|
||||
return (
|
||||
<Link
|
||||
key={locale}
|
||||
href={href}
|
||||
aria-pressed={active}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: locale === 'zh' ? 48 : 40,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
background: active ? 'var(--ink)' : 'transparent',
|
||||
color: active ? 'var(--bg)' : 'var(--ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+53
-19
@@ -4,6 +4,9 @@ import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { useTranslations } from 'next-intl'
|
||||
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
|
||||
// import LanguageSwitch from './LanguageSwitch'
|
||||
|
||||
function BrandMark() {
|
||||
return <span className="brand-mark">α</span>
|
||||
@@ -43,28 +46,59 @@ function ThemeToggle() {
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const t = useTranslations('nav')
|
||||
const pathname = usePathname()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copyAddress(addr: string) {
|
||||
let ok = false
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(addr)
|
||||
ok = true
|
||||
} else {
|
||||
// Fallback for non-secure contexts where the Clipboard API is absent.
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = addr
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
} catch { ok = false }
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
useEffect(() => { setWalletMenuOpen(false) }, [pathname, address])
|
||||
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
const path = '/' + pathname.split('/').slice(2).join('/')
|
||||
|
||||
// Two independent trading systems get their own top-level tabs — no
|
||||
// intermediate "Signals" hub. Trump = event scalp; BTC = reversal.
|
||||
const tabs = [
|
||||
{ key: '/', label: 'Overview' },
|
||||
{ key: '/posts', label: 'Signals' },
|
||||
{ key: '/trades', label: 'Trades' },
|
||||
{ key: '/analytics', label: 'Analytics' },
|
||||
{ key: '/settings', label: 'Settings' },
|
||||
{ key: '/', label: t('tabs.overview') },
|
||||
{ key: '/trump', label: t('tabs.trump') },
|
||||
{ key: '/btc', label: t('tabs.btc') },
|
||||
{ key: '/kol', label: t('tabs.kol') },
|
||||
{ key: '/trades', label: t('tabs.trades') },
|
||||
{ key: '/analytics', label: t('tabs.analytics') },
|
||||
{ key: '/settings', label: t('tabs.settings') },
|
||||
]
|
||||
|
||||
function isActive(key: string) {
|
||||
if (key === '/') return path === '/' || path === '//'
|
||||
// /posts (legacy hub) still resolves but isn't a tab; treat it + /archive
|
||||
// as part of the Trump section so a tab stays highlighted there.
|
||||
if (key === '/trump') return path.startsWith('/trump') || path.startsWith('/posts') || path.startsWith('/archive')
|
||||
return path.startsWith(key)
|
||||
}
|
||||
|
||||
@@ -72,19 +106,19 @@ export default function Navbar() {
|
||||
|
||||
return (
|
||||
<nav className="nav">
|
||||
<div className="brand">
|
||||
<Link href={`/${locale}`} className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<BrandMark />
|
||||
<span>Trump Alpha</span>
|
||||
</div>
|
||||
<span>{t('brand')}</span>
|
||||
</Link>
|
||||
|
||||
<div className="nav-tabs">
|
||||
{tabs.map(t => (
|
||||
{tabs.map(tab => (
|
||||
<Link
|
||||
key={t.key}
|
||||
href={`/${locale}${t.key === '/' ? '' : t.key}`}
|
||||
className={`nav-tab ${isActive(t.key) ? 'active' : ''}`}
|
||||
key={tab.key}
|
||||
href={`/${locale}${tab.key === '/' ? '' : tab.key}`}
|
||||
className={`nav-tab ${isActive(tab.key) ? 'active' : ''}`}
|
||||
>
|
||||
{t.label}
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -92,9 +126,10 @@ export default function Navbar() {
|
||||
<div className="nav-spacer" />
|
||||
|
||||
<div className="nav-right">
|
||||
{/* <LanguageSwitch /> — shelved until i18n coverage is complete */}
|
||||
<ThemeToggle />
|
||||
{!mounted ? (
|
||||
<button className="connect-btn lg" suppressHydrationWarning>Connect wallet</button>
|
||||
<button className="connect-btn lg" suppressHydrationWarning>{t('actions.connectWallet')}</button>
|
||||
) : isConnected && shortAddr ? (
|
||||
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -116,11 +151,10 @@ export default function Navbar() {
|
||||
role="menuitem"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (address) navigator.clipboard?.writeText(address)
|
||||
setWalletMenuOpen(false)
|
||||
if (address) copyAddress(address)
|
||||
}}
|
||||
>
|
||||
Copy address
|
||||
{copied ? t('actions.copied') : t('actions.copyAddress')}
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
@@ -131,7 +165,7 @@ export default function Navbar() {
|
||||
setWalletMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
{t('actions.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +177,7 @@ export default function Navbar() {
|
||||
if (connector) connect({ connector })
|
||||
}}
|
||||
>
|
||||
Connect wallet
|
||||
{t('actions.connectWallet')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Open positions panel — what's currently on the book.
|
||||
*
|
||||
* The single most-asked question on any trading dashboard: "what do I hold
|
||||
* right now?" Polls /api/positions/open + /api/positions/today every 15s.
|
||||
* Compact enough to embed at the top of Dashboard AND Trades.
|
||||
*
|
||||
* Empty state is intentional: explicitly says "0 open" rather than hiding,
|
||||
* so the user can confirm the bot isn't doing anything weird off-screen.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
getOpenPositions,
|
||||
getTodayStats,
|
||||
manualCloseTrade,
|
||||
setTradeGrow,
|
||||
type OpenPosition,
|
||||
type TodayStats,
|
||||
} from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
|
||||
function fmtMoney(n: number | null | undefined, opts: { sign?: boolean } = {}) {
|
||||
if (n == null || isNaN(n)) return '—'
|
||||
const sign = opts.sign === true
|
||||
const abs = Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
if (n < 0) return '-$' + abs
|
||||
if (sign && n > 0) return '+$' + abs
|
||||
return '$' + abs
|
||||
}
|
||||
function fmtPct(n: number | null | undefined) {
|
||||
if (n == null || isNaN(n)) return '—'
|
||||
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%'
|
||||
}
|
||||
function fmtHold(min: number) {
|
||||
if (min < 60) return `${min}m`
|
||||
const h = Math.floor(min / 60), m = min % 60
|
||||
if (h < 24) return `${h}h ${m}m`
|
||||
return `${Math.floor(h / 24)}d ${h % 24}h`
|
||||
}
|
||||
|
||||
function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
p: OpenPosition
|
||||
onClose: (p: OpenPosition) => void
|
||||
onToggleGrow: (p: OpenPosition) => void
|
||||
growBusy: boolean
|
||||
isZh: boolean
|
||||
}) {
|
||||
const pnlTone = (p.unrealized_pct ?? 0) > 0 ? 'up'
|
||||
: (p.unrealized_pct ?? 0) < 0 ? 'down' : 'idle'
|
||||
const pnlColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-3)'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
|
||||
gap: 12, alignItems: 'center',
|
||||
padding: '10px 14px',
|
||||
borderTop: '1px solid var(--line)',
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{/* Asset + paper tag */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`asset-dot ${p.asset.toLowerCase()}`} />
|
||||
<span style={{ fontWeight: 600 }}>{p.asset}</span>
|
||||
{p.is_paper && (
|
||||
<span style={{
|
||||
fontSize: 9, padding: '1px 5px', borderRadius: 3,
|
||||
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
|
||||
}}>P</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Side */}
|
||||
<span className={`side-pill ${p.side}`}>
|
||||
{p.side === 'long'
|
||||
? '↗ LONG'
|
||||
: '↘ SHORT'}
|
||||
</span>
|
||||
{/* Entry → Current */}
|
||||
<div className="mono" style={{ fontSize: 12 }}>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry → mark</div>
|
||||
<div>
|
||||
{fmtMoney(p.entry_price)}
|
||||
<span style={{ color: 'var(--ink-4)' }}> → </span>
|
||||
{p.current_price != null ? fmtMoney(p.current_price) : <span style={{ color: 'var(--ink-4)' }}>n/a</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Size + leverage (size = OPEN notional after de-risk) */}
|
||||
<div className="mono" style={{ fontSize: 12 }}>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>open size · lev</div>
|
||||
<div>
|
||||
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'}
|
||||
{p.leverage != null && <span style={{ color: 'var(--ink-4)' }}> · {p.leverage}×</span>}
|
||||
</div>
|
||||
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
|
||||
<div style={{ fontSize: 9, marginTop: 2 }}>
|
||||
{(p.addon_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
|
||||
)}
|
||||
{(p.derisk_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Per-trade Grow (pyramiding) switch */}
|
||||
<button
|
||||
onClick={() => onToggleGrow(p)}
|
||||
disabled={growBusy}
|
||||
title={p.grow_mode
|
||||
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
|
||||
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
|
||||
style={{
|
||||
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
|
||||
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
|
||||
border: '1px solid var(--line)',
|
||||
background: p.grow_mode ? 'var(--up, #16a34a)' : 'transparent',
|
||||
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Hold time */}
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{fmtHold(p.hold_minutes)}
|
||||
</div>
|
||||
{/* Unrealized PnL */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: pnlColor, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{fmtMoney(p.unrealized_usd, { sign: true })}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: pnlColor, opacity: 0.85 }}>
|
||||
{fmtPct(p.unrealized_pct)}
|
||||
</div>
|
||||
{p.realized_usd != null && p.realized_usd !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
banked {fmtMoney(p.realized_usd, { sign: true })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
|
||||
<button
|
||||
onClick={() => onClose(p)}
|
||||
className="btn ghost"
|
||||
style={{
|
||||
padding: '6px 10px', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--down)',
|
||||
border: '1px solid color-mix(in oklab, var(--down) 25%, var(--line))',
|
||||
}}
|
||||
title={isZh ? '立即平掉这个仓位' : 'Close this position immediately'}
|
||||
>
|
||||
{isZh ? '立即平仓' : 'Close now'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OpenPositions() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [positions, setPositions] = useState<OpenPosition[] | null>(null)
|
||||
const [today, setToday] = useState<TodayStats | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
// Close-confirmation modal state
|
||||
const [closing, setClosing] = useState<OpenPosition | null>(null)
|
||||
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
const [closeErr, setCloseErr] = useState('')
|
||||
const [growId, setGrowId] = useState<number | null>(null)
|
||||
|
||||
async function toggleGrow(p: OpenPosition) {
|
||||
if (!address || growId !== null) return
|
||||
const next = !p.grow_mode
|
||||
setGrowId(p.trade_id)
|
||||
try {
|
||||
const env = await signRequest({
|
||||
action: 'set_trade_grow', wallet: address,
|
||||
body: { trade_id: p.trade_id, enabled: next }, signMessageAsync,
|
||||
})
|
||||
const r = await setTradeGrow(env, p.trade_id, next)
|
||||
setPositions(prev => (prev ?? []).map(x =>
|
||||
x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x))
|
||||
} catch (e) {
|
||||
if (isUserRejection(e)) {
|
||||
setErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
|
||||
} else {
|
||||
setErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|
||||
|| (isZh ? 'Grow 切换失败' : 'grow toggle failed'))
|
||||
}
|
||||
} finally { setGrowId(null) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setPositions(null); setToday(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
||||
// First load uses getOrCreateViewEnvelope — may pop MetaMask exactly ONCE
|
||||
// when the page is opened (user action). All subsequent polls reuse the
|
||||
// cached envelope via getCachedViewEnvelope (no popup). When the cache
|
||||
// expires (20 min), the polling tick silently skips until the user
|
||||
// refocuses the tab or navigates back in — preventing the wallet from
|
||||
// popping in the background while the user is doing something else.
|
||||
async function load(envSource: 'first' | 'poll') {
|
||||
const env = envSource === 'first'
|
||||
? await getOrCreateViewEnvelope({
|
||||
action: 'view_positions', wallet: address!, signMessageAsync,
|
||||
})
|
||||
: getCachedViewEnvelope('view_positions', address!)
|
||||
if (!env) return // cache expired during polling — wait for user re-entry
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
getOpenPositions(address!, env),
|
||||
getTodayStats(address!, env),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPositions(p.positions); setToday(t); setErr(null)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
|
||||
}
|
||||
}
|
||||
|
||||
load('first').catch(e => {
|
||||
// Initial sign-rejection lands here. Show a soft error so the polling
|
||||
// loop still starts (a subsequent in-cache sign elsewhere will revive it).
|
||||
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
|
||||
})
|
||||
const id = setInterval(() => { void load('poll') }, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [address, isConnected, signMessageAsync, isZh])
|
||||
|
||||
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
|
||||
async function confirmCloseTrade() {
|
||||
if (!address || !closing) return
|
||||
setCloseErr(''); setCloseState('signing')
|
||||
try {
|
||||
const env = await signRequest({
|
||||
action: 'close_trade',
|
||||
wallet: address,
|
||||
body: { trade_id: closing.trade_id },
|
||||
signMessageAsync,
|
||||
})
|
||||
setCloseState('closing')
|
||||
await manualCloseTrade(env, closing.trade_id)
|
||||
// Optimistically drop the row from the list; the next poll will confirm.
|
||||
setPositions(prev => (prev ?? []).filter(x => x.trade_id !== closing.trade_id))
|
||||
setClosing(null)
|
||||
setCloseState('idle')
|
||||
} catch (e: unknown) {
|
||||
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
|
||||
setCloseState('err')
|
||||
}
|
||||
}
|
||||
|
||||
// Return null both before mount (SSR) and when not connected — same output,
|
||||
// no hydration mismatch.
|
||||
if (!mounted || !isConnected) return null
|
||||
if (positions === null && today === null) return null
|
||||
|
||||
const totalUnrealized = (positions ?? []).reduce(
|
||||
(s, p) => s + (p.unrealized_usd ?? 0), 0,
|
||||
)
|
||||
const pnlTone = today
|
||||
? (today.realized_pnl_usd > 0 ? 'up' : today.realized_pnl_usd < 0 ? 'down' : 'idle')
|
||||
: 'idle'
|
||||
const todayColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-2)'
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
|
||||
{/* Header strip — at-a-glance status */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 18px', flexWrap: 'wrap', gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
|
||||
Open
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
{today?.open_count ?? 0}
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 6, fontWeight: 400 }}>
|
||||
{`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
|
||||
Unrealized
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700,
|
||||
color: totalUnrealized > 0 ? 'var(--up)' :
|
||||
totalUnrealized < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||||
fontVariantNumeric: 'tabular-nums' }}>
|
||||
{fmtMoney(totalUnrealized, { sign: true })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
|
||||
Today realised
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: todayColor,
|
||||
fontVariantNumeric: 'tabular-nums' }}>
|
||||
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
|
||||
</div>
|
||||
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked on open`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<span style={{ fontSize: 11, color: 'var(--down)' }}>● {err}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position list (or empty state) */}
|
||||
{positions && positions.length > 0 ? (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
|
||||
gap: 12,
|
||||
padding: '8px 14px',
|
||||
background: 'var(--bg-sunk)',
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.05em',
|
||||
color: 'var(--ink-3)', textTransform: 'uppercase',
|
||||
}}>
|
||||
<span>Asset</span><span>Side</span><span>Prices</span>
|
||||
<span>Size</span><span>Hold</span>
|
||||
<span style={{ textAlign: 'right' }}>Unrealized</span>
|
||||
<span style={{ textAlign: 'right' }}>Action</span>
|
||||
</div>
|
||||
{positions.map(p => (
|
||||
<PositionRow
|
||||
key={p.trade_id}
|
||||
p={p}
|
||||
onClose={setClosing}
|
||||
onToggleGrow={toggleGrow}
|
||||
growBusy={growId === p.trade_id}
|
||||
isZh={isZh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--line)',
|
||||
padding: '20px 18px',
|
||||
fontSize: 13, color: 'var(--ink-3)', textAlign: 'center',
|
||||
}}>
|
||||
No open positions. The bot will appear here as soon as a signal triggers a trade.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Confirmation modal (P0.1 safety valve) ───────────────────
|
||||
Two-step UX so the wallet popup isn't surprising. The user
|
||||
presses "Close now", reads the impact summary, then confirms. */}
|
||||
{closing && (
|
||||
<div
|
||||
onClick={() => closeState === 'idle' && setClosing(null)}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="card"
|
||||
style={{ padding: 24, maxWidth: 440, width: '100%' }}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
|
||||
{isZh
|
||||
? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}?`
|
||||
: `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
|
||||
{closing.is_paper
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
|
||||
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '开仓价' : 'Entry'}</span>
|
||||
<span className="mono">{fmtMoney(closing.entry_price)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
|
||||
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
|
||||
</div>
|
||||
{closing.realized_usd != null && closing.realized_usd !== 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
|
||||
<span>{isZh ? '已锁定(降风险)' : 'Already banked (de-risk)'}</span>
|
||||
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
|
||||
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
|
||||
<span style={{
|
||||
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
|
||||
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{fmtMoney(closing.unrealized_usd, { sign: true })}
|
||||
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: 11 }}>
|
||||
({fmtPct(closing.unrealized_pct)})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{closeState === 'err' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--down)', marginBottom: 12 }}>{closeErr}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setClosing(null)}
|
||||
disabled={closeState === 'signing' || closeState === 'closing'}
|
||||
className="btn ghost"
|
||||
style={{ padding: '8px 16px', fontSize: 13 }}
|
||||
>
|
||||
{isZh ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmCloseTrade}
|
||||
disabled={closeState === 'signing' || closeState === 'closing'}
|
||||
className="btn"
|
||||
style={{
|
||||
padding: '8px 18px', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--down)', color: '#fff', border: 'none',
|
||||
}}
|
||||
>
|
||||
{closeState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
|
||||
: closeState === 'closing' ? (isZh ? '平仓中…' : 'Closing…')
|
||||
: (isZh ? `立即平掉 ${closing.asset}` : `Close ${closing.asset} now`)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import type { SignalSource } from '@/lib/api'
|
||||
|
||||
interface Props {
|
||||
sources: SignalSource[]
|
||||
activeSource: string
|
||||
onChange: (src: string) => void
|
||||
/** Count map keyed by source (computed by parent from live post list). */
|
||||
liveCounts: Record<string, number>
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
if (diff < 60) return `${Math.round(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
|
||||
return `${Math.round(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Source filter chips for the signals page.
|
||||
*
|
||||
* Each chip = one entry from /api/signals/sources. Shows total historical
|
||||
* count from the server + the (filtered) live count from the current post
|
||||
* payload — so the user can see "this filter would drop me from 700 to 13".
|
||||
*/
|
||||
export default function SourceChips({ sources, activeSource, onChange, liveCounts }: Props) {
|
||||
// Always have an "all" pseudo-source first
|
||||
const totalCount = sources.reduce((s, x) => s + x.count, 0)
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
color: 'var(--ink-3)', marginBottom: 10 }}>
|
||||
Signal sources
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{/* All */}
|
||||
<button
|
||||
onClick={() => onChange('all')}
|
||||
className={`chip ${activeSource === 'all' ? 'up' : ''}`}
|
||||
style={{ padding: '8px 14px', cursor: 'pointer', border: '1px solid var(--line)' }}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>All</span>
|
||||
<span style={{ marginLeft: 8, color: 'var(--ink-4)' }}>{totalCount}</span>
|
||||
</button>
|
||||
|
||||
{sources.map(s => (
|
||||
<button
|
||||
key={s.source}
|
||||
onClick={() => onChange(s.source)}
|
||||
className={`chip ${activeSource === s.source ? 'up' : ''}`}
|
||||
style={{
|
||||
padding: '8px 14px', cursor: 'pointer',
|
||||
border: '1px solid var(--line)',
|
||||
background: activeSource === s.source ? 'var(--up-soft)' : undefined,
|
||||
}}
|
||||
title={`Last signal: ${relativeTime(s.latest)}`}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{s.source}</span>
|
||||
<span style={{ marginLeft: 8, color: 'var(--ink-4)' }}>{s.count}</span>
|
||||
{/* Show live filtered count when different from total */}
|
||||
{liveCounts[s.source] !== undefined && liveCounts[s.source] !== s.count && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, color: 'var(--ink-3)' }}>
|
||||
({liveCounts[s.source]} loaded)
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginLeft: 8, fontSize: 10, color: 'var(--ink-4)' }}>
|
||||
· {relativeTime(s.latest)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
|
||||
/**
|
||||
* ONE control: the master Auto-Trade switch.
|
||||
*
|
||||
* OFF (default, safe) — signals are still scanned & shown in the feed,
|
||||
* but NO trade is opened. Pure monitoring.
|
||||
* ON — a qualifying signal auto-opens a trade (full
|
||||
* System-2 risk: dynamic leverage, staged de-risk,
|
||||
* ratchet, peak-trail). De-risk / stop-loss always
|
||||
* run on open positions regardless — never toggleable.
|
||||
*
|
||||
* Replaces the old scanner-toggle / timed manual-window / schedule / paper
|
||||
* trio. Circuit breaker is read-only; turning Auto-Trade ON acknowledges +
|
||||
* clears a tripped breaker. Per-trade pyramiding is the "Grow" switch on
|
||||
* each open position (Trades page), not here.
|
||||
*/
|
||||
|
||||
const SYS = {
|
||||
trump: { idx: '①', name: 'Trump', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
|
||||
btc: { idx: '②', name: 'BTC', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
|
||||
} as const
|
||||
|
||||
const ROW: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 12, padding: '8px 0', flexWrap: 'wrap',
|
||||
}
|
||||
const LABEL: React.CSSProperties = { fontSize: 13, color: 'var(--ink-2)' }
|
||||
|
||||
export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const s = SYS[system]
|
||||
const { address, isConnected } = useAccount()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [pub, setPub] = useState<UserPublic | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) { setPub(null); return }
|
||||
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
|
||||
setPub(p)
|
||||
}, [address])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const poll = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(poll)
|
||||
}, [refresh])
|
||||
|
||||
const subscribed = !!pub?.active
|
||||
const paper = !!pub?.paper_mode
|
||||
const autoOn = !!pub?.auto_trade
|
||||
const cbTripped = !!pub?.circuit_breaker_tripped_at &&
|
||||
(Date.now() - new Date(pub!.circuit_breaker_tripped_at!).getTime()) < 24 * 3600 * 1000
|
||||
|
||||
async function flipAuto(on: boolean) {
|
||||
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 }
|
||||
if (autoOn === on) return
|
||||
|
||||
// Show pre-signing confirmation sheet before triggering MetaMask
|
||||
const confirmed = await confirmSign(on
|
||||
? {
|
||||
label: paper
|
||||
? (isZh ? '开启 Auto-Trade(模拟模式)' : 'Enable Auto-Trade (paper mode)')
|
||||
: (isZh ? '开启 Auto-Trade(真实资金)' : 'Enable Auto-Trade (live capital)'),
|
||||
description: paper
|
||||
? (isZh
|
||||
? '机器人将在信号触发时自动开仓(Paper 模式,无真实资金)。钱包签名仅用于身份验证。'
|
||||
: 'The bot will auto-open positions when a qualifying signal fires in paper mode. No real capital is used. The wallet signature is only for identity verification.')
|
||||
: (isZh
|
||||
? '机器人将在 Hyperliquid 上用真实资金自动开仓。止损与分级减仓始终生效保护仓位。'
|
||||
: 'The bot will auto-open real positions on Hyperliquid. Stop-loss and staged de-risking remain active to protect the position.'),
|
||||
danger: !paper,
|
||||
}
|
||||
: {
|
||||
label: isZh ? '关闭 Auto-Trade' : 'Disable Auto-Trade',
|
||||
description: isZh
|
||||
? '停止自动开仓。已有持仓的止损与减仓逻辑仍会继续运行。'
|
||||
: 'Stop opening new trades automatically. Risk controls on existing positions will keep running.',
|
||||
danger: false,
|
||||
}
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const env = await signRequest({
|
||||
action: 'set_auto_trade', wallet: address,
|
||||
body: { enabled: on }, signMessageAsync,
|
||||
})
|
||||
const r = await setAutoTrade(env, on)
|
||||
setPub(p => p ? {
|
||||
...p,
|
||||
auto_trade: r.auto_trade,
|
||||
...(r.circuit_breaker_cleared
|
||||
? { circuit_breaker_tripped_at: null, circuit_breaker_reason: null }
|
||||
: {}),
|
||||
} : p)
|
||||
} catch (e) {
|
||||
if (isUserRejection(e)) {
|
||||
setErr(isZh ? '已取消' : 'Cancelled')
|
||||
} else {
|
||||
setErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 110)
|
||||
|| (isZh ? 'Auto-Trade 切换失败' : 'Auto-Trade toggle failed'))
|
||||
}
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 13, color: 'var(--ink-3)' }}>
|
||||
{isZh
|
||||
? `连接右上角的钱包后,才能操作 ${s.name} 模块。`
|
||||
: `Connect a wallet (top-right) to operate the ${s.name} system.`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 13, color: 'var(--ink-3)' }}>
|
||||
{isZh
|
||||
? `连接右上角的钱包后,才能操作 ${s.name} 模块。`
|
||||
: `Connect a wallet (top-right) to operate the ${s.name} system.`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Net result — the single sentence that matters ─────────────────────────
|
||||
let netOk = false
|
||||
let netMsg = ''
|
||||
if (!subscribed) {
|
||||
netMsg = isZh
|
||||
? '钱包还没订阅,请先去设置页完成订阅。'
|
||||
: 'Wallet not subscribed — subscribe on the Settings page first.'
|
||||
} else if (cbTripped) {
|
||||
netMsg = isZh
|
||||
? `熔断器已触发(${pub?.circuit_breaker_reason || '风险限制'})。重新打开 Auto-Trade 才会确认并恢复。`
|
||||
: `Circuit breaker tripped (${pub?.circuit_breaker_reason || 'risk limit'}). Turn Auto-Trade ON to acknowledge & resume.`
|
||||
} else if (!autoOn) {
|
||||
netMsg = isZh
|
||||
? 'Auto-Trade 当前为关闭(全局): Trump 和 BTC 信号仍会显示,但不会自动开仓。'
|
||||
: 'Auto-Trade is OFF (global) — Trump AND BTC signals are shown in the feed but NOT traded.'
|
||||
} else {
|
||||
netOk = true
|
||||
netMsg = isZh
|
||||
? `Auto-Trade 已开启(全局,覆盖 Trump 和 BTC): 下一条合格信号会自动开出${paper ? '模拟' : '真实'}仓位。`
|
||||
: `Auto-Trade ON (global, both Trump & BTC) — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying signal.`
|
||||
}
|
||||
|
||||
const Pill = ({ active, onClick, children, tone }: {
|
||||
active: boolean; onClick: () => void; children: React.ReactNode
|
||||
tone: 'green' | 'idle'
|
||||
}) => {
|
||||
const bg = active ? (tone === 'green' ? 'var(--up, #16a34a)' : 'var(--ink-3)') : 'transparent'
|
||||
return (
|
||||
<button onClick={onClick} disabled={busy}
|
||||
style={{
|
||||
padding: '8px 20px', fontSize: 13, fontWeight: 800, borderRadius: 8,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
border: active ? `1px solid ${bg}` : '1px solid var(--line)',
|
||||
background: active ? bg : 'transparent',
|
||||
color: active ? '#fff' : 'var(--ink-3)',
|
||||
}}>{children}</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<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'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master Auto-Trade switch */}
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={ROW}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>
|
||||
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 全局(Trump + BTC)' : '· GLOBAL (Trump + BTC)'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3, maxWidth: 480, lineHeight: 1.5 }}>
|
||||
{isZh ? (
|
||||
<>
|
||||
<strong>一个开关控制两套系统。</strong> 关闭时:信号仍会扫描并显示,但不会自动交易。
|
||||
开启时:满足条件的 Trump 或 BTC 信号都会自动开仓。无论开关状态如何,
|
||||
已开仓位的止损和分级降风险都会继续保护持仓。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>One switch for both systems.</strong> OFF: signals scanned
|
||||
& shown in the feed, nothing traded. ON: a qualifying Trump OR
|
||||
BTC signal auto-opens a trade. Stop-loss / staged de-risk always
|
||||
protect open positions either way.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
|
||||
<Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">OFF</Pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only status */}
|
||||
<div style={{ ...ROW, borderTop: '1px solid var(--line)', marginTop: 8, paddingTop: 12 }}>
|
||||
<span style={LABEL}>{isZh ? '执行模式' : 'Execution mode'}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: paper ? '#f59e0b' : 'var(--up)' }}>
|
||||
{paper ? (isZh ? '📝 模拟' : '📝 PAPER') : (isZh ? '💰 真实资金' : '💰 LIVE')}
|
||||
<span style={{ color: 'var(--ink-4)', fontWeight: 400, marginLeft: 8 }}>
|
||||
{isZh ? '· 订阅时确定' : '· set when subscribing'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={ROW}>
|
||||
<span style={LABEL}>{isZh ? '熔断器' : 'Circuit breaker'}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700,
|
||||
color: cbTripped ? 'var(--down)' : 'var(--up)' }}>
|
||||
{cbTripped
|
||||
? (isZh ? `🚨 已触发(${pub?.circuit_breaker_reason})` : `🚨 tripped (${pub?.circuit_breaker_reason})`)
|
||||
: (isZh ? '✓ 正常' : '✓ clear')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 10 }}>● {err}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Net result */}
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
background: netOk ? 'var(--up-soft)' : 'var(--down-soft, rgba(220,38,38,.08))',
|
||||
borderTop: '1px solid var(--line)',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 4 }}>
|
||||
{isZh ? '⟹ 当前结果' : '⟹ Net result'}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700,
|
||||
color: netOk ? 'var(--up)' : 'var(--down)' }}>
|
||||
{netOk ? '✅ ' : '⛔ '}{netMsg}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
getTelegramStatus, tgInit, tgUnbind,
|
||||
type TelegramStatus,
|
||||
} from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
import { walletErrorLabel } from '@/lib/walletError'
|
||||
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
|
||||
/**
|
||||
* Settings → Telegram alerts card.
|
||||
*
|
||||
* Three states:
|
||||
* 1. Server not configured → show "ask the admin to set up the bot"
|
||||
* 2. Not bound → deep-link to bot + optional wallet-link code
|
||||
* 3. Bound → status line + link to bot for preferences
|
||||
*
|
||||
* Preferences are managed entirely inside the bot (/trump /btc /funding /kol
|
||||
* /conf /quiet). This card is just discovery + wallet-linking for Pro users.
|
||||
*/
|
||||
export default function TelegramCard() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [status, setStatus] = useState<TelegramStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [code, setCode] = useState<{ code: string; deep_link: string } | null>(null)
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const s = await getTelegramStatus(address.toLowerCase())
|
||||
setStatus(s); setErr('')
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'load failed')
|
||||
} finally { setLoading(false) }
|
||||
}, [address])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
// Poll while waiting for wallet-link to complete.
|
||||
useEffect(() => {
|
||||
if (!code || status?.bound) return
|
||||
const id = setInterval(refresh, 3000)
|
||||
return () => clearInterval(id)
|
||||
}, [code, status?.bound, refresh])
|
||||
|
||||
const botUsername = status?.bot_username ?? 'TrumpAlpha_bot'
|
||||
const botLink = `https://t.me/${botUsername}`
|
||||
|
||||
async function handleLinkWallet() {
|
||||
if (!address) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const confirmed = await confirmSign({
|
||||
label: 'Generate Telegram link code',
|
||||
description: 'A one-time 6-char code valid for 10 min. Paste it into the bot to link your wallet for Pro features.',
|
||||
})
|
||||
if (!confirmed) { setBusy(false); return }
|
||||
const env = await signRequest({
|
||||
action: 'telegram_init', wallet: address, body: null, signMessageAsync,
|
||||
})
|
||||
const r = await tgInit(address.toLowerCase(), env)
|
||||
setCode({ code: r.code, deep_link: r.deep_link })
|
||||
} catch (e) {
|
||||
setErr(walletErrorLabel(e, 'Cancelled'))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function handleUnbind() {
|
||||
if (!address) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const confirmed = await confirmSign({
|
||||
label: 'Disconnect Telegram',
|
||||
description: 'Unlinks this wallet from Telegram. Your free subscription stays active in the bot.',
|
||||
})
|
||||
if (!confirmed) { setBusy(false); return }
|
||||
const env = await signRequest({
|
||||
action: 'telegram_unbind', wallet: address, body: null, signMessageAsync,
|
||||
})
|
||||
await tgUnbind(address.toLowerCase(), env)
|
||||
setCode(null)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setErr(walletErrorLabel(e, 'Cancelled'))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (!mounted) return null
|
||||
if (!isConnected) return null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'center', marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)' }}>
|
||||
{isZh ? 'Telegram 提醒' : 'Telegram alerts'}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 2 }}>
|
||||
{isZh ? '高置信度信号触发时发送推送提醒' : 'Push notifications when high-conviction signals fire'}
|
||||
</div>
|
||||
</div>
|
||||
{status?.bound && status.alerts_enabled && (
|
||||
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600,
|
||||
padding: '4px 10px', borderRadius: 6,
|
||||
background: 'var(--up-soft)' }}>
|
||||
● {isZh ? '已开启' : 'Active'}
|
||||
</span>
|
||||
)}
|
||||
{status?.bound && !status.alerts_enabled && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-4)', fontWeight: 600,
|
||||
padding: '4px 10px', borderRadius: 6,
|
||||
background: 'var(--bg-sunk)' }}>
|
||||
○ {isZh ? '已暂停' : 'Paused'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="skeleton sk-line sk-w-full" style={{ marginBottom: 8 }} />
|
||||
)}
|
||||
|
||||
{/* Server not configured */}
|
||||
{!loading && status && !status.configured && (
|
||||
<div style={{ padding: 12, fontSize: 12, color: 'var(--ink-3)',
|
||||
background: 'var(--bg-sunk)', borderRadius: 6 }}>
|
||||
⚠️ {isZh ? '当前服务器还没有配置 Telegram 提醒。请让管理员设置' : 'Telegram alerts are not configured on this server. Ask the operator to set'}
|
||||
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_TOKEN</code> and
|
||||
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_USERNAME</code>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configured — main body */}
|
||||
{!loading && status?.configured && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
|
||||
{/* Bot link row — always visible */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
background: 'var(--bg-sunk)', border: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 18 }}>✈️</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
<a href={botLink} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--amber, #f59e0b)', textDecoration: 'none' }}>
|
||||
@{botUsername} ↗
|
||||
</a>
|
||||
</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')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bound: wallet status + disconnect */}
|
||||
{status.bound && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)',
|
||||
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
{status.tg_username
|
||||
? <>{isZh ? '已绑定账号' : 'Linked as'} <strong style={{ color: 'var(--ink)' }}>@{status.tg_username}</strong></>
|
||||
: <>{isZh ? '聊天 ID' : 'Chat'} <strong style={{ color: 'var(--ink)' }}>#{status.chat_id}</strong></>}
|
||||
</span>
|
||||
{status.wallet_address ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<button className="btn ghost" disabled={busy} onClick={handleUnbind}
|
||||
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}>
|
||||
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not bound: optional wallet-link for Pro */}
|
||||
{!status.bound && !code && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-4)' }}>
|
||||
{isZh ? '已经在机器人里了?' : 'Already in the bot?'}{' '}
|
||||
<button onClick={handleLinkWallet} disabled={busy}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--amber, #f59e0b)', fontSize: 12, padding: 0,
|
||||
textDecoration: 'underline' }}>
|
||||
{isZh ? '绑定这个钱包以启用 Pro 功能' : 'Link this wallet for Pro features'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet-link code flow */}
|
||||
{!status.bound && code && (
|
||||
<WalletLinkPanel code={code.code} link={code.deep_link}
|
||||
onCancel={() => setCode(null)} isZh={isZh} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>● {err}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Subcomponents ────────────────────────────────────────────────────────────
|
||||
|
||||
function WalletLinkPanel({ code, link, onCancel, isZh }: {
|
||||
code: string; link: string; onCancel: () => void; isZh: boolean
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 1500)
|
||||
}).catch(() => {})
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 14, background: 'var(--bg-sunk)',
|
||||
borderRadius: 8, border: '1px solid var(--line)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||
{isZh ? '绑定钱包(Pro):' : 'Link wallet (Pro):'}
|
||||
</div>
|
||||
<ol style={{ paddingLeft: 18, fontSize: 12, color: 'var(--ink-2)',
|
||||
lineHeight: 1.7, marginBottom: 12 }}>
|
||||
<li>
|
||||
{isZh ? '打开机器人:' : 'Open the bot:'}
|
||||
<a href={link} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--amber, #f59e0b)', fontWeight: 600 }}>
|
||||
{link.replace('https://', '')} ↗
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{isZh ? '发送' : 'Send'} <code style={{ background: 'var(--bg)', padding: '1px 6px',
|
||||
borderRadius: 3 }}>/start {code}</code>
|
||||
<button onClick={copy} style={{
|
||||
marginLeft: 6, fontSize: 11, padding: '2px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--line)', background: 'var(--bg)',
|
||||
cursor: 'pointer', color: 'var(--ink-3)',
|
||||
}}>{copied ? (isZh ? '✓ 已复制' : '✓ copied') : (isZh ? '复制验证码' : 'copy code')}</button>
|
||||
</li>
|
||||
</ol>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginBottom: 10 }}>
|
||||
{isZh ? '验证码 10 分钟内有效,绑定成功后这里会自动刷新。' : 'Code expires in 10 min. This panel updates automatically once linked.'}
|
||||
</div>
|
||||
<button onClick={onCancel} style={{
|
||||
fontSize: 11, color: 'var(--ink-3)', background: 'none',
|
||||
border: 'none', cursor: 'pointer', padding: 0,
|
||||
}}>{isZh ? '取消' : 'Cancel'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
getUserPublic,
|
||||
getUser,
|
||||
setUserSettings,
|
||||
setHlApiKey,
|
||||
setManualWindow,
|
||||
subscribe,
|
||||
type UserSettings,
|
||||
} from '@/lib/api'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import {
|
||||
signRequest,
|
||||
getOrCreateViewEnvelope,
|
||||
getCachedViewEnvelope,
|
||||
} from '@/lib/signedRequest'
|
||||
import { walletErrorLabel } from '@/lib/walletError'
|
||||
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
|
||||
// Defaults shown to a brand-new, un-configured wallet. take_profit_pct,
|
||||
// stop_loss_pct and daily_budget_usd are all REQUIRED before the bot will
|
||||
// trade — we still initialise them so the inputs have sensible placeholders.
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
leverage: 3, position_size_usd: 20, take_profit_pct: 2, stop_loss_pct: 1.5,
|
||||
min_confidence: 70, daily_budget_usd: 15, sys2_leverage: 2,
|
||||
sys2_mode: 'standard',
|
||||
active_from: null, active_until: null,
|
||||
}
|
||||
|
||||
// ── datetime-local helpers (browser local tz ↔ ISO UTC) ──────────────────────
|
||||
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())}`
|
||||
}
|
||||
function localInputToIso(v: string): string | null {
|
||||
if (!v) return null
|
||||
const d = new Date(v) // interpreted as local time
|
||||
if (isNaN(d.getTime())) return null
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
// ─── Bot config panel ─────────────────────────────────────────────────────────
|
||||
export default function BotConfigPanel() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const {
|
||||
isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness,
|
||||
setSubscribed, setBotReadiness, setHlApiKeySet,
|
||||
} = useDashboardStore()
|
||||
|
||||
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
|
||||
// Tracks whether the SERVER already has values for these required fields.
|
||||
const [tpConfigured, setTpConfigured] = useState(false)
|
||||
const [slConfigured, setSlConfigured] = useState(false)
|
||||
const [budgetConfigured, setBudgetConfigured] = useState(false)
|
||||
const [useSchedule, setUseSchedule] = useState(false)
|
||||
const [fromLocal, setFromLocal] = useState('')
|
||||
const [untilLocal, setUntilLocal] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saveState, setSaveState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
|
||||
const [saveErr, setSaveErr] = useState('')
|
||||
|
||||
// HL key state
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
|
||||
const [keyErr, setKeyErr] = useState('')
|
||||
|
||||
// Subscribe state. `subPaperChoice` is the user's selection on the
|
||||
// pre-subscribe form ("paper" or "live"); written to Subscription.paper_mode
|
||||
// when the signed subscribe goes through.
|
||||
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
|
||||
const [subErr, setSubErr] = useState('')
|
||||
const [subPaperChoice, setSubPaperChoice] = useState<'paper' | 'live'>('paper')
|
||||
|
||||
// Manual window (convex-strategy "enable for N hours" override).
|
||||
// null = no override active; ISO string = bot armed until that UTC time.
|
||||
const [manualUntil, setManualUntil] = useState<string | null>(null)
|
||||
const [mwState, setMwState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
|
||||
const [mwErr, setMwErr] = useState('')
|
||||
const [mwTick, setMwTick] = useState(0) // forces countdown re-render
|
||||
|
||||
// Mounted flag: avoid SSR/CSR mismatch since wagmi state differs between the two.
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Settings-load state. We do NOT auto-pop MetaMask — only fetch with a
|
||||
// cached signature; otherwise wait for the user to click "Load settings".
|
||||
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
|
||||
// Apply a fetched user payload into local form state.
|
||||
function applyUserPayload(u: {
|
||||
active: boolean
|
||||
hl_api_key_set: boolean
|
||||
hl_api_key_masked: string | null
|
||||
settings: UserSettings
|
||||
manual_window_until?: string | null
|
||||
}) {
|
||||
setSubscribed(u.active)
|
||||
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
|
||||
setManualUntil(u.manual_window_until ?? null)
|
||||
if (u.settings) {
|
||||
const s = u.settings
|
||||
setTpConfigured(s.take_profit_pct != null)
|
||||
setSlConfigured(s.stop_loss_pct != null)
|
||||
setBudgetConfigured(s.daily_budget_usd != null)
|
||||
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setSettings({
|
||||
...s,
|
||||
take_profit_pct: s.take_profit_pct ?? 2,
|
||||
stop_loss_pct: s.stop_loss_pct ?? 1.5,
|
||||
daily_budget_usd: s.daily_budget_usd ?? 15,
|
||||
})
|
||||
const hasSched = s.active_from != null && s.active_until != null
|
||||
setUseSchedule(hasSched)
|
||||
setFromLocal(isoToLocalInput(s.active_from))
|
||||
setUntilLocal(isoToLocalInput(s.active_until))
|
||||
}
|
||||
}
|
||||
|
||||
// On connect: fetch public state only (no signature). If a fresh view
|
||||
// envelope is already cached in sessionStorage, silently hydrate full data.
|
||||
useEffect(() => {
|
||||
if (!mounted || !isConnected || !address) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const pub = await getUserPublic(address.toLowerCase()).catch(() => null)
|
||||
if (!pub || cancelled) return
|
||||
setSubscribed(pub.active)
|
||||
setHlApiKeySet(pub.hl_api_key_set)
|
||||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||||
if (!pub.active) return
|
||||
const cached = getCachedViewEnvelope('view_user', address)
|
||||
if (!cached) return
|
||||
const u = await getUser(address, cached).catch(() => null)
|
||||
if (!u || cancelled) return
|
||||
applyUserPayload(u)
|
||||
setLoadState('loaded')
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, mounted]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function handleLoadSettings() {
|
||||
if (!address) return
|
||||
setLoadErr(''); setLoadState('loading')
|
||||
try {
|
||||
const ok = await confirmSign({
|
||||
label: isZh ? '查看账户设置' : 'View account settings',
|
||||
description: isZh
|
||||
? '读取你在 Trump Alpha 的订阅状态、风控参数和 API Key 状态。签名仅用于身份验证,不做任何链上操作。'
|
||||
: '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 }
|
||||
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
|
||||
const u = await getUser(address, env)
|
||||
applyUserPayload(u)
|
||||
setLoadState('loaded')
|
||||
} catch (e: unknown) {
|
||||
setLoadErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
|
||||
setLoadState('err')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function updateSettings(patch: Partial<UserSettings>) {
|
||||
setSettings(s => ({ ...s, ...patch }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
if (!address) return
|
||||
const paperMode = subPaperChoice === 'paper'
|
||||
const ok = await confirmSign({
|
||||
label: paperMode
|
||||
? (isZh ? '订阅 Trump Alpha(模拟模式)' : 'Subscribe to Trump Alpha (paper mode)')
|
||||
: (isZh ? '订阅 Trump Alpha(真实资金)' : 'Subscribe to Trump Alpha (live capital)'),
|
||||
description: paperMode
|
||||
? (isZh
|
||||
? '注册为 Paper 模式订阅者。机器人开仓不涉及真实资金,仅供测试。'
|
||||
: 'Register as a paper-mode subscriber. Trades are simulated and do not touch real funds.')
|
||||
: (isZh
|
||||
? '注册为正式订阅者。完成后需配置 Hyperliquid API Key 才能实际开仓。'
|
||||
: 'Register as a live subscriber. You still need to configure a Hyperliquid API key before any real trade can open.'),
|
||||
danger: !paperMode,
|
||||
})
|
||||
if (!ok) return
|
||||
setSubErr(''); setSubState('signing')
|
||||
try {
|
||||
const signedBody = paperMode ? { paper_mode: true } : null
|
||||
const env = await signRequest({ action: 'subscribe', wallet: address, body: signedBody, signMessageAsync })
|
||||
setSubState('saving')
|
||||
await subscribe(env, { paper_mode: paperMode })
|
||||
setSubscribed(true)
|
||||
void refreshUserState(address)
|
||||
setSubState('idle')
|
||||
setTpConfigured(false)
|
||||
setSlConfigured(false)
|
||||
setBudgetConfigured(false)
|
||||
setLoadState('loaded')
|
||||
} catch (e: unknown) {
|
||||
setSubErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
|
||||
setSubState('err')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (!address) return
|
||||
const ok = await confirmSign({
|
||||
label: isZh ? '保存交易风控参数' : 'Save trading risk settings',
|
||||
description: isZh
|
||||
? '保存杠杆、仓位大小、止盈止损比例和每日预算设置。签名后立即生效。'
|
||||
: 'Save leverage, position size, take-profit, stop-loss, and daily budget settings. Changes apply immediately after signing.',
|
||||
})
|
||||
if (!ok) return
|
||||
setSaveErr(''); setSaveState('signing')
|
||||
try {
|
||||
let schedFrom: string | null = null
|
||||
let schedUntil: string | null = null
|
||||
if (useSchedule) {
|
||||
schedFrom = localInputToIso(fromLocal)
|
||||
schedUntil = localInputToIso(untilLocal)
|
||||
if (!schedFrom || !schedUntil) {
|
||||
setSaveErr(isZh ? '请选择开始和结束时间' : 'Pick both a start and end time'); setSaveState('err'); return
|
||||
}
|
||||
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) {
|
||||
setSaveErr(isZh ? '结束时间必须晚于开始时间' : 'End must be after start'); setSaveState('err'); return
|
||||
}
|
||||
}
|
||||
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
|
||||
if (tp == null || tp < 0.1 || tp > 50) { setSaveErr(isZh ? '止盈必须在 0.1% 到 50% 之间' : 'Take profit must be 0.1 – 50%'); setSaveState('err'); return }
|
||||
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr(isZh ? '止损必须在 0.1% 到 50% 之间' : 'Stop loss must be 0.1 – 50%'); setSaveState('err'); return }
|
||||
if (bd == null || bd <= 0 || bd > 100000){ setSaveErr(isZh ? '每日预算必须大于 0 美元' : 'Daily budget must be > $0'); setSaveState('err'); return }
|
||||
const payload: UserSettings = {
|
||||
...settings, take_profit_pct: tp, stop_loss_pct: sl, daily_budget_usd: bd,
|
||||
active_from: schedFrom, active_until: schedUntil,
|
||||
}
|
||||
const env = await signRequest({ action: 'set_settings', wallet: address, body: payload, signMessageAsync })
|
||||
setSaveState('saving')
|
||||
await setUserSettings(env, payload)
|
||||
setSettings(payload)
|
||||
setTpConfigured(true); setSlConfigured(true); setBudgetConfigured(true)
|
||||
setBotReadiness('saved')
|
||||
setDirty(false)
|
||||
setSaveState('ok')
|
||||
setTimeout(() => setSaveState('idle'), 2000)
|
||||
} catch (e: unknown) {
|
||||
setSaveErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
|
||||
setSaveState('err')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveKey() {
|
||||
if (!address || !apiKey.trim()) return
|
||||
const trimmed = apiKey.trim()
|
||||
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
|
||||
setKeyErr(isZh ? '必须是 0x 开头加 64 位十六进制字符' : 'Must be 0x + 64 hex chars'); setKeyState('err'); return
|
||||
}
|
||||
const ok = await confirmSign({
|
||||
label: isZh ? '绑定 Hyperliquid API Key' : 'Link Hyperliquid API key',
|
||||
description: isZh
|
||||
? 'API Key 将加密存储在服务端,用于机器人在 Hyperliquid 上代替你下单。签名用于验证是你本人操作。'
|
||||
: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you. The signature confirms this action came from you.',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
setKeyErr(''); setKeyState('signing')
|
||||
try {
|
||||
const env = await signRequest({ action: 'set_hl_api_key', wallet: address, body: { api_key: trimmed }, signMessageAsync })
|
||||
setKeyState('saving')
|
||||
const res = await setHlApiKey(env, trimmed)
|
||||
setHlApiKeySet(true, res.masked_key)
|
||||
setBotReadiness('saved')
|
||||
void refreshUserState(address)
|
||||
setApiKey('')
|
||||
setKeyState('ok')
|
||||
} catch (e: unknown) {
|
||||
setKeyErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
|
||||
setKeyState('err')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectWallet = useCallback(() => {
|
||||
const connector = connectors[0]
|
||||
if (connector) connect({ connector })
|
||||
}, [connect, connectors])
|
||||
|
||||
// ── Manual-window: arm for N hours, or clear (hours = 0) ──────────────────
|
||||
async function handleManualWindow(hours: number) {
|
||||
if (!address) return
|
||||
setMwErr(''); setMwState('signing')
|
||||
try {
|
||||
const env = await signRequest({
|
||||
action: 'set_manual_window',
|
||||
wallet: address,
|
||||
body: { hours },
|
||||
signMessageAsync,
|
||||
})
|
||||
setMwState('saving')
|
||||
const r = await setManualWindow(env, hours)
|
||||
setManualUntil(r.manual_window_until)
|
||||
setMwState('idle')
|
||||
} catch (e: unknown) {
|
||||
setMwErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
|
||||
setMwState('err')
|
||||
}
|
||||
}
|
||||
|
||||
// Countdown re-render every 15s while a manual window is armed.
|
||||
useEffect(() => {
|
||||
if (!manualUntil) return
|
||||
const id = setInterval(() => setMwTick(t => t + 1), 15000)
|
||||
return () => clearInterval(id)
|
||||
}, [manualUntil])
|
||||
|
||||
if (!mounted || !isConnected) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 32, textAlign: 'center', marginBottom: 28 }}>
|
||||
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>
|
||||
{isZh ? '连接钱包后即可配置机器人并查看你的交易。' : 'Connect your wallet to configure the bot and view your trades.'}
|
||||
</p>
|
||||
<button className="btn amber" onClick={handleConnectWallet}>{isZh ? '连接钱包' : 'Connect wallet'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── small local helpers ───────────────────────────────────────────────────
|
||||
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' | 'down' }) => (
|
||||
<label className={`switch ${tone === 'amber' ? '' : tone}`}>
|
||||
<input type="checkbox" checked={on} onChange={e => { onChange(e.target.checked); setDirty(true) }} />
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
)
|
||||
|
||||
const saveBtnLabel =
|
||||
saveState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
|
||||
: saveState === 'saving' ? (isZh ? '保存中…' : 'Saving…')
|
||||
: saveState === 'ok' ? (isZh ? '✓ 已保存' : '✓ Saved')
|
||||
: (isZh ? '保存更改' : 'Save changes')
|
||||
|
||||
const keyHint =
|
||||
hlApiKeySet && !apiKey
|
||||
? (isZh
|
||||
? '已保存在你的机器人配置里。如果你在 Hyperliquid 轮换了 API 钱包,重新交易前请先在这里更新。'
|
||||
: 'Saved in your bot profile. If you rotate the API wallet in Hyperliquid, update it here before trading again.')
|
||||
: (isZh
|
||||
? '请使用 app.hyperliquid.xyz/API 生成的 Hyperliquid API 钱包私钥。不要在这里粘贴 MetaMask 助记词或私钥。'
|
||||
: 'Use the Hyperliquid API wallet private key from app.hyperliquid.xyz/API. Never paste your MetaMask secret phrase or private key here.')
|
||||
|
||||
const keyStatusHint =
|
||||
keyState === 'ok'
|
||||
? (isZh
|
||||
? 'API 钱包已保存。建议下一步先用极小仓位跑一次你自己的 BTC 测试,再把这套配置当成生产可用。'
|
||||
: 'API wallet saved. Recommended next step: keep size tiny and run your own first BTC test before treating the setup as production-ready.')
|
||||
: null
|
||||
|
||||
// ── Hyperliquid key strip ─────────────────────────────────────────────────
|
||||
const hlKeyStrip = (
|
||||
<div className="card" style={{ padding: '16px 20px', marginBottom: 16, display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: hlApiKeySet ? 'var(--up-soft)' : 'var(--bg-sunk)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: hlApiKeySet ? 'var(--up)' : 'var(--ink-4)' }}>
|
||||
{hlApiKeySet ? '✓' : '◇'}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Hyperliquid API wallet</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 1 }}>
|
||||
{!isSubscribed
|
||||
? 'Subscribe first to link your HL account'
|
||||
: hlApiKeySet && !apiKey
|
||||
? <><span>Linked · </span><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only scope</span></>
|
||||
: 'Paste your API wallet private key — trade-only, never withdraw'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isSubscribed && hlApiKeySet && !apiKey && (
|
||||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }} onClick={() => setApiKey(' ')}>Rotate</button>
|
||||
)}
|
||||
{isSubscribed && (!hlApiKeySet || apiKey) && (
|
||||
<>
|
||||
<input
|
||||
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
|
||||
onChange={e => { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }}
|
||||
placeholder="0x…"
|
||||
style={{ width: 220, fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }}
|
||||
/>
|
||||
<button
|
||||
className={`btn ${keyState === 'ok' ? 'ghost' : 'amber'}`}
|
||||
style={{ padding: '8px 14px', fontSize: 12 }}
|
||||
onClick={handleSaveKey}
|
||||
disabled={keyState === 'signing' || keyState === 'saving' || !apiKey.trim()}
|
||||
>
|
||||
{keyState === 'signing' ? 'Sign…' : keyState === 'saving' ? 'Saving…' : keyState === 'ok' ? '✓' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.55 }}>{keyHint}</div>
|
||||
{keyStatusHint && (
|
||||
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--up)', lineHeight: 1.55 }}>{keyStatusHint}</div>
|
||||
)}
|
||||
{keyState === 'err' && (
|
||||
<div style={{ gridColumn: '1 / -1', fontSize: 12, color: 'var(--down)' }}>{keyErr}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Setup readiness ───────────────────────────────────────────────────────
|
||||
const missingSetup: string[] = []
|
||||
if (!isSubscribed) missingSetup.push('subscribe to the bot')
|
||||
if (!hlApiKeySet) missingSetup.push('link a Hyperliquid API wallet')
|
||||
if (!tpConfigured) missingSetup.push('take-profit')
|
||||
if (!slConfigured) missingSetup.push('stop-loss')
|
||||
if (!budgetConfigured) missingSetup.push('daily budget')
|
||||
const botReady = missingSetup.length === 0
|
||||
|
||||
const setupSteps = [
|
||||
{ label: '1. Subscribe', done: isSubscribed },
|
||||
{ label: '2. Add HL API wallet', done: hlApiKeySet },
|
||||
{ label: '3. Save risk limits', done: tpConfigured && slConfigured && budgetConfigured },
|
||||
]
|
||||
|
||||
const setupGuideMessage =
|
||||
!isSubscribed ? 'Start by subscribing with the connected wallet. That unlocks the bot profile for this address.'
|
||||
: botReadiness === 'ready' ? 'Your setup is verified. Before relying on automation, run one tiny BTC trade with your own wallet and Hyperliquid API wallet to confirm the live path end to end.'
|
||||
: !hlApiKeySet ? 'Next, paste the Hyperliquid API wallet private key you generated inside Hyperliquid. Do not paste your MetaMask private key or seed phrase here.'
|
||||
: 'Your exchange key is saved. Finish take-profit, stop-loss, and daily budget, then save changes. The backend still needs to verify this setup before we mark it ready.'
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
{/* ── Setup progress banner ── */}
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: '14px 18px', marginBottom: 12,
|
||||
background: botReady ? 'var(--up-soft)' : 'var(--amber-soft)',
|
||||
borderColor: botReady
|
||||
? 'color-mix(in oklab, var(--up) 18%, var(--line))'
|
||||
: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
{setupSteps.map((step) => (
|
||||
<span key={step.label} className={`chip ${step.done ? 'up' : ''}`} style={{ fontSize: 11 }}>
|
||||
{step.done ? '✓ ' : '○ '}{step.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.55 }}>{setupGuideMessage}</div>
|
||||
</div>
|
||||
|
||||
{hlKeyStrip}
|
||||
|
||||
{/* ── Manual window (convex-strategy override) ── */}
|
||||
{isSubscribed && (() => {
|
||||
const untilMs = manualUntil ? new Date(manualUntil).getTime() : 0
|
||||
// Touch the tick counter so countdown re-renders even if `manualUntil` is unchanged.
|
||||
void mwTick
|
||||
const remainingMin = untilMs ? Math.max(0, Math.round((untilMs - Date.now()) / 60000)) : 0
|
||||
const armed = remainingMin > 0
|
||||
const remainingLabel =
|
||||
remainingMin >= 60 ? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m`
|
||||
: `${remainingMin}m`
|
||||
const busy = mwState === 'signing' || mwState === 'saving'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
marginBottom: 16,
|
||||
background: armed ? 'var(--up-soft)' : undefined,
|
||||
borderColor: armed ? 'color-mix(in oklab, var(--up) 22%, var(--line))' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{armed ? '● Manually armed' : '○ Manual window'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{armed
|
||||
? <>Bot is live for <strong>{remainingLabel}</strong> more — overrides any schedule.</>
|
||||
: 'Arm the bot for a specific window. Best for catalysts (CPI, FOMC, news drops).'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{!armed && (
|
||||
<>
|
||||
{[1, 4, 24].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
className="btn ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(h)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && mwState === 'signing' ? 'Sign…' : `${h}h`}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{armed && (
|
||||
<button
|
||||
className="btn ghost"
|
||||
style={{ padding: '6px 14px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(0)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? 'Sign…' : 'Disarm'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{mwState === 'err' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>{mwErr}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── Main settings card ── */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid var(--line)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>Trading bot</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>Executes on Hyperliquid when a Trump post clears your filters.</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{dirty && isSubscribed && <span style={{ fontSize: 12, color: 'var(--amber-ink)' }}>● Unsaved</span>}
|
||||
{saveState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{saveErr}</span>}
|
||||
{!isSubscribed ? (
|
||||
// Subscribe button moved into the body to live alongside the
|
||||
// paper-vs-live choice. Header keeps only status chips.
|
||||
<span className="chip" style={{ fontSize: 11, background: 'var(--bg-sunk)' }}>
|
||||
○ Not subscribed
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`chip ${botReady ? 'up' : 'down'}`} style={{ fontSize: 11 }}>
|
||||
{botReady ? '● Bot ready' : '● Bot paused'}
|
||||
</span>
|
||||
<button
|
||||
className={`btn ${saveState === 'ok' ? 'ghost' : 'amber'}`}
|
||||
style={{ padding: '8px 18px', fontSize: 13 }}
|
||||
onClick={handleSaveSettings}
|
||||
disabled={!dirty || saveState === 'signing' || saveState === 'saving'}
|
||||
>
|
||||
{saveBtnLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup-incomplete warning */}
|
||||
{isSubscribed && loadState === 'loaded' && !botReady && (
|
||||
<div style={{ padding: '14px 24px', background: 'var(--down-soft)', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<span style={{ fontSize: 16, color: 'var(--down)', lineHeight: 1 }}>⚠</span>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup incomplete — the bot will not open any trades.</div>
|
||||
<div style={{ color: 'var(--ink-3)' }}>
|
||||
Finish configuring: <span style={{ color: 'var(--down)', fontWeight: 500 }}>{missingSetup.join(' · ')}</span>. Fill every required field below and save. The bot stays paused until every box is ticked.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup-complete banner */}
|
||||
{isSubscribed && loadState === 'loaded' && botReady && (
|
||||
<div style={{ padding: '14px 24px', background: 'var(--up-soft)', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<span style={{ fontSize: 16, color: 'var(--up)', lineHeight: 1 }}>✓</span>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup complete on this page.</div>
|
||||
<div style={{ color: 'var(--ink-3)' }}>
|
||||
Wallet subscription, API wallet storage, and risk fields are all saved. Before you rely on automation, use your own account to run one tiny BTC test and confirm the live trade path behaves the way you expect.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{!isSubscribed ? (
|
||||
<div style={{ padding: 28 }}>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 13, lineHeight: 1.65, marginBottom: 18 }}>
|
||||
Subscribe to activate automated trading. The bot signs positions on Hyperliquid using an API wallet scoped to trade-only — it can never withdraw. You pick the size, leverage, risk limits, and signal threshold below.
|
||||
</div>
|
||||
|
||||
{/* ── Paper-vs-Live choice (P0.2) ────────────────────────────
|
||||
New users land on PAPER by default. Forces a conscious
|
||||
opt-in to live trading rather than auto-routing real money. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 16 }}>
|
||||
{([
|
||||
{
|
||||
key: 'paper',
|
||||
title: '📝 Start in paper mode',
|
||||
tag: 'Recommended',
|
||||
body: 'Trades are simulated end-to-end. No funds at risk, no HL key needed. Promote to live anytime.',
|
||||
},
|
||||
{
|
||||
key: 'live',
|
||||
title: '💰 Start live',
|
||||
tag: 'Real money',
|
||||
body: 'Bot trades real positions on Hyperliquid. Requires an HL API key. Risk = your full position size.',
|
||||
},
|
||||
] as const).map(opt => {
|
||||
const active = subPaperChoice === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setSubPaperChoice(opt.key)}
|
||||
style={{
|
||||
textAlign: 'left', cursor: 'pointer',
|
||||
padding: '14px 16px', borderRadius: 10,
|
||||
background: active ? 'var(--up-soft)' : 'var(--bg-sunk)',
|
||||
border: active
|
||||
? '1px solid color-mix(in oklab, var(--up) 30%, var(--line))'
|
||||
: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{opt.title}</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: '2px 8px', borderRadius: 4,
|
||||
background: opt.key === 'paper' ? 'var(--up-soft)' : 'var(--amber-soft)',
|
||||
color: opt.key === 'paper' ? 'var(--up)' : 'var(--amber-ink)',
|
||||
fontWeight: 600,
|
||||
}}>{opt.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', lineHeight: 1.5 }}>
|
||||
{opt.body}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* The subscribe button moves down here so paper choice is
|
||||
resolved before the wallet popup. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
className="btn amber"
|
||||
style={{ padding: '10px 22px', fontSize: 13 }}
|
||||
onClick={handleSubscribe}
|
||||
disabled={subState === 'signing' || subState === 'saving'}
|
||||
>
|
||||
{subState === 'signing'
|
||||
? 'Sign in wallet…'
|
||||
: subState === 'saving'
|
||||
? 'Activating…'
|
||||
: subPaperChoice === 'paper'
|
||||
? 'Subscribe in paper mode'
|
||||
: 'Subscribe (live trading)'}
|
||||
</button>
|
||||
{subState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{subErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
) : loadState !== 'loaded' ? (
|
||||
<div style={{ padding: 28 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 16 }}>
|
||||
Sign a one-time message with your wallet to load your bot settings and trade history. The signature is cached for 4 minutes so repeat visits won't prompt again.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button className="btn amber" onClick={handleLoadSettings} disabled={loadState === 'loading'}>
|
||||
{loadState === 'loading' ? 'Waiting for signature…' : 'Load my settings'}
|
||||
</button>
|
||||
{loadState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{loadErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '8px 24px 24px' }}>
|
||||
{/* ── EXECUTION ── */}
|
||||
<div className="section-head">
|
||||
<span className="section-head-label">Execution</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
<span className="prefix">$</span>
|
||||
<input type="number" min={5} max={10000} step={5} value={settings.position_size_usd}
|
||||
onChange={e => updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>$5 – $10,000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Leverage <span style={{ opacity: 0.6 }}>(Trump / System 1)</span>
|
||||
<span className="hint">Event-driven scalp positions</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
<input type="range" min={1} max={50} value={settings.leverage}
|
||||
onChange={e => updateSettings({ leverage: +e.target.value })} />
|
||||
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
|
||||
</div>
|
||||
<span className="slider-readout">{settings.leverage}×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const aggressive = settings.sys2_mode === 'aggressive'
|
||||
return (
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
BTC strategy mode <span style={{ opacity: 0.6 }}>(System 2)</span>
|
||||
<span className="hint">
|
||||
A separately-funded sleeve. <strong>Aggressive</strong> =
|
||||
high leverage + earlier/heavier pyramiding + wider
|
||||
trailing → explosive in a clean bull, but a normal
|
||||
25–35% correction will scale you out hard. Both modes
|
||||
stay inside the liquidation line.
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button"
|
||||
onClick={() => updateSettings({ sys2_mode: 'standard' })}
|
||||
className={`btn ${!aggressive ? '' : 'ghost'}`}
|
||||
style={{ padding: '7px 14px', fontSize: 12, fontWeight: 700 }}>
|
||||
Standard
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => updateSettings({ sys2_mode: 'aggressive' })}
|
||||
className={`btn ${aggressive ? '' : 'ghost'}`}
|
||||
style={{ padding: '7px 14px', fontSize: 12, fontWeight: 700,
|
||||
...(aggressive ? { background: 'var(--down)', color: '#fff', border: 'none' } : {}) }}>
|
||||
🔥 Aggressive
|
||||
</button>
|
||||
</div>
|
||||
{aggressive && (
|
||||
<div className="hint" style={{ marginTop: 6, color: 'var(--down)' }}>
|
||||
⚠️ High-risk sleeve: default 8×, pyramids up to +1.5×
|
||||
base, gives back up to 42% off the peak. Fund this
|
||||
separately — expect frequent full scale-outs on
|
||||
corrections in exchange for explosive clean runs.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const aggressive = settings.sys2_mode === 'aggressive'
|
||||
const defLev = aggressive ? 8 : 2
|
||||
const lev = Math.max(1, Math.min(10, settings.sys2_leverage ?? defLev))
|
||||
const prot = Math.min(35, 0.85 * 100 / lev)
|
||||
const liq = 100 / lev
|
||||
const risky = lev > 2
|
||||
const e1 = aggressive ? '¼' : '⅓'
|
||||
const e2 = aggressive ? '¼' : '⅓'
|
||||
return (
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
BTC bottom leverage <span style={{ opacity: 0.6 }}>(System 2)</span>
|
||||
<span className="hint">
|
||||
Independent of Trump. Wrong → scales OUT in 3 stages
|
||||
inside the liquidation line (<strong>never
|
||||
exchange-liquidated</strong>). Right → pyramids IN on a
|
||||
confirmed trend, stop floored at breakeven.
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
<input type="range" min={1} max={10} value={lev}
|
||||
onChange={e => updateSettings({ sys2_leverage: +e.target.value })} />
|
||||
<div className="ticks"><span>1×</span><span>5×</span><span>10×</span></div>
|
||||
</div>
|
||||
<span className="slider-readout">{lev}×</span>
|
||||
<div className="hint" style={{ marginTop: 6, color: risky ? 'var(--down)' : 'var(--ink-3)' }}>
|
||||
At {lev}× it sheds {e1} near −{(prot * 0.6).toFixed(0)}%,
|
||||
{' '}{e2} near −{(prot * 0.8).toFixed(0)}%, fully out by
|
||||
−{prot.toFixed(0)}% (exchange would liquidate
|
||||
≈ −{liq.toFixed(0)}%).{' '}
|
||||
{risky
|
||||
? (aggressive
|
||||
? '🔥 Aggressive: a normal 25–35% bull correction will scale you out — you’re betting on clean explosive runs.'
|
||||
: '⚠️ Above 2× a normal 25–35% bull correction can scale you out before the top. To ride a super-bull, keep ≤2× — amplify via pyramiding, not leverage.')
|
||||
: 'Wide enough to ride normal bull corrections; amplification comes from pyramiding.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Min AI confidence
|
||||
<span className="hint">Skip any signal below this score (0–100)</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
<input type="range" min={0} max={100} value={settings.min_confidence}
|
||||
onChange={e => updateSettings({ min_confidence: +e.target.value })} />
|
||||
<div className="ticks"><span>0</span><span>50</span><span>100</span></div>
|
||||
</div>
|
||||
<span className="slider-readout">{settings.min_confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── LIMITS ── */}
|
||||
<div className="section-head" style={{ marginTop: 20 }}>
|
||||
<span className="section-head-label">Limits & risk</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Daily budget <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Stop opening new trades once the day's total notional reaches this (UTC). Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
<span className="prefix">$</span>
|
||||
<input type="number" min={1} max={100000} step={5}
|
||||
value={settings.daily_budget_usd ?? ''}
|
||||
onChange={e => updateSettings({ daily_budget_usd: e.target.value === '' ? null : Math.max(0, +e.target.value) })} />
|
||||
<span className="suffix">/day</span>
|
||||
</div>
|
||||
{!budgetConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 target. Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
<input type="number" min={0.1} max={50} step={0.1}
|
||||
value={settings.take_profit_pct ?? ''}
|
||||
onChange={e => updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} />
|
||||
<span className="suffix">% gain</span>
|
||||
</div>
|
||||
{!tpConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Auto-close when drawdown hits limit. Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
<input type="number" min={0.1} max={50} step={0.1}
|
||||
value={settings.stop_loss_pct ?? ''}
|
||||
onChange={e => updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} />
|
||||
<span className="suffix">% loss</span>
|
||||
</div>
|
||||
{!slConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SCHEDULE ── */}
|
||||
<div className="section-head" style={{ marginTop: 20 }}>
|
||||
<span className="section-head-label">Active schedule</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Only run in a window
|
||||
<span className="hint">Bot ignores signals outside this range. Shown in your browser's local time.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
|
||||
<Switch on={useSchedule} onChange={(v) => { setUseSchedule(v); setDirty(true) }} />
|
||||
{useSchedule && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="num-field">
|
||||
<span className="prefix">From</span>
|
||||
<input type="datetime-local" value={fromLocal}
|
||||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }}
|
||||
style={{ width: 190 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
|
||||
<div className="num-field">
|
||||
<span className="prefix">Until</span>
|
||||
<input type="datetime-local" value={untilLocal}
|
||||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }}
|
||||
style={{ width: 190 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{useSchedule && fromLocal && untilLocal && (() => {
|
||||
const fromMs = new Date(fromLocal).getTime()
|
||||
const untilMs = new Date(untilLocal).getTime()
|
||||
const nowMs = Date.now()
|
||||
if (isNaN(fromMs) || isNaN(untilMs) || untilMs <= fromMs) return null
|
||||
const inWindow = nowMs >= fromMs && nowMs < untilMs
|
||||
const durH = (untilMs - fromMs) / 3600000
|
||||
const durLabel = durH < 1 ? `${Math.round(durH * 60)} min`
|
||||
: durH < 48 ? `${durH.toFixed(1)} h`
|
||||
: `${(durH / 24).toFixed(1)} d`
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, padding: '0 0 14px' }}>
|
||||
<div />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12 }}>
|
||||
<span className={`chip ${inWindow ? 'up' : ''}`} style={{ fontSize: 11 }}>
|
||||
{inWindow ? '● In window now' : '○ Outside window'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>Duration: {durLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import type { BotTrade, TrumpPost } from '@/types'
|
||||
|
||||
// ── Formatters ────────────────────────────────────────────────────────────────
|
||||
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
|
||||
const { decimals = 2, sign = false } = opts
|
||||
if (n == null || isNaN(n)) return '—'
|
||||
const abs = Math.abs(n)
|
||||
const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
if (n < 0) return '-$' + s
|
||||
if (sign && n > 0) return '+$' + s
|
||||
return '$' + s
|
||||
}
|
||||
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
|
||||
function fmtHold(s: number) {
|
||||
if (s < 60) return s + 's'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return m + 'm'
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trades: BotTrade[]
|
||||
posts: TrumpPost[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const ASSETS = ['all', 'BTC', 'ETH', 'SOL'] as const
|
||||
const SIDES = ['all', 'long', 'short'] as const
|
||||
|
||||
export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [assetFilter, setAssetFilter] = useState('all')
|
||||
const [sideFilter, setSideFilter] = useState('all')
|
||||
const [sourceFilter, setSourceFilter] = useState('all')
|
||||
const [hidePaper, setHidePaper] = useState(false)
|
||||
|
||||
// Distinct sources present in the loaded trade set — drives the filter UI.
|
||||
// Includes 'unknown' as a bucket for trades whose trigger post was deleted.
|
||||
const sources = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const t of trades) set.add(t.trigger_source || 'unknown')
|
||||
return Array.from(set).sort()
|
||||
}, [trades])
|
||||
|
||||
// Per-source PnL aggregate — the critical view for "which module makes money".
|
||||
// Computed BEFORE the asset/side filter so the source breakdown reflects the
|
||||
// full universe, not whatever sub-filter is currently applied.
|
||||
const perSource = useMemo(() => {
|
||||
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
|
||||
for (const t of trades) {
|
||||
const k = t.trigger_source || 'unknown'
|
||||
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
|
||||
acc[k].trades += 1
|
||||
if (t.pnl_usd != null) {
|
||||
acc[k].pnl += t.pnl_usd
|
||||
if (t.pnl_usd > 0) acc[k].wins += 1
|
||||
}
|
||||
if (t.is_paper) acc[k].paper += 1
|
||||
}
|
||||
return acc
|
||||
}, [trades])
|
||||
|
||||
const filtered = trades.filter(t => {
|
||||
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
|
||||
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
|
||||
if (sideFilter !== 'all' && t.side !== sideFilter) return false
|
||||
if (hidePaper && t.is_paper) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Exclude externally-closed trades (pnl_usd null) from aggregates.
|
||||
const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined)
|
||||
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
|
||||
const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length
|
||||
const losses = priced.length - wins
|
||||
const avgHold = filtered.length > 0
|
||||
? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Per-source breakdown (the "which module makes money" view) ── */}
|
||||
{sources.length > 1 && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
|
||||
}}>
|
||||
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: 10,
|
||||
}}>
|
||||
{sources.map(src => {
|
||||
const s = perSource[src]
|
||||
const winRate = s.trades ? (s.wins / s.trades) * 100 : 0
|
||||
const tone = s.pnl > 0 ? 'up' : s.pnl < 0 ? 'down' : 'idle'
|
||||
const bg = tone === 'up' ? 'var(--up-soft)'
|
||||
: tone === 'down' ? 'var(--down-soft)'
|
||||
: 'var(--bg-sunk)'
|
||||
const fg = tone === 'up' ? 'var(--up)'
|
||||
: tone === 'down' ? 'var(--down)'
|
||||
: 'var(--ink-2)'
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => setSourceFilter(sourceFilter === src ? 'all' : src)}
|
||||
style={{
|
||||
textAlign: 'left', cursor: 'pointer',
|
||||
background: bg, borderRadius: 8, padding: '12px 14px',
|
||||
border: `1px solid ${sourceFilter === src ? fg : 'transparent'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
|
||||
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{src}
|
||||
{s.paper > 0 && (
|
||||
<span style={{
|
||||
fontSize: 9, marginLeft: 6, padding: '1px 5px',
|
||||
borderRadius: 3, background: 'rgba(245,158,11,0.15)',
|
||||
color: '#f59e0b',
|
||||
}}>
|
||||
{s.paper}P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: fg, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{fmtMoney(s.pnl, { sign: true, decimals: 0 })}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{isZh ? `${s.trades} 笔交易 · ${winRate.toFixed(0)}% 胜率` : `${s.trades} trades · ${winRate.toFixed(0)}% win`}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
|
||||
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && (
|
||||
<button
|
||||
onClick={() => setSourceFilter('all')}
|
||||
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
|
||||
border: '1px solid var(--line)', borderRadius: 4,
|
||||
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
|
||||
>
|
||||
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
|
||||
<div className="kpi">
|
||||
<div className="label">{isZh ? '总交易数' : 'Total trades'}</div>
|
||||
<div className="value">{filtered.length}</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">{isZh ? '胜率' : 'Win rate'}</div>
|
||||
<div className="value">{priced.length ? ((wins / priced.length) * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div className="foot"><span>{isZh ? `${wins} 赢 · ${losses} 亏` : `${wins}W · ${losses}L`}</span></div>
|
||||
</div>
|
||||
<div className="kpi accent">
|
||||
<div className="label">{isZh ? '净盈亏' : 'Net P&L'}</div>
|
||||
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">{isZh ? '平均持仓' : 'Avg hold'}</div>
|
||||
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div className="nav-tabs">
|
||||
{ASSETS.map(a => (
|
||||
<button
|
||||
key={a}
|
||||
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
|
||||
onClick={() => setAssetFilter(a)}
|
||||
>
|
||||
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-tabs">
|
||||
{SIDES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
|
||||
onClick={() => setSideFilter(s)}
|
||||
>
|
||||
{s === 'all' ? (isZh ? '全部方向' : 'All') : s === 'long' ? (isZh ? '做多' : 'Long') : (isZh ? '做空' : 'Short')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Hide-paper toggle: paper trades inflate volume but aren't real $ — */}
|
||||
{/* let users blend them out before reading the KPI row. */}
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, fontSize: 12,
|
||||
color: 'var(--ink-3)', cursor: 'pointer', marginLeft: 'auto',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePaper}
|
||||
onChange={e => setHidePaper(e.target.checked)}
|
||||
/>
|
||||
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>{isZh ? '加载中…' : 'Loading…'}</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!loading && (
|
||||
<div className="card flush" style={{ overflow: 'hidden' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{isZh ? '来源' : 'Source'}</th>
|
||||
<th>{isZh ? '资产' : 'Asset'}</th>
|
||||
<th>{isZh ? '方向' : 'Side'}</th>
|
||||
<th>{isZh ? '开仓' : 'Entry'}</th>
|
||||
<th>{isZh ? '平仓' : 'Exit'}</th>
|
||||
<th>{isZh ? '持仓' : 'Hold'}</th>
|
||||
<th>{isZh ? '触发内容' : 'Trigger'}</th>
|
||||
<th>P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
|
||||
{isZh ? '没有符合条件的交易。' : 'No trades found'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map(t => {
|
||||
const tp = posts.find(p => p.id === t.trigger_post_id)
|
||||
const roi =
|
||||
t.entry_price && t.exit_price != null
|
||||
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
|
||||
: 0
|
||||
const src = t.trigger_source || 'unknown'
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: 'var(--bg-sunk)',
|
||||
}}>
|
||||
{src}
|
||||
</span>
|
||||
{t.is_paper && (
|
||||
<span style={{
|
||||
fontSize: 9, padding: '1px 5px', borderRadius: 3,
|
||||
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
|
||||
}}>
|
||||
PAPER
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="row gap-s">
|
||||
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
|
||||
<span style={{ fontWeight: 500 }}>{t.asset}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`side-pill ${t.side}`}>
|
||||
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
|
||||
<td style={{ maxWidth: 260 }}>
|
||||
{tp ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{tp.text.slice(0, 60)}…
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack" style={{ alignItems: 'flex-end' }}>
|
||||
{t.pnl_usd === null || t.pnl_usd === undefined ? (
|
||||
<span
|
||||
style={{ fontSize: 12, color: 'var(--ink-4)' }}
|
||||
title={isZh ? '在 Hyperliquid 外部平仓,PnL 未记录' : 'Closed externally on Hyperliquid — PnL not recorded'}
|
||||
>
|
||||
n/a
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{fmtMoney(t.pnl_usd, { sign: true })}
|
||||
</span>
|
||||
<span className={`delta ${roi >= 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>
|
||||
{fmtPct(roi)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SignConfirmSheet — elegant pre-signing confirmation UI.
|
||||
*
|
||||
* Usage (imperative, no context needed):
|
||||
*
|
||||
* import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
*
|
||||
* const ok = await confirmSign({
|
||||
* label: 'Enable Auto-Trade',
|
||||
* description: 'Bot will open live trades on Hyperliquid when signals fire.',
|
||||
* danger: true,
|
||||
* })
|
||||
* if (!ok) return // user cancelled
|
||||
* const env = await signRequest(...) // only NOW triggers MetaMask
|
||||
*/
|
||||
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
export interface SignConfirmOptions {
|
||||
/** Short action title, e.g. "Enable Auto-Trade" */
|
||||
label: string
|
||||
/** One-sentence explanation shown to the user */
|
||||
description: string
|
||||
/** If true, renders a red/warning accent — for irreversible / live-money actions */
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
/** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */
|
||||
export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
function cleanup(result: boolean) {
|
||||
root.unmount()
|
||||
container.remove()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={() => cleanup(false)} />)
|
||||
})
|
||||
}
|
||||
|
||||
/* ─── Internal sheet component ─────────────────────────────────────────────── */
|
||||
|
||||
function Sheet({
|
||||
label, description, danger,
|
||||
onConfirm, onCancel,
|
||||
}: SignConfirmOptions & { onConfirm: () => void; onCancel: () => void }) {
|
||||
const accent = danger ? '#ef4444' : '#f5a524'
|
||||
const accentSoft = danger ? 'rgba(239,68,68,0.1)' : 'rgba(245,165,36,0.1)'
|
||||
|
||||
// Dismiss on backdrop click
|
||||
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === e.currentTarget) onCancel()
|
||||
}
|
||||
|
||||
// Dismiss on Escape
|
||||
function handleKey(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleBackdrop}
|
||||
onKeyDown={handleKey}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.70)',
|
||||
backdropFilter: 'blur(3px)',
|
||||
WebkitBackdropFilter: 'blur(3px)',
|
||||
zIndex: 99999,
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||||
padding: '0 0 env(safe-area-inset-bottom, 0)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%', maxWidth: 480,
|
||||
background: '#111',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderBottom: 'none',
|
||||
padding: '28px 24px 32px',
|
||||
boxShadow: '0 -12px 48px rgba(0,0,0,0.5)',
|
||||
animation: 'signSheetIn 0.22s cubic-bezier(0.32,0.72,0,1)',
|
||||
}}
|
||||
>
|
||||
{/* 拖拽条 */}
|
||||
<div style={{
|
||||
width: 36, height: 4, borderRadius: 2,
|
||||
background: '#333', margin: '0 auto 24px',
|
||||
}} />
|
||||
|
||||
{/* 图标 + 标题 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: accentSoft,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, flexShrink: 0,
|
||||
}}>
|
||||
🔏
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#666', letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase', marginBottom: 2 }}>
|
||||
Wallet signature required
|
||||
</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#fff', lineHeight: 1.2 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 说明文字 */}
|
||||
<div style={{
|
||||
fontSize: 14, color: '#999', lineHeight: 1.6,
|
||||
margin: '16px 0 24px',
|
||||
paddingLeft: 52, // 与标题对齐
|
||||
}}>
|
||||
{description}
|
||||
</div>
|
||||
|
||||
{/* 说明条 */}
|
||||
<div style={{
|
||||
padding: '10px 12px', borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid #2a2a2a',
|
||||
fontSize: 12, color: '#666', lineHeight: 1.5,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
Signing is used only to verify your identity. It will NOT authorize
|
||||
any on-chain transfer and will NOT cost any gas. The MetaMask popup
|
||||
will appear after you click Confirm.
|
||||
</div>
|
||||
|
||||
{/* 按钮组 */}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1, padding: '12px 0',
|
||||
borderRadius: 10, border: '1px solid #2a2a2a',
|
||||
background: 'transparent', color: '#888',
|
||||
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 2, padding: '12px 0',
|
||||
borderRadius: 10, border: 'none',
|
||||
background: accent, color: danger ? '#fff' : '#000',
|
||||
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<span>Open MetaMask</span>
|
||||
<span style={{ opacity: 0.7, fontSize: 16 }}>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes signSheetIn {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user