Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
Big-picture changes since 01be8e7:
New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.
KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.
BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.
Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.
SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.
WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.
OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.
i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.
Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.
PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.
OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.
Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.
PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.
Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).
Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.
SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import type { TrumpPost, BotPerformance, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||||
import { getPrices, getUserPublic } from '@/lib/api'
|
||||
import { getPerformance, getPrices, getUserPublic } from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
|
||||
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 OpenPositions from '@/components/positions/OpenPositions'
|
||||
|
||||
interface Props {
|
||||
initialPosts: TrumpPost[]
|
||||
initialPerformance?: BotPerformance
|
||||
}
|
||||
|
||||
// ── Inline post detail panel shown in the right rail ──────────────────────────
|
||||
@@ -124,19 +128,27 @@ function SelectHint() {
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||
Click any marker on the chart<br/>or a post below to see details
|
||||
Click any marker on the chart or a post below to see details
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main dashboard ─────────────────────────────────────────────────────────────
|
||||
export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
|
||||
export default function DashboardClient({ initialPosts }: Props) {
|
||||
const intlLocale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
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)
|
||||
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
|
||||
const [candles, setCandles] = useState<Candle[]>([])
|
||||
const [chartErr, setChartErr] = useState('')
|
||||
const [chartReload, setChartReload] = useState(0)
|
||||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||||
// For 1D: show all posts from selected day
|
||||
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
|
||||
@@ -146,6 +158,7 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
setPerformance(undefined)
|
||||
return
|
||||
}
|
||||
// Clear account-scoped bot state immediately when the connected wallet
|
||||
@@ -162,6 +175,25 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
.catch(() => {})
|
||||
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setPerformance(undefined)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const env = getCachedViewEnvelope('view_performance', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
|
||||
const data = await getPerformance(address, env)
|
||||
if (!cancelled) setPerformance(data)
|
||||
} catch {
|
||||
if (!cancelled) setPerformance(undefined)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, signMessageAsync])
|
||||
|
||||
usePriceSocket({
|
||||
onPrice: (a, price) => setLivePrice(a, price),
|
||||
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
|
||||
@@ -169,13 +201,31 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
|
||||
useEffect(() => {
|
||||
setCandles([])
|
||||
setChartErr('')
|
||||
getPrices(asset, timeframe)
|
||||
.then(setCandles)
|
||||
.catch(() => {})
|
||||
}, [asset, timeframe])
|
||||
.then(c => { setCandles(c); setChartErr('') })
|
||||
.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
|
||||
const recentPosts = posts.slice(0, 8)
|
||||
|
||||
// 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
|
||||
})()
|
||||
|
||||
// Show actionable signals first (buy/short), then most recent hold/neutral.
|
||||
// Cap at 8 total so the list doesn't get too long.
|
||||
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4)
|
||||
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
|
||||
const recentPosts = [...actionable, ...recentOthers].slice(0, 8)
|
||||
|
||||
const lastCandle = candles[candles.length - 1]
|
||||
// Live price beats the most recent candle close — the candle is only
|
||||
@@ -205,26 +255,67 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
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 winRate = initialPerformance?.win_rate ?? 0
|
||||
const netPnl = initialPerformance?.net_pnl_usd ?? 0
|
||||
const winRate = performance?.win_rate ?? 0
|
||||
const netPnl = performance?.net_pnl_usd ?? 0
|
||||
const hasPriceData = candles.length > 0
|
||||
const hasPerformanceData = Boolean(initialPerformance)
|
||||
const hasPerformanceData = Boolean(performance)
|
||||
|
||||
return (
|
||||
<div className="page wide">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Signal monitor</h1>
|
||||
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
|
||||
<p className="page-sub">
|
||||
{actionablePosts} actionable signals · Auto-trader {botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · {hasPriceData ? 'Live market context' : 'Waiting for market data'}
|
||||
{`${actionablePosts} actionable signals · Auto-trader ${botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · ${hasPriceData ? 'Live market context' : 'Waiting for market data'}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-s">
|
||||
<span className="chip"><span className="live-dot" />Live feed</span>
|
||||
<span className="chip">{totalPosts} posts tracked</span>
|
||||
<span className="chip">{`${totalPosts} posts tracked`}</span>
|
||||
</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}/btc`}
|
||||
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' }}>
|
||||
⚡ BTC bottom-reversal signal
|
||||
</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 BTC signal →
|
||||
</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">
|
||||
@@ -238,17 +329,17 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
<div className="kpi">
|
||||
<div className="label">Signals today</div>
|
||||
<div className="value">{signalsToday}</div>
|
||||
<div className="foot"><span>{actionablePosts} actionable total</span></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="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">{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div className="foot"><span>{hasPerformanceData ? `${initialPerformance?.total_trades ?? 0} trades` : 'Waiting for trade history'}</span></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>
|
||||
</div>
|
||||
|
||||
@@ -283,6 +374,15 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chartErr && (
|
||||
<div style={{ padding: '8px 12px', margin: '0 0 8px', fontSize: 12,
|
||||
color: 'var(--down)', background: 'var(--down-soft, rgba(220,38,38,.08))',
|
||||
borderRadius: 8 }}>
|
||||
⚠️ {asset} {timeframe} chart failed to load — {chartErr}{' '}
|
||||
<button className="btn ghost" style={{ fontSize: 11, padding: '3px 10px', marginLeft: 6 }}
|
||||
onClick={() => setChartReload(n => n + 1)}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-wrap">
|
||||
<ChartPanel
|
||||
posts={posts}
|
||||
@@ -311,7 +411,10 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<h2>Recent signals</h2>
|
||||
<span className="hint">Showing {recentPosts.length} of {totalPosts}</span>
|
||||
<span className="hint">
|
||||
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
|
||||
{`${totalPosts} tracked`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="post-stream">
|
||||
{recentPosts.map(p => (
|
||||
@@ -429,6 +532,9 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
) : (
|
||||
<SelectHint />
|
||||
)}
|
||||
|
||||
{/* Breakout signal monitor */}
|
||||
<SignalMonitor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user