Files
trumpsignal-frontend/components/trades/BotConfigPanel.tsx
T
k b76de36af0 refactor(settings): redesign BotConfigPanel + fix paper mode + clarify all copy
BotConfigPanel (full rewrite, 1113→858 lines):
- Remove 3-tab layout; replace with two expandable cards (Trump Signal /
  Macro Vibes) + collapsed Advanced block — fewer decisions, less noise
- Fix paper mode bug: UserResponse now returns paper_mode; BotConfigPanel
  tracks it in state; missingItems skips HL key requirement for paper users;
  HL key card shows paper status instead of empty input
- Add "Switch to live →" upgrade path — calls /subscribe with paper_mode=false
  so paper users can promote without re-subscribing from scratch
- Add confirmSign before manual override window (consistent with all other
  mutations); rename "Arm/Disarm" → "Override/Cancel override" throughout
- Fix all ambiguous copy: subscribe card, Macro Vibes description (manual
  /adopt flow, not auto-manage), missingItems strings, TP/SL "Not saved yet"
  → "Required — not saved", "Daily spend cap" → "Daily trading cap",
  "Active schedule" → "Trading schedule", "Get started" confirmSign labels

lib/api.ts:
- Add paper_mode: boolean to UserData interface (was missing)

OpenPositions:
- "Load your settings once…" → "Sign in once to view open positions" (removes
  implementation-detail explanation)
- "Today realised" → "Today's realized"; "banked" → "locked in" throughout
- "The bot will appear here" → "When the bot opens a trade, it will show up here"

SystemControl:
- "Net result" → "Current status"

settings/SettingsClient.tsx:
- id="btc-settings" → id="macro-settings" (stale rename)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 23:10:55 +08:00

