'use client' import { useEffect, useState } from 'react' import type { TrumpPost } from '@/types' function fmtPct(n: number | null | undefined) { 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) 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' } /** * 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 return {timeAgo(iso)}{suffix} } /** * 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 return {new Date(iso).toLocaleString('en-US', opts)} } // Visual identity per signal source. The post stream now mixes Trump posts // with scanner-emitted technical signals — operators need to tell them // apart at a glance without reading the text. // // When adding a new scanner source, register it here too — otherwise it // falls through to the generic "first letter" fallback which has no title // or accent colour. const SOURCE_DISPLAY: Record = { truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social' }, breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' }, vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' }, reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner' }, btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC · Macro Bottom Reversal' }, funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC · Funding Rate Reversal' }, kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL · Talks vs Trades Divergence' }, whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert' }, manual: { glyph: '✋', cls: 'manual', title: 'Manual entry' }, } function SourceIcon({ source }: { source: string }) { const d = SOURCE_DISPLAY[source.toLowerCase()] if (d) { return
{d.glyph}
} // Unknown source — show first letter so user can still tell it apart. return
{source.charAt(0).toUpperCase()}
} function SignalPill({ signal }: { signal: string | null }) { if (!signal || signal === 'hold') return HOLD if (signal === 'buy') return BUY if (signal === 'short') return SHORT if (signal === 'sell') return SELL return {signal.toUpperCase()} } interface PostRowProps { post: TrumpPost selected?: boolean onClick?: () => void } 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 (
{/* ── main row ── */}
{/* Author label depends on source — non-Trump signals come from technical scanners or external modules, not @realDonaldTrump. */} {post.source === 'truth' ? '@realDonaldTrump' : post.source} · · {post.sentiment}

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

{/* Target asset chip for actionable signals */} {post.target_asset && (post.signal === 'buy' || post.signal === 'short') && ( {post.target_asset} {post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''} )}
{impact ? ( <> 1h peak = 0 ? 'up' : 'down'}`}> {impact.m1h == null ? '…' : fmtPct(impact.m1h)} ) : ( no data )}
AI {post.ai_confidence}%
{/* ── expanded detail ── */} {expanded && (
e.stopPropagation()} > {/* AI confidence bar */}
AI confidence {post.ai_confidence}%
{/* Trade routing — target asset + expected move */} {post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
Trade {post.signal === 'buy' ? `↑ LONG ${post.target_asset}` : `↓ SHORT ${post.target_asset}`} {post.expected_move_pct != null && ( {`AI expects ~${post.expected_move_pct}% in 1h`} )} {post.category && ( {post.category.replace(/_/g, ' ')} )}
)} {/* AI reasoning */} {post.ai_reasoning && (
AI reasoning
{post.ai_reasoning}
)} {/* Price impact — peak move in signal direction per window */} {impact && (
{`Peak move · ${impact.asset}`}
{(['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 (
{label}
= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600, color: pending ? 'var(--ink-3)' : undefined }} > {pending ? '…' : fmtImpactPct(v)}
{!pending && correct != null && (
{correct ? '✓' : '✗'}
)} {pending && (
live
)}
) })}
)}
)}
) } export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }