326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import Link from 'next/link'
|
||
import { useAccount, useSignMessage } from 'wagmi'
|
||
import { useDashboardStore } from '@/store/dashboard'
|
||
import { getUser, getUserPublic, setUserSettings, type UserSettings } from '@/lib/api'
|
||
import { getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||
import BotPanel from '@/components/dashboard/BotPanel'
|
||
|
||
const ACTION_SET_SETTINGS = 'set_settings'
|
||
const ACTION_VIEW_USER = 'view_user'
|
||
const DEFAULT_SETTINGS: UserSettings = {
|
||
leverage: 3,
|
||
position_size_usd: 20,
|
||
take_profit_pct: null,
|
||
stop_loss_pct: null,
|
||
min_confidence: 80,
|
||
}
|
||
|
||
function LockedCard({ label }: { label: string }) {
|
||
return (
|
||
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-sunk)', margin: '0 auto 12px', display: 'grid', placeItems: 'center' }}>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="2"/><path d="M8 11V8a4 4 0 118 0v3" stroke="currentColor" strokeWidth="2"/></svg>
|
||
</div>
|
||
{label}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BotSettings({
|
||
connected, subscribed, wallet, initial,
|
||
}: {
|
||
connected: boolean
|
||
subscribed: boolean
|
||
wallet?: string
|
||
initial: UserSettings
|
||
}) {
|
||
if (!connected) {
|
||
return (
|
||
<div className="card" style={{ padding: 40, textAlign: 'center' }}>
|
||
<div style={{ width: 56, height: 56, borderRadius: 16, background: 'var(--bg-sunk)', margin: '0 auto 16px', display: 'grid', placeItems: 'center' }}>
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="3" stroke="currentColor" strokeWidth="2"/><path d="M3 10h18" stroke="currentColor" strokeWidth="2"/></svg>
|
||
</div>
|
||
<h3 style={{ margin: '0 0 8px', fontSize: 18 }}>Connect wallet to configure</h3>
|
||
<p style={{ color: 'var(--ink-3)', fontSize: 14, maxWidth: 380, margin: '0 auto 20px', lineHeight: 1.5 }}>
|
||
Your wallet is used to verify access and sign bot commands. We never custody your funds.
|
||
</p>
|
||
<button className="btn amber lg" onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
|
||
Connect wallet · free
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!subscribed) {
|
||
return (
|
||
<div className="card" style={{ padding: 32 }}>
|
||
<div className="row between" style={{ marginBottom: 20 }}>
|
||
<div>
|
||
<h3 style={{ margin: '0 0 4px', fontSize: 18 }}>Subscribe to unlock the bot</h3>
|
||
<p style={{ color: 'var(--ink-3)', fontSize: 13, margin: 0 }}>Get automated execution, real-time alerts, and advanced configuration.</p>
|
||
</div>
|
||
<span className="chip amber">14-day free trial</span>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||
{['Auto-execute trades on your exchange', 'SMS + Telegram alerts within 2s', 'Custom confidence + position-size rules', 'Backtest any strategy on 2y of data'].map(f => (
|
||
<div key={f} className="row gap-s" style={{ padding: 12, background: 'var(--bg-sunk)', borderRadius: 10, fontSize: 13 }}>
|
||
<span style={{ color: 'var(--up)' }}>✓</span>{f}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Link href="/" className="btn amber lg" style={{ textAlign: 'center' }}>
|
||
Activate on dashboard →
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return <BotSettingsForm wallet={wallet!} initial={initial} />
|
||
}
|
||
|
||
function BotSettingsForm({ wallet, initial }: { wallet: string; initial: UserSettings }) {
|
||
const { signMessageAsync } = useSignMessage()
|
||
const [s, setS] = useState<UserSettings>(initial)
|
||
const [useTp, setUseTp] = useState<boolean>(initial.take_profit_pct != null)
|
||
const [useSl, setUseSl] = useState<boolean>(initial.stop_loss_pct != null)
|
||
const [state, setState] = useState<'idle' | 'signing' | 'saving' | 'ok' | 'err'>('idle')
|
||
const [err, setErr] = useState('')
|
||
|
||
// Re-sync when initial changes (after /user fetch resolves)
|
||
useEffect(() => {
|
||
setS(initial)
|
||
setUseTp(initial.take_profit_pct != null)
|
||
setUseSl(initial.stop_loss_pct != null)
|
||
}, [initial])
|
||
|
||
const dirty = JSON.stringify(s) !== JSON.stringify(initial) ||
|
||
useTp !== (initial.take_profit_pct != null) ||
|
||
useSl !== (initial.stop_loss_pct != null)
|
||
|
||
async function save() {
|
||
setErr('')
|
||
try {
|
||
const payload: UserSettings = {
|
||
...s,
|
||
take_profit_pct: useTp ? s.take_profit_pct ?? 2 : null,
|
||
stop_loss_pct: useSl ? s.stop_loss_pct ?? 1.5 : null,
|
||
}
|
||
setState('signing')
|
||
const env = await signRequest({
|
||
action: ACTION_SET_SETTINGS,
|
||
wallet,
|
||
body: payload,
|
||
signMessageAsync,
|
||
})
|
||
setState('saving')
|
||
await setUserSettings(env, payload)
|
||
setS(payload)
|
||
setState('ok')
|
||
setTimeout(() => setState('idle'), 2000)
|
||
} catch (e: unknown) {
|
||
const m = e instanceof Error ? e.message : 'Save failed'
|
||
setErr(m.includes('User rejected') || m.includes('denied') ? 'Signature cancelled' : m.slice(0, 140))
|
||
setState('err')
|
||
}
|
||
}
|
||
|
||
const label = state === 'signing' ? 'Waiting for signature…' : state === 'saving' ? 'Saving…' : state === 'ok' ? '✓ Saved' : 'Save changes'
|
||
|
||
return (
|
||
<div>
|
||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
||
<div className="section-title"><h2>Execution</h2></div>
|
||
|
||
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Position size (USD)</div>
|
||
<div className="desc" style={{ marginBottom: 8 }}>Fixed notional per trade. Margin = size / leverage.</div>
|
||
<input
|
||
type="number" min={5} max={10000} step={5}
|
||
value={s.position_size_usd}
|
||
onChange={e => setS({ ...s, position_size_usd: Math.max(5, +e.target.value || 0) })}
|
||
style={{ width: 140 }}
|
||
/>
|
||
</div>
|
||
<div className="tiny mono" style={{ color: 'var(--ink-3)' }}>margin ≈ ${(s.position_size_usd / s.leverage).toFixed(2)}</div>
|
||
</div>
|
||
|
||
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Leverage</div>
|
||
<div className="desc" style={{ marginBottom: 8 }}>Isolated margin, 1–50×. Higher = bigger PnL swings.</div>
|
||
<div className="row gap-m">
|
||
<input type="range" min={1} max={50} value={s.leverage}
|
||
onChange={e => setS({ ...s, leverage: +e.target.value })}
|
||
style={{ flex: 1, accentColor: 'var(--amber)' }} />
|
||
<div className="mono" style={{ minWidth: 60, fontSize: 16, fontWeight: 500 }}>{s.leverage}×</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>Minimum AI confidence</div>
|
||
<div className="desc" style={{ marginBottom: 8 }}>Platform floor is 80%. Raise to be pickier; can't lower.</div>
|
||
<div className="row gap-m">
|
||
<input type="range" min={80} max={100} value={s.min_confidence}
|
||
onChange={e => setS({ ...s, min_confidence: +e.target.value })}
|
||
style={{ flex: 1, accentColor: 'var(--amber)' }} />
|
||
<div className="mono" style={{ minWidth: 60, fontSize: 16, fontWeight: 500 }}>{s.min_confidence}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
|
||
<div className="section-title"><h2>Risk management</h2></div>
|
||
|
||
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div className="row gap-s" style={{ marginBottom: 4 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 500 }}>Take profit</div>
|
||
<label style={{ fontSize: 12, color: 'var(--ink-3)', cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={useTp} onChange={e => setUseTp(e.target.checked)} style={{ marginRight: 6 }} />
|
||
enable
|
||
</label>
|
||
</div>
|
||
<div className="desc" style={{ marginBottom: 8 }}>Auto-close when price moves your way by this %.</div>
|
||
<div className="row gap-s" style={{ opacity: useTp ? 1 : 0.45 }}>
|
||
<input type="number" min={0.1} max={50} step={0.1}
|
||
value={s.take_profit_pct ?? 2}
|
||
disabled={!useTp}
|
||
onChange={e => setS({ ...s, take_profit_pct: +e.target.value })}
|
||
style={{ width: 100 }} />
|
||
<span className="muted">% gain</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="setting-row" style={{ padding: '14px 0', borderTop: '1px solid var(--line)' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div className="row gap-s" style={{ marginBottom: 4 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 500 }}>Stop loss</div>
|
||
<label style={{ fontSize: 12, color: 'var(--ink-3)', cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={useSl} onChange={e => setUseSl(e.target.checked)} style={{ marginRight: 6 }} />
|
||
enable
|
||
</label>
|
||
</div>
|
||
<div className="desc" style={{ marginBottom: 8 }}>Auto-close when price moves against you by this %.</div>
|
||
<div className="row gap-s" style={{ opacity: useSl ? 1 : 0.45 }}>
|
||
<input type="number" min={0.1} max={50} step={0.1}
|
||
value={s.stop_loss_pct ?? 1.5}
|
||
disabled={!useSl}
|
||
onChange={e => setS({ ...s, stop_loss_pct: +e.target.value })}
|
||
style={{ width: 100 }} />
|
||
<span className="muted">% loss</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="row gap-s" style={{ alignItems: 'center' }}>
|
||
<button
|
||
className={`btn ${state === 'ok' ? 'ghost' : 'amber'} lg`}
|
||
onClick={save}
|
||
disabled={!dirty || state === 'signing' || state === 'saving'}
|
||
>
|
||
{label}
|
||
</button>
|
||
{!dirty && state === 'idle' && <span className="tiny" style={{ color: 'var(--ink-3)' }}>No changes to save</span>}
|
||
{state === 'err' && <span className="tiny" style={{ color: 'var(--down)' }}>{err}</span>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ExchangeSettings({ subscribed }: { subscribed: boolean }) {
|
||
if (!subscribed) return <LockedCard label="Subscribe to add Hyperliquid API key" />
|
||
return <BotPanel />
|
||
}
|
||
|
||
function AccountSettings({ address }: { address?: string }) {
|
||
return (
|
||
<div className="card" style={{ padding: 24 }}>
|
||
<div className="section-title"><h2>Account</h2></div>
|
||
<div className="field">
|
||
<label>Wallet</label>
|
||
<input disabled value={address ? `${address.slice(0, 8)}…${address.slice(-6)}` : 'Not connected'} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function SettingsClient() {
|
||
const [section, setSection] = useState('bot')
|
||
const { address, isConnected } = useAccount()
|
||
const { signMessageAsync } = useSignMessage()
|
||
const { isSubscribed, setSubscribed, setHlApiKeySet } = useDashboardStore()
|
||
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
|
||
const [loadErr, setLoadErr] = useState<string>('')
|
||
|
||
useEffect(() => {
|
||
if (!isConnected || !address) return
|
||
let cancelled = false
|
||
// First fetch public state (no sig). Only request a view signature if user
|
||
// is actually subscribed — otherwise there's nothing private to fetch.
|
||
;(async () => {
|
||
try {
|
||
const pub = await getUserPublic(address.toLowerCase())
|
||
if (cancelled) return
|
||
setSubscribed(pub.active)
|
||
setHlApiKeySet(pub.hl_api_key_set)
|
||
if (!pub.active) return
|
||
const env = await getOrCreateViewEnvelope({
|
||
action: ACTION_VIEW_USER,
|
||
wallet: address,
|
||
signMessageAsync,
|
||
})
|
||
if (cancelled) return
|
||
const u = await getUser(address, env)
|
||
if (cancelled) return
|
||
setSubscribed(u.active)
|
||
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
|
||
if (u.settings) setSettings(u.settings)
|
||
} catch (e: unknown) {
|
||
const m = e instanceof Error ? e.message : 'Failed to load settings'
|
||
// Swallow cancelled signatures; surface real failures
|
||
if (!m.includes('User rejected') && !m.includes('denied')) {
|
||
setLoadErr(m.slice(0, 140))
|
||
}
|
||
}
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [address, isConnected, setSubscribed, setHlApiKeySet, signMessageAsync])
|
||
|
||
const sections = [
|
||
{ key: 'bot', label: 'Trading bot' },
|
||
{ key: 'exchange', label: 'Exchange keys' },
|
||
{ key: 'account', label: 'Account' },
|
||
]
|
||
|
||
return (
|
||
<div className="settings-grid">
|
||
<div className="settings-side">
|
||
{sections.map(s => (
|
||
<button key={s.key} className={section === s.key ? 'on' : ''} onClick={() => setSection(s.key)}>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div>
|
||
{loadErr && (
|
||
<div className="card" style={{ padding: 12, marginBottom: 12, border: '1px solid var(--down)', color: 'var(--down)', fontSize: 13 }}>
|
||
{loadErr}
|
||
</div>
|
||
)}
|
||
{section === 'bot' && <BotSettings connected={isConnected} subscribed={isSubscribed} wallet={address} initial={settings} />}
|
||
{section === 'exchange' && <ExchangeSettings subscribed={isSubscribed} />}
|
||
{section === 'account' && <AccountSettings address={address} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|