868 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { 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 { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
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,
trump_enabled: false, macro_enabled: false,
}
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)
if (isNaN(d.getTime())) return null
return d.toISOString()
}
export default function BotConfigPanel() {
useLocale() // keep next-intl context hydrated even though i18n is shelved
const { address, isConnected } = useAccount()
const { connectAsync, connectors } = useConnect()
const { signMessageAsync } = useSignMessage()
const {
isSubscribed, hlApiKeySet, hlApiKeyMasked,
setSubscribed, setBotReadiness, setHlApiKeySet,
} = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
const [tpConfigured, setTpConfigured] = useState(false)
const [slConfigured, setSlConfigured] = useState(false)
const [useBudget, setUseBudget] = 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('')
const [apiKey, setApiKey] = useState('')
const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle')
const [keyErr, setKeyErr] = useState('')
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
const [subErr, setSubErr] = useState('')
const [subPaperChoice, setSubPaperChoice] = useState<'paper' | 'live'>('paper')
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)
const [paperMode, setPaperMode] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [mounted, setMounted] = useState(false)
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
const [loadErr, setLoadErr] = useState('')
const [connectErr, setConnectErr] = useState('')
useEffect(() => { setMounted(true) }, [])
function applyUserPayload(u: {
active: boolean
hl_api_key_set: boolean
hl_api_key_masked: string | null
paper_mode?: boolean
settings: UserSettings
manual_window_until?: string | null
}) {
setSubscribed(u.active)
setPaperMode(!!u.paper_mode)
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)
const hasBudget = s.daily_budget_usd != null
setUseBudget(hasBudget)
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,
trump_enabled: s.trump_enabled ?? false,
macro_enabled: s.macro_enabled ?? false,
})
const hasSched = s.active_from != null && s.active_until != null
setUseSchedule(hasSched)
setFromLocal(isoToLocalInput(s.active_from))
setUntilLocal(isoToLocalInput(s.active_until))
}
}
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: 'View account settings',
description: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification — no on-chain action is performed.',
})
if (!ok) { setLoadState('idle'); return }
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, '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 ? 'Start in paper mode' : 'Start live trading',
description: paperMode
? 'Trades are simulated end-to-end — no funds are at risk. You can switch to live at any time from the settings panel.'
: 'Start live trading. You\'ll need to add a Hyperliquid API key (next step) before the bot can open any real position.',
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)
setPaperMode(paperMode)
void refreshUserState(address)
setSubState('idle')
setTpConfigured(false); setSlConfigured(false)
setUseBudget(false); setLoadState('loaded')
} catch (e: unknown) {
setSubErr(walletErrorLabel(e, 'Cancelled', 100))
setSubState('err')
}
}
async function handleSaveSettings() {
if (!address) return
const ok = await confirmSign({
label: 'Save trading settings',
description: 'Settings are saved to the server and apply to all new trades from this point forward. Open positions are not affected.',
})
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('Pick both a start and end time'); setSaveState('err'); return }
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) { setSaveErr('End must be after start'); setSaveState('err'); return }
}
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
if (trumpOn) {
if (tp == null || tp < 0.1 || tp > 50) { setSaveErr('Take profit must be 0.150%'); setSaveState('err'); return }
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.150%'); setSaveState('err'); return }
}
if (useBudget && (bd == null || bd <= 0 || bd > 100000)) { setSaveErr('Daily budget must be > $0'); setSaveState('err'); return }
const payload: UserSettings = { ...settings, daily_budget_usd: useBudget ? bd : null, 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(tp != null); setSlConfigured(sl != null)
setBotReadiness('saved'); setDirty(false)
setSaveState('ok')
setTimeout(() => setSaveState('idle'), 2500)
} catch (e: unknown) {
setSaveErr(walletErrorLabel(e, 'Cancelled', 100))
setSaveState('err')
}
}
async function handleSaveKey() {
if (!address || !apiKey.trim()) return
const trimmed = apiKey.trim()
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
setKeyErr('Must be 0x + 64 hex chars'); setKeyState('err'); return
}
const ok = await confirmSign({
label: 'Link Hyperliquid API key',
description: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you.',
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, 'Cancelled', 100))
setKeyState('err')
}
}
const handleConnectWallet = useCallback(async () => {
setConnectErr('')
try {
const connector = await getFirstReadyConnector(connectors)
if (!connector) { setConnectErr('No wallet connector is available right now.'); return }
await connectAsync({ connector })
} catch (err: unknown) {
setConnectErr(walletConnectErrorLabel(err))
}
}, [connectAsync, connectors])
async function handleManualWindow(hours: number) {
if (!address) return
const ok = await confirmSign(hours === 0
? {
label: 'Cancel override window',
description: 'The bot will stop accepting new trades from this override. Any open position risk controls (stop-loss, de-risk) remain active.',
}
: {
label: `Open ${hours}h override window`,
description: `The bot will accept qualifying signals for the next ${hours} hour${hours > 1 ? 's' : ''}, regardless of your trading schedule. Use this for high-conviction catalysts (e.g. CPI, FOMC). Stop-loss is always active.`,
danger: true,
}
)
if (!ok) 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, 'Cancelled', 100))
setMwState('err')
}
}
async function handleUpgradeToLive() {
if (!address) return
const ok = await confirmSign({
label: 'Switch to live trading',
description: 'Your subscription will change from paper mode to live. No trade opens immediately — you still need to add a Hyperliquid API key and enable a signal module before the bot can act.',
danger: true,
})
if (!ok) return
setSubErr(''); setSubState('signing')
try {
// paper_mode=false → signedBody=null (same as original live subscribe)
const env = await signRequest({ action: 'subscribe', wallet: address, body: null, signMessageAsync })
setSubState('saving')
await subscribe(env, { paper_mode: false })
setPaperMode(false)
void refreshUserState(address)
setSubState('idle')
} catch (e: unknown) {
setSubErr(walletErrorLabel(e, 'Cancelled', 100))
setSubState('err')
}
}
useEffect(() => {
if (!manualUntil) return
const id = setInterval(() => setMwTick(t => t + 1), 15000)
return () => clearInterval(id)
}, [manualUntil])
// ── Not connected ──────────────────────────────────────────────────────────
if (!mounted || !isConnected) {
return (
<div className="card" style={{ padding: '32px 28px', textAlign: 'center', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to continue</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginBottom: 20, lineHeight: 1.6 }}>
Your subscription, API key, and trade settings are wallet-bound. No account needed just sign a message.
</div>
<button className="btn amber" style={{ padding: '10px 24px' }} onClick={() => { void handleConnectWallet() }}>
Connect wallet
</button>
{connectErr && <p style={{ fontSize: 12, color: 'var(--down)', margin: '12px 0 0' }}>{connectErr}</p>}
</div>
)
}
// ── helpers ────────────────────────────────────────────────────────────────
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' }) => (
<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 trumpOn = !!settings.trump_enabled
const macroOn = !!settings.macro_enabled
// ── Not subscribed ─────────────────────────────────────────────────────────
if (!isSubscribed) {
return (
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your trading bot</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 24 }}>
The bot places trades on Hyperliquid with a trade-only API key it can never withdraw funds.
Start in paper mode to try it safely, or choose Live if you already have a Hyperliquid API key.
</div>
{/* Paper / Live choice */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 10, marginBottom: 16,
background: subPaperChoice === 'paper' ? 'var(--up-soft)' : 'var(--amber-soft)',
border: `1px solid ${subPaperChoice === 'paper'
? 'color-mix(in oklab, var(--up) 25%, var(--line))'
: 'color-mix(in oklab, var(--amber-ink) 25%, var(--line))'}`,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{subPaperChoice === 'paper' ? '📝 Paper mode — simulated trades, no real funds' : '💰 Live trading — real positions on Hyperliquid'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 3, lineHeight: 1.5 }}>
{subPaperChoice === 'paper'
? 'Nothing is at risk. No Hyperliquid API key needed. You can switch to live at any time.'
: 'Real positions will be opened and managed on Hyperliquid. A trade-only API key is required before the first trade.'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{ fontSize: 11, color: subPaperChoice === 'paper' ? 'var(--ink-2)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'paper' ? 600 : 400 }}>Paper</span>
<Switch
on={subPaperChoice === 'live'}
onChange={v => setSubPaperChoice(v ? 'live' : 'paper')}
tone="amber"
/>
<span style={{ fontSize: 11, color: subPaperChoice === 'live' ? 'var(--amber-ink)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'live' ? 600 : 400 }}>Live</span>
</div>
</div>
<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…' : 'Get started →'}
</button>
{subState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{subErr}</span>}
</div>
</div>
)
}
// ── Subscribed but settings not loaded yet ─────────────────────────────────
if (loadState !== 'loaded') {
return (
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Sign in to view your settings</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 20 }}>
Your settings are private and wallet-bound. Sign once to load them the permission is cached for 4 minutes so you won&apos;t be prompted again while you browse.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button className="btn amber" style={{ padding: '10px 22px', fontSize: 13 }} 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>
)
}
// ── Main config (subscribed + loaded) ──────────────────────────────────────
// Readiness check — shown at bottom near save button
const missingItems: string[] = []
if (!paperMode && !hlApiKeySet) missingItems.push('link a Hyperliquid API key')
if (!trumpOn && !macroOn) missingItems.push('enable Trump Signal or Macro Vibes below')
if (trumpOn && !tpConfigured) missingItems.push('set a Trump Signal take-profit %')
if (trumpOn && !slConfigured) missingItems.push('set a Trump Signal stop-loss %')
const botReady = missingItems.length === 0
// Manual window countdown
void mwTick
const untilMs = manualUntil ? new Date(manualUntil).getTime() : 0
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 mwBusy = mwState === 'signing' || mwState === 'saving'
const saveBtnLabel =
saveState === 'signing' ? 'Sign in wallet…'
: saveState === 'saving' ? 'Saving…'
: saveState === 'ok' ? '✓ Saved'
: 'Save changes'
return (
<div style={{ marginBottom: 28 }} id="bot-config-panel">
{/* ── HL API Key ─────────────────────────────────────────────────────── */}
<div className="card" style={{ padding: '16px 20px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* Status dot */}
<div style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: (paperMode || hlApiKeySet) ? 'var(--up-soft)' : 'var(--bg-sunk)',
border: `1px solid ${(paperMode || hlApiKeySet) ? 'color-mix(in oklab, var(--up) 25%, var(--line))' : 'var(--line)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: (paperMode || hlApiKeySet) ? 'var(--up)' : 'var(--ink-4)',
}}>
{(paperMode || hlApiKeySet) ? '✓' : '◇'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Hyperliquid API wallet</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 1 }}>
{paperMode
? <span style={{ color: 'var(--up)' }}>📝 Paper mode no real money involved. Add an API key below to switch to live.</span>
: hlApiKeySet && !apiKey
? <><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only · cannot withdraw</span></>
: 'Generate at app.hyperliquid.xyz/API — paste the private key here. Never your MetaMask key.'}
</div>
</div>
{/* Paper mode: show upgrade path instead of key input */}
{paperMode && (
<button
className="btn amber"
style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }}
onClick={handleUpgradeToLive}
disabled={subState === 'signing' || subState === 'saving'}
>
{subState === 'signing' ? 'Sign…' : subState === 'saving' ? 'Switching…' : 'Switch to live →'}
</button>
)}
{/* Live mode: show key input */}
{!paperMode && (hlApiKeySet && !apiKey ? (
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }} onClick={() => setApiKey(' ')}>Rotate</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={e => { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }}
placeholder="0x…"
style={{ width: 200, maxWidth: '100%', fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }}
/>
<button
className={`btn ${keyState === 'ok' ? 'ghost' : 'amber'}`}
style={{ padding: '8px 14px', fontSize: 12, flexShrink: 0 }}
onClick={handleSaveKey}
disabled={keyState === 'signing' || keyState === 'saving' || !apiKey.trim()}
>
{keyState === 'signing' ? 'Sign…' : keyState === 'saving' ? 'Saving…' : keyState === 'ok' ? '✓' : 'Save'}
</button>
</div>
))}
</div>
{keyState === 'err' && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8, paddingLeft: 50 }}>{keyErr}</div>}
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Run one small test trade to confirm the live path end-to-end.</div>}
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
</div>
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: trumpOn ? '1px solid var(--line)' : 'none',
background: trumpOn ? 'var(--amber-soft)' : undefined,
transition: 'background 0.2s',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: trumpOn ? 'var(--amber-ink)' : 'var(--ink)' }}>
Trump Signal
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{trumpOn
? 'Opens positions when a Trump Truth Social post fires a signal.'
: 'Disabled — bot ignores Trump posts.'}
</div>
</div>
<Switch on={trumpOn} onChange={v => updateSettings({ trump_enabled: v })} tone="amber" />
</div>
{/* Form — only when enabled */}
{trumpOn && (
<div style={{ padding: '20px 20px 8px' }}>
<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 className="hint">Event-driven scalp use lower leverage if unsure</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>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Skip signals below this score (0 = take all, 100 = only the highest-conviction)</span>
</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>
<div style={{ height: 1, background: 'var(--line)', margin: '8px 0 16px' }} />
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when unrealised gain hits this target. Required.</span>
</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)' }}>Required not saved</span>}
</div>
</div>
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when drawdown hits this limit. Required.</span>
</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)' }}>Required not saved</span>}
</div>
</div>
</div>
)}
</div>
{/* ── Macro Vibes ────────────────────────────────────────────────────── */}
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: macroOn ? '1px solid var(--line)' : 'none',
background: macroOn ? 'var(--up-soft)' : undefined,
transition: 'background 0.2s',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: macroOn ? 'var(--up)' : 'var(--ink)' }}>
Macro Vibes
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{macroOn
? 'You open a BTC long on Hyperliquid, then use /adopt in the Telegram bot to hand it to the bot for exit management.'
: 'Disabled — Macro Vibes alerts will not include bot management instructions.'}
</div>
</div>
<Switch on={macroOn} onChange={v => updateSettings({ macro_enabled: v })} tone="up" />
</div>
{/* Form — only when enabled */}
{macroOn && (() => {
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
return (
<div style={{ padding: '20px 20px 8px' }}>
<div className="form-row">
<div className="form-row-label">
BTC strategy mode
<span className="hint">Standard is calmer. Aggressive uses higher leverage and wider pyramiding.</span>
</div>
<div className="form-row-control">
<div style={{
display: 'inline-flex', gap: 6, padding: 4,
borderRadius: 999, background: 'var(--bg-sunk)', border: '1px solid var(--line)',
}}>
<button type="button"
onClick={() => updateSettings({ sys2_mode: 'standard' })}
aria-pressed={!aggressive}
style={{
padding: '8px 16px', fontSize: 12, fontWeight: 700, borderRadius: 999, cursor: 'pointer',
border: !aggressive ? '1px solid var(--line)' : '1px solid transparent',
background: !aggressive ? 'var(--surface)' : 'transparent',
color: !aggressive ? 'var(--ink)' : 'var(--ink-3)',
boxShadow: !aggressive ? 'var(--shadow-1)' : 'none',
}}>
Standard
</button>
<button type="button"
onClick={() => updateSettings({ sys2_mode: 'aggressive' })}
aria-pressed={aggressive}
style={{
padding: '8px 16px', fontSize: 12, fontWeight: 700, borderRadius: 999, cursor: 'pointer',
border: aggressive ? '1px solid var(--down)' : '1px solid transparent',
background: aggressive ? 'var(--down)' : 'transparent',
color: aggressive ? '#fff' : 'var(--ink-3)',
boxShadow: aggressive ? 'var(--shadow-1)' : 'none',
}}>
🔥 Aggressive
</button>
</div>
{aggressive && (
<div className="settings-note warn" style={{ marginTop: 6 }}>
High-risk sleeve. More leverage, harder pyramiding, wider peak-trail. Treat as a separate risk bucket.
</div>
)}
</div>
</div>
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
BTC bottom leverage
<span className="hint">Separate from Trump leverage. The bot de-risks in stages before exchange liquidation.</span>
</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={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
At {lev}× it sheds near {(prot * 0.6).toFixed(0)}%, near {(prot * 0.8).toFixed(0)}%, fully out by {prot.toFixed(0)}%.
Exchange liquidation {liq.toFixed(0)}%.
{risky ? ' Above 2×, a normal correction can push you out early.' : ' Wide enough to survive a normal correction.'}
</div>
</div>
</div>
</div>
)
})()}
</div>
{/* ── Advanced ───────────────────────────────────────────────────────── */}
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
style={{
width: '100%', padding: '14px 20px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', background: 'transparent', border: 'none',
cursor: 'pointer', borderBottom: showAdvanced ? '1px solid var(--line)' : 'none',
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Advanced</span>
<span style={{
fontSize: 11, color: 'var(--ink-4)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
{armed && <span style={{ color: 'var(--up)', fontWeight: 600 }}> Override {remainingLabel}</span>}
{useBudget && settings.daily_budget_usd && <span>${settings.daily_budget_usd}/day limit</span>}
{useSchedule && <span>Scheduled</span>}
<span style={{ fontSize: 13 }}>{showAdvanced ? '▴' : '▾'}</span>
</span>
</button>
{showAdvanced && (
<div style={{ padding: '16px 20px 8px' }}>
{/* Daily spend cap */}
<div className="form-row">
<div className="form-row-label">
Daily trading cap
<span className="hint">Optional bot stops opening new trades once total notional crosses this limit in a UTC day.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
{useBudget ? (
<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>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>No cap</span>
)}
</div>
</div>
{/* Schedule */}
<div className="form-row">
<div className="form-row-label">
Trading schedule
<span className="hint">Bot only accepts signals inside this window. Times in your browser&apos;s local timezone. Leave off to trade anytime.</span>
</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>
{/* Manual window */}
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Override window
<span className="hint">Open a timed override so the bot accepts signals for the next 124 hours useful for high-conviction catalysts like CPI or FOMC releases.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
{armed ? (
<>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}> Override active {remainingLabel} remaining</span>
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
{mwBusy ? 'Sign…' : 'Cancel override'}
</button>
</>
) : (
[1, 4, 24].map(h => (
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
{mwBusy && mwState === 'signing' ? 'Sign…' : `Override ${h}h`}
</button>
))
)}
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
</div>
</div>
</div>
)}
</div>
{/* ── Save ───────────────────────────────────────────────────────────── */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, flexWrap: 'wrap',
padding: '16px 20px',
background: 'var(--surface)',
border: '1px solid var(--line)',
borderRadius: 12,
}}>
{/* Status */}
<div style={{ fontSize: 12, color: botReady ? 'var(--up)' : 'var(--ink-3)', lineHeight: 1.5 }}>
{saveState === 'err' ? (
<span style={{ color: 'var(--down)' }}> {saveErr}</span>
) : botReady ? (
<span> Bot is configured and ready</span>
) : (
<span>Still needed: {missingItems.join(' · ')}</span>
)}
</div>
{/* Button */}
<button
className={`btn ${saveState === 'ok' ? 'ghost' : 'amber'}`}
style={{ padding: '10px 24px', fontSize: 13 }}
onClick={handleSaveSettings}
disabled={!dirty || saveState === 'signing' || saveState === 'saving'}
>
{saveBtnLabel}
</button>
</div>
</div>
)
}