Files

232 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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}` : `Couldnt 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>
)
}