'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([]) 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 (

{isZh ? '交易执行' : 'Trades'}

{/* Says what the page is, not where things sit — the section cards below are self-labelled ("Open positions", KPI row, table). */} What the bot holds right now, and every trade it has closed.
{/* 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 ? (
Connect your wallet to see your trades
Positions and trade history are wallet-bound and private. Use the Connect wallet button in the top-right corner.
) : ( <> {needsSetup && (
{isZh ? '机器人尚未配置完成' : 'Bot not configured'}
{isZh ? '在机器人开始交易前,请先去设置页订阅并绑定 Hyperliquid API。' : 'Subscribe and link your Hyperliquid API wallet on the Settings page before the bot can trade.'}
{isZh ? '前往设置 →' : 'Go to Settings →'}
)} {mounted && isConnected && !loading && needsUnlock && (
🔒 Your trade history is private. Sign once to unlock it on this device (valid for a few minutes, no transaction, no gas).
)} {!loading && loadErr && (
⚠️ {isZh ? `无法加载交易历史:${loadErr}` : `Couldn’t load trade history — ${loadErr}`}
)} )}
) }