232 lines
9.9 KiB
TypeScript
232 lines
9.9 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import Link from 'next/link'
|
||
import { usePathname } from 'next/navigation'
|
||
import { useLocale } from 'next-intl'
|
||
import { useAccount, useSignMessage } from 'wagmi'
|
||
import type { BotTrade } from '@/types'
|
||
import { getTrades, getUserPublic } from '@/lib/api'
|
||
import { useDashboardStore } from '@/store/dashboard'
|
||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
|
||
import TradeTable from '@/components/trades/TradeTable'
|
||
import OpenPositions from '@/components/positions/OpenPositions'
|
||
import PageHint from '@/components/ui/PageHint'
|
||
|
||
export default function TradesPageClient() {
|
||
const intlLocale = useLocale()
|
||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||
const { address, isConnected } = useAccount()
|
||
const { signMessageAsync } = useSignMessage()
|
||
const { isSubscribed, hlApiKeySet, paperMode,
|
||
setSubscribed, setHlApiKeySet, setPaperMode, setBotReadiness } = useDashboardStore()
|
||
const pathname = usePathname()
|
||
const locale = pathname.split('/')[1] || 'en'
|
||
|
||
const [mounted, setMounted] = useState(false)
|
||
const [trades, setTrades] = useState<BotTrade[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadErr, setLoadErr] = useState('')
|
||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||
const [unlocking, setUnlocking] = useState(false)
|
||
|
||
const aliveRef = useRef(true)
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
aliveRef.current = true
|
||
return () => { aliveRef.current = false }
|
||
}, [])
|
||
|
||
// genRef guards loadAll against stale-closure wallet-switch races.
|
||
// `snapAddr !== address` inside an async function compares two copies of
|
||
// the same closure-captured value — it's always equal even when the wallet
|
||
// has changed, so the check is a no-op. genRef is a mutable ref that every
|
||
// closure can read, so `gen !== genRef.current` correctly detects staleness.
|
||
const genRef = useRef(0)
|
||
useEffect(() => { genRef.current++ }, [address])
|
||
|
||
// B41: TradesPageClient can be the entry point (direct navigation to /trades)
|
||
// without DashboardClient or BotConfigPanel ever running. In that case the
|
||
// Zustand store has isSubscribed=false (initial default), causing the
|
||
// "Bot not configured" banner to appear for valid subscribers.
|
||
// Fix: fetch /user/{wallet}/public here too — it's lightweight, unauthenticated,
|
||
// and idempotent with the same fetch DashboardClient does.
|
||
useEffect(() => {
|
||
if (!address || !isConnected) return
|
||
let cancelled = false
|
||
const snap = address
|
||
getUserPublic(snap.toLowerCase())
|
||
.then(u => {
|
||
if (cancelled || snap !== address) return
|
||
setSubscribed(u.active)
|
||
setHlApiKeySet(u.hl_api_key_set)
|
||
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
|
||
setPaperMode(!!u.paper_mode)
|
||
})
|
||
.catch(() => {})
|
||
return () => { cancelled = true }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [address, isConnected])
|
||
|
||
// Load public posts always; load private trades only if we have a view
|
||
// envelope. `forcedEnv` lets the in-page Unlock button pass a freshly-minted
|
||
// one so the user never has to detour through the Settings page first.
|
||
async function loadAll(forcedEnv?: SignedEnvelope) {
|
||
if (!address || !isConnected) {
|
||
setTrades([]); setLoading(false); setLoadErr(''); setNeedsUnlock(false)
|
||
return
|
||
}
|
||
const gen = genRef.current
|
||
const snapAddr = address // for API calls only — NOT for stale detection
|
||
setLoading(true)
|
||
const env = forcedEnv
|
||
?? getCachedViewEnvelope('view_trades', snapAddr)
|
||
?? getCachedViewEnvelope('view_user', snapAddr)
|
||
let failed = false
|
||
try {
|
||
const t = env
|
||
? await getTrades(snapAddr, env, 500, 1).catch(e => {
|
||
failed = true
|
||
setLoadErr(e instanceof Error ? e.message : 'Failed to load trades')
|
||
return [] as BotTrade[]
|
||
})
|
||
: []
|
||
// Use genRef — NOT `snapAddr !== address` (stale closure: both are the
|
||
// same closed-over value and the comparison is always false).
|
||
if (!aliveRef.current || gen !== genRef.current) return
|
||
setTrades(t)
|
||
setNeedsUnlock(!env)
|
||
if (env && !failed) setLoadErr('')
|
||
} finally {
|
||
if (aliveRef.current && gen === genRef.current) setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
// B27/B35: clear stale previous-wallet data BEFORE the async fetch so the
|
||
// UI never briefly shows another wallet's private trades.
|
||
setTrades([])
|
||
setNeedsUnlock(false)
|
||
setLoadErr('')
|
||
// On navigation we only use a cached envelope — never auto-popup the
|
||
// wallet. If none is cached the user unlocks explicitly via the button.
|
||
void loadAll()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [address, isConnected])
|
||
|
||
// Mint a view envelope right here (one signature) and reload — no Settings detour.
|
||
async function handleUnlock() {
|
||
if (!address) return
|
||
setUnlocking(true)
|
||
setLoadErr('')
|
||
try {
|
||
// view_user is a superset accepted by /trades, /positions/open,
|
||
// /positions/today — one signature unlocks everything on this page.
|
||
// Previously view_trades was used here, but that left OpenPositions
|
||
// locked (it requires view_positions or view_user).
|
||
const env = await getOrCreateViewEnvelope({
|
||
action: 'view_user', wallet: address, signMessageAsync,
|
||
})
|
||
await loadAll(env)
|
||
} catch (e) {
|
||
// User rejected the signature or it failed — keep the Unlock CTA visible.
|
||
const msg = e instanceof Error ? e.message : 'Signature cancelled'
|
||
if (aliveRef.current) setLoadErr(/reject|denied|cancel/i.test(msg) ? '' : msg)
|
||
} finally {
|
||
if (aliveRef.current) setUnlocking(false)
|
||
}
|
||
}
|
||
|
||
// B40: paper users have no HL key but the bot IS configured for them — don't
|
||
// show "Bot not configured" just because hlApiKeySet is false.
|
||
// B41: on cold start (navigating directly to /trades), isSubscribed starts
|
||
// false in the store until DashboardClient or BotConfigPanel fetches
|
||
// /user/public. Guard with !loading so the banner only appears once
|
||
// the page has confirmed the wallet is genuinely unconfigured.
|
||
const needsSetup = mounted && isConnected && !loading &&
|
||
(!isSubscribed || (!hlApiKeySet && !paperMode))
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
|
||
{/* Says what the page is, not where things sit — the section cards
|
||
below are self-labelled ("Open positions", KPI row, table). */}
|
||
<PageHint>
|
||
What the bot holds right now, and every trade it has closed.
|
||
</PageHint>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Guest view: a zero-filled KPI row + empty table reads like a broken
|
||
dashboard. Show one connect prompt instead and skip the data UI. */}
|
||
{mounted && !isConnected ? (
|
||
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
|
||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to see your trades</div>
|
||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
|
||
Positions and trade history are wallet-bound and private.
|
||
Use the Connect wallet button in the top-right corner.
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{needsSetup && (
|
||
<div className="card" style={{
|
||
padding: '14px 18px', marginBottom: 16,
|
||
background: 'var(--amber-soft)',
|
||
borderColor: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
flexWrap: 'wrap', gap: 12,
|
||
}}>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>{isZh ? '机器人尚未配置完成' : 'Bot not configured'}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
|
||
{isZh ? '在机器人开始交易前,请先去设置页订阅并绑定 Hyperliquid API。' : 'Subscribe and link your Hyperliquid API wallet on the Settings page before the bot can trade.'}
|
||
</div>
|
||
</div>
|
||
<Link
|
||
href={`/${locale}/settings`}
|
||
className="btn amber"
|
||
style={{ padding: '8px 16px', fontSize: 13, textDecoration: 'none' }}
|
||
>
|
||
{isZh ? '前往设置 →' : 'Go to Settings →'}
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
<OpenPositions />
|
||
|
||
{mounted && isConnected && !loading && needsUnlock && (
|
||
<div className="card" style={{
|
||
padding: 16, margin: '12px 0', textAlign: 'center', fontSize: 13,
|
||
background: 'var(--amber-soft)',
|
||
borderColor: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
|
||
}}>
|
||
<div style={{ marginBottom: 10, color: 'var(--ink-2)' }}>
|
||
🔒 Your trade history is private. Sign once to unlock it on this device
|
||
(valid for a few minutes, no transaction, no gas).
|
||
</div>
|
||
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px' }}
|
||
disabled={unlocking} onClick={handleUnlock}>
|
||
{unlocking ? 'Waiting for signature…' : 'Unlock trade history'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && loadErr && (
|
||
<div className="card" style={{ padding: 16, margin: '12px 0', textAlign: 'center',
|
||
color: 'var(--down)', fontSize: 13 }}>
|
||
⚠️ {isZh ? `无法加载交易历史:${loadErr}` : `Couldn’t load trade history — ${loadErr}`}
|
||
<button className="btn ghost" style={{ fontSize: 12, padding: '5px 12px', marginLeft: 10 }}
|
||
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
|
||
</div>
|
||
)}
|
||
|
||
<TradeTable trades={trades} loading={loading} locked={needsUnlock} />
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|