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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user