feat: revamp dashboard, trades, and add landing/legal pages

- Major UI updates across dashboard, analytics, posts, trades, settings
- New landing page, robots/sitemap, contact/privacy/terms pages
- Updated globals.css with extensive styling and new landing.css
- Refactor signedRequest, realtime data hook, and dashboard store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-04-25 16:04:57 +08:00
parent 83e5892ddf
commit 040e1df685
28 changed files with 4133 additions and 690 deletions
+45 -11
View File
@@ -1,10 +1,10 @@
'use client'
import { useState } from 'react'
import { useAccount, useSignMessage } from 'wagmi'
import { useEffect, useState } from 'react'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import type { BotPerformance } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { setHlApiKey, subscribe } from '@/lib/api'
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
// Action names must match backend/app/api/{user,subscribe}.py
@@ -25,8 +25,9 @@ function fmtHold(s: number) {
}
export default function BotPanel({ performance }: Props) {
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore()
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setBotReadiness, setHlApiKeySet, setSubscribed } = useDashboardStore()
const { address, isConnected } = useAccount()
const { connect, connectors } = useConnect()
const { signMessageAsync } = useSignMessage()
const [apiKey, setApiKey] = useState('')
@@ -35,6 +36,33 @@ export default function BotPanel({ performance }: Props) {
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
const [subError, setSubError] = useState('')
useEffect(() => {
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
return
}
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
getUserPublic(address.toLowerCase())
.then((user) => {
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
})
.catch(() => {})
}, [address, isConnected, setHlApiKeySet, setSubscribed, setBotReadiness])
async function refreshUserState(wallet: string) {
const pub = await getUserPublic(wallet.toLowerCase())
setSubscribed(pub.active)
setHlApiKeySet(pub.hl_api_key_set)
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
return pub
}
async function handleSubscribe() {
if (!address) return
setSubError('')
@@ -48,7 +76,7 @@ export default function BotPanel({ performance }: Props) {
})
setSubState('saving')
await subscribe(env)
setSubscribed(true)
await refreshUserState(address)
setSubState('idle')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
@@ -76,7 +104,8 @@ export default function BotPanel({ performance }: Props) {
})
setSaveState('saving')
const res = await setHlApiKey(env, trimmed)
setHlApiKeySet(true, res.masked_key)
const pub = await refreshUserState(address)
setHlApiKeySet(pub.hl_api_key_set, res.masked_key)
setApiKey('')
setSaveState('success')
} catch (err: unknown) {
@@ -96,13 +125,18 @@ export default function BotPanel({ performance }: Props) {
: saveState === 'success' ? '✓ Saved'
: 'Save key'
function handleConnectWallet() {
const connector = connectors[0]
if (connector) connect({ connector })
}
return (
<>
{/* Bot status card — dark design */}
<div className="bot-status">
<div className="bot-head">
<h3>
<span style={{ width: 8, height: 8, borderRadius: 999, background: isSubscribed ? 'var(--amber)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
<span style={{ width: 8, height: 8, borderRadius: 999, background: botReadiness === 'ready' ? 'var(--amber)' : hlApiKeySet ? 'var(--up)' : isSubscribed ? 'var(--up)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
Auto-trader
</h3>
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
@@ -112,7 +146,7 @@ export default function BotPanel({ performance }: Props) {
<div className="bot-stat">
<div className="k">Net P&amp;L</div>
<div className="v amber">
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString('en-US', { maximumFractionDigits: 0 }) : '—'}
</div>
</div>
<div className="bot-stat">
@@ -134,7 +168,7 @@ export default function BotPanel({ performance }: Props) {
<div className="bot-cta">
{!isConnected && (
<button className="btn amber" style={{ width: '100%' }}
onClick={() => document.querySelector<HTMLButtonElement>('.connect-btn')?.click()}>
onClick={handleConnectWallet}>
Connect wallet
</button>
)}
@@ -155,12 +189,12 @@ export default function BotPanel({ performance }: Props) {
)}
{isConnected && isSubscribed && !hlApiKeySet && (
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
Paste your Hyperliquid API key below to activate
Paste your Hyperliquid API key below to finish setup
</div>
)}
{isConnected && isSubscribed && hlApiKeySet && (
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
Bot active · waiting for next signal
Setup saved · verification still depends on backend
</div>
)}
</div>
+77 -55
View File
@@ -9,9 +9,10 @@ interface ChartPanelProps {
candles?: Candle[]
externalSelectedId?: number | null
onSelectPost?: (id: number | null) => void
onSelectDayPosts?: (posts: TrumpPost[]) => void
}
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost, onSelectDayPosts }: ChartPanelProps) {
const { timeframe } = useDashboardStore()
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<unknown>(null)
@@ -25,6 +26,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
timeframeRef.current = timeframe
const onSelectRef = useRef(onSelectPost)
onSelectRef.current = onSelectPost
const onSelectDayPostsRef = useRef(onSelectDayPosts)
onSelectDayPostsRef.current = onSelectDayPosts
// Detect current theme for chart colors
function getChartColors() {
@@ -41,6 +44,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
useEffect(() => {
if (!containerRef.current || typeof window === 'undefined') return
let destroyed = false
let ro: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
if (destroyed || !containerRef.current) return
@@ -100,38 +105,57 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
return Math.floor(pt / bucketSecs) * bucketSecs === clickBucket
})
.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0))
if (inBucket.length === 0) {
onSelectRef.current?.(null)
return
}
const currentIdx = inBucket.findIndex((p) => p.id === selectedPostIdRef.current)
if (currentIdx >= 0) {
const nextIdx = currentIdx + 1
if (nextIdx >= inBucket.length) {
onSelectRef.current?.(null)
} else {
onSelectRef.current?.(inBucket[nextIdx].id)
}
} else {
onSelectRef.current?.(inBucket[0].id)
// Multiple posts in this candle bucket → show all of them in the right rail
if (inBucket.length > 1 && onSelectDayPostsRef.current) {
const sorted = [...inBucket].sort(
(a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
)
onSelectDayPostsRef.current(sorted)
return
}
// Single post → show detail directly
onSelectRef.current?.(inBucket[0].id)
})
const ro = new ResizeObserver(() => {
ro = new ResizeObserver(() => {
if (containerRef.current && !destroyed) {
chart.applyOptions({ width: containerRef.current.clientWidth })
}
})
ro.observe(containerRef.current)
return () => { ro.disconnect() }
themeObserver = new MutationObserver(() => {
const colors = getChartColors()
chart.applyOptions({
layout: {
background: { color: colors.background },
textColor: colors.textColor,
},
grid: {
vertLines: { color: colors.gridColor },
horzLines: { color: colors.gridColor },
},
rightPriceScale: { borderColor: colors.borderColor },
timeScale: { borderColor: colors.borderColor },
})
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
})
return () => {
destroyed = true
ro?.disconnect()
themeObserver?.disconnect()
fittedRef.current = false
if (chartRef.current) {
// @ts-expect-error lightweight-charts type
@@ -169,48 +193,46 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
return t >= minTime && t <= maxTime
})
if (visible.length > 0) {
const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
}
const candleSpacing = sorted.length > 1 ? sorted[1].time - sorted[0].time : 300
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
const bucketMap = new Map<number, typeof visible>()
for (const p of visible) {
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
bucketMap.get(bucket)!.push(p)
}
bucketMap.forEach((ps) => ps.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0)))
const markers = Array.from(bucketMap.entries())
.sort(([a], [b]) => a - b)
.map(([bucketTime, bPosts]) => {
const isSelected = bPosts.some((p) => p.id === externalSelectedId)
const best = bPosts[0]
const count = bPosts.length
const signalColor = isSelected
? '#f59e0b'
: best.signal === 'short' || best.signal === 'sell'
? '#ef5350'
: best.signal === 'buy'
? '#26a69a'
: '#aaaaaa'
return {
time: bucketTime as number,
position: 'aboveBar' as const,
color: signalColor,
shape: 'circle' as const,
text: count > 1 ? String(count) : '',
size: isSelected ? 2 : count > 1 ? 1.5 : 1,
}
})
// @ts-expect-error lightweight-charts type
series.setMarkers(markers)
const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
}
const candleSpacing = sorted.length > 1 ? sorted[1].time - sorted[0].time : 300
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
const bucketMap = new Map<number, typeof visible>()
for (const p of visible) {
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
bucketMap.get(bucket)!.push(p)
}
bucketMap.forEach((ps) => ps.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0)))
const markers = Array.from(bucketMap.entries())
.sort(([a], [b]) => a - b)
.map(([bucketTime, bPosts]) => {
const isSelected = bPosts.some((p) => p.id === externalSelectedId)
const best = bPosts[0]
const count = bPosts.length
const signalColor = isSelected
? '#f59e0b'
: best.signal === 'short' || best.signal === 'sell'
? '#ef5350'
: best.signal === 'buy'
? '#26a69a'
: '#aaaaaa'
return {
time: bucketTime as number,
position: 'aboveBar' as const,
color: signalColor,
shape: 'circle' as const,
text: count > 1 ? String(count) : '',
size: isSelected ? 2 : count > 1 ? 1.5 : 1,
}
})
// @ts-expect-error lightweight-charts type
series.setMarkers(markers)
if (!fittedRef.current) {
// @ts-expect-error lightweight-charts type
+149 -31
View File
@@ -1,13 +1,20 @@
'use client'
import { useEffect, useState } from 'react'
import type { TrumpPost } from '@/types'
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—'
if (n == null || isNaN(n)) return '—' // null = window not yet closed
const s = n.toFixed(2) + '%'
return n >= 0 ? '+' + s : s
}
function fmtImpactPct(v: number | null | undefined) {
if (v == null || isNaN(v)) return '—'
const s = Math.abs(v).toFixed(2) + '%'
return v >= 0 ? '+' + s : '-' + s
}
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
@@ -18,8 +25,32 @@ function timeAgo(iso: string) {
return Math.floor(h / 24) + 'd'
}
function SourceIcon({ source }: { source: string }) {
if (source === 'x') return <div className="src-ico x">𝕏</div>
/**
* Hydration-safe wrapper for relative time. Returns an empty placeholder on
* SSR / first client render, then the real relative time after mount. Prevents
* the SSR/CSR "5m vs 6m" mismatch error.
*/
function TimeAgo({ iso, suffix = '' }: { iso: string; suffix?: string }) {
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return <span suppressHydrationWarning></span>
return <span>{timeAgo(iso)}{suffix}</span>
}
/**
* Hydration-safe absolute date formatter. Uses fixed 'en-US' locale + options
* so the server/client outputs match once mounted; renders a placeholder before
* mount to avoid timezone mismatches.
*/
function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatOptions }) {
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return <span suppressHydrationWarning></span>
return <span>{new Date(iso).toLocaleString('en-US', opts)}</span>
}
function SourceIcon({ source: _source }: { source: string }) {
// Truth Social only — no X/Twitter support.
return <div className="src-ico truth">T</div>
}
@@ -35,41 +66,128 @@ interface PostRowProps {
}
export default function PostRow({ post, selected, onClick }: PostRowProps) {
const [expanded, setExpanded] = useState(false)
const impact = post.price_impact
function handleClick() {
if (!onClick) setExpanded(e => !e)
onClick?.()
}
return (
<div className={`post-row ${selected ? 'selected' : ''}`} onClick={onClick}>
<SourceIcon source={post.source} />
<div className="post-body">
<div className="meta">
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span>
<span>·</span>
<span>{timeAgo(post.published_at)} ago</span>
<span>·</span>
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
{post.sentiment}
</span>
<div
className={`post-row ${selected ? 'selected' : ''}`}
onClick={handleClick}
>
{/* ── main row ── */}
<div className="post-row-main">
<SourceIcon source={post.source} />
<div className="post-body">
<div className="meta">
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>@realDonaldTrump</span>
<span>·</span>
<TimeAgo iso={post.published_at} suffix=" ago" />
<span>·</span>
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
{post.sentiment}
</span>
</div>
<p className="text" style={expanded ? { display: 'block', overflow: 'visible', WebkitLineClamp: 'unset' } : {}}>
{expanded ? post.text : (post.text.slice(0, 180) + (post.text.length > 180 ? '…' : ''))}
</p>
</div>
<div className="post-aside">
<SignalPill signal={post.signal} />
<div className="impact-mini">
{impact ? (
<>
<span className="tf">1h peak</span>
<span className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}>
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
</span>
</>
) : (
<span className="tf">no data</span>
)}
</div>
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
<span>AI</span>
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>{post.ai_confidence}%</span>
</div>
</div>
<p className="text">{post.text.slice(0, 180)}{post.text.length > 180 ? '…' : ''}</p>
</div>
<div className="post-aside">
<SignalPill signal={post.signal} />
<div className="impact-mini">
{impact ? (
<>
<span className="tf">1h</span>
<span className={`delta ${(impact.m1h ?? 0) >= 0 ? 'up' : 'down'}`}>{fmtPct(impact.m1h)}</span>
</>
) : (
<span className="tf">no data</span>
{/* ── expanded detail ── */}
{expanded && (
<div
className="post-row-detail"
onClick={e => e.stopPropagation()}
>
{/* AI confidence bar */}
<div>
<div className="ai-metric-head">
<span>AI confidence</span>
<strong>{post.ai_confidence}%</strong>
</div>
<div className="confidence-bar">
<div style={{ width: post.ai_confidence + '%' }} />
</div>
</div>
{/* AI reasoning */}
{post.ai_reasoning && (
<div>
<div className="ai-reasoning-label">AI reasoning</div>
<div className="ai-reasoning-card">
{post.ai_reasoning}
</div>
</div>
)}
{/* Price impact — peak move in signal direction per window */}
{impact && (
<div>
<div className="tiny" style={{ marginBottom: 8 }}>Peak move · {impact.asset}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{(['m5', 'm15', 'm1h'] as const).map(key => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
const v = impact[key] // null = window still open
const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
const pending = v == null
return (
<div key={key} style={{
padding: 10,
background: 'var(--bg-sunk)',
borderRadius: 'var(--r-sm)',
border: `1px solid ${pending ? 'var(--amber)' : 'var(--line)'}`,
textAlign: 'center',
opacity: pending ? 0.75 : 1,
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
<div
className={pending ? '' : `delta ${v >= 0 ? 'up' : 'down'}`}
style={{ fontSize: 14, fontWeight: 600, color: pending ? 'var(--ink-3)' : undefined }}
>
{pending ? '…' : fmtImpactPct(v)}
</div>
{!pending && correct != null && (
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
{pending && (
<div style={{ fontSize: 9, marginTop: 4, color: 'var(--amber)' }}>live</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
<span>AI</span>
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>{post.ai_confidence}%</span>
</div>
</div>
)}
</div>
)
}
export { SignalPill, SourceIcon, fmtPct, timeAgo }
export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }
+26 -20
View File
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
function BrandMark() {
return <span className="brand-mark">α</span>
@@ -46,10 +45,12 @@ function ThemeToggle() {
export default function Navbar() {
const pathname = usePathname()
const { address, isConnected } = useAccount()
const { connect } = useConnect()
const { connect, connectors } = useConnect()
const { disconnect } = useDisconnect()
const [mounted, setMounted] = useState(false)
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
useEffect(() => { setMounted(true) }, [])
useEffect(() => { setWalletMenuOpen(false) }, [pathname, address])
const locale = pathname.split('/')[1] || 'en'
const path = '/' + pathname.split('/').slice(2).join('/')
@@ -98,45 +99,50 @@ export default function Navbar() {
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
<button
className="wallet-chip"
onClick={(e) => {
e.stopPropagation()
const menu = (e.currentTarget.nextElementSibling as HTMLElement | null)
if (menu) menu.style.display = menu.style.display === 'block' ? 'none' : 'block'
}}
onClick={() => setWalletMenuOpen(open => !open)}
onBlur={(e) => {
const menu = e.currentTarget.nextElementSibling as HTMLElement | null
setTimeout(() => { if (menu) menu.style.display = 'none' }, 150)
if (!e.currentTarget.parentElement?.contains(e.relatedTarget as Node | null)) {
setWalletMenuOpen(false)
}
}}
aria-expanded={walletMenuOpen}
aria-haspopup="menu"
>
<span className="ava" />
<span className="mono">{shortAddr}</span>
</button>
<div
style={{
display: 'none', position: 'absolute', right: 0, top: 'calc(100% + 6px)',
background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 'var(--r-sm)',
minWidth: 180, padding: 6, zIndex: 1000, boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
}}
>
<div className={`wallet-menu ${walletMenuOpen ? 'open' : ''}`} role="menu">
<button
role="menuitem"
onMouseDown={(e) => {
e.preventDefault()
if (address) navigator.clipboard?.writeText(address)
setWalletMenuOpen(false)
}}
style={{ width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'left', borderRadius: 6, color: 'var(--ink)' }}
>
Copy address
</button>
<button
onMouseDown={(e) => { e.preventDefault(); disconnect() }}
style={{ width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'left', borderRadius: 6, color: 'var(--down)' }}
role="menuitem"
className="danger"
onMouseDown={(e) => {
e.preventDefault()
disconnect()
setWalletMenuOpen(false)
}}
>
Disconnect
</button>
</div>
</div>
) : (
<button className="connect-btn lg" onClick={() => connect({ connector: injected() })}>
<button
className="connect-btn lg"
onClick={() => {
const connector = connectors[0]
if (connector) connect({ connector })
}}
>
Connect wallet
</button>
)}