Files
trumpsignal-frontend/app/[locale]/trades/TradesPageClient.tsx
T
k 3a113aaa8f fix: wallet/trades UX + SEO + commit Next.js 16 routing migration
Pre-launch audit findings (frontend):

- trades: unlock private history in-page. TradesPageClient only read a CACHED
  view envelope, so a connected user who hadn't visited Settings first saw
  "load settings once to unlock" with no way forward. Added an explicit
  "Unlock trade history" button that mints the view envelope via
  getOrCreateViewEnvelope (one signature, no gas) and reloads — no detour.
- wagmi: broaden connector from injected({target:'metaMask'}) to generic
  injected(). The MetaMask pin made Rabby / Coinbase ext / Brave / Frame / OKX
  and non-MetaMask browsers fail to connect. wagmi v2 EIP-6963 discovery covers
  all injected wallets; still uses the native provider path (not the MM SDK).
- sitemap: drop /settings. It was emitted in sitemap.xml while robots.ts
  disallows it — a self-contradicting crawler signal. Private pages stay out
  of both.
- docs: note the app runs on port 3001 (package.json), not 3000. The CLAUDE.md
  verify sweep targeted 3000 and would have reported every page as down.
- routing: COMMIT the Next.js 16 migration that was sitting uncommitted —
  delete middleware.ts, add root proxy.ts (next-intl createMiddleware), update
  next-env.d.ts. Vercel deploys from git, so leaving proxy.ts untracked would
  have shipped the old routing. `next build` confirms "Proxy (Middleware)" is
  active and all routes compile. tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:34:51 +08:00

170 lines
6.7 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, TrumpPost } from '@/types'
import { getTrades, getPosts } 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 } = useDashboardStore()
const pathname = usePathname()
const locale = pathname.split('/')[1] || 'en'
const [mounted, setMounted] = useState(false)
const [trades, setTrades] = useState<BotTrade[]>([])
const [posts, setPosts] = useState<TrumpPost[]>([])
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 }
}, [])
// 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([]); setPosts([]); setLoading(false); setLoadErr(''); setNeedsUnlock(false)
return
}
setLoading(true)
const env = forcedEnv
?? getCachedViewEnvelope('view_trades', address)
?? getCachedViewEnvelope('view_user', address)
let failed = false
try {
const [t, p] = await Promise.all([
env
? getTrades(address, env, 100, 1).catch(e => {
failed = true
setLoadErr(e instanceof Error ? e.message : 'Failed to load trades')
return [] as BotTrade[]
})
: Promise.resolve([] as BotTrade[]),
getPosts(500, 1).catch(() => [] as TrumpPost[]),
])
if (!aliveRef.current) return
setTrades(t)
setPosts(p)
setNeedsUnlock(!env)
if (env && !failed) setLoadErr('')
} finally {
if (aliveRef.current) setLoading(false)
}
}
useEffect(() => {
// 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 {
const env = await getOrCreateViewEnvelope({
action: 'view_trades', 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)
}
}
const needsSetup = mounted && isConnected && (!isSubscribed || !hlApiKeySet)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
<PageHint>
What the bot actually did with your money currently-open positions
on top, 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} posts={posts} loading={loading} />
</div>
)
}