From d01adc479048043e9a914f959b0a83c5c0083341 Mon Sep 17 00:00:00 2001 From: k Date: Wed, 27 May 2026 11:25:59 +0800 Subject: [PATCH] style(dashboard): unify Macro composite into one card + dark-mode fix for Performance accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
- 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 --- app/[locale]/DashboardClient.tsx | 259 ++++--- .../analytics/AnalyticsPageClient.tsx | 38 +- app/[locale]/btc/page.tsx | 10 + app/[locale]/globals.css | 696 +++++++++++++++++- app/[locale]/kol/KolPageClient.tsx | 561 +++++++------- app/[locale]/settings/SettingsClient.tsx | 134 +++- app/[locale]/trades/TradesPageClient.tsx | 26 +- components/btc/MacroPanel.tsx | 2 +- components/dashboard/BotPanel.tsx | 32 +- components/dashboard/ChartPanel.tsx | 124 +++- components/nav/LanguageSwitch.tsx | 10 +- components/nav/Navbar.tsx | 117 +-- components/positions/OpenPositions.tsx | 55 +- components/signals/SystemControl.tsx | 81 +- components/telegram/TelegramCard.tsx | 19 +- components/trades/BotConfigPanel.tsx | 154 ++-- components/wallet/SignConfirmSheet.tsx | 12 +- i18n.ts | 2 +- lib/signedRequest.ts | 8 +- lib/wagmi.ts | 12 +- lib/walletConnect.ts | 23 + proxy.ts => middleware.ts | 2 +- next.config.mjs | 8 + 23 files changed, 1793 insertions(+), 592 deletions(-) create mode 100644 app/[locale]/btc/page.tsx create mode 100644 lib/walletConnect.ts rename proxy.ts => middleware.ts (91%) diff --git a/app/[locale]/DashboardClient.tsx b/app/[locale]/DashboardClient.tsx index 9a37c18..27118e3 100644 --- a/app/[locale]/DashboardClient.tsx +++ b/app/[locale]/DashboardClient.tsx @@ -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(initialPosts) @@ -151,6 +151,7 @@ export default function DashboardClient({ initialPosts }: Props) { const [chartErr, setChartErr] = useState('') const [chartReload, setChartReload] = useState(0) const [selectedPostId, setSelectedPostId] = useState(null) + const [macro, setMacro] = useState(null) // For 1D: show all posts from selected day const [selectedDayPosts, setSelectedDayPosts] = useState(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 (
@@ -277,72 +303,124 @@ export default function DashboardClient({ initialPosts }: Props) {
- {/* Pinned BTC bottom-reversal alert — the rarest / highest-conviction - signal. Always sits ABOVE everything so it's never missed. */} - {btcReversalAlert && ( - -
- - ⚡ Macro Vibes · Bottom trigger - - - conf {Math.round(btcReversalAlert.ai_confidence)} - - - {timeAgo(btcReversalAlert.published_at)} - - - Open Macro Vibes → - -
-
- {btcReversalAlert.text} -
-
- )} - {/* Open positions — what's on the book right now. Renders only when a subscribed wallet is connected, so guests see the normal feed. */} - {/* KPI Row */} -
-
-
BTC
-
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
-
- = 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} - 24h +
+
+
+
+
+
Market and macro
+
+
+ {displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'} +
+ = 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h +
+
BTC spot with live signal context
+
+
+
+ + +
+
+ {(['5m', '15m', '1H', '4H', '1D'] as const).map(t => ( + + ))} +
+
+
+ +
+
+
+
+
Macro composite
+
today · 8 indicators
+
+
{macroSummary}
+
+
+
+ {macroScore == null ? '—' : `${macroScore > 0 ? '+' : ''}${macroScore.toFixed(1)}`} +
+
{macroRegime ?? 'Waiting'}
+
+
+
+
+
+
+ −100 bear + 0 neutral + +100 bull +
+
+ +
+ + Trump + {trumpActionable} + + + Macro + {macroActionable} + + + KOL + {kolMentions} + +
+ Signals today + {signalsToday} +
+
+
+ +
+
+
Execution
+
{actionablePosts}
+
actionable signals tracked in feed
+
+
+
Performance
+
{hasPerformanceData ? `${netPnl >= 0 ? '+$' : '-$'}${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}` : '—'}
+
{hasPerformanceData ? '30d bot net P&L' : 'Load settings once to unlock private performance'}
+
-
-
Signals today
-
{signalsToday}
-
{`${actionablePosts} actionable total`}
-
-
-
30d Net P&L
-
{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}
-
{hasPerformanceData ? 'Bot performance' : 'Performance pending'}
-
-
-
Win rate
-
{performance ? (winRate * 100).toFixed(1) + '%' : '—'}
-
{hasPerformanceData ? `${performance?.total_trades ?? 0} trades` : 'Connect wallet to load your trade history'}
-
+ +
@@ -359,21 +437,7 @@ export default function DashboardClient({ initialPosts }: Props) { = 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h
-
-
- - -
-
- {(['5m', '15m', '1H', '4H', '1D'] as const).map(t => ( - - ))} -
-
+
Live chart with signal markers
{chartErr && ( @@ -405,7 +469,8 @@ export default function DashboardClient({ initialPosts }: Props) {
Buy signal
Short signal
Hold / filtered
-
Click marker to see details →
+ {asset === 'BTC' &&
Macro reversal highlight
} +
Binance candles via backend API · click marker to inspect
@@ -535,8 +600,6 @@ export default function DashboardClient({ initialPosts }: Props) { )} - {/* Breakout signal monitor */} - diff --git a/app/[locale]/analytics/AnalyticsPageClient.tsx b/app/[locale]/analytics/AnalyticsPageClient.tsx index 4961427..6dbcb41 100644 --- a/app/[locale]/analytics/AnalyticsPageClient.tsx +++ b/app/[locale]/analytics/AnalyticsPageClient.tsx @@ -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(null) const [trades, setTrades] = useState([]) const [accuracy, setAccuracy] = useState(null) const [period, setPeriod] = useState('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() { + {privateLocked && ( +
+
Private performance is locked.
+
+ Load your settings once to unlock cached trade history and wallet-specific performance. Public signal-accuracy data below remains available. +
+
+ )} +
@@ -189,7 +203,7 @@ export default function AnalyticsPageClient() { return (
{w.replace('m','').replace('1h','1h')} - {pct.toFixed(0)}% + {fmtAccuracyPct(pct)}
) })} @@ -213,7 +227,7 @@ export default function AnalyticsPageClient() { return (
{w} - {pct.toFixed(0)}% + {fmtAccuracyPct(pct)}
) })} diff --git a/app/[locale]/btc/page.tsx b/app/[locale]/btc/page.tsx new file mode 100644 index 0000000..216ec77 --- /dev/null +++ b/app/[locale]/btc/page.tsx @@ -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`) +} diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css index 429b0c0..1847506 100644 --- a/app/[locale]/globals.css +++ b/app/[locale]/globals.css @@ -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 { diff --git a/app/[locale]/kol/KolPageClient.tsx b/app/[locale]/kol/KolPageClient.tsx index 184a41a..714f8e6 100644 --- a/app/[locale]/kol/KolPageClient.tsx +++ b/app/[locale]/kol/KolPageClient.tsx @@ -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({
- KOL signal digest + What KOLs are pushing now
{data - ? `${data.post_count} posts · ${data.ticker_count} assets` + ? `${data.post_count} posts analysed · ${data.ticker_count} assets with repeat mentions` : 'Loading…'}
@@ -185,9 +210,19 @@ function DigestWidget({
)} {!loading && !err && data && data.tickers.length > 0 && ( -
+
{data.tickers.map(t => onTickerClick(t.ticker)} />)} + key={t.ticker} + t={t} + isZh={isZh} + active={activeTicker === t.ticker} + hasActive={!!activeTicker} + onClick={() => onTickerClick(t.ticker)} + />)}
)}
@@ -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} > -
- +
+ {t.ticker} + {active && ( + + ✓ + + )} {actionLabel}
-
- {t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}% +
+ {digestSideCopy(t.side)} +
+
+ {t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}% max conviction
{t.kols.map(k => '@' + k).join(', ')}
+ {active && ( +
+ Filtering the page by {t.ticker} +
+ )} ) } -// ── On-chain changes widget ─────────────────────────────────────────────── - const CHANGE_COLOR: Record = { new_position: '#16a34a', increased: '#22c55e', @@ -256,162 +319,134 @@ const CHANGE_COLOR: Record = { closed: '#dc2626', } -function OnchainWidget({ +function WalletCheckWidget({ isZh, dateLocale, + initialDigest = null, initialChanges = null, -}: { - isZh: boolean - dateLocale: string - initialChanges?: KolHoldingChange[] | null -}) { - const [data, setData] = useState(initialChanges ?? []) - const [loading, setLoading] = useState(initialChanges === null) - const [days, setDays] = useState(7) - - useEffect(() => { - if (initialChanges === null || days !== 7) { - setLoading(true) - } - 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 ( -
-
-
-
- On-chain positioning -
-
- {loading - ? 'Loading…' - : data.length === 0 - ? 'No tracked wallets yet. Add KOL addresses and this feed will populate automatically.' - : `${data.length} changes`} -
-
-
- {([1, 7, 30] as const).map(d => ( - - ))} -
-
- - {!loading && data.length === 0 && ( -
- <> - Add tracked wallets through POST /api/kol/wallets, - or use{' '} - Arkham Intelligence{' '} - to discover labeled addresses. Ethereum, Solana, and Hyperliquid are supported. - -
- )} - - {!loading && data.length > 0 && ( -
- {data.slice(0, 20).map(c => { - const color = CHANGE_COLOR[c.change_type] - const label = changeLabel(c.change_type, isZh) - return ( -
- - {label} - - {c.ticker} - - @{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)}%)`} - - - {new Date(c.detected_at).toLocaleDateString(dateLocale)} - -
- ) - })} -
- )} -
- ) -} - -// ── Talks-vs-trades divergence widget ──────────────────────────────────────── -const DIR_COLOR = { long: '#16a34a', short: '#dc2626' } - -function DivergenceWidget({ - isZh, - dateLocale, initialItems = null, + tickerFilter = null, }: { isZh: boolean dateLocale: string + initialDigest?: KolDigest | null + initialChanges?: KolHoldingChange[] | null initialItems?: KolDivergence[] | null + tickerFilter?: string | null }) { - const [data, setData] = useState(initialItems ?? []) - const [loading, setLoading] = useState(initialItems === null) - const [filter, setFilter] = useState<'all' | 'divergence' | 'alignment'>('all') - const [days, setDays] = useState(30) + const [digest, setDigest] = useState(initialDigest) + const [changes, setChanges] = useState(initialChanges ?? []) + const [items, setItems] = useState(initialItems ?? []) + const [loading, setLoading] = useState( + initialDigest === null || initialChanges === null || initialItems === null, + ) + const [days, setDays] = useState(7) useEffect(() => { - if (initialItems === null || filter !== 'all' || days !== 30) { - setLoading(true) - } - swrFetch( - `kol-divergence-${filter}-${days}`, - 30 * 60_000, - () => getKolDivergence({ signal_type: filter === 'all' ? undefined : filter, days }), - fresh => setData(fresh.items), - ) - .then(r => setData(r.items)) - .catch(() => setData([])) + 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 => setChanges(fresh.changes), + ), + swrFetch( + `kol-divergence-all-${days}`, + 30 * 60_000, + () => 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 (
- {/* Header */}
- Talks vs trades + Talks vs wallets
{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`}
-
- {/* filter tabs */} -
- {(['all', 'divergence', 'alignment'] as const).map(f => ( - - ))} -
- {/* day tabs */} -
- {([7, 30, 90] as const).map(d => ( - - ))} -
+
+ {([1, 7, 30] as const).map(d => ( + + ))}
- {/* Rows */} - {!loading && data.length === 0 && ( + {!loading && rows.length === 0 && (
- <> - 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.
- 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.
)} - {!loading && data.length > 0 && ( -
- {data.slice(0, 20).map(d => { - const meta = divergenceMeta(d.signal_type, isZh) - const dirColor = DIR_COLOR[d.direction] - return ( -
- {/* Signal badge */} - - {meta.label} - - - {/* Ticker + direction */} -
- {d.ticker} + {!loading && rows.length > 0 && ( +
+ {rows.map(row => ( +
+
+
+ {row.ticker} - {directionLabel(d.direction, isZh)} + {row.verdict.label}
- - {/* KOL + actions — flex: 1 1 0 with minWidth:0 so it shrinks on narrow screens */} -
- @{d.handle} - {' '} - - {postActionLabel(d.post_action, isZh)} - - {isZh ? 'vs' : 'vs'} - - {chainActionLabel(d.onchain_action, isZh)} - - {d.usd_after != null && d.usd_after > 0 && - - {formatShortUsd(d.usd_after)} - } +
+ {row.talkLine}
- - {/* Time info */} -
-
- {`${d.days_apart?.toFixed(1) ?? '?'} days apart`} -
-
{new Date(d.onchain_at).toLocaleDateString(dateLocale)}
+
+ {row.speakers} +
+
+ {row.conviction}
- ) - })} + +
+
+ Wallet evidence +
+
+ {row.latestMatch + ? `@${row.latestMatch.handle} ${chainActionLabel(row.latestMatch.onchain_action, isZh).toLowerCase()}` + : row.latestChange + ? compactChangeCopy(row.latestChange) + : 'No tracked move yet'} +
+
+ {row.latestMatch + ? `${row.verdict.note} ${row.latestMatch.usd_after ? `Size ${formatShortUsd(row.latestMatch.usd_after)}.` : ''}` + : row.verdict.note} +
+ {(row.divergenceCount > 0 || row.alignmentCount > 0) && ( +
+ {row.alignmentCount > 0 && ( + + {row.alignmentCount} alignment + + )} + {row.divergenceCount > 0 && ( + + {row.divergenceCount} divergence + + )} +
+ )} +
+ +
+ {row.latestMatch + ? new Date(row.latestMatch.onchain_at).toLocaleDateString(dateLocale) + : row.latestChange + ? new Date(row.latestChange.detected_at).toLocaleDateString(dateLocale) + : 'No date'} +
+
+ ))}
)}
@@ -804,10 +827,8 @@ export default function KolPage({

{isZh ? 'KOL 信号' : 'KOL Signals'}

- 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.
@@ -815,20 +836,18 @@ export default function KolPage({ setTickerFilter(prev => prev === sym ? null : sym)} /> - - - {tickerFilter && ( diff --git a/app/[locale]/settings/SettingsClient.tsx b/app/[locale]/settings/SettingsClient.tsx index 8978b42..f3d667e 100644 --- a/app/[locale]/settings/SettingsClient.tsx +++ b/app/[locale]/settings/SettingsClient.tsx @@ -29,17 +29,73 @@ 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 ( -
-
+ <> +
-

{isZh ? '设置' : 'Settings'}

- - Everything that controls your account — subscription, Hyperliquid - API key, bot risk parameters, and Telegram alerts. - +
Control center
+
One place to arm, limit, and verify the bot
+
+ Subscription, execution permissions, per-system risk, and alert delivery all live here. +
+
+
+ Wallet + {walletLabel} +
+
+ Private data + {mounted && isConnected ? 'Ready to unlock' : 'Connect first'} +
+
+ Main actions + Load settings, save limits, link API +
+
+
+ +
+ + Everything that controls your account — subscription, Hyperliquid + API key, bot risk parameters, and Telegram alerts. + +
+ Trump settings control event-driven entries. Macro Vibes settings control BTC manage-only behavior. Global limits apply account-wide. +
+
+ +
+
+
Trump Signal
+
Event-driven entry settings
+
+ Position size, Trump leverage, and minimum AI confidence used when a Truth Social post becomes actionable. +
+ Open panel ↓ +
+ +
+
Macro Vibes
+
BTC manage-only settings
+
+ Strategy mode, BTC leverage, and de-risk behavior used by the Macro Vibes sleeve. +
+ Open panel ↓ +
+ +
+
Global
+
Account-wide execution controls
+
+ Subscription, Hyperliquid API wallet, manual window, schedule, and guardrails that apply across the account. +
+ Open panel ↓ +
{/* Account card — quick "who am I logged in as" */} @@ -70,25 +126,55 @@ export default function SettingsClient() {
- {/* The full bot config UI (subscribe, HL key, risk settings, schedule) */} - - - {/* Telegram push alerts — only renders something useful when wallet - connected + server configured + (eventually) subscribed. */} - - - {/* Legal & support */} -
-
- {isZh ? '法律与支持' : 'Legal & support'} +
+
+
+
Execution setup
+
Trading permissions and risk controls
+
+
+ Load once, then adjust by module without leaving the page. +
-
- {isZh ? '隐私政策 →' : 'Privacy Policy →'} - {isZh ? '服务条款 →' : 'Terms of Service →'} - {isZh ? '联系我们 →' : 'Contact Us →'} + + {/* The full bot config UI (subscribe, HL key, risk settings, schedule) */} + +
+ +
+
+
+
Delivery
+
Telegram alerts
+
+
+ Bind notifications after the trading path is configured. +
+
+ +
+ +
+
+
+
Support
+
Legal and contact
+
+
+ + {/* Legal & support */} +
+
+ {isZh ? '法律与支持' : 'Legal & support'} +
+
+ {isZh ? '隐私政策 →' : 'Privacy Policy →'} + {isZh ? '服务条款 →' : 'Terms of Service →'} + {isZh ? '联系我们 →' : 'Contact Us →'} +
-
+ ) } diff --git a/app/[locale]/trades/TradesPageClient.tsx b/app/[locale]/trades/TradesPageClient.tsx index e777a06..08f3450 100644 --- a/app/[locale]/trades/TradesPageClient.tsx +++ b/app/[locale]/trades/TradesPageClient.tsx @@ -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 => { - failed = true - setLoadErr(e instanceof Error ? e.message : (isZh ? '交易加载失败' : 'Failed to load trades')) - return [] as BotTrade[] - }), + 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) diff --git a/components/btc/MacroPanel.tsx b/components/btc/MacroPanel.tsx index 44021b4..d7bb9ac 100644 --- a/components/btc/MacroPanel.tsx +++ b/components/btc/MacroPanel.tsx @@ -406,7 +406,7 @@ export default function MacroPanel() { label="AHR999" value={ind.ahr999?.toFixed(4) ?? '—'} tone={toneAhr(ind.ahr999)} - hint="BTC valuation index: price vs 200d MA × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.45–0.75 = fair value leaning cheap, 0.75–1.2 = neutral / no edge, > 1.2 = expensive." + hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.45–0.75 = fair value leaning cheap, 0.75–1.2 = neutral / no edge, > 1.2 = expensive." summary={ahrSay} activeIndex={ahrActive} thresholds={[ diff --git a/components/dashboard/BotPanel.tsx b/components/dashboard/BotPanel.tsx index f386277..0344fd3 100644 --- a/components/dashboard/BotPanel.tsx +++ b/components/dashboard/BotPanel.tsx @@ -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) {
{(!mounted || !isConnected) && ( - + <> + + {connectError && ( +

{connectError}

+ )} + )} {mounted && isConnected && !isSubscribed && (
diff --git a/components/dashboard/ChartPanel.tsx b/components/dashboard/ChartPanel.tsx index 11917d5..5ef1432 100644 --- a/components/dashboard/ChartPanel.tsx +++ b/components/dashboard/ChartPanel.tsx @@ -17,6 +17,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI const containerRef = useRef(null) const chartRef = useRef(null) const seriesRef = useRef(null) + const macroPriceLineRef = useRef(null) + const macroVerticalRef = useRef(null) + const macroLabelRef = useRef(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 (
) } diff --git a/components/nav/LanguageSwitch.tsx b/components/nav/LanguageSwitch.tsx index 5ea0c8f..35d4028 100644 --- a/components/nav/LanguageSwitch.tsx +++ b/components/nav/LanguageSwitch.tsx @@ -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(() => { 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', diff --git a/components/nav/Navbar.tsx b/components/nav/Navbar.tsx index c9f2c48..b6ff778 100644 --- a/components/nav/Navbar.tsx +++ b/components/nav/Navbar.tsx @@ -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,58 +147,60 @@ export default function Navbar() {
{/* — shelved until i18n coverage is complete */} - {!mounted ? ( - - ) : isConnected && shortAddr ? ( -
- -
+
+ {!mounted ? ( + + ) : isConnected && shortAddr ? ( +
- +
+ + +
-
- ) : ( - - )} + ) : ( + + )} + {!isConnected && connectError ? ( +

{connectError}

+ ) : null} +
) diff --git a/components/positions/OpenPositions.tsx b/components/positions/OpenPositions.tsx index dbda3ad..026cca4 100644 --- a/components/positions/OpenPositions.tsx +++ b/components/positions/OpenPositions.tsx @@ -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(null) const [today, setToday] = useState(null) const [err, setErr] = useState(null) + const [needsUnlock, setNeedsUnlock] = useState(false) // Close-confirmation modal state const [closing, setClosing] = useState(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 ( +
+
+ Open positions +
+
+ Load your settings once to unlock private position data. +
+
+ 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. +
+
+ ) + } if (positions === null && today === null) return null const totalUnrealized = (positions ?? []).reduce( diff --git a/components/signals/SystemControl.tsx b/components/signals/SystemControl.tsx index 5909106..ed4ebab 100644 --- a/components/signals/SystemControl.tsx +++ b/components/signals/SystemControl.tsx @@ -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,24 +138,49 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { if (!isConnected) { return (
- - - {s.name} Auto-Trade - - · - - OFF — connect wallet (top-right) to enable - +
+ + + {s.name} Auto-Trade + + · + + OFF — connect wallet (top-right) to enable + +
+ + + Settings + + {settingsLabel} + +
) } @@ -276,6 +304,31 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) { color: netOk ? 'var(--up)' : 'var(--down)' }}> {netOk ? '✅ ' : '⛔ '}{netMsg}
+
+ + + Settings + + {settingsLabel} + + +
) diff --git a/components/telegram/TelegramCard.tsx b/components/telegram/TelegramCard.tsx index f0dfc6f..70d575b 100644 --- a/components/telegram/TelegramCard.tsx +++ b/components/telegram/TelegramCard.tsx @@ -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 ( +
+
+ Telegram alerts +
+
+ Connect your wallet first. After that, you can link Telegram for alert delivery and Pro wallet-bound notifications. +
+
+ ) + } return (
diff --git a/components/trades/BotConfigPanel.tsx b/components/trades/BotConfigPanel.tsx index 435b106..87cbfc9 100644 --- a/components/trades/BotConfigPanel.tsx +++ b/components/trades/BotConfigPanel.tsx @@ -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() {

{isZh ? '连接钱包后即可配置机器人并查看你的交易。' : 'Connect your wallet to configure the bot and view your trades.'}

- + + {connectErr ? ( +

{connectErr}

+ ) : null}
) } @@ -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 ( + + {label} + + ) + } + // ── Hyperliquid key strip ───────────────────────────────────────────────── const hlKeyStrip = (
@@ -537,12 +565,12 @@ export default function BotConfigPanel() { })()} {/* ── Main settings card ── */} -
+
{/* Header bar */}
Trading bot
-
Executes on Hyperliquid when a Trump post clears your filters.
+
Controls Trump Signal entries, Macro Vibes BTC management, and shared execution limits on Hyperliquid.
{dirty && isSubscribed && ● Unsaved} @@ -688,14 +716,17 @@ export default function BotConfigPanel() { ) : (
{/* ── EXECUTION ── */} -
- Execution +
+ Trump Signal settings
+
+ Event-driven trade sizing, leverage, and signal threshold for Trump-triggered entries. +
- Per-trade size + Per-trade size Notional in USD. margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×
@@ -710,7 +741,7 @@ export default function BotConfigPanel() {
- Leverage (Trump / System 1) + Leverage (Trump / System 1) Event-driven scalp positions
@@ -726,43 +757,71 @@ export default function BotConfigPanel() { {(() => { const aggressive = settings.sys2_mode === 'aggressive' return ( + <> +
+ Macro Vibes settings +
+
+
+ Manage-only BTC mode, leverage, and pyramiding behavior for the Macro Vibes system. +
- BTC strategy mode (System 2) + BTC strategy mode (System 2) - A separately-funded sleeve. Aggressive = - high leverage + earlier/heavier pyramiding + wider - trailing → explosive in a clean bull, but a normal - 25–35% correction will scale you out hard. Both modes - stay inside the liquidation line. + Choose how hard the BTC system presses winners. Standard is calmer. Aggressive adds more leverage and wider pyramiding.
-
+
{aggressive && ( -
- ⚠️ 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. +
+ High-risk sleeve. Uses more leverage, pyramids harder, and gives back more off the peak. Best treated as a separate risk bucket.
)}
+ ) })()} @@ -778,12 +837,9 @@ export default function BotConfigPanel() { return (
- BTC bottom leverage (System 2) + BTC bottom leverage (System 2) - Independent of Trump. Wrong → scales OUT in 3 stages - inside the liquidation line (never - exchange-liquidated). 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.
@@ -793,16 +849,13 @@ export default function BotConfigPanel() {
10×
{lev}× -
- 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)}%).{' '} +
+ At {lev}× it sheds {e1} near −{(prot * 0.6).toFixed(0)}%, {e2} near −{(prot * 0.8).toFixed(0)}%, and is fully out by −{prot.toFixed(0)}%. + Exchange liquidation would be around −{liq.toFixed(0)}%. + {' '} {risky - ? (aggressive - ? '🔥 Aggressive: a normal 25–35% bull correction will scale you out — you’re betting on clean explosive runs.' - : '⚠️ Above 2× a normal 25–35% bull correction can scale you out before the top. To ride a super-bull, keep ≤2× — amplify via pyramiding, not leverage.') - : 'Wide enough to ride normal bull corrections; amplification comes from pyramiding.'} + ? 'Above 2×, a normal bull-market correction can push you out early.' + : 'This is wide enough to survive a normal correction; extra upside should come from pyramiding, not leverage.'}
@@ -811,7 +864,7 @@ export default function BotConfigPanel() {
- Min AI confidence + Min AI confidence Skip any signal below this score (0–100)
@@ -825,14 +878,17 @@ export default function BotConfigPanel() {
{/* ── LIMITS ── */} -
- Limits & risk +
+ Global risk limits
+
+ Shared safety rules that cap or gate automated trading across the account. +
- Daily budget * + Daily budget * Stop opening new trades once the day's total notional reaches this (UTC). Required.
@@ -849,7 +905,7 @@ export default function BotConfigPanel() {
- Take profit * + Take profit * Auto-close when unrealised gain hits target. Required.
@@ -865,7 +921,7 @@ export default function BotConfigPanel() {
- Stop loss * + Stop loss * Auto-close when drawdown hits limit. Required.
diff --git a/components/wallet/SignConfirmSheet.tsx b/components/wallet/SignConfirmSheet.tsx index 383198b..d016e68 100644 --- a/components/wallet/SignConfirmSheet.tsx +++ b/components/wallet/SignConfirmSheet.tsx @@ -30,13 +30,23 @@ export interface SignConfirmOptions { /** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */ export function confirmSign(opts: SignConfirmOptions): Promise { 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) } diff --git a/i18n.ts b/i18n.ts index c71b0a2..3755049 100644 --- a/i18n.ts +++ b/i18n.ts @@ -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' diff --git a/lib/signedRequest.ts b/lib/signedRequest.ts index 38e19a3..d444583 100644 --- a/lib/signedRequest.ts +++ b/lib/signedRequest.ts @@ -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) diff --git a/lib/wagmi.ts b/lib/wagmi.ts index ae2b5b5..47f746f 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -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(), }, diff --git a/lib/walletConnect.ts b/lib/walletConnect.ts new file mode 100644 index 0000000..5f9b6a2 --- /dev/null +++ b/lib/walletConnect.ts @@ -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 { + 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 +} diff --git a/proxy.ts b/middleware.ts similarity index 91% rename from proxy.ts rename to middleware.ts index 221d2c7..f2a56e2 100644 --- a/proxy.ts +++ b/middleware.ts @@ -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*'], } diff --git a/next.config.mjs b/next.config.mjs index c4e168e..9345373 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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,