style(dashboard): unify Macro composite into one card + dark-mode fix
for Performance accent
User flagged the macro index box on the overview page as feeling
disjointed. Reshaped it from three stacked elements (band → track →
scale) into a single bordered card so it reads as ONE component.
- JSX: wrap the three pieces in <div className="overview-macro-card">
- CSS: new .overview-macro-card with tone-coloured rim (bull/bear/neutral)
- Solid neutral-gray filled needle (was a hollow ring — looked like a
placeholder); tone-coloured background + white inner ring + double
shadow so it stands out on any gradient position
- Removed the .overview-score-fill overlay — the gradient already
encodes the spectrum; layering an opaque fill obscured it near 0
- Thinner track (14px vs 22px), tighter scale labels, smaller pill
- Added "TODAY · 8 INDICATORS" stamp next to the title — gives users
a quick anchor of what they're looking at + freshness
Plus: dark-mode override for .overview-stat-card.accent (the Performance
card). It was using a cream gradient that floated as a glaring
out-of-theme block on dark mode. Mirrored the existing .kpi.accent dark
treatment so it stays visually grouped with the rest of the dashboard.
Also includes the in-flight overview rewrite from the other AI tool
(legacy /btc redirect to /macro, middleware.ts replacing proxy.ts for
Next.js routing, refactored several dashboard panels). TypeScript clean,
production build passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { useAccount } from 'wagmi'
|
||||
import type { TrumpPost, BotPerformance, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||||
import { getPerformance, getPrices, getUserPublic } from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
|
||||
import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, type MacroSnapshot } from '@/lib/api'
|
||||
import { getCachedViewEnvelope } from '@/lib/signedRequest'
|
||||
import { swrFetch } from '@/lib/cache'
|
||||
import ChartPanel from '@/components/dashboard/ChartPanel'
|
||||
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||||
import SignalMonitor from '@/components/dashboard/SignalMonitor'
|
||||
import PostRow, { SignalPill, SourceIcon, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||||
import OpenPositions from '@/components/positions/OpenPositions'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
|
||||
@@ -142,7 +143,6 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
|
||||
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
|
||||
const { address, isConnected } = useAccount()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const params = useParams()
|
||||
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
|
||||
@@ -151,6 +151,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
const [chartErr, setChartErr] = useState('')
|
||||
const [chartReload, setChartReload] = useState(0)
|
||||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||||
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
|
||||
// For 1D: show all posts from selected day
|
||||
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
|
||||
|
||||
@@ -185,7 +186,11 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
;(async () => {
|
||||
try {
|
||||
const env = getCachedViewEnvelope('view_performance', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
|
||||
?? getCachedViewEnvelope('view_user', address)
|
||||
if (!env) {
|
||||
if (!cancelled) setPerformance(undefined)
|
||||
return
|
||||
}
|
||||
const data = await getPerformance(address, env)
|
||||
if (!cancelled) setPerformance(data)
|
||||
} catch {
|
||||
@@ -193,7 +198,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, signMessageAsync])
|
||||
}, [address, isConnected])
|
||||
|
||||
usePriceSocket({
|
||||
onPrice: (a, price) => setLivePrice(a, price),
|
||||
@@ -208,19 +213,24 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
.catch(e => setChartErr(e instanceof Error ? e.message : (isZh ? '价格数据加载失败' : 'Failed to load price data')))
|
||||
}, [asset, timeframe, chartReload, isZh])
|
||||
|
||||
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
function load() {
|
||||
swrFetch(
|
||||
'macro-snapshot',
|
||||
10 * 60_000,
|
||||
() => getMacroSnapshot(),
|
||||
fresh => { if (alive) setMacro(fresh) },
|
||||
)
|
||||
.then((snap) => { if (alive) setMacro(snap) })
|
||||
.catch(() => {})
|
||||
}
|
||||
load()
|
||||
const id = setInterval(load, 10 * 60_000)
|
||||
return () => { alive = false; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
// Pinned BTC bottom-reversal alert: the rarest, highest-conviction signal.
|
||||
// Surface the most recent btc_bottom_reversal post fired in the last 21
|
||||
// days right at the top of the overview so it's never missed.
|
||||
const btcReversalAlert = (() => {
|
||||
const cutoff = Date.now() - 21 * 24 * 3600 * 1000
|
||||
const hits = posts
|
||||
.filter(p => (p.source || '') === 'btc_bottom_reversal'
|
||||
&& new Date(p.published_at).getTime() >= cutoff)
|
||||
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
|
||||
return hits[0] ?? null
|
||||
})()
|
||||
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
|
||||
|
||||
// Show actionable signals first (buy/short), then most recent hold/neutral.
|
||||
// Cap at 8 total so the list doesn't get too long.
|
||||
@@ -256,10 +266,26 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
const todayKey = new Date().toISOString().slice(0, 10)
|
||||
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
|
||||
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
|
||||
const trumpActionable = posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length
|
||||
const macroActionable = posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length
|
||||
const kolMentions = posts.filter(p => (p.source || '') === 'kol').length
|
||||
const winRate = performance?.win_rate ?? 0
|
||||
const netPnl = performance?.net_pnl_usd ?? 0
|
||||
const hasPriceData = candles.length > 0
|
||||
const hasPerformanceData = Boolean(performance)
|
||||
const macroScore = macro?.composite_score ?? null
|
||||
const macroRegime = macro?.regime_label ?? null
|
||||
const macroPct = macroScore == null ? 50 : Math.max(0, Math.min(100, (macroScore + 100) / 2))
|
||||
const macroTone =
|
||||
macroScore == null ? 'neutral'
|
||||
: macroScore > 15 ? 'bull'
|
||||
: macroScore < -15 ? 'bear'
|
||||
: 'neutral'
|
||||
const macroSummary =
|
||||
macroScore == null ? 'Daily macro composite not loaded yet.'
|
||||
: macroTone === 'bull' ? 'Risk backdrop is supportive. Trend-following setups get more room.'
|
||||
: macroTone === 'bear' ? 'Backdrop is defensive. Preserve size and expect cleaner downside moves.'
|
||||
: 'Backdrop is mixed. Useful for context, not a blind directional trigger.'
|
||||
|
||||
return (
|
||||
<div className="page wide">
|
||||
@@ -277,72 +303,124 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned BTC bottom-reversal alert — the rarest / highest-conviction
|
||||
signal. Always sits ABOVE everything so it's never missed. */}
|
||||
{btcReversalAlert && (
|
||||
<a
|
||||
href={`/${locale}/macro`}
|
||||
style={{
|
||||
display: 'block', textDecoration: 'none',
|
||||
border: '1px solid color-mix(in oklab, var(--up) 45%, var(--line))',
|
||||
background: 'var(--up-soft, rgba(22,163,74,0.10))',
|
||||
borderRadius: 12, padding: '14px 18px', marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: '.04em',
|
||||
color: 'var(--up)', textTransform: 'uppercase' }}>
|
||||
⚡ Macro Vibes · Bottom trigger
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px',
|
||||
borderRadius: 999, background: 'var(--up)', color: '#fff' }}>
|
||||
conf {Math.round(btcReversalAlert.ai_confidence)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{timeAgo(btcReversalAlert.published_at)}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, fontWeight: 700,
|
||||
color: 'var(--up)' }}>
|
||||
Open Macro Vibes →
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 6,
|
||||
lineHeight: 1.45, overflow: 'hidden', display: '-webkit-box',
|
||||
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{btcReversalAlert.text}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Open positions — what's on the book right now. Renders only when
|
||||
a subscribed wallet is connected, so guests see the normal feed. */}
|
||||
<OpenPositions />
|
||||
|
||||
{/* KPI Row */}
|
||||
<div className="kpi-row">
|
||||
<div className="kpi">
|
||||
<div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div>
|
||||
<div className="value">{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}</div>
|
||||
<div className="foot">
|
||||
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span>
|
||||
<span>24h</span>
|
||||
<div className="overview-shell">
|
||||
<div className="overview-main">
|
||||
<section className="overview-market-card">
|
||||
<div className="overview-card-head">
|
||||
<div>
|
||||
<div className="overview-kicker">Market and macro</div>
|
||||
<div className="overview-headline-row">
|
||||
<div className="hero-value mono" style={{ fontSize: 40 }}>
|
||||
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
</div>
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||||
</div>
|
||||
<div className="overview-market-subtitle">BTC spot with live signal context</div>
|
||||
</div>
|
||||
<div className="overview-controls">
|
||||
<div className="asset-switch">
|
||||
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
|
||||
<span className="asset-dot btc" /> BTC
|
||||
</button>
|
||||
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
|
||||
<span className="asset-dot eth" /> ETH
|
||||
</button>
|
||||
</div>
|
||||
<div className="tf-bar">
|
||||
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
|
||||
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Signals today</div>
|
||||
<div className="value">{signalsToday}</div>
|
||||
<div className="foot"><span>{`${actionablePosts} actionable total`}</span></div>
|
||||
</div>
|
||||
<div className="kpi accent">
|
||||
<div className="label">30d Net P&L</div>
|
||||
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}</div>
|
||||
<div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div>
|
||||
|
||||
<div className={`overview-macro-card ${macroTone}`}>
|
||||
<div className="overview-macro-band">
|
||||
<div className="overview-macro-copy">
|
||||
<div className="overview-macro-title-row">
|
||||
<div className="overview-macro-title">Macro composite</div>
|
||||
<div className="overview-macro-stamp">today · 8 indicators</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Win rate</div>
|
||||
<div className="value">{performance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div className="foot"><span>{hasPerformanceData ? `${performance?.total_trades ?? 0} trades` : 'Connect wallet to load your trade history'}</span></div>
|
||||
<div className="overview-macro-text">{macroSummary}</div>
|
||||
</div>
|
||||
<div className="overview-macro-score">
|
||||
<div className={`overview-score-value ${macroTone}`}>
|
||||
{macroScore == null ? '—' : `${macroScore > 0 ? '+' : ''}${macroScore.toFixed(1)}`}
|
||||
</div>
|
||||
<div className={`overview-score-pill ${macroTone}`}>{macroRegime ?? 'Waiting'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overview-score-track">
|
||||
<div className={`overview-score-needle ${macroTone}`} style={{ left: `calc(${macroPct}% - 11px)` }} />
|
||||
</div>
|
||||
<div className="overview-score-scale">
|
||||
<span>−100 bear</span>
|
||||
<span>0 neutral</span>
|
||||
<span>+100 bull</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-system-strip">
|
||||
<Link href={`/${locale}/trump`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">Trump</span>
|
||||
<strong>{trumpActionable}</strong>
|
||||
</Link>
|
||||
<Link href={`/${locale}/macro`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">Macro</span>
|
||||
<strong>{macroActionable}</strong>
|
||||
</Link>
|
||||
<Link href={`/${locale}/kol`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">KOL</span>
|
||||
<strong>{kolMentions}</strong>
|
||||
</Link>
|
||||
<div className="overview-system-chip passive">
|
||||
<span className="overview-system-chip-name">Signals today</span>
|
||||
<strong>{signalsToday}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-secondary-grid">
|
||||
<section className="overview-stat-card">
|
||||
<div className="overview-kicker">Execution</div>
|
||||
<div className="overview-stat-value">{actionablePosts}</div>
|
||||
<div className="overview-stat-label">actionable signals tracked in feed</div>
|
||||
</section>
|
||||
<section className="overview-stat-card accent">
|
||||
<div className="overview-kicker">Performance</div>
|
||||
<div className="overview-stat-value">{hasPerformanceData ? `${netPnl >= 0 ? '+$' : '-$'}${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}` : '—'}</div>
|
||||
<div className="overview-stat-label">{hasPerformanceData ? '30d bot net P&L' : 'Load settings once to unlock private performance'}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="overview-side">
|
||||
<section className="overview-side-card">
|
||||
<div className="overview-kicker">Account</div>
|
||||
<div className="overview-account-list">
|
||||
<div className="overview-account-item">
|
||||
<span>Wallet</span>
|
||||
<strong>{isConnected && address ? `${address.slice(0, 6)}…${address.slice(-4)}` : 'Not connected'}</strong>
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Private data</span>
|
||||
<strong>{hasPerformanceData ? 'Unlocked' : 'Locked until Settings load'}</strong>
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Win rate</span>
|
||||
<strong>{hasPerformanceData ? `${(winRate * 100).toFixed(1)}%` : '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="overview-side-card compact">
|
||||
<div className="overview-kicker">Chart focus</div>
|
||||
<div className="overview-side-copy">Live chart with signal markers and drill-down on click.</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
@@ -359,21 +437,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack gap-s" style={{ alignItems: 'flex-end' }}>
|
||||
<div className="asset-switch">
|
||||
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
|
||||
<span className="asset-dot btc" /> BTC
|
||||
</button>
|
||||
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
|
||||
<span className="asset-dot eth" /> ETH
|
||||
</button>
|
||||
</div>
|
||||
<div className="tf-bar">
|
||||
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
|
||||
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
|
||||
</div>
|
||||
|
||||
{chartErr && (
|
||||
@@ -405,7 +469,8 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
|
||||
<div style={{ marginLeft: 'auto' }}>Click marker to see details →</div>
|
||||
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
|
||||
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -535,8 +600,6 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<SelectHint />
|
||||
)}
|
||||
|
||||
{/* Breakout signal monitor */}
|
||||
<SignalMonitor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { getPerformance, getTrades, getSignalAccuracy } from '@/lib/api'
|
||||
import type { BotPerformance, BotTrade } from '@/types'
|
||||
import type { SignalAccuracy } from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
|
||||
import { getCachedViewEnvelope } from '@/lib/signedRequest'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
import InfoTip from '@/components/ui/InfoTip'
|
||||
|
||||
@@ -28,6 +28,10 @@ function fmtHold(s: number) {
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
|
||||
}
|
||||
|
||||
function fmtAccuracyPct(pct: number | null | undefined) {
|
||||
return pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function inPeriod(iso: string, period: Period) {
|
||||
if (period === 'All') return true
|
||||
const days = Number.parseInt(period, 10)
|
||||
@@ -59,11 +63,11 @@ export default function AnalyticsPageClient() {
|
||||
const locale = 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 [perf, setPerf] = useState<BotPerformance | null>(null)
|
||||
const [trades, setTrades] = useState<BotTrade[]>([])
|
||||
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
|
||||
const [period, setPeriod] = useState<Period>('30d')
|
||||
const [privateLocked, setPrivateLocked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -71,17 +75,18 @@ export default function AnalyticsPageClient() {
|
||||
setPerf(null)
|
||||
setTrades([])
|
||||
setAccuracy(null)
|
||||
setPrivateLocked(false)
|
||||
return
|
||||
}
|
||||
;(async () => {
|
||||
try {
|
||||
const perfEnv = getCachedViewEnvelope('view_performance', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
|
||||
const tradesEnv = getCachedViewEnvelope('view_trades', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_trades', wallet: address, signMessageAsync })
|
||||
const sharedEnv = getCachedViewEnvelope('view_user', address)
|
||||
const perfEnv = getCachedViewEnvelope('view_performance', address) ?? sharedEnv
|
||||
const tradesEnv = getCachedViewEnvelope('view_trades', address) ?? sharedEnv
|
||||
if (!cancelled) setPrivateLocked(!perfEnv && !tradesEnv)
|
||||
const [p, t, a] = await Promise.all([
|
||||
getPerformance(address, perfEnv).catch(() => null),
|
||||
getTrades(address, tradesEnv, 100, 1).catch(() => []),
|
||||
perfEnv ? getPerformance(address, perfEnv).catch(() => null) : Promise.resolve(null),
|
||||
tradesEnv ? getTrades(address, tradesEnv, 100, 1).catch(() => []) : Promise.resolve([]),
|
||||
getSignalAccuracy().catch(() => null),
|
||||
])
|
||||
if (!cancelled) {
|
||||
@@ -97,7 +102,7 @@ export default function AnalyticsPageClient() {
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, signMessageAsync])
|
||||
}, [address, isConnected])
|
||||
|
||||
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
|
||||
const pricedTrades = filteredTrades.filter(
|
||||
@@ -148,6 +153,15 @@ export default function AnalyticsPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{privateLocked && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Private performance is locked.</div>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ padding: 28, marginBottom: 20 }}>
|
||||
<div className="row between" style={{ alignItems: 'flex-start', marginBottom: 20 }}>
|
||||
<div>
|
||||
@@ -189,7 +203,7 @@ export default function AnalyticsPageClient() {
|
||||
return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w.replace('m','').replace('1h','1h')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -213,7 +227,7 @@ export default function AnalyticsPageClient() {
|
||||
return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
interface LegacyBtcPageProps {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export default async function LegacyBtcPage({ params }: LegacyBtcPageProps) {
|
||||
const { locale } = await params
|
||||
redirect(`/${locale}/macro`)
|
||||
}
|
||||
+678
-18
@@ -98,6 +98,15 @@ html[data-theme="dark"] .kpi.accent {
|
||||
background: linear-gradient(135deg, oklch(28% 0.05 75), oklch(24% 0.07 70));
|
||||
}
|
||||
|
||||
/* Dashboard Performance card uses the same "amber accent" treatment as
|
||||
.kpi.accent — without a dark-mode override the cream background floats
|
||||
on the dark grid as a glaring out-of-theme block. Mirror the kpi dark
|
||||
gradient so the card stays visually grouped with everything else. */
|
||||
html[data-theme="dark"] .overview-stat-card.accent {
|
||||
background: linear-gradient(135deg, oklch(28% 0.05 75), oklch(24% 0.07 70));
|
||||
border-color: color-mix(in oklab, var(--amber) 28%, var(--line));
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .landing-nav {
|
||||
background: oklch(15% 0.008 85 / 0.8);
|
||||
}
|
||||
@@ -155,6 +164,55 @@ a { color: inherit; text-decoration: none; }
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.wallet-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wallet-connect-error {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 10px);
|
||||
margin: 0;
|
||||
width: min(320px, calc(100vw - 28px));
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in oklab, var(--down) 24%, var(--line));
|
||||
background: color-mix(in oklab, var(--down-soft) 72%, var(--surface));
|
||||
color: var(--down);
|
||||
box-shadow: var(--shadow-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
z-index: 50;
|
||||
transform-origin: top right;
|
||||
animation: walletErrorIn 160ms ease-out;
|
||||
}
|
||||
|
||||
.wallet-connect-error::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 26px;
|
||||
top: -7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: inherit;
|
||||
border-left: 1px solid color-mix(in oklab, var(--down) 24%, var(--line));
|
||||
border-top: 1px solid color-mix(in oklab, var(--down) 24%, var(--line));
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@keyframes walletErrorIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -897,20 +955,34 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
|
||||
.macro-composite-track {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.macro-composite-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: color-mix(in oklab, var(--ink) 14%, transparent);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.macro-composite-needle {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--ink);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-2);
|
||||
transform: translateY(-50%);
|
||||
transition: left 0.4s cubic-bezier(.2,.8,.2,1);
|
||||
/* React re-mounts this element via `key={composite_score}` whenever the
|
||||
score changes, which re-triggers the pulse animation below — a brief
|
||||
@@ -918,9 +990,9 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
animation: macro-needle-pulse 0.9s ease-out 1;
|
||||
}
|
||||
@keyframes macro-needle-pulse {
|
||||
0% { transform: scale(0.85); box-shadow: 0 0 0 0 currentColor; }
|
||||
40% { transform: scale(1.18); box-shadow: 0 0 0 8px color-mix(in oklab, currentColor 18%, transparent); }
|
||||
100% { transform: scale(1); box-shadow: var(--shadow-2); }
|
||||
0% { transform: translateY(-50%) scale(0.85); box-shadow: 0 0 0 0 currentColor; }
|
||||
40% { transform: translateY(-50%) scale(1.18); box-shadow: 0 0 0 8px color-mix(in oklab, currentColor 18%, transparent); }
|
||||
100% { transform: translateY(-50%) scale(1); box-shadow: var(--shadow-2); }
|
||||
}
|
||||
|
||||
.macro-composite-scale {
|
||||
@@ -1114,28 +1186,32 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
/* ── Form row: label | control, strict-aligned ─────────────── */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
padding: 14px 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.form-row + .form-row { border-top: 1px solid var(--line); }
|
||||
.form-row-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-row-label .hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ink-4);
|
||||
margin-top: 3px;
|
||||
color: var(--ink-3);
|
||||
margin-top: 5px;
|
||||
line-height: 1.5;
|
||||
max-width: 28ch;
|
||||
}
|
||||
.form-row-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1157,6 +1233,161 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.settings-scope-intro {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-control-center {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, color-mix(in oklab, var(--amber) 7%, var(--surface)) 0%, var(--surface) 48%, color-mix(in oklab, var(--up) 6%, var(--surface)) 100%);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.settings-control-kicker,
|
||||
.settings-section-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-4);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.settings-control-title,
|
||||
.settings-section-title {
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.settings-control-copy {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3);
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.settings-control-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-meta-card {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--surface) 92%, transparent);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.settings-meta-card strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.settings-meta-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.settings-scope-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-scope-card {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.settings-scope-card.trump {
|
||||
background: color-mix(in oklab, var(--amber) 8%, var(--surface));
|
||||
}
|
||||
|
||||
.settings-scope-card.btc {
|
||||
background: color-mix(in oklab, var(--up) 8%, var(--surface));
|
||||
}
|
||||
|
||||
.settings-scope-card.global {
|
||||
background: color-mix(in oklab, var(--ink) 3%, var(--surface));
|
||||
}
|
||||
|
||||
.settings-scope-eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-4);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-scope-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.settings-scope-copy {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.settings-scope-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.settings-section-shell {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.settings-section-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-section-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-3);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Inline $ prefix input ─────────────────────────────────── */
|
||||
.num-field {
|
||||
display: inline-flex;
|
||||
@@ -1219,12 +1450,29 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 48px;
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-sunk);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.settings-note.warn {
|
||||
color: var(--down);
|
||||
border-color: color-mix(in oklab, var(--down) 18%, var(--line));
|
||||
background: var(--down-soft);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Dashboard specific
|
||||
============================================================ */
|
||||
@@ -1357,6 +1605,367 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
|
||||
}
|
||||
.kpi.accent .label { color: var(--amber-ink); }
|
||||
|
||||
/* Overview */
|
||||
.overview-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-market-card,
|
||||
.overview-side-card,
|
||||
.overview-stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-market-card {
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in oklab, var(--amber) 8%, var(--surface)) 0%, var(--surface) 52%, color-mix(in oklab, var(--up) 6%, var(--surface)) 100%);
|
||||
}
|
||||
|
||||
.overview-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-4);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.overview-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.overview-headline-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.overview-market-subtitle {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.overview-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Macro composite — unified card (band + track + scale together) ── */
|
||||
|
||||
/* Outer wrapper so band / track / scale read as ONE component.
|
||||
Tone-coloured rim hints at the regime without competing with the big
|
||||
number; it only kicks in for bull / bear so a neutral day stays calm. */
|
||||
.overview-macro-card {
|
||||
margin-top: 4px;
|
||||
padding: 16px 18px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--bg-sunk) 72%, var(--surface));
|
||||
box-shadow: var(--shadow-1);
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
.overview-macro-card.bull {
|
||||
border-color: color-mix(in oklab, var(--up) 30%, var(--line));
|
||||
background: color-mix(in oklab, var(--up) 5%, color-mix(in oklab, var(--bg-sunk) 72%, var(--surface)));
|
||||
}
|
||||
.overview-macro-card.bear {
|
||||
border-color: color-mix(in oklab, var(--down) 30%, var(--line));
|
||||
background: color-mix(in oklab, var(--down) 5%, color-mix(in oklab, var(--bg-sunk) 72%, var(--surface)));
|
||||
}
|
||||
|
||||
/* Band = title-row + summary (left) | big score + pill (right).
|
||||
No own border any more — the card around it is the visual edge. */
|
||||
.overview-macro-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.overview-macro-copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.overview-macro-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.overview-macro-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Small dim caption next to the title — gives the user a quick "what is
|
||||
this and when did it update" anchor without crowding the headline. */
|
||||
.overview-macro-stamp {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-4);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overview-macro-text,
|
||||
.overview-side-copy,
|
||||
.overview-stat-label {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.overview-macro-score {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overview-score-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 38px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.overview-score-value.bull { color: var(--up); }
|
||||
.overview-score-value.bear { color: var(--down); }
|
||||
.overview-score-value.neutral { color: var(--ink-2); }
|
||||
|
||||
.overview-score-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.overview-score-pill.bull {
|
||||
color: var(--up);
|
||||
border-color: color-mix(in oklab, var(--up) 32%, var(--line));
|
||||
background: color-mix(in oklab, var(--up) 12%, var(--surface));
|
||||
}
|
||||
|
||||
.overview-score-pill.bear {
|
||||
color: var(--down);
|
||||
border-color: color-mix(in oklab, var(--down) 32%, var(--line));
|
||||
background: color-mix(in oklab, var(--down) 12%, var(--surface));
|
||||
}
|
||||
|
||||
/* Gradient track. Removed the .overview-score-fill overlay — the gradient
|
||||
already encodes "where am I on the spectrum"; layering an opaque fill
|
||||
only obscured the gradient at scores near 0. */
|
||||
.overview-score-track {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-top: 14px;
|
||||
border-radius: 999px;
|
||||
overflow: visible;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in oklab, var(--down) 65%, transparent) 0%,
|
||||
color-mix(in oklab, var(--down) 18%, transparent) 35%,
|
||||
var(--bg-sunk) 50%,
|
||||
color-mix(in oklab, var(--up) 18%, transparent) 65%,
|
||||
color-mix(in oklab, var(--up) 65%, transparent) 100%);
|
||||
}
|
||||
|
||||
/* Vertical centerline tick so 0 is visually obvious. */
|
||||
.overview-score-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: color-mix(in oklab, var(--ink) 18%, transparent);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* SOLID needle (was a hollow ring before — looked like a placeholder).
|
||||
Tone-coloured fill + white ring so it stands out against any gradient
|
||||
position. Two-layer shadow for depth on light AND dark themes. */
|
||||
.overview-score-needle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--ink-2);
|
||||
border: 3px solid var(--surface);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px color-mix(in oklab, var(--ink) 18%, transparent);
|
||||
transform: translateY(-50%);
|
||||
transition: left 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.overview-score-needle.bull { background: var(--up); }
|
||||
.overview-score-needle.bear { background: var(--down); }
|
||||
.overview-score-needle.neutral { background: var(--ink-3); }
|
||||
|
||||
.overview-score-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.overview-system-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.overview-system-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-1);
|
||||
transition: transform 120ms, border-color 120ms, box-shadow 120ms;
|
||||
}
|
||||
|
||||
.overview-system-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--line-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.overview-system-chip.passive {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.overview-system-chip-name {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.overview-system-chip strong {
|
||||
font-family: var(--mono);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.overview-secondary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-stat-card {
|
||||
min-height: 144px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overview-stat-card.accent {
|
||||
background: linear-gradient(135deg, oklch(97% 0.04 85), oklch(95% 0.07 80));
|
||||
border-color: var(--amber-ring);
|
||||
}
|
||||
|
||||
.overview-stat-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 42px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.overview-side-card.compact {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.overview-account-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.overview-account-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.overview-account-item:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-account-item strong {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Right rail */
|
||||
.rail { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
@@ -1788,6 +2397,14 @@ html[data-theme="dark"] .ai-reasoning-card {
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.overview-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-side {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.dash-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1840,6 +2457,11 @@ html[data-theme="dark"] .ai-reasoning-card {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-system-strip,
|
||||
.overview-secondary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1914,6 +2536,27 @@ html[data-theme="dark"] .ai-reasoning-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-card-head,
|
||||
.overview-controls,
|
||||
.overview-macro-band,
|
||||
.overview-account-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.overview-score-value {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.overview-account-item strong {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.overview-system-strip,
|
||||
.overview-secondary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.macro-panel-head,
|
||||
.macro-composite-head {
|
||||
flex-direction: column;
|
||||
@@ -1978,6 +2621,23 @@ html[data-theme="dark"] .ai-reasoning-card {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.settings-scope-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-control-center {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-section-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-section-note {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Composite scale labels — "BEAR / NEUTRAL / BULL" letters crowd at narrow
|
||||
widths. Shorten them. */
|
||||
.macro-composite-scale {
|
||||
|
||||
+272
-253
@@ -106,12 +106,37 @@ function formatShortUsd(value: number) {
|
||||
return `$${(value / 1e3).toFixed(0)}K`
|
||||
}
|
||||
|
||||
function digestSideCopy(side: KolDigestTicker['side']) {
|
||||
if (side === 'long') return 'Bullish flow'
|
||||
if (side === 'short') return 'Bearish flow'
|
||||
return 'Mention flow'
|
||||
}
|
||||
|
||||
function compactChangeCopy(change: KolHoldingChange) {
|
||||
const verb = change.change_type === 'new_position'
|
||||
? 'opened'
|
||||
: change.change_type === 'increased'
|
||||
? 'added'
|
||||
: change.change_type === 'decreased'
|
||||
? 'reduced'
|
||||
: 'closed'
|
||||
const size = change.usd_after != null && change.usd_after > 0
|
||||
? ` ${formatShortUsd(change.usd_after)}`
|
||||
: ''
|
||||
const pct = change.pct_change != null
|
||||
? ` (${change.pct_change > 0 ? '+' : ''}${change.pct_change.toFixed(0)}%)`
|
||||
: ''
|
||||
return `@${change.handle} ${verb}${size}${pct}`
|
||||
}
|
||||
|
||||
function DigestWidget({
|
||||
onTickerClick,
|
||||
activeTicker,
|
||||
isZh,
|
||||
initialDigest = null,
|
||||
}: {
|
||||
onTickerClick: (ticker: string) => void
|
||||
activeTicker?: string | null
|
||||
isZh: boolean
|
||||
initialDigest?: KolDigest | null
|
||||
}) {
|
||||
@@ -149,11 +174,11 @@ function DigestWidget({
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)',
|
||||
letterSpacing: 1, textTransform: 'uppercase',
|
||||
marginBottom: 2 }}>
|
||||
KOL signal digest
|
||||
What KOLs are pushing now
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{data
|
||||
? `${data.post_count} posts · ${data.ticker_count} assets`
|
||||
? `${data.post_count} posts analysed · ${data.ticker_count} assets with repeat mentions`
|
||||
: 'Loading…'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,9 +210,19 @@ function DigestWidget({
|
||||
</div>
|
||||
)}
|
||||
{!loading && !err && data && data.tickers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
|
||||
gap: 10,
|
||||
}}>
|
||||
{data.tickers.map(t => <DigestTickerChip
|
||||
key={t.ticker} t={t} isZh={isZh} onClick={() => onTickerClick(t.ticker)} />)}
|
||||
key={t.ticker}
|
||||
t={t}
|
||||
isZh={isZh}
|
||||
active={activeTicker === t.ticker}
|
||||
hasActive={!!activeTicker}
|
||||
onClick={() => onTickerClick(t.ticker)}
|
||||
/>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -195,8 +230,8 @@ function DigestWidget({
|
||||
}
|
||||
|
||||
function DigestTickerChip({
|
||||
t, onClick, isZh,
|
||||
}: { t: KolDigestTicker; onClick: () => void; isZh: boolean }) {
|
||||
t, onClick, isZh, active, hasActive,
|
||||
}: { t: KolDigestTicker; onClick: () => void; isZh: boolean; active: boolean; hasActive: boolean }) {
|
||||
const sideColor = t.side === 'long' ? '#16a34a'
|
||||
: t.side === 'short' ? '#dc2626' : '#9ca3af'
|
||||
const sideLabel = t.side === 'long'
|
||||
@@ -214,41 +249,69 @@ function DigestTickerChip({
|
||||
style={{
|
||||
display: 'inline-flex', flexDirection: 'column',
|
||||
alignItems: 'flex-start', gap: 4,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
background: 'var(--bg-sunk)',
|
||||
border: `1px solid ${sideColor}55`,
|
||||
padding: '12px 14px', borderRadius: 12,
|
||||
background: active ? `${sideColor}12` : hasActive ? 'color-mix(in oklab, var(--bg-sunk) 92%, transparent)' : 'var(--bg-sunk)',
|
||||
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
|
||||
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
minWidth: 110,
|
||||
minHeight: 108,
|
||||
width: '100%',
|
||||
transform: active ? 'translateY(-2px)' : 'none',
|
||||
opacity: hasActive && !active ? 0.6 : 1,
|
||||
transition: 'background 120ms, border-color 120ms, box-shadow 120ms, transform 120ms, opacity 120ms',
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<strong style={{ fontSize: 15, color: 'var(--ink-1)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<strong style={{ fontSize: 18, color: 'var(--ink-1)' }}>
|
||||
{t.ticker}
|
||||
</strong>
|
||||
{active && (
|
||||
<span style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: sideColor,
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
}}>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, color: sideColor,
|
||||
padding: '1px 5px', borderRadius: 4,
|
||||
padding: '2px 6px', borderRadius: 999,
|
||||
background: `${sideColor}22`,
|
||||
}}>
|
||||
{actionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)' }}>
|
||||
{t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}%
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-2)', fontWeight: 600 }}>
|
||||
{digestSideCopy(t.side)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
|
||||
{t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}% max conviction
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10, color: 'var(--ink-3)',
|
||||
maxWidth: 160, whiteSpace: 'nowrap',
|
||||
fontSize: 11, color: 'var(--ink-3)',
|
||||
maxWidth: '100%', whiteSpace: 'nowrap',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{t.kols.map(k => '@' + k).join(', ')}
|
||||
</div>
|
||||
{active && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: sideColor, marginTop: 2 }}>
|
||||
Filtering the page by {t.ticker}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── On-chain changes widget ───────────────────────────────────────────────
|
||||
|
||||
const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
|
||||
new_position: '#16a34a',
|
||||
increased: '#22c55e',
|
||||
@@ -256,162 +319,134 @@ const CHANGE_COLOR: Record<KolHoldingChange['change_type'], string> = {
|
||||
closed: '#dc2626',
|
||||
}
|
||||
|
||||
function OnchainWidget({
|
||||
function WalletCheckWidget({
|
||||
isZh,
|
||||
dateLocale,
|
||||
initialDigest = null,
|
||||
initialChanges = null,
|
||||
initialItems = null,
|
||||
tickerFilter = null,
|
||||
}: {
|
||||
isZh: boolean
|
||||
dateLocale: string
|
||||
initialDigest?: KolDigest | null
|
||||
initialChanges?: KolHoldingChange[] | null
|
||||
initialItems?: KolDivergence[] | null
|
||||
tickerFilter?: string | null
|
||||
}) {
|
||||
const [data, setData] = useState<KolHoldingChange[]>(initialChanges ?? [])
|
||||
const [loading, setLoading] = useState(initialChanges === null)
|
||||
const [digest, setDigest] = useState<KolDigest | null>(initialDigest)
|
||||
const [changes, setChanges] = useState<KolHoldingChange[]>(initialChanges ?? [])
|
||||
const [items, setItems] = useState<KolDivergence[]>(initialItems ?? [])
|
||||
const [loading, setLoading] = useState(
|
||||
initialDigest === null || initialChanges === null || initialItems === null,
|
||||
)
|
||||
const [days, setDays] = useState(7)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialChanges === null || days !== 7) {
|
||||
setLoading(true)
|
||||
}
|
||||
Promise.all([
|
||||
swrFetch(
|
||||
`kol-digest-${days}`,
|
||||
15 * 60_000,
|
||||
() => getKolDigest(days),
|
||||
fresh => setDigest(fresh),
|
||||
),
|
||||
swrFetch(
|
||||
`kol-changes-${days}`,
|
||||
30 * 60_000,
|
||||
() => getKolChanges({ days }),
|
||||
fresh => setData(fresh.changes),
|
||||
)
|
||||
.then(r => setData(r.changes))
|
||||
.catch(() => setData([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [days, initialChanges])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
|
||||
borderRadius: 12, background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
|
||||
textTransform: 'uppercase', marginBottom: 2 }}>
|
||||
On-chain positioning
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: data.length === 0
|
||||
? 'No tracked wallets yet. Add KOL addresses and this feed will populate automatically.'
|
||||
: `${data.length} changes`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
||||
{([1, 7, 30] as const).map(d => (
|
||||
<button key={d} onClick={() => setDays(d)}
|
||||
className={`nav-tab ${days === d ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}>
|
||||
{windowLabel(d, isZh)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && data.length === 0 && (
|
||||
<div style={{
|
||||
padding: '12px 16px', borderRadius: 8,
|
||||
background: 'var(--bg-sunk)',
|
||||
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
|
||||
}}>
|
||||
<>
|
||||
Add tracked wallets through <code style={{ fontSize: 11 }}>POST /api/kol/wallets</code>,
|
||||
or use{' '}
|
||||
<a href="https://platform.arkhamintelligence.com" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent)' }}>Arkham Intelligence</a>{' '}
|
||||
to discover labeled addresses. Ethereum, Solana, and Hyperliquid are supported.
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{data.slice(0, 20).map(c => {
|
||||
const color = CHANGE_COLOR[c.change_type]
|
||||
const label = changeLabel(c.change_type, isZh)
|
||||
return (
|
||||
<div key={c.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
flexWrap: 'wrap', // ← prevent horizontal overflow on mobile
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
background: 'var(--bg-sunk)',
|
||||
borderLeft: `3px solid ${color}`,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, color,
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: `${color}22`, flexShrink: 0,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<strong style={{ fontSize: 14 }}>{c.ticker}</strong>
|
||||
<span style={{
|
||||
fontSize: 12, color: 'var(--ink-3)',
|
||||
flex: '1 1 140px', minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
@{c.handle}
|
||||
{c.usd_after != null && c.usd_after > 0 &&
|
||||
` · ${formatShortUsd(c.usd_after)}`}
|
||||
{c.pct_change != null &&
|
||||
` (${c.pct_change > 0 ? '+' : ''}${c.pct_change.toFixed(0)}%)`}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>
|
||||
{new Date(c.detected_at).toLocaleDateString(dateLocale)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Talks-vs-trades divergence widget ────────────────────────────────────────
|
||||
const DIR_COLOR = { long: '#16a34a', short: '#dc2626' }
|
||||
|
||||
function DivergenceWidget({
|
||||
isZh,
|
||||
dateLocale,
|
||||
initialItems = null,
|
||||
}: {
|
||||
isZh: boolean
|
||||
dateLocale: string
|
||||
initialItems?: KolDivergence[] | null
|
||||
}) {
|
||||
const [data, setData] = useState<KolDivergence[]>(initialItems ?? [])
|
||||
const [loading, setLoading] = useState(initialItems === null)
|
||||
const [filter, setFilter] = useState<'all' | 'divergence' | 'alignment'>('all')
|
||||
const [days, setDays] = useState(30)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialItems === null || filter !== 'all' || days !== 30) {
|
||||
setLoading(true)
|
||||
}
|
||||
fresh => setChanges(fresh.changes),
|
||||
),
|
||||
swrFetch(
|
||||
`kol-divergence-${filter}-${days}`,
|
||||
`kol-divergence-all-${days}`,
|
||||
30 * 60_000,
|
||||
() => getKolDivergence({ signal_type: filter === 'all' ? undefined : filter, days }),
|
||||
fresh => setData(fresh.items),
|
||||
)
|
||||
.then(r => setData(r.items))
|
||||
.catch(() => setData([]))
|
||||
() => getKolDivergence({ days }),
|
||||
fresh => setItems(fresh.items),
|
||||
),
|
||||
])
|
||||
.then(([nextDigest, nextChanges, nextItems]) => {
|
||||
setDigest(nextDigest)
|
||||
setChanges(nextChanges.changes)
|
||||
setItems(nextItems.items)
|
||||
})
|
||||
.catch(() => {
|
||||
setDigest(null)
|
||||
setChanges([])
|
||||
setItems([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [days, filter, initialItems])
|
||||
}, [days])
|
||||
|
||||
const divCount = data.filter(d => d.signal_type === 'divergence').length
|
||||
const aliCount = data.filter(d => d.signal_type === 'alignment').length
|
||||
const rows = useMemo(() => {
|
||||
const sourceTickers = digest?.tickers ?? []
|
||||
const filteredTickers = tickerFilter
|
||||
? sourceTickers.filter(t => t.ticker === tickerFilter)
|
||||
: sourceTickers
|
||||
|
||||
return filteredTickers.map(t => {
|
||||
const relatedChanges = changes
|
||||
.filter(c => c.ticker === t.ticker)
|
||||
.sort((a, b) => +new Date(b.detected_at) - +new Date(a.detected_at))
|
||||
const relatedItems = items
|
||||
.filter(i => i.ticker === t.ticker)
|
||||
.sort((a, b) => +new Date(b.onchain_at) - +new Date(a.onchain_at))
|
||||
const divergenceCount = relatedItems.filter(i => i.signal_type === 'divergence').length
|
||||
const alignmentCount = relatedItems.filter(i => i.signal_type === 'alignment').length
|
||||
const latestChange = relatedChanges[0] ?? null
|
||||
const latestMatch = relatedItems[0] ?? null
|
||||
|
||||
const verdict =
|
||||
divergenceCount > 0 && alignmentCount === 0
|
||||
? {
|
||||
label: 'Mismatch',
|
||||
color: '#f59e0b',
|
||||
bg: '#f59e0b22',
|
||||
note: 'Wallet action disagrees with the public pitch.',
|
||||
}
|
||||
: alignmentCount > 0 && divergenceCount === 0
|
||||
? {
|
||||
label: 'Aligned',
|
||||
color: '#22c55e',
|
||||
bg: '#22c55e22',
|
||||
note: 'Wallet action supports the public pitch.',
|
||||
}
|
||||
: alignmentCount > 0 && divergenceCount > 0
|
||||
? {
|
||||
label: 'Mixed',
|
||||
color: '#a855f7',
|
||||
bg: '#a855f722',
|
||||
note: 'Different KOLs or time windows point in both directions.',
|
||||
}
|
||||
: latestChange
|
||||
? {
|
||||
label: 'Watch',
|
||||
color: CHANGE_COLOR[latestChange.change_type],
|
||||
bg: `${CHANGE_COLOR[latestChange.change_type]}22`,
|
||||
note: 'Wallet movement exists, but no clean talk-vs-wallet match yet.',
|
||||
}
|
||||
: {
|
||||
label: 'No wallet proof',
|
||||
color: '#9ca3af',
|
||||
bg: '#9ca3af22',
|
||||
note: 'No tracked wallet move linked to this asset in the selected window.',
|
||||
}
|
||||
|
||||
return {
|
||||
ticker: t.ticker,
|
||||
talkLine: `${t.kol_count} KOLs pushing ${t.side === 'short' ? 'bearish' : t.side === 'long' ? 'bullish' : 'mixed'} flow`,
|
||||
speakers: t.kols.map(k => '@' + k).join(', '),
|
||||
conviction: `${(t.max_conviction * 100).toFixed(0)}% max conviction`,
|
||||
verdict,
|
||||
divergenceCount,
|
||||
alignmentCount,
|
||||
latestChange,
|
||||
latestMatch,
|
||||
}
|
||||
})
|
||||
}, [changes, digest, items, tickerFilter])
|
||||
|
||||
const totalMismatch = rows.filter(r => r.verdict.label === 'Mismatch').length
|
||||
const totalAligned = rows.filter(r => r.verdict.label === 'Aligned').length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -419,7 +454,6 @@ function DivergenceWidget({
|
||||
borderRadius: 12, background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'flex-start', flexWrap: 'wrap', gap: 8, marginBottom: 12,
|
||||
@@ -427,126 +461,115 @@ function DivergenceWidget({
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
|
||||
textTransform: 'uppercase', marginBottom: 2 }}>
|
||||
Talks vs trades
|
||||
Talks vs wallets
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: data.length === 0
|
||||
? 'No cross-signals yet. This feed needs both a post and a wallet move.'
|
||||
: `⚠️ ${divCount} divergences · ✅ ${aliCount} alignments`}
|
||||
: rows.length === 0
|
||||
? 'No overlapping talk and wallet evidence in this window.'
|
||||
: `${totalAligned} aligned · ${totalMismatch} mismatched across the assets KOLs are pushing`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap',
|
||||
maxWidth: '100%', overflowX: 'auto' }}>
|
||||
{/* filter tabs */}
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
||||
{(['all', 'divergence', 'alignment'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={`nav-tab ${filter === f ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
|
||||
whiteSpace: 'nowrap' }}>
|
||||
{f === 'all'
|
||||
? (isZh ? '全部' : 'All')
|
||||
: f === 'divergence'
|
||||
? (isZh ? '⚠️ 不符' : '⚠️ Divergence')
|
||||
: (isZh ? '✅ 一致' : '✅ Alignment')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* day tabs */}
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
||||
{([7, 30, 90] as const).map(d => (
|
||||
<button key={d} onClick={() => setDays(d)}
|
||||
{([1, 7, 30] as const).map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`nav-tab ${days === d ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
|
||||
whiteSpace: 'nowrap' }}>
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
{windowLabel(d, isZh)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{!loading && data.length === 0 && (
|
||||
{!loading && rows.length === 0 && (
|
||||
<div style={{
|
||||
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
|
||||
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.7,
|
||||
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
|
||||
}}>
|
||||
<>
|
||||
This module needs two pieces of evidence: ① a directional KOL post extracted by AI,
|
||||
and ② a matching wallet position change in the same token within ±7 days.<br />
|
||||
The wallet snapshot layer is still early, so signal count will grow with daily data.
|
||||
</>
|
||||
This panel only answers one question: when KOLs talk about an asset, do tracked wallets confirm it?
|
||||
If there is no match yet, the asset stays in watch mode until wallet evidence appears.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.slice(0, 20).map(d => {
|
||||
const meta = divergenceMeta(d.signal_type, isZh)
|
||||
const dirColor = DIR_COLOR[d.direction]
|
||||
return (
|
||||
<div key={d.id} style={{
|
||||
padding: '10px 14px', borderRadius: 10,
|
||||
{!loading && rows.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{rows.map(row => (
|
||||
<div key={row.ticker} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 1.4fr) auto',
|
||||
gap: 12,
|
||||
alignItems: 'start',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--bg-sunk)',
|
||||
borderLeft: `3px solid ${meta.color}`,
|
||||
display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center',
|
||||
}}
|
||||
title={meta.tip}
|
||||
>
|
||||
{/* Signal badge */}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, color: meta.color,
|
||||
padding: '2px 7px', borderRadius: 4, background: meta.bg,
|
||||
flexShrink: 0, whiteSpace: 'nowrap',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
{/* Ticker + direction */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<strong style={{ fontSize: 15 }}>{d.ticker}</strong>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 18 }}>{row.ticker}</strong>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, color: dirColor,
|
||||
padding: '1px 6px', borderRadius: 4,
|
||||
background: `${dirColor}22`,
|
||||
fontSize: 10, fontWeight: 700, color: row.verdict.color,
|
||||
padding: '3px 8px', borderRadius: 999, background: row.verdict.bg,
|
||||
}}>
|
||||
{directionLabel(d.direction, isZh)}
|
||||
{row.verdict.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
|
||||
{row.talkLine}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row.speakers}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>
|
||||
{row.conviction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KOL + actions — flex: 1 1 0 with minWidth:0 so it shrinks on narrow screens */}
|
||||
<div style={{ flex: '1 1 140px', fontSize: 12, color: 'var(--ink-2)',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>@{d.handle}</span>
|
||||
{' '}
|
||||
<span style={{ color: 'var(--ink-3)' }}>
|
||||
{postActionLabel(d.post_action, isZh)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 5 }}>
|
||||
Wallet evidence
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
|
||||
{row.latestMatch
|
||||
? `@${row.latestMatch.handle} ${chainActionLabel(row.latestMatch.onchain_action, isZh).toLowerCase()}`
|
||||
: row.latestChange
|
||||
? compactChangeCopy(row.latestChange)
|
||||
: 'No tracked move yet'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5 }}>
|
||||
{row.latestMatch
|
||||
? `${row.verdict.note} ${row.latestMatch.usd_after ? `Size ${formatShortUsd(row.latestMatch.usd_after)}.` : ''}`
|
||||
: row.verdict.note}
|
||||
</div>
|
||||
{(row.divergenceCount > 0 || row.alignmentCount > 0) && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
{row.alignmentCount > 0 && (
|
||||
<span style={{ fontSize: 11, color: '#22c55e' }}>
|
||||
{row.alignmentCount} alignment
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-3)', margin: '0 6px' }}>{isZh ? 'vs' : 'vs'}</span>
|
||||
<span style={{ color: 'var(--ink-3)' }}>
|
||||
{chainActionLabel(d.onchain_action, isZh)}
|
||||
)}
|
||||
{row.divergenceCount > 0 && (
|
||||
<span style={{ fontSize: 11, color: '#f59e0b' }}>
|
||||
{row.divergenceCount} divergence
|
||||
</span>
|
||||
{d.usd_after != null && d.usd_after > 0 &&
|
||||
<span style={{ color: 'var(--ink-2)', marginLeft: 6 }}>
|
||||
{formatShortUsd(d.usd_after)}
|
||||
</span>}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time info */}
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)',
|
||||
flexShrink: 0, textAlign: 'right' }}>
|
||||
<div>
|
||||
{`${d.days_apart?.toFixed(1) ?? '?'} days apart`}
|
||||
</div>
|
||||
<div>{new Date(d.onchain_at).toLocaleDateString(dateLocale)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{row.latestMatch
|
||||
? new Date(row.latestMatch.onchain_at).toLocaleDateString(dateLocale)
|
||||
: row.latestChange
|
||||
? new Date(row.latestChange.detected_at).toLocaleDateString(dateLocale)
|
||||
: 'No date'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -804,10 +827,8 @@ export default function KolPage({
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? 'KOL 信号' : 'KOL Signals'}</h1>
|
||||
<PageHint count={`${posts.length} posts`}>
|
||||
19 crypto KOLs' essays and podcasts ingested daily, AI extracts every
|
||||
ticker call, then cross-checked against the same KOLs' on-chain
|
||||
wallets. Catches "talks vs trades" — when their wallet says the
|
||||
opposite of their tweets.
|
||||
This page answers two questions only: which assets KOLs are pushing now,
|
||||
and whether tracked wallets confirm or contradict that public stance.
|
||||
</PageHint>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,20 +836,18 @@ export default function KolPage({
|
||||
<DigestWidget
|
||||
isZh={isZh}
|
||||
initialDigest={initialDigest}
|
||||
activeTicker={tickerFilter}
|
||||
onTickerClick={(sym) =>
|
||||
setTickerFilter(prev => prev === sym ? null : sym)}
|
||||
/>
|
||||
|
||||
<OnchainWidget
|
||||
<WalletCheckWidget
|
||||
isZh={isZh}
|
||||
dateLocale={dateLocale}
|
||||
initialDigest={initialDigest}
|
||||
initialChanges={initialChanges}
|
||||
/>
|
||||
|
||||
<DivergenceWidget
|
||||
isZh={isZh}
|
||||
dateLocale={dateLocale}
|
||||
initialItems={initialDivergence}
|
||||
tickerFilter={tickerFilter}
|
||||
/>
|
||||
|
||||
{tickerFilter && (
|
||||
|
||||
@@ -29,19 +29,75 @@ export default function SettingsClient() {
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
const href = (path: string) => `/${locale}${path}`
|
||||
const walletLabel = mounted && isConnected && address
|
||||
? `${address.slice(0, 6)}…${address.slice(-4)}`
|
||||
: 'Not connected'
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<>
|
||||
<div className="settings-control-center">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '设置' : 'Settings'}</h1>
|
||||
<div className="settings-control-kicker">Control center</div>
|
||||
<div className="settings-control-title">One place to arm, limit, and verify the bot</div>
|
||||
<div className="settings-control-copy">
|
||||
Subscription, execution permissions, per-system risk, and alert delivery all live here.
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-control-meta">
|
||||
<div className="settings-meta-card">
|
||||
<span className="settings-meta-label">Wallet</span>
|
||||
<strong className="mono">{walletLabel}</strong>
|
||||
</div>
|
||||
<div className="settings-meta-card">
|
||||
<span className="settings-meta-label">Private data</span>
|
||||
<strong>{mounted && isConnected ? 'Ready to unlock' : 'Connect first'}</strong>
|
||||
</div>
|
||||
<div className="settings-meta-card">
|
||||
<span className="settings-meta-label">Main actions</span>
|
||||
<strong>Load settings, save limits, link API</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-scope-intro">
|
||||
<PageHint>
|
||||
Everything that controls your account — subscription, Hyperliquid
|
||||
API key, bot risk parameters, and Telegram alerts.
|
||||
</PageHint>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 8 }}>
|
||||
Trump settings control event-driven entries. Macro Vibes settings control BTC manage-only behavior. Global limits apply account-wide.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-scope-grid" style={{ marginBottom: 16 }}>
|
||||
<section id="trump-settings" className="settings-scope-card trump">
|
||||
<div className="settings-scope-eyebrow">Trump Signal</div>
|
||||
<div className="settings-scope-title">Event-driven entry settings</div>
|
||||
<div className="settings-scope-copy">
|
||||
Position size, Trump leverage, and minimum AI confidence used when a Truth Social post becomes actionable.
|
||||
</div>
|
||||
<a href="#trump-settings-panel" className="settings-scope-link">Open panel ↓</a>
|
||||
</section>
|
||||
|
||||
<section id="btc-settings" className="settings-scope-card btc">
|
||||
<div className="settings-scope-eyebrow">Macro Vibes</div>
|
||||
<div className="settings-scope-title">BTC manage-only settings</div>
|
||||
<div className="settings-scope-copy">
|
||||
Strategy mode, BTC leverage, and de-risk behavior used by the Macro Vibes sleeve.
|
||||
</div>
|
||||
<a href="#btc-settings-panel" className="settings-scope-link">Open panel ↓</a>
|
||||
</section>
|
||||
|
||||
<section id="global-settings" className="settings-scope-card global">
|
||||
<div className="settings-scope-eyebrow">Global</div>
|
||||
<div className="settings-scope-title">Account-wide execution controls</div>
|
||||
<div className="settings-scope-copy">
|
||||
Subscription, Hyperliquid API wallet, manual window, schedule, and guardrails that apply across the account.
|
||||
</div>
|
||||
<a href="#global-settings-panel" className="settings-scope-link">Open panel ↓</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Account card — quick "who am I logged in as" */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
@@ -70,12 +126,41 @@ export default function SettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-shell">
|
||||
<div className="settings-section-head">
|
||||
<div>
|
||||
<div className="settings-section-kicker">Execution setup</div>
|
||||
<div className="settings-section-title">Trading permissions and risk controls</div>
|
||||
</div>
|
||||
<div className="settings-section-note">
|
||||
Load once, then adjust by module without leaving the page.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The full bot config UI (subscribe, HL key, risk settings, schedule) */}
|
||||
<BotConfigPanel />
|
||||
</div>
|
||||
|
||||
{/* Telegram push alerts — only renders something useful when wallet
|
||||
connected + server configured + (eventually) subscribed. */}
|
||||
<div className="settings-section-shell">
|
||||
<div className="settings-section-head">
|
||||
<div>
|
||||
<div className="settings-section-kicker">Delivery</div>
|
||||
<div className="settings-section-title">Telegram alerts</div>
|
||||
</div>
|
||||
<div className="settings-section-note">
|
||||
Bind notifications after the trading path is configured.
|
||||
</div>
|
||||
</div>
|
||||
<TelegramCard />
|
||||
</div>
|
||||
|
||||
<div className="settings-section-shell">
|
||||
<div className="settings-section-head">
|
||||
<div>
|
||||
<div className="settings-section-kicker">Support</div>
|
||||
<div className="settings-section-title">Legal and contact</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal & support */}
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
@@ -90,5 +175,6 @@ export default function SettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 } from '@/lib/signedRequest'
|
||||
import { getCachedViewEnvelope } from '@/lib/signedRequest'
|
||||
import TradeTable from '@/components/trades/TradeTable'
|
||||
import OpenPositions from '@/components/positions/OpenPositions'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
@@ -17,7 +17,6 @@ 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'
|
||||
@@ -44,26 +43,33 @@ export default function TradesPageClient() {
|
||||
let failed = false
|
||||
try {
|
||||
const env = getCachedViewEnvelope('view_trades', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_trades', wallet: address, signMessageAsync })
|
||||
?? getCachedViewEnvelope('view_user', address)
|
||||
const [t, p] = await Promise.all([
|
||||
getTrades(address, env, 100, 1).catch(e => {
|
||||
env
|
||||
? getTrades(address, env, 100, 1).catch(e => {
|
||||
failed = true
|
||||
setLoadErr(e instanceof Error ? e.message : (isZh ? '交易加载失败' : 'Failed to load trades'))
|
||||
return [] as BotTrade[]
|
||||
}),
|
||||
})
|
||||
: Promise.resolve([] as BotTrade[]),
|
||||
getPosts(500, 1).catch(() => [] as TrumpPost[]),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setTrades(t)
|
||||
setPosts(p)
|
||||
if (!failed) setLoadErr('')
|
||||
if (!env) {
|
||||
failed = true
|
||||
setLoadErr('Load your settings once on the Settings page to unlock private trade history.')
|
||||
} else if (!failed) {
|
||||
setLoadErr('')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, signMessageAsync, isZh])
|
||||
}, [address, isConnected, isZh])
|
||||
|
||||
const needsSetup = mounted && isConnected && (!isSubscribed || !hlApiKeySet)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ export default function MacroPanel() {
|
||||
label="AHR999"
|
||||
value={ind.ahr999?.toFixed(4) ?? '—'}
|
||||
tone={toneAhr(ind.ahr999)}
|
||||
hint="BTC valuation index: price vs 200d MA × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.45–0.75 = fair value leaning cheap, 0.75–1.2 = neutral / no edge, > 1.2 = expensive."
|
||||
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.45–0.75 = fair value leaning cheap, 0.75–1.2 = neutral / no edge, > 1.2 = expensive."
|
||||
summary={ahrSay}
|
||||
activeIndex={ahrActive}
|
||||
thresholds={[
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDashboardStore } from '@/store/dashboard'
|
||||
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
|
||||
// Action names must match backend/app/api/{user,subscribe}.py
|
||||
const ACTION_SET_API_KEY = 'set_hl_api_key'
|
||||
@@ -28,7 +29,7 @@ function fmtHold(s: number) {
|
||||
export default function BotPanel({ performance }: Props) {
|
||||
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setBotReadiness, setHlApiKeySet, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { connectAsync, connectors } = useConnect()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -37,6 +38,7 @@ export default function BotPanel({ performance }: Props) {
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
|
||||
const [subError, setSubError] = useState('')
|
||||
const [connectError, setConnectError] = useState('')
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
@@ -127,9 +129,18 @@ export default function BotPanel({ performance }: Props) {
|
||||
: saveState === 'success' ? '✓ Saved'
|
||||
: 'Save key'
|
||||
|
||||
function handleConnectWallet() {
|
||||
const connector = connectors[0]
|
||||
if (connector) connect({ connector })
|
||||
async function handleConnectWallet() {
|
||||
setConnectError('')
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectError('No wallet connector is available right now.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
} catch (err: unknown) {
|
||||
setConnectError(walletConnectErrorLabel(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -169,10 +180,15 @@ export default function BotPanel({ performance }: Props) {
|
||||
|
||||
<div className="bot-cta">
|
||||
{(!mounted || !isConnected) && (
|
||||
<>
|
||||
<button className="btn amber" style={{ width: '100%' }}
|
||||
onClick={handleConnectWallet}>
|
||||
onClick={() => { void handleConnectWallet() }}>
|
||||
Connect wallet
|
||||
</button>
|
||||
{connectError && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{connectError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mounted && isConnected && !isSubscribed && (
|
||||
<div style={{ width: '100%' }}>
|
||||
|
||||
@@ -17,6 +17,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<unknown>(null)
|
||||
const seriesRef = useRef<unknown>(null)
|
||||
const macroPriceLineRef = useRef<unknown>(null)
|
||||
const macroVerticalRef = useRef<HTMLDivElement | null>(null)
|
||||
const macroLabelRef = useRef<HTMLDivElement | null>(null)
|
||||
const fittedRef = useRef(false)
|
||||
const postsRef = useRef(posts)
|
||||
postsRef.current = posts
|
||||
@@ -37,6 +40,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
textColor: isDark ? '#666666' : '#888888',
|
||||
gridColor: isDark ? '#1e1e1e' : '#f0ede8',
|
||||
borderColor: isDark ? '#2a2a2a' : '#e8e4de',
|
||||
macroAccent: isDark ? '#f59e0b' : '#b45309',
|
||||
macroAccentSoft: isDark ? 'rgba(245,158,11,0.24)' : 'rgba(180,83,9,0.18)',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +56,42 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
if (destroyed || !containerRef.current) return
|
||||
const colors = getChartColors()
|
||||
|
||||
const vertical = document.createElement('div')
|
||||
vertical.style.position = 'absolute'
|
||||
vertical.style.top = '0'
|
||||
vertical.style.bottom = '0'
|
||||
vertical.style.width = '0'
|
||||
vertical.style.borderLeft = `2px dashed ${colors.macroAccent}`
|
||||
vertical.style.opacity = '0'
|
||||
vertical.style.pointerEvents = 'none'
|
||||
vertical.style.zIndex = '2'
|
||||
vertical.style.transition = 'left 160ms ease, opacity 120ms ease'
|
||||
containerRef.current.appendChild(vertical)
|
||||
macroVerticalRef.current = vertical
|
||||
|
||||
const label = document.createElement('div')
|
||||
label.style.position = 'absolute'
|
||||
label.style.top = '14px'
|
||||
label.style.left = '0'
|
||||
label.style.padding = '5px 8px'
|
||||
label.style.borderRadius = '999px'
|
||||
label.style.border = `1px solid ${colors.macroAccentSoft}`
|
||||
label.style.background = colors.background
|
||||
label.style.boxShadow = '0 6px 24px rgba(15, 23, 42, 0.08)'
|
||||
label.style.color = colors.macroAccent
|
||||
label.style.fontSize = '11px'
|
||||
label.style.fontWeight = '700'
|
||||
label.style.letterSpacing = '0.04em'
|
||||
label.style.textTransform = 'uppercase'
|
||||
label.style.whiteSpace = 'nowrap'
|
||||
label.style.pointerEvents = 'none'
|
||||
label.style.opacity = '0'
|
||||
label.style.transform = 'translateX(-50%)'
|
||||
label.style.zIndex = '3'
|
||||
label.style.transition = 'left 160ms ease, opacity 120ms ease'
|
||||
containerRef.current.appendChild(label)
|
||||
macroLabelRef.current = label
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: 360,
|
||||
@@ -144,6 +185,14 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
rightPriceScale: { borderColor: colors.borderColor },
|
||||
timeScale: { borderColor: colors.borderColor },
|
||||
})
|
||||
if (macroVerticalRef.current) {
|
||||
macroVerticalRef.current.style.borderLeft = `2px dashed ${colors.macroAccent}`
|
||||
}
|
||||
if (macroLabelRef.current) {
|
||||
macroLabelRef.current.style.color = colors.macroAccent
|
||||
macroLabelRef.current.style.borderColor = colors.macroAccentSoft
|
||||
macroLabelRef.current.style.background = colors.background
|
||||
}
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
@@ -162,6 +211,15 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
chartRef.current = null
|
||||
seriesRef.current = null
|
||||
}
|
||||
if (macroVerticalRef.current?.parentNode) {
|
||||
macroVerticalRef.current.parentNode.removeChild(macroVerticalRef.current)
|
||||
}
|
||||
if (macroLabelRef.current?.parentNode) {
|
||||
macroLabelRef.current.parentNode.removeChild(macroLabelRef.current)
|
||||
}
|
||||
macroVerticalRef.current = null
|
||||
macroLabelRef.current = null
|
||||
macroPriceLineRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -232,12 +290,74 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
// @ts-expect-error lightweight-charts type
|
||||
series.setMarkers(markers)
|
||||
|
||||
// Highlight the most recent visible Macro Vibes reversal signal on BTC.
|
||||
const visibleMacro = asset === 'BTC'
|
||||
? visible
|
||||
.filter((p) =>
|
||||
((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') &&
|
||||
(p.signal === 'buy' || p.signal === 'short'),
|
||||
)
|
||||
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
|
||||
: []
|
||||
|
||||
const macroSignal = visibleMacro[0] ?? null
|
||||
const colors = getChartColors()
|
||||
const priceSeries = series as any
|
||||
if (macroPriceLineRef.current && priceSeries?.removePriceLine) {
|
||||
try { priceSeries.removePriceLine(macroPriceLineRef.current as any) } catch {}
|
||||
macroPriceLineRef.current = null
|
||||
}
|
||||
|
||||
if (macroSignal && chart && macroVerticalRef.current) {
|
||||
const pt = Math.floor(new Date(macroSignal.published_at).getTime() / 1000)
|
||||
const bucketTime = Math.floor(pt / bucketSecs) * bucketSecs
|
||||
// Only draw the horizontal reference when we have a true signal-entry
|
||||
// price from the backend. Falling back to a bucket close made the line
|
||||
// drift across timeframe changes and looked like a bug because it was.
|
||||
const priceAtSignal =
|
||||
macroSignal.price_impact?.asset === 'BTC' && macroSignal.price_impact?.price_at_post
|
||||
? macroSignal.price_impact.price_at_post
|
||||
: null
|
||||
|
||||
if (priceAtSignal != null && priceSeries?.createPriceLine) {
|
||||
macroPriceLineRef.current = priceSeries.createPriceLine({
|
||||
price: priceAtSignal,
|
||||
color: colors.macroAccent,
|
||||
lineWidth: 2,
|
||||
lineStyle: 2,
|
||||
axisLabelVisible: false,
|
||||
title: (macroSignal.source || '') === 'btc_bottom_reversal' ? 'Macro bottom' : 'Funding reversal',
|
||||
})
|
||||
}
|
||||
|
||||
const x = (chart as any).timeScale?.().timeToCoordinate?.(bucketTime)
|
||||
if (typeof x === 'number' && Number.isFinite(x)) {
|
||||
macroVerticalRef.current.style.left = `${x}px`
|
||||
macroVerticalRef.current.style.opacity = '1'
|
||||
if (macroLabelRef.current) {
|
||||
macroLabelRef.current.textContent =
|
||||
(macroSignal.source || '') === 'btc_bottom_reversal'
|
||||
? 'Macro bottom'
|
||||
: 'Funding reversal'
|
||||
const clamped = Math.max(76, Math.min((containerRef.current?.clientWidth ?? x) - 76, x))
|
||||
macroLabelRef.current.style.left = `${clamped}px`
|
||||
macroLabelRef.current.style.opacity = '1'
|
||||
}
|
||||
} else {
|
||||
macroVerticalRef.current.style.opacity = '0'
|
||||
if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0'
|
||||
}
|
||||
} else if (macroVerticalRef.current) {
|
||||
macroVerticalRef.current.style.opacity = '0'
|
||||
if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0'
|
||||
}
|
||||
|
||||
if (!fittedRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chart.timeScale().fitContent()
|
||||
fittedRef.current = true
|
||||
}
|
||||
}, [candles, posts, externalSelectedId])
|
||||
}, [candles, posts, externalSelectedId, asset])
|
||||
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe])
|
||||
|
||||
@@ -264,7 +384,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair' }}
|
||||
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ function replaceLocale(pathname: string, nextLocale: Locale) {
|
||||
export default function LanguageSwitch() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const options: Array<{ locale: Locale; label: string }> = [
|
||||
{ locale: 'en', label: 'EN' },
|
||||
]
|
||||
|
||||
const activeLocale = useMemo<Locale>(() => {
|
||||
const seg = pathname.split('/').filter(Boolean)[0]
|
||||
@@ -39,10 +42,7 @@ export default function LanguageSwitch() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{([
|
||||
['en', 'EN'],
|
||||
['zh', '中文'],
|
||||
] as const).map(([locale, label]) => {
|
||||
{options.map(({ locale, label }) => {
|
||||
const active = activeLocale === locale
|
||||
const nextPath = replaceLocale(pathname || `/${defaultLocale}`, locale)
|
||||
const qs = searchParams.toString()
|
||||
@@ -56,7 +56,7 @@ export default function LanguageSwitch() {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: locale === 'zh' ? 48 : 40,
|
||||
minWidth: 40,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
|
||||
// import LanguageSwitch from './LanguageSwitch'
|
||||
|
||||
@@ -49,11 +50,12 @@ export default function Navbar() {
|
||||
const t = useTranslations('nav')
|
||||
const pathname = usePathname()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { connectAsync, connectors } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [connectError, setConnectError] = useState('')
|
||||
|
||||
async function copyAddress(addr: string) {
|
||||
let ok = false
|
||||
@@ -78,10 +80,27 @@ export default function Navbar() {
|
||||
}
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
useEffect(() => { setWalletMenuOpen(false) }, [pathname, address])
|
||||
useEffect(() => {
|
||||
if (isConnected) setConnectError('')
|
||||
}, [isConnected])
|
||||
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
const path = '/' + pathname.split('/').slice(2).join('/')
|
||||
|
||||
async function handleConnectWallet() {
|
||||
setConnectError('')
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectError('No wallet connector is available right now.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
} catch (err: unknown) {
|
||||
setConnectError(walletConnectErrorLabel(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Two independent trading systems get their own top-level tabs — no
|
||||
// intermediate "Signals" hub. Trump = event scalp; BTC = reversal.
|
||||
const tabs = [
|
||||
@@ -128,6 +147,7 @@ export default function Navbar() {
|
||||
<div className="nav-right">
|
||||
{/* <LanguageSwitch /> — shelved until i18n coverage is complete */}
|
||||
<ThemeToggle />
|
||||
<div className="wallet-anchor">
|
||||
{!mounted ? (
|
||||
<button className="connect-btn lg" suppressHydrationWarning>{t('actions.connectWallet')}</button>
|
||||
) : isConnected && shortAddr ? (
|
||||
@@ -172,14 +192,15 @@ export default function Navbar() {
|
||||
) : (
|
||||
<button
|
||||
className="connect-btn lg"
|
||||
onClick={() => {
|
||||
const connector = connectors[0]
|
||||
if (connector) connect({ connector })
|
||||
}}
|
||||
onClick={() => { void handleConnectWallet() }}
|
||||
>
|
||||
{t('actions.connectWallet')}
|
||||
</button>
|
||||
)}
|
||||
{!isConnected && connectError ? (
|
||||
<p className="wallet-connect-error" role="alert">{connectError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type OpenPosition,
|
||||
type TodayStats,
|
||||
} from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||||
import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
@@ -171,6 +171,7 @@ export default function OpenPositions() {
|
||||
const [positions, setPositions] = useState<OpenPosition[] | null>(null)
|
||||
const [today, setToday] = useState<TodayStats | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||||
// Close-confirmation modal state
|
||||
const [closing, setClosing] = useState<OpenPosition | null>(null)
|
||||
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
|
||||
@@ -203,43 +204,38 @@ export default function OpenPositions() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setPositions(null); setToday(null)
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
||||
// First load uses getOrCreateViewEnvelope — may pop MetaMask exactly ONCE
|
||||
// when the page is opened (user action). All subsequent polls reuse the
|
||||
// cached envelope via getCachedViewEnvelope (no popup). When the cache
|
||||
// expires (20 min), the polling tick silently skips until the user
|
||||
// refocuses the tab or navigates back in — preventing the wallet from
|
||||
// popping in the background while the user is doing something else.
|
||||
async function load(envSource: 'first' | 'poll') {
|
||||
const env = envSource === 'first'
|
||||
? await getOrCreateViewEnvelope({
|
||||
action: 'view_positions', wallet: address!, signMessageAsync,
|
||||
})
|
||||
: getCachedViewEnvelope('view_positions', address!)
|
||||
if (!env) return // cache expired during polling — wait for user re-entry
|
||||
// Only reuse a cached envelope here. Open positions should never trigger
|
||||
// a fresh signature popup on their own while the user is browsing.
|
||||
async function load() {
|
||||
const env = getCachedViewEnvelope('view_positions', address!)
|
||||
?? getCachedViewEnvelope('view_user', address!)
|
||||
if (!env) {
|
||||
if (!cancelled) {
|
||||
setNeedsUnlock(true)
|
||||
setErr(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
getOpenPositions(address!, env),
|
||||
getTodayStats(address!, env),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPositions(p.positions); setToday(t); setErr(null)
|
||||
setPositions(p.positions); setToday(t); setErr(null); setNeedsUnlock(false)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
|
||||
}
|
||||
}
|
||||
|
||||
load('first').catch(e => {
|
||||
// Initial sign-rejection lands here. Show a soft error so the polling
|
||||
// loop still starts (a subsequent in-cache sign elsewhere will revive it).
|
||||
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
|
||||
})
|
||||
const id = setInterval(() => { void load('poll') }, POLL_MS)
|
||||
void load()
|
||||
const id = setInterval(() => { void load() }, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [address, isConnected, signMessageAsync, isZh])
|
||||
|
||||
@@ -269,6 +265,21 @@ export default function OpenPositions() {
|
||||
// Return null both before mount (SSR) and when not connected — same output,
|
||||
// no hydration mismatch.
|
||||
if (!mounted || !isConnected) return null
|
||||
if (needsUnlock && positions === null && today === null) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 600, marginBottom: 6 }}>
|
||||
Open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Load your settings once to unlock private position data.
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
||||
This panel no longer triggers a wallet signature on its own while you browse. Open the Settings page and load once, then the cached permission will populate positions here.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (positions === null && today === null) return null
|
||||
|
||||
const totalUnrealized = (positions ?? []).reduce(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
@@ -126,6 +126,9 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
// wagmi state that resolves client-side.
|
||||
if (!mounted) return null
|
||||
|
||||
const settingsHref = `/${locale}/settings#${system === 'trump' ? 'trump-settings-panel' : 'btc-settings-panel'}`
|
||||
const settingsLabel = system === 'trump' ? 'Trump Signal' : 'Macro Vibes'
|
||||
|
||||
// Wallet not connected: a previous version showed a giant full-width
|
||||
// "Connect a wallet..." card here, which was redundant with the navbar's
|
||||
// Connect button. The opposite extreme — rendering null — silently hid
|
||||
@@ -135,13 +138,14 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap',
|
||||
padding: '10px 14px', marginBottom: 16,
|
||||
background: 'var(--bg-sunk)',
|
||||
border: '1px dashed var(--line)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12, color: 'var(--ink-3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: 'var(--ink-4)', flexShrink: 0,
|
||||
@@ -154,6 +158,30 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
OFF — connect wallet (top-right) to enable
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={settingsHref}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--surface)',
|
||||
fontSize: 12,
|
||||
color: 'var(--ink)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 700,
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,6 +304,31 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
color: netOk ? 'var(--up)' : 'var(--down)' }}>
|
||||
{netOk ? '✅ ' : '⛔ '}{netMsg}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Link
|
||||
href={settingsHref}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--surface)',
|
||||
fontSize: 12,
|
||||
color: 'var(--ink)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 700,
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,11 @@ export default function TelegramCard() {
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) return
|
||||
if (!address) {
|
||||
setLoading(false)
|
||||
setStatus(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const s = await getTelegramStatus(address.toLowerCase())
|
||||
@@ -101,7 +105,18 @@ export default function TelegramCard() {
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (!mounted) return null
|
||||
if (!isConnected) return null
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
|
||||
Telegram alerts
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6 }}>
|
||||
Connect your wallet first. After that, you can link Telegram for alert delivery and Pro wallet-bound notifications.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getCachedViewEnvelope,
|
||||
} from '@/lib/signedRequest'
|
||||
import { walletErrorLabel } from '@/lib/walletError'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
|
||||
// Defaults shown to a brand-new, un-configured wallet. take_profit_pct,
|
||||
@@ -51,7 +52,7 @@ export default function BotConfigPanel() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors } = useConnect()
|
||||
const { connectAsync, connectors } = useConnect()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const {
|
||||
isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness,
|
||||
@@ -97,6 +98,7 @@ export default function BotConfigPanel() {
|
||||
// cached signature; otherwise wait for the user to click "Load settings".
|
||||
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [connectErr, setConnectErr] = useState('')
|
||||
|
||||
// Apply a fetched user payload into local form state.
|
||||
function applyUserPayload(u: {
|
||||
@@ -296,10 +298,19 @@ export default function BotConfigPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectWallet = useCallback(() => {
|
||||
const connector = connectors[0]
|
||||
if (connector) connect({ connector })
|
||||
}, [connect, connectors])
|
||||
const handleConnectWallet = useCallback(async () => {
|
||||
setConnectErr('')
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectErr('No wallet connector is available right now.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
} catch (err: unknown) {
|
||||
setConnectErr(walletConnectErrorLabel(err))
|
||||
}
|
||||
}, [connectAsync, connectors])
|
||||
|
||||
// ── Manual-window: arm for N hours, or clear (hours = 0) ──────────────────
|
||||
async function handleManualWindow(hours: number) {
|
||||
@@ -335,7 +346,10 @@ export default function BotConfigPanel() {
|
||||
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>
|
||||
{isZh ? '连接钱包后即可配置机器人并查看你的交易。' : 'Connect your wallet to configure the bot and view your trades.'}
|
||||
</p>
|
||||
<button className="btn amber" onClick={handleConnectWallet}>{isZh ? '连接钱包' : 'Connect wallet'}</button>
|
||||
<button className="btn amber" onClick={() => { void handleConnectWallet() }}>{isZh ? '连接钱包' : 'Connect wallet'}</button>
|
||||
{connectErr ? (
|
||||
<p style={{ fontSize: 12, color: 'var(--down)', margin: '12px 0 0' }}>{connectErr}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -370,6 +384,20 @@ export default function BotConfigPanel() {
|
||||
: 'API wallet saved. Recommended next step: keep size tiny and run your own first BTC test before treating the setup as production-ready.')
|
||||
: null
|
||||
|
||||
function ScopePill({
|
||||
label,
|
||||
tone = 'amber',
|
||||
}: {
|
||||
label: string
|
||||
tone?: 'amber' | 'up' | 'violet'
|
||||
}) {
|
||||
return (
|
||||
<span className={`chip ${tone}`} style={{ fontSize: 10, padding: '2px 8px', marginLeft: 8 }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hyperliquid key strip ─────────────────────────────────────────────────
|
||||
const hlKeyStrip = (
|
||||
<div className="card" style={{ padding: '16px 20px', marginBottom: 16, display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 16 }}>
|
||||
@@ -537,12 +565,12 @@ export default function BotConfigPanel() {
|
||||
})()}
|
||||
|
||||
{/* ── Main settings card ── */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="card" id="bot-config-panel" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid var(--line)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>Trading bot</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>Executes on Hyperliquid when a Trump post clears your filters.</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>Controls Trump Signal entries, Macro Vibes BTC management, and shared execution limits on Hyperliquid.</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{dirty && isSubscribed && <span style={{ fontSize: 12, color: 'var(--amber-ink)' }}>● Unsaved</span>}
|
||||
@@ -688,14 +716,17 @@ export default function BotConfigPanel() {
|
||||
) : (
|
||||
<div style={{ padding: '8px 24px 24px' }}>
|
||||
{/* ── EXECUTION ── */}
|
||||
<div className="section-head">
|
||||
<span className="section-head-label">Execution</span>
|
||||
<div className="section-head" id="trump-settings-panel">
|
||||
<span className="section-head-label">Trump Signal settings</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', margin: '4px 0 10px' }}>
|
||||
Event-driven trade sizing, leverage, and signal threshold for Trump-triggered entries.
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Per-trade size
|
||||
Per-trade size <ScopePill label="Trump only" />
|
||||
<span className="hint">Notional in USD. margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
@@ -710,7 +741,7 @@ export default function BotConfigPanel() {
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Leverage <span style={{ opacity: 0.6 }}>(Trump / System 1)</span>
|
||||
Leverage <span style={{ opacity: 0.6 }}>(Trump / System 1)</span> <ScopePill label="Trump only" />
|
||||
<span className="hint">Event-driven scalp positions</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
@@ -726,43 +757,71 @@ export default function BotConfigPanel() {
|
||||
{(() => {
|
||||
const aggressive = settings.sys2_mode === 'aggressive'
|
||||
return (
|
||||
<>
|
||||
<div className="section-head" id="btc-settings-panel" style={{ marginTop: 20 }}>
|
||||
<span className="section-head-label">Macro Vibes settings</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', margin: '4px 0 10px' }}>
|
||||
Manage-only BTC mode, leverage, and pyramiding behavior for the Macro Vibes system.
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
BTC strategy mode <span style={{ opacity: 0.6 }}>(System 2)</span>
|
||||
BTC strategy mode <span style={{ opacity: 0.6 }}>(System 2)</span> <ScopePill label="BTC only" tone="up" />
|
||||
<span className="hint">
|
||||
A separately-funded sleeve. <strong>Aggressive</strong> =
|
||||
high leverage + earlier/heavier pyramiding + wider
|
||||
trailing → explosive in a clean bull, but a normal
|
||||
25–35% correction will scale you out hard. Both modes
|
||||
stay inside the liquidation line.
|
||||
Choose how hard the BTC system presses winners. Standard is calmer. Aggressive adds more leverage and wider pyramiding.
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
gap: 6,
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
background: 'var(--bg-sunk)',
|
||||
border: '1px solid var(--line)',
|
||||
}}>
|
||||
<button type="button"
|
||||
onClick={() => updateSettings({ sys2_mode: 'standard' })}
|
||||
className={`btn ${!aggressive ? '' : 'ghost'}`}
|
||||
style={{ padding: '7px 14px', fontSize: 12, fontWeight: 700 }}>
|
||||
aria-pressed={!aggressive}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
borderRadius: 999,
|
||||
border: !aggressive ? '1px solid var(--line)' : '1px solid transparent',
|
||||
background: !aggressive ? 'var(--surface)' : 'transparent',
|
||||
color: !aggressive ? 'var(--ink)' : 'var(--ink-3)',
|
||||
boxShadow: !aggressive ? 'var(--shadow-1)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
Standard
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => updateSettings({ sys2_mode: 'aggressive' })}
|
||||
className={`btn ${aggressive ? '' : 'ghost'}`}
|
||||
style={{ padding: '7px 14px', fontSize: 12, fontWeight: 700,
|
||||
...(aggressive ? { background: 'var(--down)', color: '#fff', border: 'none' } : {}) }}>
|
||||
aria-pressed={aggressive}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
borderRadius: 999,
|
||||
border: aggressive ? '1px solid var(--down)' : '1px solid transparent',
|
||||
background: aggressive ? 'var(--down)' : 'transparent',
|
||||
color: aggressive ? '#fff' : 'var(--ink-3)',
|
||||
boxShadow: aggressive ? 'var(--shadow-1)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
🔥 Aggressive
|
||||
</button>
|
||||
</div>
|
||||
{aggressive && (
|
||||
<div className="hint" style={{ marginTop: 6, color: 'var(--down)' }}>
|
||||
⚠️ High-risk sleeve: default 8×, pyramids up to +1.5×
|
||||
base, gives back up to 42% off the peak. Fund this
|
||||
separately — expect frequent full scale-outs on
|
||||
corrections in exchange for explosive clean runs.
|
||||
<div className="settings-note warn" style={{ marginTop: 6 }}>
|
||||
High-risk sleeve. Uses more leverage, pyramids harder, and gives back more off the peak. Best treated as a separate risk bucket.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -778,12 +837,9 @@ export default function BotConfigPanel() {
|
||||
return (
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
BTC bottom leverage <span style={{ opacity: 0.6 }}>(System 2)</span>
|
||||
BTC bottom leverage <span style={{ opacity: 0.6 }}>(System 2)</span> <ScopePill label="BTC only" tone="up" />
|
||||
<span className="hint">
|
||||
Independent of Trump. Wrong → scales OUT in 3 stages
|
||||
inside the liquidation line (<strong>never
|
||||
exchange-liquidated</strong>). Right → pyramids IN on a
|
||||
confirmed trend, stop floored at breakeven.
|
||||
Separate from Trump leverage. The bot de-risks in stages before the exchange liquidation line.
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
@@ -793,16 +849,13 @@ export default function BotConfigPanel() {
|
||||
<div className="ticks"><span>1×</span><span>5×</span><span>10×</span></div>
|
||||
</div>
|
||||
<span className="slider-readout">{lev}×</span>
|
||||
<div className="hint" style={{ marginTop: 6, color: risky ? 'var(--down)' : 'var(--ink-3)' }}>
|
||||
At {lev}× it sheds {e1} near −{(prot * 0.6).toFixed(0)}%,
|
||||
{' '}{e2} near −{(prot * 0.8).toFixed(0)}%, fully out by
|
||||
−{prot.toFixed(0)}% (exchange would liquidate
|
||||
≈ −{liq.toFixed(0)}%).{' '}
|
||||
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
|
||||
At {lev}× it sheds {e1} near −{(prot * 0.6).toFixed(0)}%, {e2} near −{(prot * 0.8).toFixed(0)}%, and is fully out by −{prot.toFixed(0)}%.
|
||||
Exchange liquidation would be around −{liq.toFixed(0)}%.
|
||||
{' '}
|
||||
{risky
|
||||
? (aggressive
|
||||
? '🔥 Aggressive: a normal 25–35% bull correction will scale you out — you’re betting on clean explosive runs.'
|
||||
: '⚠️ Above 2× a normal 25–35% bull correction can scale you out before the top. To ride a super-bull, keep ≤2× — amplify via pyramiding, not leverage.')
|
||||
: 'Wide enough to ride normal bull corrections; amplification comes from pyramiding.'}
|
||||
? 'Above 2×, a normal bull-market correction can push you out early.'
|
||||
: 'This is wide enough to survive a normal correction; extra upside should come from pyramiding, not leverage.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -811,7 +864,7 @@ export default function BotConfigPanel() {
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Min AI confidence
|
||||
Min AI confidence <ScopePill label="Trump only" />
|
||||
<span className="hint">Skip any signal below this score (0–100)</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
@@ -825,14 +878,17 @@ export default function BotConfigPanel() {
|
||||
</div>
|
||||
|
||||
{/* ── LIMITS ── */}
|
||||
<div className="section-head" style={{ marginTop: 20 }}>
|
||||
<span className="section-head-label">Limits & risk</span>
|
||||
<div className="section-head" id="global-settings-panel" style={{ marginTop: 20 }}>
|
||||
<span className="section-head-label">Global risk limits</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', margin: '4px 0 10px' }}>
|
||||
Shared safety rules that cap or gate automated trading across the account.
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Daily budget <span style={{ color: 'var(--down)' }}>*</span>
|
||||
Daily budget <span style={{ color: 'var(--down)' }}>*</span> <ScopePill label="Global" tone="violet" />
|
||||
<span className="hint">Stop opening new trades once the day's total notional reaches this (UTC). Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
@@ -849,7 +905,7 @@ export default function BotConfigPanel() {
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Take profit <span style={{ color: 'var(--down)' }}>*</span>
|
||||
Take profit <span style={{ color: 'var(--down)' }}>*</span> <ScopePill label="Trump only" />
|
||||
<span className="hint">Auto-close when unrealised gain hits target. Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
@@ -865,7 +921,7 @@ export default function BotConfigPanel() {
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
|
||||
Stop loss <span style={{ color: 'var(--down)' }}>*</span> <ScopePill label="Trump only" />
|
||||
<span className="hint">Auto-close when drawdown hits limit. Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
|
||||
@@ -30,13 +30,23 @@ export interface SignConfirmOptions {
|
||||
/** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */
|
||||
export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const existing = document.getElementById('sign-confirm-sheet-root')
|
||||
if (existing?.parentNode) {
|
||||
existing.parentNode.removeChild(existing)
|
||||
}
|
||||
const container = document.createElement('div')
|
||||
container.id = 'sign-confirm-sheet-root'
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
let settled = false
|
||||
|
||||
function cleanup(result: boolean) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
root.unmount()
|
||||
container.remove()
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container)
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
|
||||
export const locales = ['en', 'zh'] as const
|
||||
export const locales = ['en'] as const
|
||||
export type Locale = (typeof locales)[number]
|
||||
export const defaultLocale: Locale = 'en'
|
||||
|
||||
|
||||
@@ -50,10 +50,11 @@ export interface SignedEnvelope {
|
||||
*/
|
||||
/**
|
||||
* Get-or-create a cached "view" envelope for a wallet. Reused across page loads
|
||||
* within a 4-minute window (server accepts 5-min skew). Backend's replay-guard
|
||||
* is disabled for the view action so the same sig can be used multiple times.
|
||||
* within a short window below the server's 5-minute skew allowance.
|
||||
* Backend's replay-guard is disabled for the view action so the same sig can
|
||||
* be used multiple times while it remains fresh.
|
||||
*/
|
||||
const VIEW_TTL_MS = 20 * 60 * 1000 // 20 min — backend's replay-guard is disabled for view actions
|
||||
const VIEW_TTL_MS = 4 * 60 * 1000 // 4 min — stays safely inside backend's 5 min skew window
|
||||
|
||||
/**
|
||||
* Read-only: returns a cached, not-yet-expired view envelope if one exists.
|
||||
@@ -67,6 +68,7 @@ export function getCachedViewEnvelope(action: string, wallet: string): SignedEnv
|
||||
try {
|
||||
const env = JSON.parse(raw) as SignedEnvelope
|
||||
if (Date.now() - env.timestamp < VIEW_TTL_MS) return env
|
||||
sessionStorage.removeItem(cacheKey)
|
||||
} catch (err) {
|
||||
console.warn('[signedRequest] corrupt cached envelope, ignoring', err)
|
||||
sessionStorage.removeItem(cacheKey)
|
||||
|
||||
+10
-2
@@ -1,12 +1,20 @@
|
||||
import { createConfig, createStorage, http } from 'wagmi'
|
||||
import { mainnet } from 'wagmi/chains'
|
||||
import { metaMask } from 'wagmi/connectors'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
|
||||
export const chains = [mainnet] as const
|
||||
|
||||
export const config = createConfig({
|
||||
chains,
|
||||
connectors: [metaMask()],
|
||||
connectors: [
|
||||
injected({
|
||||
target: 'metaMask',
|
||||
// Use the native injected provider path in desktop browsers.
|
||||
// This avoids the MetaMask SDK account-sync bug we hit in the
|
||||
// in-app browser and keeps connect to a single requestAccounts flow.
|
||||
shimDisconnect: false,
|
||||
}),
|
||||
],
|
||||
transports: {
|
||||
[mainnet.id]: http(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Connector } from 'wagmi'
|
||||
|
||||
import { walletErrorLabel } from '@/lib/walletError'
|
||||
|
||||
export function walletConnectErrorLabel(err: unknown): string {
|
||||
const msg = walletErrorLabel(err, 'Cancelled', 140)
|
||||
if (/Provider not found/i.test(msg)) {
|
||||
return 'No wallet provider found in this browser. Open Trump Alpha in a wallet-enabled browser.'
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
export async function getFirstReadyConnector(connectors: readonly Connector[]): Promise<Connector | null> {
|
||||
for (const connector of connectors) {
|
||||
try {
|
||||
const provider = await connector.getProvider?.()
|
||||
if (provider) return connector
|
||||
} catch {
|
||||
// Keep scanning — some connectors throw while probing availability.
|
||||
}
|
||||
}
|
||||
return connectors[0] ?? null
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export default createMiddleware({
|
||||
export const config = {
|
||||
// Only match locale-prefixed routes. `/` is served by `app/page.tsx`
|
||||
// (the landing page) and must not be intercepted by the i18n middleware.
|
||||
matcher: ['/(en|zh)/:path*'],
|
||||
matcher: ['/(en)/:path*'],
|
||||
}
|
||||
@@ -5,6 +5,14 @@ const withNextIntl = createNextIntlPlugin('./i18n.ts')
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/proxy/api/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
Reference in New Issue
Block a user