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>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
type OpenPosition,
|
||||
type TodayStats,
|
||||
} from '@/lib/api'
|
||||
import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest, type SignedEnvelope } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
@@ -85,7 +86,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
</span>
|
||||
{/* Entry → Current */}
|
||||
<div className="mono" style={{ fontSize: 12 }}>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry → mark</div>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry / current price</div>
|
||||
<div>
|
||||
{fmtMoney(p.entry_price)}
|
||||
<span style={{ color: 'var(--ink-4)' }}> → </span>
|
||||
@@ -102,10 +103,10 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
|
||||
<div style={{ fontSize: 9, marginTop: 2 }}>
|
||||
{(p.addon_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
|
||||
<span style={{ color: 'var(--up)' }}>{`↑ scaled in ×${p.addon_steps} `}</span>
|
||||
)}
|
||||
{(p.derisk_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
|
||||
<span style={{ color: 'var(--down)' }}>{`↓ trimmed ×${p.derisk_steps}`}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -114,8 +115,8 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
onClick={() => onToggleGrow(p)}
|
||||
disabled={growBusy}
|
||||
title={p.grow_mode
|
||||
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
|
||||
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
|
||||
? (isZh ? '加仓已开启:趋势确认后自动追加仓位。点击关闭。' : 'Scale-in ON — bot adds to this position when trend is confirmed. Click to turn off.')
|
||||
: (isZh ? '加仓已关闭:仅持有并做保护性减仓。' : 'Scale-in OFF — hold + protective trim only. Click to allow adding.')}
|
||||
style={{
|
||||
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
|
||||
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
|
||||
@@ -124,7 +125,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
|
||||
{p.grow_mode ? '↑ Scale-in ON' : 'Scale-in OFF'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Hold time */}
|
||||
@@ -141,7 +142,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
</div>
|
||||
{p.realized_usd != null && p.realized_usd !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
locked in {fmtMoney(p.realized_usd, { sign: true })}
|
||||
banked {fmtMoney(p.realized_usd, { sign: true })} from partial close
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -172,6 +173,7 @@ export default function OpenPositions() {
|
||||
const [today, setToday] = useState<TodayStats | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||||
const [unlocking, setUnlocking] = useState(false)
|
||||
// Close-confirmation modal state
|
||||
const [closing, setClosing] = useState<OpenPosition | null>(null)
|
||||
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
|
||||
@@ -212,10 +214,11 @@ export default function OpenPositions() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
return
|
||||
}
|
||||
// B39: wipe stale data from the previous wallet immediately — don't wait
|
||||
// for the async fetch to complete before the UI shows a clean slate.
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
|
||||
if (!isConnected || !address) return
|
||||
let cancelled = false
|
||||
|
||||
// Only reuse a cached envelope here. Open positions should never trigger
|
||||
@@ -253,6 +256,31 @@ export default function OpenPositions() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address, isConnected])
|
||||
|
||||
// In-page unlock: mint a view_user envelope (one signature) right here and
|
||||
// load positions immediately — no detour to the Settings page. view_user is
|
||||
// a superset accepted by /positions/open and /positions/today.
|
||||
async function handleUnlock() {
|
||||
if (!address || unlocking) return
|
||||
setUnlocking(true); setErr(null)
|
||||
try {
|
||||
const env = await getOrCreateViewEnvelope({
|
||||
action: 'view_user', wallet: address, signMessageAsync,
|
||||
})
|
||||
const [p, t] = await Promise.all([
|
||||
getOpenPositions(address, env),
|
||||
getTodayStats(address, env),
|
||||
])
|
||||
setPositions(p.positions); setToday(t); setNeedsUnlock(false); setErr(null)
|
||||
} catch (e: unknown) {
|
||||
// User-cancelled signature is benign — keep the unlock CTA visible.
|
||||
if (!isUserRejection(e)) {
|
||||
setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '解锁失败' : 'unlock failed'))
|
||||
}
|
||||
} finally {
|
||||
setUnlocking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
|
||||
async function confirmCloseTrade() {
|
||||
if (!address || !closing) return
|
||||
@@ -293,15 +321,39 @@ export default function OpenPositions() {
|
||||
Open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Sign in once to view open positions
|
||||
Sign in to see open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
||||
Go to the <strong>Settings page</strong> and click <em>Sign in to view your settings</em>. After signing, your open positions will appear here automatically.
|
||||
Sign once on this device to unlock your positions (valid for a few
|
||||
minutes, no transaction, no gas).
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px' }}
|
||||
disabled={unlocking} onClick={handleUnlock}>
|
||||
{unlocking ? 'Waiting for signature…' : 'Unlock positions'}
|
||||
</button>
|
||||
<Link href={`/${locale}/settings`} style={{ fontSize: 12, color: 'var(--ink-3)', textDecoration: 'none' }}>
|
||||
or go to Settings →
|
||||
</Link>
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>⚠️ {err}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (positions === null && today === null) return null
|
||||
// When the first load fails, positions and today are still null but err is set.
|
||||
// Without this guard the component returns null (blank), swallowing the error.
|
||||
if (positions === null && today === null) {
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '12px 16px', marginBottom: 16, fontSize: 12, color: 'var(--down)' }}>
|
||||
⚠️ Could not load positions — {err}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const totalUnrealized = (positions ?? []).reduce(
|
||||
(s, p) => s + (p.unrealized_usd ?? 0), 0,
|
||||
@@ -363,7 +415,7 @@ export default function OpenPositions() {
|
||||
</div>
|
||||
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} locked in on open trades`}
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked from partial closes`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -447,8 +499,8 @@ export default function OpenPositions() {
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
|
||||
{closing.is_paper
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
|
||||
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会动用真实资金。' : 'Paper trade — simulated close, no real funds involved.')
|
||||
: (isZh ? '立即按市价在 Hyperliquid 上平仓。盈亏将立即结算,无法撤销。' : 'Closes at market price on Hyperliquid right now. The profit or loss becomes final and cannot be undone.')}
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
|
||||
@@ -458,17 +510,17 @@ export default function OpenPositions() {
|
||||
<span className="mono">{fmtMoney(closing.entry_price)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Current price'}</span>
|
||||
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
|
||||
</div>
|
||||
{closing.realized_usd != null && closing.realized_usd !== 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
|
||||
<span>{isZh ? '已锁定(降风险)' : 'Locked in (de-risked)'}</span>
|
||||
<span>{isZh ? '已落袋(之前减仓)' : 'Already banked'}</span>
|
||||
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
|
||||
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
|
||||
<span>{isZh ? '平仓后将结算' : 'You\'ll get if you close now'}</span>
|
||||
<span style={{
|
||||
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
|
||||
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||||
|
||||
Reference in New Issue
Block a user