From 83e5892ddfc25369eaf3661e6083f301c53b43a5 Mon Sep 17 00:00:00 2001 From: k Date: Tue, 21 Apr 2026 19:32:53 +0800 Subject: [PATCH] done --- .claude/launch.json | 11 + app/[locale]/DashboardClient.tsx | 309 +++++++- app/[locale]/analytics/page.tsx | 101 ++- app/[locale]/globals.css | 909 ++++++++++++++++++++++- app/[locale]/layout.tsx | 21 +- app/[locale]/page.tsx | 6 +- app/[locale]/posts/page.tsx | 126 +++- app/[locale]/settings/SettingsClient.tsx | 328 +++++++- app/[locale]/settings/page.tsx | 14 +- app/[locale]/trades/page.tsx | 182 ++++- components/dashboard/BotPanel.tsx | 320 +++++--- components/dashboard/ChartPanel.tsx | 207 +++--- components/dashboard/ExpandDetail.tsx | 87 --- components/dashboard/KpiRow.tsx | 77 -- components/dashboard/PostCards.tsx | 220 ++---- components/nav/Navbar.tsx | 171 +++-- components/trades/TradesTable.tsx | 147 ---- components/ui/Badge.tsx | 36 - components/ui/Card.tsx | 16 - components/ui/Pill.tsx | 23 - lib/api.ts | 78 +- lib/signedRequest.ts | 93 +++ lib/useRealtimeData.ts | 4 +- store/dashboard.ts | 7 + types/index.ts | 5 + 25 files changed, 2582 insertions(+), 916 deletions(-) create mode 100644 .claude/launch.json delete mode 100644 components/dashboard/ExpandDetail.tsx delete mode 100644 components/dashboard/KpiRow.tsx delete mode 100644 components/trades/TradesTable.tsx delete mode 100644 components/ui/Badge.tsx delete mode 100644 components/ui/Card.tsx delete mode 100644 components/ui/Pill.tsx create mode 100644 lib/signedRequest.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..0c80573 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "trumpsignal", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3001 + } + ] +} diff --git a/app/[locale]/DashboardClient.tsx b/app/[locale]/DashboardClient.tsx index fee534f..b7ba785 100644 --- a/app/[locale]/DashboardClient.tsx +++ b/app/[locale]/DashboardClient.tsx @@ -1,64 +1,315 @@ 'use client' import { useState, useEffect } from 'react' -import type { TrumpPost, BotTrade, BotPerformance, Candle } from '@/types' +import { useAccount } from 'wagmi' +import type { TrumpPost, BotPerformance, Candle } from '@/types' import { useDashboardStore } from '@/store/dashboard' import { usePriceSocket } from '@/lib/useRealtimeData' -import { getPrices } from '@/lib/api' -import KpiRow from '@/components/dashboard/KpiRow' +import { getPrices, getUserPublic } from '@/lib/api' import ChartPanel from '@/components/dashboard/ChartPanel' import BotPanel from '@/components/dashboard/BotPanel' -import TradesTable from '@/components/trades/TradesTable' +import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards' interface Props { initialPosts: TrumpPost[] initialPerformance?: BotPerformance - initialTrades: BotTrade[] } -export default function DashboardClient({ initialPosts, initialPerformance, initialTrades }: Props) { - const { asset, timeframe, setLivePrice } = useDashboardStore() +// ── Inline post detail panel shown in the right rail ────────────────────────── +function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) { + const impact = post.price_impact + + function fmtImpactPct(v: number | null | undefined) { + if (v == null || isNaN(v)) return '—' + const s = Math.abs(v).toFixed(2) + '%' + return v >= 0 ? '+' + s : '-' + s + } + + return ( +
+ {/* header */} +
+ Signal detail + +
+ + {/* source + time */} +
+ +
+
@realDonaldTrump
+
{timeAgo(post.published_at)} ago · {new Date(post.published_at).toLocaleString()}
+
+
+ + {/* post text */} +

{post.text}

+ + {/* signal + sentiment */} +
+ + + {post.sentiment} + +
+ + {/* AI confidence */} +
+ AI confidence + {post.ai_confidence}% +
+
+
+
+ + {/* AI reasoning */} + {post.ai_reasoning && ( + <> +
AI reasoning
+
+ {post.ai_reasoning} +
+ + )} + + {/* Price impact grid */} + {impact && ( + <> +
Price impact · {impact.asset}
+
+ {([['m5', '5m'], ['m15', '15m'], ['m1h', '1h']] as const).map(([key, label]) => { + const v = impact[key] + const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h'] + return ( +
+
{label}
+
= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}> + {fmtImpactPct(v)} +
+ {correct != null && ( +
+ {correct ? '✓' : '✗'} +
+ )} +
+ ) + })} +
+ + )} +
+ ) +} + +// ── Empty state for when nothing is selected ────────────────────────────────── +function SelectHint() { + return ( +
+ + + + +

+ Click any marker on the chart
or a post below to see details +

+
+ ) +} + +// ── Main dashboard ───────────────────────────────────────────────────────────── +export default function DashboardClient({ initialPosts, initialPerformance }: Props) { + const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setHlApiKeySet, isSubscribed } = useDashboardStore() + function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') } + const { address, isConnected } = useAccount() const [posts, setPosts] = useState(initialPosts) const [candles, setCandles] = useState([]) - const [candlesLoading, setCandlesLoading] = useState(false) + const [selectedPostId, setSelectedPostId] = useState(null) + + useEffect(() => { + if (!isConnected || !address) { + setSubscribed(false) + setHlApiKeySet(false) + return + } + getUserPublic(address.toLowerCase()) + .then((user) => { + setSubscribed(user.active) + setHlApiKeySet(user.hl_api_key_set) + }) + .catch(() => {}) + }, [address, isConnected, setSubscribed, setHlApiKeySet]) usePriceSocket({ onPrice: (a, price) => setLivePrice(a, price), - onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 50)), + onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)), }) useEffect(() => { setCandles([]) - setCandlesLoading(true) getPrices(asset, timeframe) .then(setCandles) .catch(() => {}) - .finally(() => setCandlesLoading(false)) }, [asset, timeframe]) - return ( -
-
- {/* KPI 行 */} - + const selectedPost = posts.find(p => p.id === selectedPostId) ?? null + const recentPosts = posts.slice(0, 8) - {/* 主内容区:图表 + Bot 面板 */} -
-
- - {candlesLoading && ( -
- Loading... -
- )} + const lastCandle = candles[candles.length - 1] + const firstCandle = candles[0] + const priceChange = lastCandle && firstCandle + ? ((lastCandle.close - firstCandle.open) / firstCandle.open) * 100 + : 0 + + const totalPosts = posts.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 + + return ( +
+
+
+

Signal monitor

+

+ {actionablePosts} actionable signals · Auto-trader {isSubscribed ? 'running' : 'standby'} · Model v4-selective +

+
+
+ Live feed + {totalPosts} posts tracked +
+
+ + {/* KPI Row */} +
+
+
BTC
+
{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}
+
+ = 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)} + {timeframe}
-
- +
+
+
Signals today
+
{actionablePosts}
+
Actionable posts
+
+
+
30d Net P&L
+
{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
Bot performance
+
+
+
Win rate
+
{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}
+
{initialPerformance?.total_trades ?? 0} trades
+
+
+ +
+ {/* Left: Chart + signal stream */} +
+
+
+
+
Price · {asset}
+
+
+ {lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'} +
+ = 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)} · {timeframe} +
+
+
+
+ + +
+
+ {(['5m', '15m', '1H', '4H', '1D'] as const).map(t => ( + + ))} +
+
+
+ +
+ +
+ +
+
Buy signal
+
Short signal
+
Hold / filtered
+
Click marker to see details →
+
+
+ + {/* Recent signals list */} +
+
+

Recent signals

