Files
trumpsignal-frontend/app/[locale]/settings/SettingsClient.tsx
T
2026-04-21 19:32:53 +08:00

326 lines
14 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 { 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, 150×. 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&apos;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>
)
}