fix(analytics): in-page unlock for private performance (parity with trades)

The analytics page had the same view-envelope gap just fixed on the trades
page: a connected wallet that hadn't first visited Settings only saw "Private
performance is locked" with no way to unlock in place. Added an "Unlock
performance" button that mints a view_user envelope (one signature, no gas) —
which both /performance and /trades accept — then reloads. Public
signal-accuracy still loads regardless. Refactored the loader to a reusable
loadAll(forcedEnv?) with an aliveRef guard, mirroring TradesPageClient.

Verified backend contracts unchanged: /performance accepts view_performance
or view_user; /signals/accuracy still returns overall + by_signal +
total_directional_signals. tsc + next build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 15:07:06 +08:00
parent b2b96bb9c0
commit 3ac1431336
+55 -23
View File
@@ -1,12 +1,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useLocale } from 'next-intl' import { useLocale } from 'next-intl'
import { useAccount } from 'wagmi' import { useAccount, useSignMessage } from 'wagmi'
import { getPerformance, getTrades, getSignalAccuracy } from '@/lib/api' import { getPerformance, getTrades, getSignalAccuracy } from '@/lib/api'
import type { BotPerformance, BotTrade } from '@/types' import type { BotPerformance, BotTrade } from '@/types'
import type { SignalAccuracy } from '@/lib/api' import type { SignalAccuracy } from '@/lib/api'
import { getCachedViewEnvelope } from '@/lib/signedRequest' import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
import PageHint from '@/components/ui/PageHint' import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip' import InfoTip from '@/components/ui/InfoTip'
@@ -63,47 +63,70 @@ export default function AnalyticsPageClient() {
const locale = useLocale() const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount() const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [perf, setPerf] = useState<BotPerformance | null>(null) const [perf, setPerf] = useState<BotPerformance | null>(null)
const [trades, setTrades] = useState<BotTrade[]>([]) const [trades, setTrades] = useState<BotTrade[]>([])
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null) const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
const [period, setPeriod] = useState<Period>('30d') const [period, setPeriod] = useState<Period>('30d')
const [privateLocked, setPrivateLocked] = useState(false) const [privateLocked, setPrivateLocked] = useState(false)
const [unlocking, setUnlocking] = useState(false)
const [unlockErr, setUnlockErr] = useState('')
const aliveRef = useRef(true)
useEffect(() => { useEffect(() => {
let cancelled = false aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button
// load private data without a detour through the Settings page. Public
// signal-accuracy always loads regardless.
async function loadAll(forcedEnv?: SignedEnvelope) {
if (!address || !isConnected) { if (!address || !isConnected) {
setPerf(null) setPerf(null); setTrades([]); setAccuracy(null); setPrivateLocked(false)
setTrades([])
setAccuracy(null)
setPrivateLocked(false)
return return
} }
;(async () => { const sharedEnv = forcedEnv ?? getCachedViewEnvelope('view_user', address)
try {
const sharedEnv = getCachedViewEnvelope('view_user', address)
const perfEnv = getCachedViewEnvelope('view_performance', address) ?? sharedEnv const perfEnv = getCachedViewEnvelope('view_performance', address) ?? sharedEnv
const tradesEnv = getCachedViewEnvelope('view_trades', address) ?? sharedEnv const tradesEnv = getCachedViewEnvelope('view_trades', address) ?? sharedEnv
if (!cancelled) setPrivateLocked(!perfEnv && !tradesEnv) if (aliveRef.current) setPrivateLocked(!perfEnv && !tradesEnv)
try {
const [p, t, a] = await Promise.all([ const [p, t, a] = await Promise.all([
perfEnv ? getPerformance(address, perfEnv).catch(() => null) : Promise.resolve(null), perfEnv ? getPerformance(address, perfEnv).catch(() => null) : Promise.resolve(null),
tradesEnv ? getTrades(address, tradesEnv, 100, 1).catch(() => []) : Promise.resolve([]), tradesEnv ? getTrades(address, tradesEnv, 100, 1).catch(() => []) : Promise.resolve([]),
getSignalAccuracy().catch(() => null), getSignalAccuracy().catch(() => null),
]) ])
if (!cancelled) { if (aliveRef.current) { setPerf(p); setTrades(t); setAccuracy(a) }
setPerf(p)
setTrades(t)
setAccuracy(a)
}
} catch { } catch {
if (!cancelled) { if (aliveRef.current) { setPerf(null); setTrades([]) }
setPerf(null)
setTrades([])
} }
} }
})()
return () => { cancelled = true } useEffect(() => {
// Navigation only uses a cached envelope — never auto-popup the wallet.
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected]) }, [address, isConnected])
// Mint a view_user envelope (one signature) — unlocks both /performance and
// /trades (each accepts view_user as fallback) — then reload.
async function handleUnlock() {
if (!address) return
setUnlocking(true)
setUnlockErr('')
try {
const env = await getOrCreateViewEnvelope({
action: 'view_user', wallet: address, signMessageAsync,
})
await loadAll(env)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Signature cancelled'
if (aliveRef.current) setUnlockErr(/reject|denied|cancel/i.test(msg) ? '' : msg)
} finally {
if (aliveRef.current) setUnlocking(false)
}
}
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period)) const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
const pricedTrades = filteredTrades.filter( const pricedTrades = filteredTrades.filter(
(t) => t.pnl_usd !== null && t.pnl_usd !== undefined, (t) => t.pnl_usd !== null && t.pnl_usd !== undefined,
@@ -157,8 +180,17 @@ export default function AnalyticsPageClient() {
<div className="card" style={{ padding: 16, marginBottom: 20 }}> <div className="card" style={{ padding: 16, marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Private performance is locked.</div> <div style={{ fontSize: 13, fontWeight: 600 }}>Private performance is locked.</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}> <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
Load your settings once to unlock cached trade history and wallet-specific performance. Public signal-accuracy data below remains available. Sign once to unlock your wallet-specific performance and trade history
on this device (valid a few minutes, no transaction, no gas). Public
signal-accuracy data below is always available.
</div> </div>
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px', marginTop: 12 }}
disabled={unlocking} onClick={handleUnlock}>
{unlocking ? 'Waiting for signature…' : 'Unlock performance'}
</button>
{unlockErr && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}> {unlockErr}</div>
)}
</div> </div>
)} )}