+ Showing {recentPosts.length} of {totalPosts} +
+
+ {recentPosts.map(p => ( + setSelectedPostId(selectedPostId === p.id ? null : p.id)} + /> + ))} +
- {/* 交易记录 */} - + {/* Right rail: bot stats + post detail */} +
+ + + {/* Post detail — shown when something is selected, else hint */} + {selectedPost + ? setSelectedPostId(null)} /> + : + } +
) diff --git a/app/[locale]/analytics/page.tsx b/app/[locale]/analytics/page.tsx index 7920df0..eea7cb7 100644 --- a/app/[locale]/analytics/page.tsx +++ b/app/[locale]/analytics/page.tsx @@ -1,14 +1,101 @@ -import { getTranslations } from 'next-intl/server' +'use client' -export default async function AnalyticsPage() { - const t = await getTranslations('analytics') +import { useState, useEffect } from 'react' +import { getPerformance, getTrades } from '@/lib/api' +import type { BotPerformance, BotTrade } from '@/types' + +function fmtMoney(n: number) { + if (n == null || isNaN(n)) return '—' + const abs = Math.abs(n) + const s = abs.toLocaleString('en-US', { maximumFractionDigits: 0 }) + if (n < 0) return '-$' + s + if (n > 0) return '+$' + s + return '$' + s +} + +function fmtHold(s: number) { + if (s < 60) return s + 's' + const m = Math.floor(s / 60) + if (m < 60) return m + 'm' + return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' +} + +export default function AnalyticsPage() { + const [perf, setPerf] = useState(null) + const [trades, setTrades] = useState([]) + const [period, setPeriod] = useState('30d') + + useEffect(() => { + Promise.all([ + getPerformance().catch(() => null), + getTrades(200, 1).catch(() => []), + ]).then(([p, t]) => { + setPerf(p) + setTrades(t) + }) + }, []) + + const winRate = perf ? perf.win_rate * 100 : 0 + const bestTrade = trades.length ? Math.max(...trades.map(t => t.pnl_usd)) : 0 + const worstTrade = trades.length ? Math.min(...trades.map(t => t.pnl_usd)) : 0 + const avgTrade = trades.length ? trades.reduce((s, t) => s + t.pnl_usd, 0) / trades.length : 0 + + const metrics = [ + { k: 'Max drawdown', v: perf ? perf.max_drawdown_pct.toFixed(1) + '%' : '—', sub: 'Worst peak-to-trough', down: true }, + { k: 'Total trades', v: String(perf?.total_trades ?? '—'), sub: 'Bot executions' }, + { k: 'Avg hold time', v: perf ? fmtHold(perf.avg_hold_seconds) : '—', sub: 'Per trade' }, + { k: 'Avg trade P&L', v: fmtMoney(avgTrade), sub: 'Mean per trade', up: avgTrade > 0 }, + { k: 'Best trade', v: fmtMoney(bestTrade), sub: 'Largest single win', up: true }, + { k: 'Worst trade', v: fmtMoney(worstTrade), sub: 'Largest single loss', down: true }, + ] return ( -
-
-

{t('title')}

-

{t('comingSoon')}

+
+
+
+

Analytics

+

Deep dive into {period} of signals and trades.

+
+
+ {['7d', '30d', '90d', 'All'].map(p => ( + + ))} +
+ + {/* Summary card */} +
+
+
+
Performance · {period}
+
+ {perf ? fmtMoney(perf.net_pnl_usd) : '—'} +
+
+ = 0.5 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% win rate + {perf?.total_trades ?? 0} trades +
+
+
+
+ + {/* Metric grid */} +
+ {metrics.map(m => ( +
+
{m.k}
+
{m.v}
+
{m.sub}
+
+ ))} +
+ + {/* No data message */} + {!perf && !trades.length && ( +
+

No trade data yet. The bot will populate analytics once it starts executing.

+
+ )}
) } diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css index 0e8fa3d..1e43891 100644 --- a/app/[locale]/globals.css +++ b/app/[locale]/globals.css @@ -1,22 +1,903 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +/* ============================================================ + Trump Alpha — Design System + ============================================================ */ -* { - box-sizing: border-box; +:root { + /* Surfaces — warm off-whites */ + --bg: oklch(99% 0.004 85); + --bg-sunk: oklch(97.8% 0.006 85); + --surface: #ffffff; + --surface-2: oklch(97.2% 0.006 85); + --surface-3: oklch(94% 0.008 85); + + /* Text */ + --ink: oklch(18% 0.008 85); + --ink-2: oklch(38% 0.008 85); + --ink-3: oklch(55% 0.008 85); + --ink-4: oklch(70% 0.006 85); + + /* Borders */ + --line: oklch(93% 0.008 85); + --line-2: oklch(88% 0.01 85); + + /* Amber accent */ + --amber: oklch(78% 0.17 75); + --amber-ink: oklch(45% 0.16 70); + --amber-soft: oklch(96% 0.05 85); + --amber-ring: oklch(88% 0.12 80); + + /* Signal colors */ + --up: oklch(62% 0.17 148); + --up-soft: oklch(95% 0.05 148); + --down: oklch(58% 0.22 25); + --down-soft: oklch(95% 0.05 25); + --violet: oklch(55% 0.17 280); + --violet-soft: oklch(96% 0.04 280); + + /* Radii */ + --r-xs: 8px; + --r-sm: 10px; + --r-md: 14px; + --r-lg: 20px; + --r-xl: 28px; + --r-pill: 999px; + + /* Shadow */ + --shadow-1: 0 1px 2px rgba(20, 18, 14, 0.04), 0 1px 1px rgba(20, 18, 14, 0.02); + --shadow-2: 0 4px 16px rgba(20, 18, 14, 0.06), 0 1px 2px rgba(20, 18, 14, 0.03); + --shadow-3: 0 12px 40px rgba(20, 18, 14, 0.08), 0 2px 4px rgba(20, 18, 14, 0.04); + + --sans: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif; + --mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; } -body { - background-color: #000000; +html[data-theme="dark"] { + --bg: oklch(15% 0.008 85); + --bg-sunk: oklch(12% 0.008 85); + --surface: oklch(18% 0.008 85); + --surface-2: oklch(21% 0.008 85); + --surface-3: oklch(25% 0.01 85); + + --ink: oklch(97% 0.005 85); + --ink-2: oklch(82% 0.006 85); + --ink-3: oklch(62% 0.006 85); + --ink-4: oklch(45% 0.006 85); + + --line: oklch(24% 0.008 85); + --line-2: oklch(30% 0.01 85); + + --amber: oklch(78% 0.17 75); + --amber-ink: oklch(82% 0.16 75); + --amber-soft: oklch(25% 0.05 75); + --amber-ring: oklch(38% 0.12 75); + + --up: oklch(70% 0.18 148); + --up-soft: oklch(24% 0.06 148); + --down: oklch(68% 0.22 25); + --down-soft: oklch(24% 0.08 25); + --violet: oklch(70% 0.17 280); + --violet-soft: oklch(24% 0.06 280); + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-2: 0 4px 16px rgba(0, 0, 0, 0.35); + --shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5); + + color-scheme: dark; } -/* Remove default focus ring and apply custom */ -button:focus-visible { - outline: 1px solid #f97316; - outline-offset: 2px; +html[data-theme="dark"] .src-ico.x { background: oklch(92% 0.005 85); color: oklch(15% 0.008 85); } + +html[data-theme="dark"] .bot-status { + background: linear-gradient(160deg, oklch(28% 0.015 85) 0%, oklch(20% 0.01 85) 100%); + border: 1px solid oklch(30% 0.01 85); } -input:focus-visible { - outline: 1px solid #222222; - outline-offset: 0; +html[data-theme="dark"] .kpi.accent { + background: linear-gradient(135deg, oklch(28% 0.05 75), oklch(24% 0.07 70)); } + +html[data-theme="dark"] .landing-nav { + background: oklch(15% 0.008 85 / 0.8); +} + +html[data-theme="dark"] .src-ico.truth { + background: oklch(30% 0.08 25); color: oklch(80% 0.15 25); +} + +html[data-theme="dark"] .post-row.selected { + background: oklch(22% 0.03 75); +} + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'ss01', 'cv11'; + transition: background 200ms, color 200ms; +} + +* { box-sizing: border-box; } + +button { font-family: inherit; cursor: pointer; border: 0; background: transparent; color: inherit; } +input, textarea { font-family: inherit; } +a { color: inherit; text-decoration: none; } + +.mono { font-family: var(--mono); font-feature-settings: 'tnum', 'zero'; } +.tnum { font-variant-numeric: tabular-nums; } + +/* ============================================================ + App scaffold + ============================================================ */ + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ---------- Top navbar ---------- */ +.nav { + height: 64px; + padding: 0 28px; + display: flex; + align-items: center; + gap: 32px; + background: var(--bg); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 40; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 15px; + letter-spacing: -0.01em; +} +.brand-mark { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--ink); + color: var(--bg); + display: grid; + place-items: center; + font-family: var(--mono); + font-size: 15px; + font-weight: 500; + position: relative; +} +.brand-mark::after { + content: ''; + position: absolute; + right: -3px; + bottom: -3px; + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--amber); + border: 2px solid var(--bg); +} + +.nav-tabs { + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + background: var(--bg-sunk); + border: 1px solid var(--line); + border-radius: var(--r-pill); +} +.nav-tab { + padding: 7px 14px; + border-radius: var(--r-pill); + font-size: 13px; + font-weight: 500; + color: var(--ink-3); + transition: color 120ms, background 120ms; +} +.nav-tab:hover { color: var(--ink); } +.nav-tab.active { + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-1); +} + +.nav-spacer { flex: 1; } + +.nav-right { + display: flex; + align-items: center; + gap: 10px; +} + +.icon-btn { + width: 36px; + height: 36px; + border-radius: var(--r-pill); + background: var(--bg-sunk); + border: 1px solid var(--line); + display: grid; + place-items: center; + color: var(--ink-2); + transition: background 120ms, color 120ms; +} +.icon-btn:hover { background: var(--surface-3); color: var(--ink); } + +.connect-btn { + padding: 12px 22px; + border-radius: var(--r-pill); + background: var(--ink); + color: var(--bg); + font-size: 14px; + font-weight: 600; + letter-spacing: -0.005em; + transition: background 120ms, transform 120ms, box-shadow 120ms; + box-shadow: var(--shadow-1); +} +.connect-btn:hover { background: oklch(30% 0.01 85); box-shadow: var(--shadow-2); } +.connect-btn:active { transform: scale(0.98); } +.connect-btn.lg { padding: 13px 26px; font-size: 14px; } +html[data-theme="dark"] .connect-btn { background: var(--amber); color: oklch(20% 0.04 75); } +html[data-theme="dark"] .connect-btn:hover { background: oklch(82% 0.17 75); } + +.theme-toggle { width: 40px; height: 40px; } +html[data-theme="dark"] .icon-btn { background: var(--surface-2); border-color: var(--line); } +html[data-theme="dark"] .icon-btn:hover { background: var(--surface-3); } +html[data-theme="dark"] .nav-tab.active { background: var(--surface-2); } +html[data-theme="dark"] .asset-switch button.on { background: var(--surface-2); } + +.wallet-chip { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px 6px 6px; + border-radius: var(--r-pill); + background: var(--bg-sunk); + border: 1px solid var(--line); + font-size: 13px; +} +.wallet-chip .ava { + width: 24px; + height: 24px; + border-radius: 999px; + background: linear-gradient(135deg, var(--amber), oklch(65% 0.2 35)); +} + +/* ============================================================ + Page shell + ============================================================ */ + +.page { + max-width: 1360px; + margin: 0 auto; + padding: 32px 28px 80px; + width: 100%; +} +.page.wide { max-width: 1600px; } + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 28px; + gap: 24px; +} +.page-title { + font-size: 34px; + letter-spacing: -0.02em; + font-weight: 600; + line-height: 1.05; + margin: 0; +} +.page-sub { + color: var(--ink-3); + font-size: 14px; + margin-top: 6px; + margin-bottom: 0; +} + +/* ============================================================ + Cards + primitives + ============================================================ */ + +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: 20px; +} +.card.flush { padding: 0; } +.card.soft { background: var(--bg-sunk); } +.card.raise { box-shadow: var(--shadow-1); } + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.section-title h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} +.section-title .hint { + font-size: 12px; + color: var(--ink-3); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--r-pill); + background: var(--bg-sunk); + border: 1px solid var(--line); + font-size: 12px; + color: var(--ink-2); + font-weight: 500; +} +.chip .dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; } +.chip.up { color: oklch(40% 0.16 148); background: var(--up-soft); border-color: oklch(85% 0.08 148); } +.chip.down { color: oklch(44% 0.2 25); background: var(--down-soft); border-color: oklch(87% 0.08 25); } +.chip.amber { color: var(--amber-ink); background: var(--amber-soft); border-color: var(--amber-ring); } +.chip.violet { color: oklch(38% 0.15 280); background: var(--violet-soft); border-color: oklch(86% 0.08 280); } +.chip.neutral { color: var(--ink-2); } + +.live-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--up); + position: relative; + display: inline-block; +} +.live-dot::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 999px; + background: var(--up); + opacity: 0.3; + animation: pulse 1.6s ease-out infinite; +} +@keyframes pulse { + 0% { transform: scale(0.6); opacity: 0.5; } + 100% { transform: scale(2.2); opacity: 0; } +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + border-radius: var(--r-pill); + font-size: 13px; + font-weight: 500; + transition: background 120ms, transform 120ms; + border: 1px solid transparent; +} +.btn.primary { background: var(--ink); color: var(--bg); } +.btn.primary:hover { background: oklch(30% 0.01 85); } +.btn.amber { + background: var(--amber); + color: oklch(22% 0.04 75); + font-weight: 600; +} +.btn.amber:hover { background: oklch(82% 0.17 75); } +.btn.ghost { background: var(--bg-sunk); color: var(--ink); border-color: var(--line); } +.btn.ghost:hover { background: var(--surface-3); } +.btn.lg { padding: 14px 22px; font-size: 15px; } +.btn:active { transform: scale(0.98); } + +.delta { font-family: var(--mono); font-weight: 500; } +.delta.up { color: oklch(42% 0.16 148); } +.delta.down { color: oklch(46% 0.2 25); } + +/* ============================================================ + Dashboard specific + ============================================================ */ + +.dash-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 20px; + align-items: start; +} + +.hero-value { + font-size: 44px; + font-weight: 500; + letter-spacing: -0.025em; + line-height: 1; + font-family: var(--mono); + font-feature-settings: 'tnum'; +} +.hero-value .cents { color: var(--ink-3); font-size: 0.6em; } + +.asset-switch { + display: inline-flex; + padding: 4px; + background: var(--bg-sunk); + border: 1px solid var(--line); + border-radius: var(--r-pill); + gap: 2px; +} +.asset-switch button { + padding: 7px 14px; + border-radius: var(--r-pill); + font-size: 13px; + font-weight: 500; + color: var(--ink-3); + display: flex; + align-items: center; + gap: 6px; +} +.asset-switch button.on { + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-1); +} +.asset-dot { + width: 14px; + height: 14px; + border-radius: 999px; +} +.asset-dot.btc { background: linear-gradient(135deg, #f7931a, #f2a93b); } +.asset-dot.eth { background: linear-gradient(135deg, #627eea, #8fa2ff); } + +.tf-bar { + display: inline-flex; + gap: 2px; + padding: 3px; + background: var(--bg-sunk); + border: 1px solid var(--line); + border-radius: var(--r-pill); +} +.tf-bar button { + padding: 5px 10px; + border-radius: var(--r-pill); + font-size: 12px; + font-weight: 500; + color: var(--ink-3); + font-family: var(--mono); +} +.tf-bar button.on { background: var(--ink); color: var(--bg); } + +.chart-wrap { + margin-top: 8px; + position: relative; + height: 360px; +} + +.chart-footnote { + display: flex; + gap: 18px; + font-size: 12px; + color: var(--ink-3); + border-top: 1px dashed var(--line); + padding-top: 14px; + margin-top: 4px; +} +.chart-footnote .item { display: flex; align-items: center; gap: 6px; } +.legend-dot { width: 8px; height: 8px; border-radius: 2px; } + +/* KPI tiles */ +.kpi-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 20px; +} +.kpi { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 6px; + position: relative; + overflow: hidden; +} +.kpi .label { + font-size: 12px; + color: var(--ink-3); + display: flex; + align-items: center; + gap: 6px; +} +.kpi .value { + font-size: 22px; + font-weight: 500; + letter-spacing: -0.01em; + font-family: var(--mono); +} +.kpi .foot { + font-size: 12px; + color: var(--ink-3); + display: flex; + align-items: center; + gap: 6px; +} +.kpi.accent { + background: linear-gradient(135deg, oklch(97% 0.04 85), oklch(95% 0.07 80)); + border-color: var(--amber-ring); +} +.kpi.accent .label { color: var(--amber-ink); } + +/* Right rail */ +.rail { display: flex; flex-direction: column; gap: 20px; } + +.signal-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: 18px; +} +.signal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.signal-head h3 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.latest-post { + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 14px; + background: var(--bg-sunk); +} +.latest-post .meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--ink-3); + margin-bottom: 10px; +} +.latest-post .text { + font-size: 13px; + line-height: 1.55; + color: var(--ink); + margin: 0 0 12px; +} +.latest-post .divider { + height: 1px; + background: var(--line); + margin: 12px 0; +} +.latest-post .lp-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} +.latest-post .lp-row + .lp-row { margin-top: 6px; } +.latest-post .lp-row .k { color: var(--ink-3); } + +.confidence-bar { + height: 6px; + background: var(--surface-3); + border-radius: 999px; + overflow: hidden; + position: relative; + margin-top: 6px; +} +.confidence-bar > div { + height: 100%; + background: linear-gradient(90deg, var(--amber) 0%, oklch(68% 0.2 45) 100%); + border-radius: 999px; + transition: width 600ms ease-out; +} + +/* Bot status */ +.bot-status { + background: linear-gradient(160deg, oklch(22% 0.01 85) 0%, oklch(14% 0.01 85) 100%); + color: oklch(96% 0.005 85); + border-radius: var(--r-lg); + padding: 22px; + position: relative; + overflow: hidden; +} +.bot-status::after { + content: ''; + position: absolute; + right: -40px; + top: -40px; + width: 180px; + height: 180px; + background: radial-gradient(circle, oklch(75% 0.17 75 / 0.4) 0%, transparent 70%); + pointer-events: none; +} +.bot-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 18px; +} +.bot-head h3 { margin: 0; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.bot-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 20px; + position: relative; + z-index: 1; +} +.bot-stat .k { + font-size: 11px; + color: oklch(70% 0.01 85); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 4px; +} +.bot-stat .v { + font-size: 20px; + font-family: var(--mono); + font-weight: 500; + letter-spacing: -0.01em; +} +.bot-stat .v.up { color: oklch(75% 0.17 148); } +.bot-stat .v.amber { color: var(--amber); } + +.bot-cta { + margin-top: 18px; + display: flex; + gap: 8px; + position: relative; + z-index: 1; +} +.bot-cta .btn { flex: 1; } + +/* Post list (stream) */ +.post-stream { + display: flex; + flex-direction: column; + gap: 10px; +} +.post-row { + display: grid; + grid-template-columns: 36px 1fr auto; + gap: 14px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-md); + align-items: flex-start; + transition: border-color 120ms, background 120ms; + cursor: pointer; +} +.post-row:hover { border-color: var(--line-2); } +.post-row.selected { border-color: var(--amber-ring); background: oklch(99% 0.02 85); } +.src-ico { + width: 36px; + height: 36px; + border-radius: 10px; + display: grid; + place-items: center; + font-family: var(--mono); + font-weight: 600; + font-size: 15px; + flex-shrink: 0; +} +.src-ico.x { background: #111; color: #fff; } +.src-ico.truth { background: oklch(94% 0.05 25); color: oklch(45% 0.2 25); } +.post-body .meta { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-size: 12px; + color: var(--ink-3); +} +.post-body .text { + font-size: 14px; + line-height: 1.5; + color: var(--ink); + margin: 0 0 10px; +} +.post-aside { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; + min-width: 120px; +} + +/* Signal pill */ +.sig { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: var(--r-pill); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; +} +.sig.buy { background: var(--up-soft); color: oklch(38% 0.16 148); } +.sig.sell, .sig.short { background: var(--down-soft); color: oklch(42% 0.2 25); } +.sig.hold { background: var(--bg-sunk); color: var(--ink-2); } + +.impact-mini { + display: flex; + gap: 8px; + align-items: center; + font-family: var(--mono); + font-size: 12px; +} +.impact-mini .tf { color: var(--ink-4); font-size: 11px; } + +/* Trades table */ +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} +.table th { + text-align: left; + font-weight: 500; + color: var(--ink-3); + padding: 14px 18px; + border-bottom: 1px solid var(--line); + font-size: 12px; + background: var(--bg-sunk); +} +.table th:first-child { border-top-left-radius: var(--r-md); } +.table th:last-child { border-top-right-radius: var(--r-md); text-align: right; } +.table td { + padding: 14px 18px; + border-bottom: 1px solid var(--line); +} +.table td:last-child { text-align: right; } +.table tr:last-child td { border-bottom: 0; } +.table tr:hover td { background: var(--bg-sunk); } + +.side-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--r-pill); + font-size: 12px; + font-weight: 600; +} +.side-pill.long { background: var(--up-soft); color: oklch(38% 0.16 148); } +.side-pill.short { background: var(--down-soft); color: oklch(42% 0.2 25); } + +/* Settings */ +.settings-grid { + display: grid; + grid-template-columns: 260px 1fr; + gap: 32px; + align-items: start; +} +.settings-side { + position: sticky; + top: 84px; + display: flex; + flex-direction: column; + gap: 2px; +} +.settings-side button { + text-align: left; + padding: 10px 14px; + border-radius: var(--r-sm); + font-size: 13px; + color: var(--ink-2); + font-weight: 500; +} +.settings-side button.on { background: var(--bg-sunk); color: var(--ink); } +.settings-side button:hover { background: var(--bg-sunk); color: var(--ink); } + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 20px; +} +.field label { + font-size: 13px; + font-weight: 500; +} +.field .hint { + font-size: 12px; + color: var(--ink-3); +} +.field input, .field select, .field textarea { + border: 1px solid var(--line); + background: var(--surface); + border-radius: var(--r-sm); + padding: 11px 14px; + font-size: 14px; + outline: none; + transition: border-color 120ms, box-shadow 120ms; + color: var(--ink); +} +.field input:focus, .field textarea:focus { + border-color: var(--amber-ring); + box-shadow: 0 0 0 3px oklch(88% 0.12 80 / 0.4); +} + +.switch { + --w: 40px; + width: var(--w); + height: 24px; + border-radius: 999px; + background: var(--surface-3); + position: relative; + cursor: pointer; + transition: background 150ms; + border: 1px solid var(--line); + flex-shrink: 0; +} +.switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--surface); + box-shadow: var(--shadow-1); + transition: transform 180ms; +} +.switch.on { + background: var(--ink); + border-color: var(--ink); +} +.switch.on::after { transform: translateX(16px); } + +.setting-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 16px 0; + border-bottom: 1px solid var(--line); +} +.setting-row:last-child { border-bottom: 0; } +.setting-row .desc { font-size: 12px; color: var(--ink-3); margin-top: 4px; max-width: 440px; line-height: 1.5; } + +/* Analytics */ +.metric-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +/* Misc utilities */ +.stack { display: flex; flex-direction: column; } +.row { display: flex; align-items: center; } +.between { justify-content: space-between; } +.gap-s { gap: 8px; } +.gap-m { gap: 14px; } +.gap-l { gap: 20px; } +.grow { flex: 1; } +.mono-num { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.muted { color: var(--ink-3); } +.tiny { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); font-weight: 500; } + +/* Scrollbar */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 999px; border: 2px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: oklch(80% 0.01 85); } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 260b7cc..62cdc8a 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,17 +1,14 @@ import type { Metadata } from 'next' -import { Inter } from 'next/font/google' import { NextIntlClientProvider } from 'next-intl' import { getMessages } from 'next-intl/server' import { notFound } from 'next/navigation' import { locales } from '@/i18n' -import Navbar from '@/components/nav/Navbar' import Providers from './Providers' +import Navbar from '@/components/nav/Navbar' import './globals.css' -const inter = Inter({ subsets: ['latin'] }) - export const metadata: Metadata = { - title: 'TrumpSignal — AI-powered crypto trading signals', + title: 'Trump Alpha — AI signal trading', description: 'Trade crypto based on Trump social media signals with AI confidence scoring.', } @@ -29,11 +26,19 @@ export default async function LocaleLayout({ children, params: { locale } }: Lay return ( - + + + + + + - -
{children}
+ +
{children}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index ac4290e..ae22dd3 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,20 +1,18 @@ -import { getPosts, getPerformance, getTrades } from '@/lib/api' +import { getPosts, getPerformance } from '@/lib/api' import DashboardClient from './DashboardClient' export const revalidate = 30 export default async function OverviewPage() { - const [posts, performance, trades] = await Promise.allSettled([ + const [posts, performance] = await Promise.allSettled([ getPosts(500, 1), getPerformance(), - getTrades(20, 1), ]) return ( ) } diff --git a/app/[locale]/posts/page.tsx b/app/[locale]/posts/page.tsx index 19d94b0..1122647 100644 --- a/app/[locale]/posts/page.tsx +++ b/app/[locale]/posts/page.tsx @@ -1,14 +1,124 @@ -import { useTranslations } from 'next-intl' -import { getTranslations } from 'next-intl/server' +'use client' -export default async function PostsPage() { - const t = await getTranslations('posts') +import { useState, useEffect } from 'react' +import type { TrumpPost } from '@/types' +import { getPosts } from '@/lib/api' +import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards' + +function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) { + return ( +
+
+ Signal detail + +
+
+ +
+
@realDonaldTrump
+
{new Date(post.published_at).toLocaleString()}
+
+
+

{post.text}

+ +
+ + {post.sentiment} +
+ +
AI reasoning
+
+ {post.ai_reasoning || 'No reasoning available.'} +
+ +
+ AI confidence + {post.ai_confidence}% +
+
+ + {post.price_impact && ( +
+
Actual price impact · {post.price_impact.asset}
+
+ {(['m5', 'm15', 'm1h'] as const).map(k => { + const v = post.price_impact![k] + const correct = post.price_impact!['correct_' + k as 'correct_m5' | 'correct_m15' | 'correct_m1h'] + const label = k === 'm5' ? '5m' : k === 'm15' ? '15m' : '1h' + return ( +
+
{label}
+
= 0 ? 'up' : 'down'}`} style={{ fontSize: 15 }}>{fmtPct(v)}
+ {correct != null && ( +
+ {correct ? '✓ correct' : '✗ missed'} +
+ )} +
+ ) + })} +
+
+ )} +
+ ) +} + +export default function PostsPage() { + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState('all') + const [selected, setSelected] = useState(null) + + useEffect(() => { + getPosts(200, 1).then(setPosts).catch(() => {}).finally(() => setLoading(false)) + }, []) + + const filtered = posts.filter(p => { + if (filter !== 'all' && p.sentiment !== filter) return false + return true + }) + + const counts = { + all: posts.length, + bullish: posts.filter(p => p.sentiment === 'bullish').length, + bearish: posts.filter(p => p.sentiment === 'bearish').length, + neutral: posts.filter(p => p.sentiment === 'neutral').length, + } + + const selectedPost = posts.find(p => p.id === selected) return ( -
-
-

{t('title')}

-

{t('comingSoon')}

+
+
+
+

Signals feed

+

Every post we tracked, every prediction we made. {posts.length} posts in the last 30 days.

+
+ Streaming +
+ +
+
+ {(['all', 'bullish', 'bearish', 'neutral'] as const).map(f => ( + + ))} +
+
+ + {loading &&
Loading…
} + +
+
+ {filtered.map(p => ( + setSelected(selected === p.id ? null : p.id)} /> + ))} +
+ {selected && selectedPost && setSelected(null)} />}
) diff --git a/app/[locale]/settings/SettingsClient.tsx b/app/[locale]/settings/SettingsClient.tsx index 2dde770..25f8ca7 100644 --- a/app/[locale]/settings/SettingsClient.tsx +++ b/app/[locale]/settings/SettingsClient.tsx @@ -1,31 +1,325 @@ 'use client' -import { useTranslations } from 'next-intl' -import { useAccount } from 'wagmi' -import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useAccount, useSignMessage } from 'wagmi' +import { useDashboardStore } from '@/store/dashboard' +import { getUser, getUserPublic, setUserSettings, type UserSettings } from '@/lib/api' +import { getOrCreateViewEnvelope, signRequest } from '@/lib/signedRequest' +import BotPanel from '@/components/dashboard/BotPanel' -export default function SettingsClient() { - const t = useTranslations('settings') - const { isConnected } = useAccount() - const { openConnectModal } = useConnectModal() +const ACTION_SET_SETTINGS = 'set_settings' +const ACTION_VIEW_USER = 'view_user' +const DEFAULT_SETTINGS: UserSettings = { + leverage: 3, + position_size_usd: 20, + take_profit_pct: null, + stop_loss_pct: null, + min_confidence: 80, +} - if (!isConnected) { +function LockedCard({ label }: { label: string }) { + return ( +
+
+ +
+ {label} +
+ ) +} + +function BotSettings({ + connected, subscribed, wallet, initial, +}: { + connected: boolean + subscribed: boolean + wallet?: string + initial: UserSettings +}) { + if (!connected) { return ( -
-

{t('connectRequired')}

-
) } + if (!subscribed) { + return ( +
+
+
+

Subscribe to unlock the bot

+

Get automated execution, real-time alerts, and advanced configuration.

+
+ 14-day free trial +
+
+ {['Auto-execute trades on your exchange', 'SMS + Telegram alerts within 2s', 'Custom confidence + position-size rules', 'Backtest any strategy on 2y of data'].map(f => ( +
+ {f} +
+ ))} +
+ + Activate on dashboard → + +
+ ) + } + + return +} + +function BotSettingsForm({ wallet, initial }: { wallet: string; initial: UserSettings }) { + const { signMessageAsync } = useSignMessage() + const [s, setS] = useState(initial) + const [useTp, setUseTp] = useState(initial.take_profit_pct != null) + const [useSl, setUseSl] = useState(initial.stop_loss_pct != null) + const [state, setState] = useState<'idle' | 'signing' | 'saving' | 'ok' | 'err'>('idle') + const [err, setErr] = useState('') + + // Re-sync when initial changes (after /user fetch resolves) + useEffect(() => { + setS(initial) + setUseTp(initial.take_profit_pct != null) + setUseSl(initial.stop_loss_pct != null) + }, [initial]) + + const dirty = JSON.stringify(s) !== JSON.stringify(initial) || + useTp !== (initial.take_profit_pct != null) || + useSl !== (initial.stop_loss_pct != null) + + async function save() { + setErr('') + try { + const payload: UserSettings = { + ...s, + take_profit_pct: useTp ? s.take_profit_pct ?? 2 : null, + stop_loss_pct: useSl ? s.stop_loss_pct ?? 1.5 : null, + } + setState('signing') + const env = await signRequest({ + action: ACTION_SET_SETTINGS, + wallet, + body: payload, + signMessageAsync, + }) + setState('saving') + await setUserSettings(env, payload) + setS(payload) + setState('ok') + setTimeout(() => setState('idle'), 2000) + } catch (e: unknown) { + const m = e instanceof Error ? e.message : 'Save failed' + setErr(m.includes('User rejected') || m.includes('denied') ? 'Signature cancelled' : m.slice(0, 140)) + setState('err') + } + } + + const label = state === 'signing' ? 'Waiting for signature…' : state === 'saving' ? 'Saving…' : state === 'ok' ? '✓ Saved' : 'Save changes' + return ( -
-

{t('comingSoon')}

+
+
+

Execution

+ +
+
+
Position size (USD)
+
Fixed notional per trade. Margin = size / leverage.
+ setS({ ...s, position_size_usd: Math.max(5, +e.target.value || 0) })} + style={{ width: 140 }} + /> +
+
margin ≈ ${(s.position_size_usd / s.leverage).toFixed(2)}
+
+ +
+
+
Leverage
+
Isolated margin, 1–50×. Higher = bigger PnL swings.
+
+ setS({ ...s, leverage: +e.target.value })} + style={{ flex: 1, accentColor: 'var(--amber)' }} /> +
{s.leverage}×
+
+
+
+ +
+
+
Minimum AI confidence
+
Platform floor is 80%. Raise to be pickier; can't lower.
+
+ setS({ ...s, min_confidence: +e.target.value })} + style={{ flex: 1, accentColor: 'var(--amber)' }} /> +
{s.min_confidence}%
+
+
+
+
+ +
+

Risk management

+ +
+
+
+
Take profit
+ +
+
Auto-close when price moves your way by this %.
+
+ setS({ ...s, take_profit_pct: +e.target.value })} + style={{ width: 100 }} /> + % gain +
+
+
+ +
+
+
+
Stop loss
+ +
+
Auto-close when price moves against you by this %.
+
+ setS({ ...s, stop_loss_pct: +e.target.value })} + style={{ width: 100 }} /> + % loss +
+
+
+
+ +
+ + {!dirty && state === 'idle' && No changes to save} + {state === 'err' && {err}} +
+
+ ) +} + +function ExchangeSettings({ subscribed }: { subscribed: boolean }) { + if (!subscribed) return + return +} + +function AccountSettings({ address }: { address?: string }) { + return ( +
+

Account

+
+ + +
+
+ ) +} + +export default function SettingsClient() { + const [section, setSection] = useState('bot') + const { address, isConnected } = useAccount() + const { signMessageAsync } = useSignMessage() + const { isSubscribed, setSubscribed, setHlApiKeySet } = useDashboardStore() + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [loadErr, setLoadErr] = useState('') + + useEffect(() => { + if (!isConnected || !address) return + let cancelled = false + // First fetch public state (no sig). Only request a view signature if user + // is actually subscribed — otherwise there's nothing private to fetch. + ;(async () => { + try { + const pub = await getUserPublic(address.toLowerCase()) + if (cancelled) return + setSubscribed(pub.active) + setHlApiKeySet(pub.hl_api_key_set) + if (!pub.active) return + const env = await getOrCreateViewEnvelope({ + action: ACTION_VIEW_USER, + wallet: address, + signMessageAsync, + }) + if (cancelled) return + const u = await getUser(address, env) + if (cancelled) return + setSubscribed(u.active) + setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined) + if (u.settings) setSettings(u.settings) + } catch (e: unknown) { + const m = e instanceof Error ? e.message : 'Failed to load settings' + // Swallow cancelled signatures; surface real failures + if (!m.includes('User rejected') && !m.includes('denied')) { + setLoadErr(m.slice(0, 140)) + } + } + })() + return () => { cancelled = true } + }, [address, isConnected, setSubscribed, setHlApiKeySet, signMessageAsync]) + + const sections = [ + { key: 'bot', label: 'Trading bot' }, + { key: 'exchange', label: 'Exchange keys' }, + { key: 'account', label: 'Account' }, + ] + + return ( +
+
+ {sections.map(s => ( + + ))} +
+
+ {loadErr && ( +
+ {loadErr} +
+ )} + {section === 'bot' && } + {section === 'exchange' && } + {section === 'account' && } +
) } diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 45532b4..233c5cb 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -1,12 +1,14 @@ -import { getTranslations } from 'next-intl/server' import SettingsClient from './SettingsClient' -export default async function SettingsPage() { - const t = await getTranslations('settings') - +export default function SettingsPage() { return ( -
-

{t('title')}

+
+
+
+

Settings

+

Configure your bot, alerts, and account.

+
+
) diff --git a/app/[locale]/trades/page.tsx b/app/[locale]/trades/page.tsx index 54612c1..ee61f17 100644 --- a/app/[locale]/trades/page.tsx +++ b/app/[locale]/trades/page.tsx @@ -1,17 +1,179 @@ -import { getTranslations } from 'next-intl/server' -import TradesTable from '@/components/trades/TradesTable' -import { getTrades } from '@/lib/api' +'use client' -export const revalidate = 10 +import { useState, useEffect } from 'react' +import type { BotTrade, TrumpPost } from '@/types' +import { getTrades, getPosts } from '@/lib/api' -export default async function TradesPage() { - const t = await getTranslations('trades') - const trades = await getTrades(100, 1).catch(() => []) +function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) { + const { decimals = 2, sign = false } = opts + if (n == null || isNaN(n)) return '—' + const abs = Math.abs(n) + const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + if (n < 0) return '-$' + s + if (sign && n > 0) return '+$' + s + return '$' + s +} + +function fmtPct(n: number) { + const s = n.toFixed(2) + '%' + return n >= 0 ? '+' + s : s +} + +function fmtHold(s: number) { + if (s < 60) return s + 's' + const m = Math.floor(s / 60) + if (m < 60) return m + 'm' + return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' +} + +function SourceIcon({ source }: { source: string }) { + if (source === 'x') return
𝕏
+ return
T
+} + +export default function TradesPage() { + const [trades, setTrades] = useState([]) + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [assetFilter, setAssetFilter] = useState('all') + const [sideFilter, setSideFilter] = useState('all') + + useEffect(() => { + Promise.all([ + getTrades(200, 1).catch(() => []), + getPosts(500, 1).catch(() => []), + ]).then(([t, p]) => { + setTrades(t) + setPosts(p) + }).finally(() => setLoading(false)) + }, []) + + const filtered = trades.filter(t => { + if (assetFilter !== 'all' && t.asset !== assetFilter) return false + if (sideFilter !== 'all' && t.side !== sideFilter) return false + return true + }) + + const totalPnl = filtered.reduce((s, t) => s + t.pnl_usd, 0) + const wins = filtered.filter(t => t.pnl_usd > 0).length + const avgHold = filtered.length > 0 ? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length) : 0 return ( -
-

{t('title')}

- +
+
+
+

Trades

+

Every trade your bot executed. Transparent and auditable.

+
+
+ 30 days +
+
+ +
+
+
Total trades
+
{filtered.length}
+
Filtered
+
+
+
Win rate
+
{filtered.length ? ((wins / filtered.length) * 100).toFixed(1) + '%' : '—'}
+
{wins}W · {filtered.length - wins}L
+
+
+
Net P&L
+
{fmtMoney(totalPnl, { sign: true, decimals: 0 })}
+
All assets
+
+
+
Avg hold
+
{avgHold ? fmtHold(avgHold) : '—'}
+
Per trade
+
+
+ +
+
+
+ {(['all', 'BTC', 'ETH'] as const).map(a => ( + + ))} +
+
+ {(['all', 'long', 'short'] as const).map(s => ( + + ))} +
+
+
+ + {loading &&
Loading…
} + + {!loading && ( +
+ + + + + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.map(t => { + const triggerPost = posts.find(p => p.id === t.trigger_post_id) + const roiPct = ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1) + return ( + + + + + + + + + + ) + })} + +
AssetSideEntryExitHoldTriggerP&L
No trades found
+
+ + {t.asset} +
+
{t.side === 'long' ? '↗ LONG' : '↘ SHORT'}${t.entry_price.toLocaleString()}${t.exit_price.toLocaleString()}{fmtHold(t.hold_seconds)} + {triggerPost ? ( +
+ + + {triggerPost.text.slice(0, 50)}… + +
+ ) : ( + + )} +
+
+ = 0 ? 'up' : 'down'}`} style={{ fontWeight: 600, fontSize: 14 }}> + {fmtMoney(t.pnl_usd, { sign: true })} + + = 0 ? 'up' : 'down'}`} style={{ fontSize: 11, opacity: 0.7 }}>{fmtPct(roiPct)} +
+
+
+ )}
) } diff --git a/components/dashboard/BotPanel.tsx b/components/dashboard/BotPanel.tsx index a0c0583..0706844 100644 --- a/components/dashboard/BotPanel.tsx +++ b/components/dashboard/BotPanel.tsx @@ -1,124 +1,244 @@ 'use client' -import { useTranslations } from 'next-intl' import { useState } from 'react' -import { useAccount } from 'wagmi' -import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useAccount, useSignMessage } from 'wagmi' import type { BotPerformance } from '@/types' import { useDashboardStore } from '@/store/dashboard' -import { formatPct, formatHold } from '@/lib/utils' +import { setHlApiKey, subscribe } from '@/lib/api' +import { signRequest } from '@/lib/signedRequest' -const MOCK_PERFORMANCE: BotPerformance = { - period_days: 30, - total_trades: 89, - win_rate: 0.73, - net_pnl_usd: 12840, - avg_hold_seconds: 14 * 60, - max_drawdown_pct: 8.2, +// Action names must match backend/app/api/{user,subscribe}.py +const ACTION_SET_API_KEY = 'set_hl_api_key' +const ACTION_SUBSCRIBE = 'subscribe' + +interface Props { + performance?: BotPerformance | null } -interface BotPanelProps { - performance?: BotPerformance +type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error' + +function fmtHold(s: number) { + if (s < 60) return s + 's' + const m = Math.floor(s / 60) + if (m < 60) return m + 'm' + return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' } -export default function BotPanel({ performance = MOCK_PERFORMANCE }: BotPanelProps) { - const t = useTranslations('bot') - const { isSubscribed, setSubscribed } = useDashboardStore() +export default function BotPanel({ performance }: Props) { + const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setHlApiKeySet, setSubscribed } = useDashboardStore() const { address, isConnected } = useAccount() - const { openConnectModal } = useConnectModal() - const [apiKey, setApiKey] = useState('') + const { signMessageAsync } = useSignMessage() - const stats = [ - { label: t('winRate'), value: formatPct(performance.win_rate * 100 - 100 + performance.win_rate * 100), display: `${Math.round(performance.win_rate * 100)}%` }, - { label: t('netPnl'), value: `+$${performance.net_pnl_usd.toLocaleString()}`, positive: true }, - { label: t('totalTrades'), value: String(performance.total_trades) }, - { label: t('avgHold'), value: formatHold(performance.avg_hold_seconds) }, - ] + const [apiKey, setApiKey] = useState('') + const [saveState, setSaveState] = useState('idle') + const [errorMsg, setErrorMsg] = useState('') + const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle') + const [subError, setSubError] = useState('') + + async function handleSubscribe() { + if (!address) return + setSubError('') + try { + setSubState('signing') + const env = await signRequest({ + action: ACTION_SUBSCRIBE, + wallet: address, + body: null, + signMessageAsync, + }) + setSubState('saving') + await subscribe(env) + setSubscribed(true) + setSubState('idle') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unknown error' + setSubError(msg.includes('User rejected') || msg.includes('denied') ? 'Signature cancelled' : msg.slice(0, 120)) + setSubState('error') + } + } + + async function handleSaveKey() { + if (!address || !apiKey.trim()) return + if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) { + setErrorMsg('Key must start with 0x and be 66 characters') + setSaveState('error') + return + } + setErrorMsg('') + try { + setSaveState('signing') + const trimmed = apiKey.trim() + const env = await signRequest({ + action: ACTION_SET_API_KEY, + wallet: address, + body: { api_key: trimmed }, + signMessageAsync, + }) + setSaveState('saving') + const res = await setHlApiKey(env, trimmed) + setHlApiKeySet(true, res.masked_key) + setApiKey('') + setSaveState('success') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unknown error' + if (msg.includes('User rejected') || msg.includes('denied')) { + setErrorMsg('Signature cancelled') + } else { + setErrorMsg(msg.slice(0, 120)) + } + setSaveState('error') + } + } + + const saveLabel = + saveState === 'signing' ? 'Waiting for signature…' + : saveState === 'saving' ? 'Saving…' + : saveState === 'success' ? '✓ Saved' + : 'Save key' return ( -
- {/* Performance card */} -
-

- {t('title')} · {t('period')} -

-
- {stats.map((stat) => ( -
-

{stat.label}

-

- {stat.display ?? stat.value} -

+ <> + {/* Bot status card — dark design */} +
+
+

+ + Auto-trader +

+ 30 days +
+ +
+
+
Net P&L
+
+ {performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
- ))} +
+
+
Win rate
+
+ {performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'} +
+
+
+
Trades
+
{performance?.total_trades ?? '—'}
+
+
+
Avg hold
+
{performance ? fmtHold(performance.avg_hold_seconds) : '—'}
+
+
+ +
+ {!isConnected && ( + + )} + {isConnected && !isSubscribed && ( +
+ + {subState === 'error' && subError && ( +

{subError}

+ )} +
+ )} + {isConnected && isSubscribed && !hlApiKeySet && ( +
+ ↓ Paste your Hyperliquid API key below to activate +
+ )} + {isConnected && isSubscribed && hlApiKeySet && ( +
+ ✓ Bot active · waiting for next signal +
+ )}
- {/* Divider */} -
- - {/* Conditional bottom section */} - {!isConnected && ( -
-

{t('connectCta')}

-

{t('connectDesc')}

- -
- )} - - {isConnected && !isSubscribed && ( -
-
-

{t('settingsTitle')}

- - Locked - -
- {/* Disabled API key input */} -
- - -
- -
- )} - + {/* HL API Key card — only when subscribed */} {isConnected && isSubscribed && ( -
-
-

{t('settingsTitle')}

- - {t('activeStatus')} - +
+
+

Hyperliquid API key

+ {hlApiKeySet && ✓ Connected}
-
- - setApiKey(e.target.value)} - placeholder={t('apiKeyPlaceholder')} - className="w-full bg-[#060606] border border-[#141414] rounded-lg px-3 py-2 text-[12px] text-white placeholder-[#333333] focus:outline-none focus:border-[#222222]" - /> -
- + + {hlApiKeySet && !apiKey && ( +
+ + {hlApiKeyMasked ?? '···'} + + +
+ )} + + {(!hlApiKeySet || apiKey) && ( + <> +
+ + { + setApiKey(e.target.value) + if (saveState === 'error' || saveState === 'success') setSaveState('idle') + }} + placeholder="0x…" + /> + +
+ + {saveState === 'error' && errorMsg && ( +

{errorMsg}

+ )} + + + + )} + + {!hlApiKeySet && ( +
+ + How to get your API key + +
    +
  1. Deposit USDC at app.hyperliquid.xyz
  2. +
  3. Go to app.hyperliquid.xyz/API
  4. +
  5. Click Generate API Wallet
  6. +
  7. Sign with MetaMask (no gas)
  8. +
  9. Copy the private key → paste above
  10. +
+
+ )}
)} -
+ ) } diff --git a/components/dashboard/ChartPanel.tsx b/components/dashboard/ChartPanel.tsx index 2861b1e..c6447a3 100644 --- a/components/dashboard/ChartPanel.tsx +++ b/components/dashboard/ChartPanel.tsx @@ -3,56 +3,64 @@ import { useEffect, useRef } from 'react' import type { TrumpPost, Candle } from '@/types' import { useDashboardStore } from '@/store/dashboard' -import Pill from '@/components/ui/Pill' -import PostCards, { MOCK_POSTS } from './PostCards' -import ExpandDetail from './ExpandDetail' interface ChartPanelProps { posts?: TrumpPost[] candles?: Candle[] + externalSelectedId?: number | null + onSelectPost?: (id: number | null) => void } -export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) { - const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore() +export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) { + const { timeframe } = useDashboardStore() const containerRef = useRef(null) const chartRef = useRef(null) const seriesRef = useRef(null) const fittedRef = useRef(false) - // Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing const postsRef = useRef(posts) postsRef.current = posts - const selectedPostIdRef = useRef(selectedPostId) - selectedPostIdRef.current = selectedPostId + const selectedPostIdRef = useRef(externalSelectedId) + selectedPostIdRef.current = externalSelectedId + const timeframeRef = useRef(timeframe) + timeframeRef.current = timeframe + const onSelectRef = useRef(onSelectPost) + onSelectRef.current = onSelectPost - const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH'] - const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W'] - - const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null + // Detect current theme for chart colors + function getChartColors() { + const isDark = document.documentElement.dataset.theme === 'dark' + return { + background: isDark ? '#121212' : '#ffffff', + textColor: isDark ? '#666666' : '#888888', + gridColor: isDark ? '#1e1e1e' : '#f0ede8', + borderColor: isDark ? '#2a2a2a' : '#e8e4de', + } + } // Create chart once on mount useEffect(() => { if (!containerRef.current || typeof window === 'undefined') return - let destroyed = false import('lightweight-charts').then(({ createChart, CrosshairMode }) => { if (destroyed || !containerRef.current) return + const colors = getChartColors() const chart = createChart(containerRef.current, { width: containerRef.current.clientWidth, height: 360, layout: { - background: { color: '#050505' }, - textColor: '#555555', + background: { color: colors.background }, + textColor: colors.textColor, }, grid: { - vertLines: { color: '#111111' }, - horzLines: { color: '#111111' }, + vertLines: { color: colors.gridColor }, + horzLines: { color: colors.gridColor }, }, crosshair: { mode: CrosshairMode.Normal }, - rightPriceScale: { borderColor: '#1a1a1a' }, + rightPriceScale: { borderColor: colors.borderColor }, timeScale: { - borderColor: '#1a1a1a', + borderColor: colors.borderColor, timeVisible: true, rightOffset: 5, barSpacing: 10, @@ -61,47 +69,54 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa handleScale: true, }) - // @ts-expect-error lightweight-charts type chartRef.current = chart const series = chart.addCandlestickSeries({ - upColor: '#4ade80', - downColor: '#ef4444', - borderUpColor: '#4ade80', - borderDownColor: '#ef4444', - wickUpColor: '#4ade80', - wickDownColor: '#ef4444', + upColor: '#26a69a', + downColor: '#ef5350', + borderUpColor: '#26a69a', + borderDownColor: '#ef5350', + wickUpColor: '#26a69a', + wickDownColor: '#ef5350', }) - // @ts-expect-error lightweight-charts type seriesRef.current = series - // Click on chart: find nearest post marker within 2-bar tolerance - chart.subscribeClick((param: { time?: number | string }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chart.subscribeClick((param: any) => { if (!param.time) return const clickTime = typeof param.time === 'number' ? param.time : 0 if (!clickTime) return - const allPosts = postsRef.current - let closest: TrumpPost | null = null - let closestDiff = Infinity + const bucketByTf: Record = { + '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800, + } + const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600 + const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs - for (const p of allPosts) { - if (!p.published_at) continue - const pt = Math.floor(new Date(p.published_at).getTime() / 1000) - const diff = Math.abs(pt - clickTime) - if (diff < closestDiff) { - closestDiff = diff - closest = p - } + const inBucket = postsRef.current + .filter((p) => { + if (!p.published_at) return false + 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 } - // Only select if click is within 2 hours of a post (7200 seconds) - if (closest && closestDiff <= 7200) { - const newId = closest.id === selectedPostIdRef.current ? null : closest.id - setSelectedPost(newId) + 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 { - setSelectedPost(null) + onSelectRef.current?.(inBucket[0].id) } }) @@ -112,9 +127,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa }) ro.observe(containerRef.current) - return () => { - ro.disconnect() - } + return () => { ro.disconnect() } }) return () => { @@ -130,7 +143,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Update candles + markers whenever data changes + // Update candles + markers useEffect(() => { const series = seriesRef.current const chart = chartRef.current @@ -147,7 +160,6 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa close: c.close, }))) - // Show markers for all posts within visible time range const minTime = sorted[0].time const maxTime = sorted[sorted.length - 1].time @@ -158,20 +170,44 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa }) if (visible.length > 0) { - const markers = [...visible] - .sort((a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime()) - .map((p) => ({ - time: Math.floor(new Date(p.published_at).getTime() / 1000) as number, - position: 'aboveBar' as const, - color: p.id === selectedPostId - ? '#fb923c' - : p.sentiment === 'bearish' - ? '#ef4444' - : '#f97316', - shape: 'circle' as const, - text: '', - size: p.id === selectedPostId ? 2 : 1, - })) + const bucketByTf: Record = { + '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() + 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) } @@ -181,45 +217,14 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa chart.timeScale().fitContent() fittedRef.current = true } - }, [candles, posts, selectedPostId]) + }, [candles, posts, externalSelectedId]) - // Reset fit flag on timeframe/asset switch - useEffect(() => { - fittedRef.current = false - }, [asset, timeframe]) + useEffect(() => { fittedRef.current = false }, [timeframe]) return ( -
- {/* Controls */} -
-
- {assets.map((a) => ( - setAsset(a)}> - {a} - - ))} -
-
- {timeframes.map((tf) => ( - setTimeframe(tf as '4H' | '1D' | '1W')}> - {tf} - - ))} -
-
- - {/* Chart */} -
- - {/* Selected post detail — shown immediately below chart */} - - - {/* Post cards list */} - -
+
) } diff --git a/components/dashboard/ExpandDetail.tsx b/components/dashboard/ExpandDetail.tsx deleted file mode 100644 index 94bd1a6..0000000 --- a/components/dashboard/ExpandDetail.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client' - -import type { TrumpPost } from '@/types' -import Badge from '@/components/ui/Badge' -import { formatPct } from '@/lib/utils' - -interface ExpandDetailProps { - post: TrumpPost | null -} - -export default function ExpandDetail({ post }: ExpandDetailProps) { - return ( -
- {post && ( -
-
- {/* Post content */} -
-
- - - - {new Date(post.published_at).toLocaleString()} - -
-

{post.text}

-
- - {/* Stats */} -
- {/* AI Confidence */} -
-
- - AI Confidence - - - {post.ai_confidence}% - -
-
-
-
-
- - {/* Price impact */} - {post.price_impact ? ( -
-

- Price Impact ({post.price_impact.asset}) -

-
- {[ - { label: '5m', value: post.price_impact.m5 }, - { label: '15m', value: post.price_impact.m15 }, - { label: '1h', value: post.price_impact.m1h }, - ].map((item) => ( -
-

{item.label}

-

= 0 ? 'text-[#4ade80]' : 'text-[#ef4444]' - }`} - > - {formatPct(item.value)} -

-
- ))} -
-
- ) : ( -

No significant price impact detected.

- )} -
-
-
- )} -
- ) -} diff --git a/components/dashboard/KpiRow.tsx b/components/dashboard/KpiRow.tsx deleted file mode 100644 index 51efd8d..0000000 --- a/components/dashboard/KpiRow.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client' - -import { useTranslations } from 'next-intl' -import type { BotPerformance } from '@/types' -import { formatPrice, formatPct } from '@/lib/utils' -import { useDashboardStore } from '@/store/dashboard' - -interface KpiRowProps { - performance?: BotPerformance - postsCount?: number -} - -interface StatCard { - labelKey: string - value: string - change?: string - changePositive?: boolean -} - -export default function KpiRow({ performance, postsCount }: KpiRowProps) { - const t = useTranslations('kpi') - const { livePrices } = useDashboardStore() - - const stats: StatCard[] = [ - { - labelKey: 'btcPrice', - value: formatPrice(livePrices.BTC ?? 94230), - change: livePrices.BTC ? 'live' : '+1.4%', - changePositive: true, - }, - { - labelKey: 'ethPrice', - value: formatPrice(livePrices.ETH ?? 1847), - change: livePrices.ETH ? 'live' : '-0.8%', - changePositive: false, - }, - { - labelKey: 'postsTracked', - value: postsCount != null ? postsCount.toLocaleString() : '1,247', - change: '+12 today', - changePositive: true, - }, - { - labelKey: 'avgMove', - value: performance - ? formatPct(performance.net_pnl_usd > 0 ? 2.3 : -2.3) - : formatPct(2.3), - change: 'after Trump posts', - changePositive: true, - }, - ] - - return ( -
- {stats.map((stat) => ( -
-

- {t(stat.labelKey as 'btcPrice' | 'ethPrice' | 'postsTracked' | 'avgMove')} -

-

{stat.value}

- {stat.change && ( -

- {stat.change} -

- )} -
- ))} -
- ) -} diff --git a/components/dashboard/PostCards.tsx b/components/dashboard/PostCards.tsx index 0a8e144..3f88184 100644 --- a/components/dashboard/PostCards.tsx +++ b/components/dashboard/PostCards.tsx @@ -1,181 +1,75 @@ 'use client' -import { useEffect, useRef } from 'react' -import { useTranslations } from 'next-intl' import type { TrumpPost } from '@/types' -import Badge from '@/components/ui/Badge' -import { useDashboardStore } from '@/store/dashboard' -import { formatPct } from '@/lib/utils' -const MOCK_POSTS: TrumpPost[] = [ - { - id: 1, - text: 'BITCOIN IS THE FUTURE OF MONEY! We will make America the crypto capital of the world. BIG things coming very soon. The dollar will be STRONGER than ever with Bitcoin as our reserve!', - source: 'truth', - published_at: new Date(Date.now() - 1000 * 60 * 14).toISOString(), - sentiment: 'bullish', - ai_confidence: 91, - relevant: true, - price_impact: { - asset: 'BTC', - m5: 1.2, - m15: 2.8, - m1h: 3.4, - price_at_post: 92800, - }, - }, - { - id: 2, - text: 'Ethereum and all these so-called "smart contracts" are nothing but a scam by the radical left globalists. Very bad for America. We need REAL money, not fake computer tricks!', - source: 'x', - published_at: new Date(Date.now() - 1000 * 60 * 47).toISOString(), - sentiment: 'bearish', - ai_confidence: 84, - relevant: true, - price_impact: { - asset: 'ETH', - m5: -1.9, - m15: -3.1, - m1h: -4.2, - price_at_post: 1920, - }, - }, - { - id: 3, - text: "Met with many great business leaders today. Discussed the future of America's economy. Jobs are coming back FAST. Nobody builds like Trump!", - source: 'truth', - published_at: new Date(Date.now() - 1000 * 60 * 120).toISOString(), - sentiment: 'neutral', - ai_confidence: 42, - relevant: false, - price_impact: null, - }, - { - id: 4, - text: 'Crypto regulations will be ELIMINATED under my watch. No more Biden weaponization of the SEC against Bitcoin holders. We will have the most CRYPTO FRIENDLY government in history!', - source: 'truth', - published_at: new Date(Date.now() - 1000 * 60 * 210).toISOString(), - sentiment: 'bullish', - ai_confidence: 88, - relevant: true, - price_impact: { - asset: 'BTC', - m5: 0.9, - m15: 2.1, - m1h: 2.7, - price_at_post: 91500, - }, - }, -] - -interface PostCardsProps { - posts?: TrumpPost[] +function fmtPct(n: number | null | undefined) { + if (n == null || isNaN(n)) return '—' + const s = n.toFixed(2) + '%' + return n >= 0 ? '+' + s : s } -function timeAgo(iso: string): string { +function timeAgo(iso: string) { const diff = Date.now() - new Date(iso).getTime() - const mins = Math.floor(diff / 60000) - if (mins < 60) return `${mins}m ago` - const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}h ago` - return `${Math.floor(hours / 24)}d ago` + const m = Math.floor(diff / 60000) + if (m < 1) return 'just now' + if (m < 60) return m + 'm' + const h = Math.floor(m / 60) + if (h < 24) return h + 'h' + return Math.floor(h / 24) + 'd' } -export default function PostCards({ posts = MOCK_POSTS }: PostCardsProps) { - const t = useTranslations('common') - const { selectedPostId, setSelectedPost } = useDashboardStore() - const selectedRef = useRef(null) - const scrollRef = useRef(null) +function SourceIcon({ source }: { source: string }) { + if (source === 'x') return
𝕏
+ return
T
+} - // Auto-scroll selected card into view - useEffect(() => { - if (selectedRef.current && scrollRef.current) { - selectedRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }) - } - }, [selectedPostId]) +function SignalPill({ signal }: { signal: string | null }) { + if (!signal || signal === 'hold') return HOLD + return {signal.toUpperCase()} +} +interface PostRowProps { + post: TrumpPost + selected?: boolean + onClick?: () => void +} + +export default function PostRow({ post, selected, onClick }: PostRowProps) { + const impact = post.price_impact return ( -
-
- - Posts · {posts.length} - - {selectedPostId && ( - - )} +
+ +
+
+ @realDonaldTrump + · + {timeAgo(post.published_at)} ago + · + + {post.sentiment} + +
+

{post.text.slice(0, 180)}{post.text.length > 180 ? '…' : ''}

- - {/* Horizontally scrollable row */} -
- {posts.map((post) => { - const isSelected = selectedPostId === post.id - const impact = post.price_impact - - return ( -
setSelectedPost(isSelected ? null : post.id)} - className={`shrink-0 w-[200px] bg-[#0a0a0a] border rounded-[10px] p-3 cursor-pointer transition-colors hover:bg-[#0d0d0d] ${ - isSelected ? 'border-[#f97316]' : 'border-[#141414]' - }`} - > - {/* Header */} -
-
- - -
- {timeAgo(post.published_at)} -
- - {/* Text */} -

- {post.text.slice(0, 90)} - {post.text.length > 90 ? '…' : ''} -

- - {/* Confidence bar */} -
-
- AI - {post.ai_confidence}% -
-
-
-
-
- - {/* Price impact */} - {impact ? ( - = 0 ? 'text-[#4ade80]' : 'text-[#ef4444]' - }`} - > - {formatPct(impact.m1h)} 1h - - ) : ( - {t('neutral')} - )} -
- ) - })} +
+ +
+ {impact ? ( + <> + 1h + = 0 ? 'up' : 'down'}`}>{fmtPct(impact.m1h)} + + ) : ( + no data + )} +
+
+ AI + {post.ai_confidence}% +
) } -export { MOCK_POSTS } +export { SignalPill, SourceIcon, fmtPct, timeAgo } diff --git a/components/nav/Navbar.tsx b/components/nav/Navbar.tsx index ff589ed..21395d0 100644 --- a/components/nav/Navbar.tsx +++ b/components/nav/Navbar.tsx @@ -1,74 +1,143 @@ 'use client' +import { useState, useEffect } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { useTranslations } from 'next-intl' -import { useAccount } from 'wagmi' -import { useConnectModal } from '@rainbow-me/rainbowkit' -import { shortenAddress } from '@/lib/utils' +import { useAccount, useConnect, useDisconnect } from 'wagmi' +import { injected } from 'wagmi/connectors' -const navItems = [ - { key: 'overview', href: '' }, - { key: 'posts', href: '/posts' }, - { key: 'trades', href: '/trades' }, - { key: 'analytics', href: '/analytics' }, -] as const - -interface NavbarProps { - locale: string +function BrandMark() { + return α } -export default function Navbar({ locale }: NavbarProps) { - const t = useTranslations('nav') - const pathname = usePathname() - const { address, isConnected } = useAccount() - const { openConnectModal } = useConnectModal() +function ThemeToggle() { + const [theme, setTheme] = useState<'light' | 'dark'>('light') - function isActive(href: string) { - const full = `/${locale}${href}` - if (href === '') return pathname === `/${locale}` || pathname === `/${locale}/` - return pathname.startsWith(full) + useEffect(() => { + const stored = localStorage.getItem('theme') as 'light' | 'dark' | null + const t = stored || 'light' + setTheme(t) + document.documentElement.dataset.theme = t + }, []) + + function toggle() { + const next = theme === 'dark' ? 'light' : 'dark' + setTheme(next) + localStorage.setItem('theme', next) + document.documentElement.dataset.theme = next } return ( -