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
+161 -98
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>
<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="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>
<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>
<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>
<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 {
+290 -271
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,
}: {
isZh: boolean
dateLocale: string
initialChanges?: KolHoldingChange[] | null
}) {
const [data, setData] = useState<KolHoldingChange[]>(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 (
<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,
tickerFilter = null,
}: {
isZh: boolean
dateLocale: string
initialDigest?: KolDigest | null
initialChanges?: KolHoldingChange[] | null
initialItems?: KolDivergence[] | null
tickerFilter?: string | 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)
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 (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 (
<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)}
className={`nav-tab ${days === d ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer', fontSize: 11,
whiteSpace: 'nowrap' }}>
{windowLabel(d, isZh)}
</button>
))}
</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>
{/* 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,
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',
}}>
{meta.label}
</span>
{/* Ticker + direction */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<strong style={{ fontSize: 15 }}>{d.ticker}</strong>
{!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)',
border: '1px solid var(--border)',
}}>
<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>
{/* 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)}
</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)}
</span>
{d.usd_after != null && d.usd_after > 0 &&
<span style={{ color: 'var(--ink-2)', marginLeft: 6 }}>
{formatShortUsd(d.usd_after)}
</span>}
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
{row.talkLine}
</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: 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>
)
})}
<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>
)}
{row.divergenceCount > 0 && (
<span style={{ fontSize: 11, color: '#f59e0b' }}>
{row.divergenceCount} divergence
</span>
)}
</div>
)}
</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 && (
+110 -24
View File
@@ -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 (
<div className="page">
<div className="page-head">
<>
<div className="settings-control-center">
<div>
<h1 className="page-title">{isZh ? '设置' : 'Settings'}</h1>
<PageHint>
Everything that controls your account subscription, Hyperliquid
API key, bot risk parameters, and Telegram alerts.
</PageHint>
<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" */}
@@ -70,25 +126,55 @@ export default function SettingsClient() {
</div>
</div>
{/* The full bot config UI (subscribe, HL key, risk settings, schedule) */}
<BotConfigPanel />
{/* Telegram push alerts — only renders something useful when wallet
connected + server configured + (eventually) subscribed. */}
<TelegramCard />
{/* Legal & support */}
<div className="card" style={{ padding: 20 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 12 }}>
{isZh ? '法律与支持' : 'Legal & support'}
<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>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Link href={href('/privacy')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '隐私政策 →' : 'Privacy Policy →'}</Link>
<Link href={href('/terms')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '服务条款 →' : 'Terms of Service →'}</Link>
<Link href={href('/contact')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '联系我们 →' : 'Contact Us →'}</Link>
{/* The full bot config UI (subscribe, HL key, risk settings, schedule) */}
<BotConfigPanel />
</div>
<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 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 12 }}>
{isZh ? '法律与支持' : 'Legal & support'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Link href={href('/privacy')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '隐私政策 →' : 'Privacy Policy →'}</Link>
<Link href={href('/terms')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '服务条款 →' : 'Terms of Service →'}</Link>
<Link href={href('/contact')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '联系我们 →' : 'Contact Us →'}</Link>
</div>
</div>
</div>
</div>
</>
)
}
+16 -10
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 => {
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)