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:
k
2026-05-27 11:25:59 +08:00
parent ee3648c4cb
commit d01adc4790
23 changed files with 1793 additions and 592 deletions
+158 -95
View File
@@ -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>
+26 -12
View File
@@ -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>
)
})}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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 && (
+91 -5
View File
@@ -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>
</>
)
}
+13 -7
View File
@@ -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)
+1 -1
View File
@@ -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.450.75 = fair value leaning cheap, 0.751.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.450.75 = fair value leaning cheap, 0.751.2 = neutral / no edge, > 1.2 = expensive."
summary={ahrSay}
activeIndex={ahrActive}
thresholds={[
+21 -5
View File
@@ -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%' }}>
+122 -2
View File
@@ -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' }}
/>
)
}
+5 -5
View File
@@ -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',
+26 -5
View File
@@ -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>
)
+33 -22
View File
@@ -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(
+55 -2
View File
@@ -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>
)
+17 -2
View File
@@ -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 }}>
+105 -49
View File
@@ -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
2535% 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 2535% bull correction will scale you out — youre betting on clean explosive runs.'
: '⚠️ Above 2× a normal 2535% 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 (0100)</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 &amp; 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&apos;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">
+11 -1
View File
@@ -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 -1
View File
@@ -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'
+5 -3
View File
@@ -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
View File
@@ -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(),
},
+23
View File
@@ -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
}
+1 -1
View File
@@ -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*'],
}
+8
View File
@@ -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,