d50c05b120
Frontend half of the pre-launch audit campaign: - types/index.ts + kol/KolPageClient.tsx: add missing 'reduce' KolAction (backend emits it; frontend lacked the type + color/label maps → undefined styling). Adds ACTION_COLOR/actionLabel/postActionLabel entries. - proxy/[...path]/route.ts: relay real client IP (x-forwarded-for / x-real-ip) so the backend rate limiter buckets per-user instead of per-Next-server (BUG-02). - Settings/BotConfigPanel redesign, paper-mode clarity, copy cleanup. - Assorted page/display fixes, loading states, Pagination component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
881 lines
42 KiB
TypeScript
881 lines
42 KiB
TypeScript
'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.1–50%'); setSaveState('err'); return }
|
||
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.1–50%'); 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')
|
||
// Clear any stale subscribe-error from a previous attempt so
|
||
// the user sees a clean state for the new paper/live choice.
|
||
if (subState === 'err') { setSubState('idle'); setSubErr('') }
|
||
}}
|
||
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'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={() => {
|
||
// Switch to edit mode AND clear any stale error / status from
|
||
// a previous failed save so the row isn't pre-stained red.
|
||
setApiKey(' '); setKeyState('idle'); setKeyErr('')
|
||
}}
|
||
>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'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" lang="en-US" 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" lang="en-US" 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 1–24 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>
|
||
)
|
||
}
|