Files
trumpsignal-frontend/app/[locale]/trades/TradesPageClient.tsx
T
k 4c3c8c6f87 KOL count: 29 → 25 across marketing/SEO copy
Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists

Bundles other in-flight frontend work already in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:27 +08:00

216 lines
9.1 KiB
TypeScript
Raw 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>
<PageHint>
Open positions above · closed trade history with realized P&amp;L below.
</PageHint>
</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>
)
}