269 lines
9.7 KiB
TypeScript
269 lines
9.7 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { ArrowRight } from 'lucide-react';
|
|
import { useAppContext } from '../../context/AppContext';
|
|
import { fetchNews, type NewsArticle as MarketNewsArticle } from '../../lib/marketApi';
|
|
import { SkeletonBlock, SkeletonText } from '../Skeleton';
|
|
import { Button } from '../ui/Primitives';
|
|
|
|
// ─── Portfolio Summary ────────────────────────────────────────────────────────
|
|
|
|
function PortfolioSummary() {
|
|
const { botState } = useAppContext();
|
|
const positions = botState.positions ?? [];
|
|
const account = botState.accountSnapshot;
|
|
|
|
const totalValue = account
|
|
? account.cash + positions.reduce((s, p) => s + (p.marketValue ?? 0), 0)
|
|
: positions.reduce((s, p) => s + (p.marketValue ?? 0), 0);
|
|
|
|
const totalPnl = positions.reduce((s, p) => s + (p.unrealizedPnl ?? 0), 0);
|
|
const pnlPos = totalPnl >= 0;
|
|
|
|
const fmt$ = (n: number) =>
|
|
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
|
|
|
return (
|
|
<div style={{ padding: '16px 16px 12px' }}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--foreground)' }}>Portfolio</span>
|
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
|
View All <ArrowRight size={12} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Total value */}
|
|
<div style={{ marginBottom: 12 }}>
|
|
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--foreground)', letterSpacing: '-0.5px' }}>
|
|
{fmt$(totalValue)}
|
|
</div>
|
|
<div style={{
|
|
fontSize: 12, fontWeight: 600, marginTop: 2,
|
|
color: pnlPos ? 'var(--bl-success)' : 'var(--bl-danger)',
|
|
}}>
|
|
{pnlPos ? '+' : ''}{fmt$(totalPnl)} unrealized
|
|
</div>
|
|
</div>
|
|
|
|
{/* Column headers */}
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '2fr 1.2fr 1fr 1.2fr',
|
|
gap: 4, paddingBottom: 6,
|
|
borderBottom: '1px solid var(--border)', marginBottom: 4,
|
|
}}>
|
|
{['Symbol','Price','Change','Value'].map(h => (
|
|
<span key={h} style={{ fontSize: 10, color: 'var(--muted-foreground)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
{h}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Rows */}
|
|
{positions.length === 0 ? (
|
|
<div style={{
|
|
border: '1px dashed var(--border)',
|
|
borderRadius: 'var(--bl-radius-card)',
|
|
background: 'var(--card-elevated)',
|
|
color: 'var(--muted-foreground)',
|
|
padding: '18px 14px',
|
|
textAlign: 'center',
|
|
}}>
|
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--foreground)' }}>No open positions</div>
|
|
<div style={{ fontSize: 12, lineHeight: 1.5, marginTop: 4 }}>
|
|
Filled Trade Plans and manual entries will appear here.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{positions.slice(0, 6).map(pos => {
|
|
const pct = pos.unrealizedPnlPercent ?? 0;
|
|
const pos_ = pct >= 0;
|
|
return (
|
|
<div key={pos.id} style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '2fr 1.2fr 1fr 1.2fr',
|
|
gap: 4, padding: '8px 0',
|
|
borderBottom: '1px solid var(--border)',
|
|
alignItems: 'center',
|
|
}}>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--foreground)' }}>{pos.symbol}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{pos.currentPrice?.toFixed(2) ?? '—'}</span>
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: pos_ ? 'var(--bl-success)' : 'var(--bl-danger)' }}>
|
|
{pos_ ? '+' : ''}{pct.toFixed(2)}%
|
|
</span>
|
|
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{fmt$(pos.marketValue ?? 0)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── News Feed ────────────────────────────────────────────────────────────────
|
|
|
|
type NewsArticle = MarketNewsArticle;
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const m = Math.floor(diff / 60_000);
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
return `${Math.floor(h / 24)}d ago`;
|
|
}
|
|
|
|
function NewsCard({ article }: { article: NewsArticle }) {
|
|
const img = article.images?.[0]?.url;
|
|
return (
|
|
<a
|
|
href={article.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
display: 'flex', gap: 10,
|
|
padding: '10px 16px',
|
|
textDecoration: 'none',
|
|
borderBottom: '1px solid var(--border)',
|
|
transition: 'background 0.1s',
|
|
}}
|
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--muted)')}
|
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
|
>
|
|
{img && (
|
|
<img
|
|
src={img} alt=""
|
|
style={{ width: 52, height: 44, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }}
|
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
)}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: 12, fontWeight: 600, color: 'var(--foreground)',
|
|
lineHeight: 1.4,
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden',
|
|
marginBottom: 4,
|
|
}}>
|
|
{article.headline}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: 'var(--muted-foreground)' }}>
|
|
{article.source} · {timeAgo(article.created_at)}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function NewsFeedSkeleton() {
|
|
return (
|
|
<div role="status" aria-label="Loading news" style={{ padding: '4px 0 8px' }}>
|
|
{[0, 1, 2].map((i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
display: 'flex',
|
|
gap: 10,
|
|
padding: '10px 16px',
|
|
borderBottom: i < 2 ? '1px solid var(--border)' : 'none',
|
|
}}
|
|
>
|
|
<SkeletonBlock width={48} height={48} radius={10} style={{ flexShrink: 0 }} />
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 7, paddingTop: 3 }}>
|
|
<SkeletonText width="92%" />
|
|
<SkeletonText width="68%" />
|
|
<SkeletonText width="36%" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NewsFeed() {
|
|
const { activeSymbol } = useAppContext();
|
|
const [news, setNews] = useState<NewsArticle[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!activeSymbol) { setNews([]); return; }
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
// Use the authenticated marketApi helper so the request carries the
|
|
// platform Bearer token; the raw `fetch()` we used before was hitting
|
|
// requireAuth and 401-ing on every render.
|
|
fetchNews(activeSymbol, 8)
|
|
.then(list => { if (!cancelled) setNews(list); })
|
|
.catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load news'); })
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [activeSymbol]);
|
|
|
|
return (
|
|
<div>
|
|
<div style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '12px 16px 8px',
|
|
}}>
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--foreground)' }}>Latest News</span>
|
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
|
View All <ArrowRight size={12} />
|
|
</Button>
|
|
</div>
|
|
|
|
{loading && <NewsFeedSkeleton />}
|
|
|
|
{!loading && error && (
|
|
<div style={{ fontSize: 12, color: 'var(--bl-danger)', padding: '12px 16px' }}>{error}</div>
|
|
)}
|
|
|
|
{!loading && !error && news.length === 0 && (
|
|
<div style={{
|
|
border: '1px dashed var(--border)',
|
|
borderRadius: 'var(--bl-radius-card)',
|
|
background: 'var(--card-elevated)',
|
|
color: 'var(--muted-foreground)',
|
|
margin: '8px 16px 16px',
|
|
padding: '18px 14px',
|
|
textAlign: 'center',
|
|
}}>
|
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--foreground)' }}>
|
|
{activeSymbol ? `No news for ${activeSymbol}` : 'No symbol selected'}
|
|
</div>
|
|
<div style={{ fontSize: 12, lineHeight: 1.5, marginTop: 4 }}>
|
|
{activeSymbol ? 'Try another ticker or check back after the next market update.' : 'Search a ticker to load market news.'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && news.map((a, i) => <NewsCard key={a.id ?? a.url ?? i} article={a} />)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Right Panel container ────────────────────────────────────────────────────
|
|
|
|
export function RightPanel() {
|
|
return (
|
|
<aside className="dashboard-right-panel" style={{
|
|
background: 'var(--card)',
|
|
borderLeft: '1px solid var(--border)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflowY: 'auto',
|
|
flexShrink: 0,
|
|
}}>
|
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<PortfolioSummary />
|
|
</div>
|
|
<NewsFeed />
|
|
</aside>
|
|
);
|
|
}
|