done
This commit is contained in:
@@ -1,31 +1,325 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useConnectModal } from '@rainbow-me/rainbowkit'
|
||||
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'
|
||||
|
||||
export default function SettingsClient() {
|
||||
const t = useTranslations('settings')
|
||||
const { isConnected } = useAccount()
|
||||
const { openConnectModal } = useConnectModal()
|
||||
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,
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
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="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
|
||||
<p className="text-[14px] text-[#555555] mb-4">{t('connectRequired')}</p>
|
||||
<button
|
||||
onClick={openConnectModal}
|
||||
className="bg-[#f97316] text-black font-medium text-[13px] rounded-lg px-6 py-2.5 hover:bg-[#fb923c] transition-colors"
|
||||
>
|
||||
Connect wallet
|
||||
<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 className="bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-10 text-center">
|
||||
<p className="text-[14px] text-[#555555]">{t('comingSoon')}</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user