← back to Patty
components/orbit/OrbitTab.tsx
2383 lines
'use client';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Globe, RefreshCw, Loader2, ExternalLink, Copy, Check,
TrendingUp, Link2, Rss, BarChart3, Sparkles, X,
Send, Mail, ChevronDown, ChevronUp, FileText, Zap,
Eye, Target, Clock, AlertCircle, Newspaper,
ZoomIn, ZoomOut, Maximize2, Activity, Camera, Film,
Gauge, Triangle,
} from 'lucide-react';
/* ═══════════════════════════════════════════════════════════════════════════
Types
═══════════════════════════════════════════════════════════════════════════ */
interface KalshiMarket {
id: string;
title: string;
ticker: string;
category: string;
yes_price: number;
no_price: number;
volume: number;
status: string;
close_time: string | null;
// DB fields (optional, used for mapping)
yes_bid?: number;
yes_ask?: number;
no_bid?: number;
no_ask?: number;
last_price?: number;
}
interface Petition {
id: string;
title: string;
summary: string;
status: 'draft' | 'active' | 'closed';
signature_count: number;
signature_goal: number;
category: string;
created_at: string;
}
interface OrbitLink {
id: string;
market_id: string;
petition_id: string;
link_type: 'influence' | 'counter' | 'support' | 'alliance';
strength: number;
ai_reasoning: string;
}
interface RSSArticle {
id: string;
source: string;
feed_name?: string;
title: string;
url: string;
published_at: string;
sentiment: 'positive' | 'negative' | 'neutral';
matched_tickers?: string[] | null;
matched_markets?: string[] | null;
feed_category: 'national' | 'local' | 'wire';
}
interface OrbitStats {
total_markets: number;
linked_petitions: number;
active_feeds: number;
articles_today: number;
avg_sentiment: number;
}
interface TrendingTopic {
id: string;
title: string;
content?: string;
category: string;
tags?: string[];
engagement_score: number;
source_url?: string;
created_at: string;
}
interface AgentInfo {
name: string;
role: string;
port: number;
status: 'online' | 'offline' | 'error';
uptime?: number;
}
interface GeneratedPetition {
title: string;
summary: string;
body_html: string;
body_text: string;
suggested_target: string;
suggested_tags: string[];
suggested_goal: number;
// Alliance fields (optional — present when generated via alliance endpoint)
alliance_insight?: string;
group_a_appeal?: string;
group_b_appeal?: string;
impact_multiplier?: string;
}
/* ═══════════════════════════════════════════════════════════════════════════
Helpers
═══════════════════════════════════════════════════════════════════════════ */
function decodeEntities(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/�?39;/g, "'")
.replace(/'/g, "'");
}
/* ═══════════════════════════════════════════════════════════════════════════
Constants
═══════════════════════════════════════════════════════════════════════════ */
const CATEGORY_COLORS: Record<string, string> = {
Politics: '#3b82f6',
Economics: '#22c55e',
Climate: '#f97316',
Science: '#8b5cf6',
Sports: '#ef4444',
World: '#06b6d4',
Entertainment: '#ec4899',
default: '#71717a',
};
const TRENDING_COLOR = '#10b981'; // emerald-500
const LINK_COLORS: Record<string, string> = {
influence: '#7c3aed',
counter: '#ef4444',
support: '#22c55e',
alliance: '#f59e0b',
};
const SOURCE_COLORS: Record<string, string> = {
'CNN': '#cc0000',
'Fox News': '#003366',
'NBC': '#6a5acd',
'Reuters': '#ff8c00',
'AP': '#ff0000',
'NPR': '#2b9cd8',
'BBC': '#bb1919',
'Politico': '#ff3333',
'The Hill': '#1a5276',
'Washington Post': '#231f20',
default: '#71717a',
};
const PLATFORMS: Record<string, { name: string; url: string; color: string }> = {
moveon: { name: 'MoveOn', url: 'https://sign.moveon.org/petitions/new', color: '#e53e3e' },
change_org: { name: 'Change.org', url: 'https://www.change.org/start-a-petition', color: '#e53e3e' },
we_the_people: { name: 'We The People', url: 'https://petitions.whitehouse.gov', color: '#1d4ed8' },
};
const STATUS_STYLES: Record<string, { bg: string; text: string; border: string }> = {
draft: { bg: 'rgba(161,161,170,0.15)', text: '#a1a1aa', border: 'rgba(161,161,170,0.3)' },
active: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
closed: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
};
/* ─── Mock data for graceful display when API not ready ──────────────── */
const MOCK_MARKETS: KalshiMarket[] = [
{ id: 'm1', title: 'Will Congress pass student debt relief by Q3?', ticker: 'STUDEBT-Q3', category: 'Politics', yes_price: 0.34, no_price: 0.66, volume: 245000, status: 'open', close_time: '2026-09-30T23:59:00Z' },
{ id: 'm2', title: 'Fed rate cut before July 2026?', ticker: 'FEDCUT-JUL26', category: 'Economics', yes_price: 0.58, no_price: 0.42, volume: 892000, status: 'open', close_time: '2026-07-01T23:59:00Z' },
{ id: 'm3', title: 'US rejoins Paris Climate Agreement by 2027?', ticker: 'PARIS-27', category: 'Climate', yes_price: 0.22, no_price: 0.78, volume: 167000, status: 'open', close_time: '2027-01-01T23:59:00Z' },
{ id: 'm4', title: 'Supreme Court rules on social media regulation?', ticker: 'SCOTUS-SM', category: 'Politics', yes_price: 0.71, no_price: 0.29, volume: 534000, status: 'open', close_time: '2026-06-30T23:59:00Z' },
{ id: 'm5', title: 'Unemployment stays below 5% through 2026?', ticker: 'UNEMP-5PCT', category: 'Economics', yes_price: 0.62, no_price: 0.38, volume: 312000, status: 'open', close_time: '2026-12-31T23:59:00Z' },
{ id: 'm6', title: 'AI regulation bill passes Senate?', ticker: 'AIREG-SEN', category: 'Science', yes_price: 0.45, no_price: 0.55, volume: 423000, status: 'open', close_time: '2026-12-31T23:59:00Z' },
];
const MOCK_PETITIONS: Petition[] = [
{ id: 'p1', title: 'Cancel Student Loan Interest', summary: 'Demand Congress eliminate interest on federal student loans', status: 'active', signature_count: 12450, signature_goal: 25000, category: 'debt_relief', created_at: '2026-02-15T10:00:00Z' },
{ id: 'p2', title: 'Protect Social Media Free Speech', summary: 'Oppose government overreach in social media regulation', status: 'active', signature_count: 8320, signature_goal: 15000, category: 'policy', created_at: '2026-02-10T08:00:00Z' },
{ id: 'p3', title: 'Support AI Worker Protections', summary: 'Require AI impact assessments for workforce changes', status: 'draft', signature_count: 0, signature_goal: 10000, category: 'policy', created_at: '2026-02-20T14:00:00Z' },
{ id: 'p4', title: 'Restore Climate Commitments', summary: 'Push for renewed Paris Agreement participation', status: 'active', signature_count: 34210, signature_goal: 50000, category: 'policy', created_at: '2026-01-28T09:00:00Z' },
{ id: 'p5', title: 'Lower Prescription Drug Costs', summary: 'Support Medicare negotiation for all drug prices', status: 'active', signature_count: 19800, signature_goal: 30000, category: 'consumer_protection', created_at: '2026-02-01T11:00:00Z' },
];
const MOCK_LINKS: OrbitLink[] = [
{ id: 'l1', market_id: 'm1', petition_id: 'p1', link_type: 'influence', strength: 0.85, ai_reasoning: 'Market probability directly tracks legislative momentum on student debt' },
{ id: 'l2', market_id: 'm4', petition_id: 'p2', link_type: 'counter', strength: 0.72, ai_reasoning: 'SCOTUS ruling could override petition goals on social media regulation' },
{ id: 'l3', market_id: 'm6', petition_id: 'p3', link_type: 'support', strength: 0.68, ai_reasoning: 'AI regulation bill aligns with worker protection petition demands' },
{ id: 'l4', market_id: 'm3', petition_id: 'p4', link_type: 'influence', strength: 0.91, ai_reasoning: 'Paris Agreement market directly predicts petition outcome likelihood' },
{ id: 'l5', market_id: 'm2', petition_id: 'p5', link_type: 'support', strength: 0.45, ai_reasoning: 'Fed policy indirectly affects healthcare cost dynamics' },
];
const MOCK_ARTICLES: RSSArticle[] = [
{ id: 'a1', source: 'CNN', title: 'Student debt relief bill gains bipartisan support in Senate committee', url: '#', published_at: new Date(Date.now() - 2 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['STUDEBT-Q3'], feed_category: 'national' },
{ id: 'a2', source: 'Reuters', title: 'Federal Reserve signals potential rate adjustment in upcoming meeting', url: '#', published_at: new Date(Date.now() - 4 * 3600000).toISOString(), sentiment: 'neutral', matched_tickers: ['FEDCUT-JUL26'], feed_category: 'wire' },
{ id: 'a3', source: 'Politico', title: 'Supreme Court to hear oral arguments on social media liability case', url: '#', published_at: new Date(Date.now() - 5 * 3600000).toISOString(), sentiment: 'negative', matched_tickers: ['SCOTUS-SM'], feed_category: 'national' },
{ id: 'a4', source: 'NPR', title: 'Climate activists renew push for international cooperation on emissions', url: '#', published_at: new Date(Date.now() - 7 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['PARIS-27'], feed_category: 'national' },
{ id: 'a5', source: 'AP', title: 'Tech companies lobby against proposed AI oversight legislation', url: '#', published_at: new Date(Date.now() - 8 * 3600000).toISOString(), sentiment: 'negative', matched_tickers: ['AIREG-SEN'], feed_category: 'wire' },
{ id: 'a6', source: 'The Hill', title: 'Prescription drug pricing reform advances through committee markup', url: '#', published_at: new Date(Date.now() - 10 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['UNEMP-5PCT'], feed_category: 'national' },
{ id: 'a7', source: 'BBC', title: 'Global unemployment trends show mixed signals across economies', url: '#', published_at: new Date(Date.now() - 12 * 3600000).toISOString(), sentiment: 'neutral', matched_tickers: ['UNEMP-5PCT'], feed_category: 'wire' },
];
const MOCK_STATS: OrbitStats = {
total_markets: 6,
linked_petitions: 5,
active_feeds: 7,
articles_today: 23,
avg_sentiment: 0.42,
};
/* ═══════════════════════════════════════════════════════════════════════════
Helpers
═══════════════════════════════════════════════════════════════════════════ */
function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function truncate(str: string, len: number): string {
return str.length > len ? str.slice(0, len) + '...' : str;
}
function sentimentColor(s: string): string {
if (s === 'positive') return '#22c55e';
if (s === 'negative') return '#ef4444';
return '#71717a';
}
function sentimentLabel(score: number): { label: string; color: string } {
if (score > 0.6) return { label: 'Positive', color: '#22c55e' };
if (score > 0.4) return { label: 'Mixed', color: '#f59e0b' };
return { label: 'Negative', color: '#ef4444' };
}
/* ═══════════════════════════════════════════════════════════════════════════
SVG Mind Map Sub-Component
═══════════════════════════════════════════════════════════════════════════ */
interface MindMapProps {
markets: KalshiMarket[];
petitions: Petition[];
links: OrbitLink[];
trendingTopics: TrendingTopic[];
hoveredMarket: string | null;
hoveredPetition: string | null;
selectedMarkets: string[];
selectedPetition: string | null;
onHoverMarket: (id: string | null) => void;
onHoverPetition: (id: string | null) => void;
onSelectMarket: (id: string | null, shift?: boolean) => void;
onSelectPetition: (id: string | null) => void;
onSelectTrending: (id: string | null) => void;
}
function MindMap({
markets, petitions, links, trendingTopics,
hoveredMarket, hoveredPetition,
selectedMarkets, selectedPetition,
onHoverMarket, onHoverPetition,
onSelectMarket, onSelectPetition, onSelectTrending,
}: MindMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [dims, setDims] = useState({ w: 800, h: 600 });
// Zoom system
const [zoom, setZoom] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const handleZoomIn = useCallback(() => setZoom(z => Math.min(z + 0.2, 3)), []);
const handleZoomOut = useCallback(() => setZoom(z => Math.max(z - 0.2, 0.4)), []);
const handleZoomReset = useCallback(() => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }, []);
// Drag system
const [posOverrides, setPosOverrides] = useState<Record<string, { x: number; y: number }>>({});
const dragRef = useRef<{
id: string; type: 'market' | 'petition' | 'trending';
offsetX: number; offsetY: number;
startX: number; startY: number;
dragged: boolean;
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
// Hovered trending topic
const [hoveredTrending, setHoveredTrending] = useState<string | null>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const centerX = dims.w / 2;
const centerY = dims.h / 2;
const radius = Math.min(dims.w, dims.h) * 0.36;
// Compute positions with drag overrides
const marketPositions = useMemo(() => {
return markets.map((m, i) => {
const angle = (i / Math.max(markets.length, 1)) * 2 * Math.PI - Math.PI / 2;
const baseX = centerX - radius * 0.55 + radius * 0.85 * Math.cos(angle);
const baseY = centerY + radius * 0.85 * Math.sin(angle);
const override = posOverrides[`m-${m.id}`];
return { ...m, x: override?.x ?? baseX, y: override?.y ?? baseY };
});
}, [markets, centerX, centerY, radius, posOverrides]);
const petitionPositions = useMemo(() => {
return petitions.map((p, i) => {
const angle = (i / Math.max(petitions.length, 1)) * 2 * Math.PI - Math.PI / 2;
const baseX = centerX + radius * 0.55 + radius * 0.85 * Math.cos(angle);
const baseY = centerY + radius * 0.85 * Math.sin(angle);
const override = posOverrides[`p-${p.id}`];
return { ...p, x: override?.x ?? baseX, y: override?.y ?? baseY };
});
}, [petitions, centerX, centerY, radius, posOverrides]);
// Trending topic positions (bottom arc)
const trendingPositions = useMemo(() => {
return trendingTopics.map((t, i) => {
const spread = Math.PI * 0.6; // 108-degree arc at the bottom
const startAngle = Math.PI / 2 - spread / 2;
const angle = startAngle + (i / Math.max(trendingTopics.length - 1, 1)) * spread;
const baseX = centerX + radius * 0.95 * Math.cos(angle);
const baseY = centerY + radius * 0.95 * Math.sin(angle);
const override = posOverrides[`t-${t.id}`];
return { ...t, x: override?.x ?? baseX, y: override?.y ?? baseY };
});
}, [trendingTopics, centerX, centerY, radius, posOverrides]);
// SVG drag handlers
const handleSvgMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (!dragRef.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left - dragRef.current.offsetX;
const y = e.clientY - rect.top - dragRef.current.offsetY;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.dragged = true;
const prefix = dragRef.current.type === 'market' ? 'm' : dragRef.current.type === 'petition' ? 'p' : 't';
const key = `${prefix}-${dragRef.current.id}`;
setPosOverrides(prev => ({ ...prev, [key]: { x, y } }));
}, []);
const handleSvgMouseUp = useCallback(() => {
if (dragRef.current) {
if (!dragRef.current.dragged) {
const { id, type } = dragRef.current;
if (type === 'market') onSelectMarket(id);
else if (type === 'petition') onSelectPetition(selectedPetition === id ? null : id);
else onSelectTrending(id);
}
dragRef.current = null;
setIsDragging(false);
}
}, [onSelectMarket, onSelectPetition, onSelectTrending, selectedPetition]);
const handleNodeMouseDown = useCallback((e: React.MouseEvent, id: string, type: 'market' | 'petition' | 'trending', nodeX: number, nodeY: number) => {
e.preventDefault();
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = e.clientX - rect.left;
const svgY = e.clientY - rect.top;
dragRef.current = {
id, type,
offsetX: svgX - nodeX,
offsetY: svgY - nodeY,
startX: e.clientX,
startY: e.clientY,
dragged: false,
};
setIsDragging(true);
}, []);
// Which nodes are highlighted (supports multi-select)
const highlightedMarkets = useMemo(() => {
const s = new Set<string>();
if (hoveredMarket) s.add(hoveredMarket);
selectedMarkets.forEach(id => s.add(id));
if (hoveredPetition || selectedPetition) {
const pid = hoveredPetition || selectedPetition;
links.filter(l => l.petition_id === pid).forEach(l => s.add(l.market_id));
}
return s;
}, [hoveredMarket, selectedMarkets, hoveredPetition, selectedPetition, links]);
const highlightedPetitions = useMemo(() => {
const s = new Set<string>();
if (hoveredPetition) s.add(hoveredPetition);
if (selectedPetition) s.add(selectedPetition);
if (hoveredMarket || selectedMarkets.length > 0) {
const mids = hoveredMarket ? [hoveredMarket] : selectedMarkets;
mids.forEach(mid => links.filter(l => l.market_id === mid).forEach(l => s.add(l.petition_id)));
}
return s;
}, [hoveredPetition, selectedPetition, hoveredMarket, selectedMarkets, links]);
const highlightedLinks = useMemo(() => {
const s = new Set<string>();
const mids = hoveredMarket ? [hoveredMarket] : selectedMarkets;
const pid = hoveredPetition || selectedPetition;
links.forEach(l => {
if (mids.some(mid => l.market_id === mid)) s.add(l.id);
if (pid && l.petition_id === pid) s.add(l.id);
});
return s;
}, [hoveredMarket, selectedMarkets, hoveredPetition, selectedPetition, links]);
const anyHighlight = highlightedMarkets.size > 0 || highlightedPetitions.size > 0;
// Max volume for sizing
const maxVol = Math.max(...markets.map(m => m.volume), 1);
return (
<div ref={containerRef} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<svg
ref={svgRef}
width={dims.w} height={dims.h}
viewBox={`${panOffset.x + (dims.w - dims.w / zoom) / 2} ${panOffset.y + (dims.h - dims.h / zoom) / 2} ${dims.w / zoom} ${dims.h / zoom}`}
style={{ display: 'block', cursor: isDragging ? 'grabbing' : 'default' }}
onMouseMove={handleSvgMouseMove}
onMouseUp={handleSvgMouseUp}
onMouseLeave={handleSvgMouseUp}
onWheel={(e) => {
e.preventDefault();
if (e.deltaY < 0) handleZoomIn();
else handleZoomOut();
}}
>
<defs>
{/* Animated dash pattern */}
<style>{`
@keyframes orbitDash {
to { stroke-dashoffset: -40; }
}
@keyframes orbitPulse {
0%, 100% { r: 22; opacity: 0.7; }
50% { r: 26; opacity: 1; }
}
@keyframes orbitGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
`}</style>
<radialGradient id="hubGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#a78bfa" />
<stop offset="100%" stopColor="#7c3aed" />
</radialGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Connection lines */}
{links.map(link => {
const mp = marketPositions.find(m => m.id === link.market_id);
const pp = petitionPositions.find(p => p.id === link.petition_id);
if (!mp || !pp) return null;
const isHl = highlightedLinks.has(link.id);
const opacity = anyHighlight ? (isHl ? 1 : 0.08) : 0.45 + link.strength * 0.3;
const strokeWidth = 1.5 + link.strength * 3;
const color = LINK_COLORS[link.link_type] || '#7c3aed';
// Deterministic curve offset from link ID (no Math.random flickering)
const hash = link.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const midX = centerX + ((hash % 40) - 20);
const midY = centerY + (((hash * 7) % 40) - 20);
return (
<g key={link.id} filter={isHl ? 'url(#glow)' : undefined}>
{/* Glow underlay for visibility on dark bg */}
<path
d={`M ${mp.x} ${mp.y} Q ${midX} ${midY} ${pp.x} ${pp.y}`}
fill="none"
stroke={color}
strokeWidth={strokeWidth + 2}
opacity={opacity * 0.3}
strokeLinecap="round"
/>
{/* Main connection line */}
<path
d={`M ${mp.x} ${mp.y} Q ${midX} ${midY} ${pp.x} ${pp.y}`}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
opacity={opacity}
strokeDasharray={isHl ? '8 6' : 'none'}
strokeLinecap="round"
style={isHl ? { animation: 'orbitDash 1.5s linear infinite' } : {}}
/>
{/* Endpoint dots */}
<circle cx={mp.x} cy={mp.y} r={isHl ? 3 : 2} fill={color} opacity={opacity * 0.8} />
<circle cx={pp.x} cy={pp.y} r={isHl ? 3 : 2} fill={color} opacity={opacity * 0.8} />
{/* Label on hover */}
{isHl && (
<text
x={(mp.x + pp.x) / 2}
y={(mp.y + pp.y) / 2 - 10}
textAnchor="middle"
fontSize={9}
fill={color}
opacity={0.9}
fontWeight={600}
>
{truncate(link.ai_reasoning, 50)}
</text>
)}
</g>
);
})}
{/* Center hub */}
<circle
cx={centerX}
cy={centerY}
r={24}
fill="url(#hubGradient)"
filter="url(#glow)"
style={{ animation: 'orbitPulse 3s ease-in-out infinite' }}
/>
<circle
cx={centerX}
cy={centerY}
r={34}
fill="none"
stroke="#7c3aed"
strokeWidth={1}
opacity={0.2}
style={{ animation: 'orbitGlow 3s ease-in-out infinite' }}
/>
<text
x={centerX}
y={centerY + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize={10}
fontWeight={800}
fill="#fff"
letterSpacing="0.1em"
>
ORBIT
</text>
{/* Market nodes (left arc) */}
{marketPositions.map(m => {
const catColor = CATEGORY_COLORS[m.category] || CATEGORY_COLORS.default;
const nodeR = 14 + (m.volume / maxVol) * 16;
const isHl = highlightedMarkets.has(m.id);
const isSelected = selectedMarkets.includes(m.id);
const opacity = anyHighlight ? (isHl ? 1 : 0.25) : 0.85;
return (
<g
key={m.id}
style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
opacity={opacity}
onMouseEnter={() => !isDragging && onHoverMarket(m.id)}
onMouseLeave={() => !isDragging && onHoverMarket(null)}
onMouseDown={(e) => {
// Shift-click handled inside handleSvgMouseUp via onSelectMarket
if (e.shiftKey) {
e.preventDefault();
onSelectMarket(m.id, true);
return;
}
handleNodeMouseDown(e, m.id, 'market', m.x, m.y);
}}
>
{/* Glow ring on selection */}
{isSelected && (
<circle
cx={m.x} cy={m.y} r={nodeR + 6}
fill="none" stroke={catColor} strokeWidth={2}
opacity={0.5}
strokeDasharray="4 3"
style={{ animation: 'orbitDash 2s linear infinite' }}
/>
)}
<circle
cx={m.x} cy={m.y} r={nodeR}
fill={`${catColor}18`}
stroke={catColor}
strokeWidth={isHl ? 2.5 : 1.5}
filter={isSelected ? 'url(#glow)' : undefined}
/>
{/* Yes probability arc */}
<circle
cx={m.x} cy={m.y} r={nodeR - 3}
fill="none"
stroke={catColor}
strokeWidth={3}
strokeDasharray={`${m.yes_price * 2 * Math.PI * (nodeR - 3)} ${2 * Math.PI * (nodeR - 3)}`}
strokeDashoffset={0}
transform={`rotate(-90, ${m.x}, ${m.y})`}
opacity={0.75}
/>
{/* Probability text */}
<text
x={m.x} y={m.y - 1}
textAnchor="middle" dominantBaseline="middle"
fontSize={nodeR > 20 ? 12 : 10} fontWeight={800}
fill={catColor}
>
{Math.round(m.yes_price * 100)}%
</text>
{/* Title below with background for readability */}
<text
x={m.x} y={m.y + nodeR + 12}
textAnchor="middle"
fontSize={9}
fill="var(--color-text)"
fontWeight={600}
style={{ filter: 'drop-shadow(0 0 3px rgba(10,10,18,0.9)) drop-shadow(0 0 6px rgba(10,10,18,0.7))' }}
>
{truncate(m.title, 22)}
</text>
</g>
);
})}
{/* Petition nodes (right arc) */}
{petitionPositions.map(p => {
const sty = STATUS_STYLES[p.status] || STATUS_STYLES.draft;
const progress = p.signature_goal > 0 ? p.signature_count / p.signature_goal : 0;
const isHl = highlightedPetitions.has(p.id);
const isSelected = selectedPetition === p.id;
const opacity = anyHighlight ? (isHl ? 1 : 0.25) : 0.85;
const rectW = 130;
const rectH = 44;
return (
<g
key={p.id}
style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
opacity={opacity}
onMouseEnter={() => !isDragging && onHoverPetition(p.id)}
onMouseLeave={() => !isDragging && onHoverPetition(null)}
onMouseDown={(e) => handleNodeMouseDown(e, p.id, 'petition', p.x, p.y)}
>
{/* Selection ring */}
{isSelected && (
<rect
x={p.x - rectW / 2 - 4} y={p.y - rectH / 2 - 4}
width={rectW + 8} height={rectH + 8}
rx={14} ry={14}
fill="none" stroke="#a78bfa" strokeWidth={2}
opacity={0.5}
strokeDasharray="4 3"
style={{ animation: 'orbitDash 2s linear infinite' }}
/>
)}
<rect
x={p.x - rectW / 2} y={p.y - rectH / 2}
width={rectW} height={rectH}
rx={10} ry={10}
fill="rgba(30, 30, 50, 0.9)"
stroke={isHl ? '#a78bfa' : 'rgba(100, 100, 140, 0.4)'}
strokeWidth={isHl ? 2 : 1}
/>
{/* Left accent bar */}
<rect
x={p.x - rectW / 2 + 1} y={p.y - rectH / 2 + 6}
width={3} height={rectH - 12}
rx={1.5} ry={1.5}
fill={sty.text}
/>
{/* Title */}
<text
x={p.x + 2} y={p.y - 8}
textAnchor="middle" dominantBaseline="middle"
fontSize={9} fontWeight={700}
fill="var(--color-text)"
>
{truncate(p.title, 20)}
</text>
{/* Progress bar */}
<rect
x={p.x - rectW / 2 + 8} y={p.y + 4}
width={rectW - 16} height={4}
rx={2} ry={2}
fill="var(--color-bg)"
/>
<rect
x={p.x - rectW / 2 + 8} y={p.y + 4}
width={(rectW - 16) * Math.min(progress, 1)} height={4}
rx={2} ry={2}
fill={sty.text}
/>
{/* Sig count */}
<text
x={p.x} y={p.y + 16}
textAnchor="middle" dominantBaseline="middle"
fontSize={7}
fill="var(--color-text-muted)"
>
{((p.signature_count || 0) / 1000).toFixed(1)}k / {((p.signature_goal || 1) / 1000).toFixed(0)}k
</text>
</g>
);
})}
{/* Trending topic nodes (bottom arc — green diamonds) */}
{trendingPositions.map(t => {
const maxScore = Math.max(...trendingTopics.map(tt => tt.engagement_score), 1);
const size = 10 + (t.engagement_score / maxScore) * 10;
const isHovered = hoveredTrending === t.id;
const opacity = isHovered ? 1 : 0.8;
return (
<g
key={`trending-${t.id}`}
style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
opacity={opacity}
onMouseEnter={() => { if (!isDragging) setHoveredTrending(t.id); }}
onMouseLeave={() => { if (!isDragging) setHoveredTrending(null); }}
onMouseDown={(e) => handleNodeMouseDown(e, t.id, 'trending', t.x, t.y)}
>
{/* Diamond shape (rotated square) */}
<rect
x={t.x - size / 2} y={t.y - size / 2}
width={size} height={size}
rx={3} ry={3}
transform={`rotate(45, ${t.x}, ${t.y})`}
fill={`${TRENDING_COLOR}25`}
stroke={TRENDING_COLOR}
strokeWidth={isHovered ? 2.5 : 1.5}
/>
{/* Engagement score inside */}
<text
x={t.x} y={t.y + 1}
textAnchor="middle" dominantBaseline="middle"
fontSize={7} fontWeight={700}
fill={TRENDING_COLOR}
>
{t.engagement_score}
</text>
{/* Title below — only show on hover to reduce clutter */}
{isHovered && (
<text
x={t.x} y={t.y + size / 2 + 14}
textAnchor="middle"
fontSize={8}
fill="var(--color-text)"
fontWeight={600}
>
{truncate(t.title, 28)}
</text>
)}
{/* Tooltip on hover */}
{isHovered && t.content && (
<g>
<rect
x={t.x - 90} y={t.y - size / 2 - 32}
width={180} height={22}
rx={6} fill="var(--color-surface-el)"
stroke="var(--color-border)" strokeWidth={0.5}
/>
<text
x={t.x} y={t.y - size / 2 - 18}
textAnchor="middle" dominantBaseline="middle"
fontSize={7} fill="var(--color-text-muted)"
>
{truncate(t.content, 50)}
</text>
</g>
)}
</g>
);
})}
{/* Alliance line between 2 selected markets */}
{selectedMarkets.length === 2 && (() => {
const m1 = marketPositions.find(m => m.id === selectedMarkets[0]);
const m2 = marketPositions.find(m => m.id === selectedMarkets[1]);
if (!m1 || !m2) return null;
const midX = (m1.x + m2.x) / 2;
const midY = (m1.y + m2.y) / 2;
return (
<g>
<line
x1={m1.x} y1={m1.y} x2={m2.x} y2={m2.y}
stroke="#f59e0b" strokeWidth={2.5}
strokeDasharray="6 4" opacity={0.8}
style={{ animation: 'orbitDash 1.5s linear infinite' }}
/>
<rect x={midX - 36} y={midY - 10} width={72} height={18} rx={9} fill="#f59e0b" opacity={0.9} />
<text x={midX} y={midY + 2} textAnchor="middle" dominantBaseline="middle"
fontSize={8} fontWeight={700} fill="#000" letterSpacing="0.03em">
ALLIANCE
</text>
</g>
);
})()}
{/* Legend */}
<g transform={`translate(12, ${dims.h - 80})`}>
<text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">LINK TYPES</text>
{Object.entries(LINK_COLORS).map(([type, color], i) => (
<g key={type} transform={`translate(0, ${16 + i * 15})`}>
<line x1={0} y1={0} x2={18} y2={0} stroke={color} strokeWidth={2.5} />
<text x={22} y={3} fontSize={8} fill="var(--color-text-muted)" fontWeight={500}>{type.charAt(0).toUpperCase() + type.slice(1)}</text>
</g>
))}
</g>
{/* Category legend */}
<g transform={`translate(12, 12)`}>
<text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">CATEGORIES</text>
{Object.entries(CATEGORY_COLORS).filter(([k]) => k !== 'default').slice(0, 5).map(([cat, color], i) => (
<g key={cat} transform={`translate(${i * 68}, 16)`}>
<circle cx={5} cy={0} r={4} fill={color} />
<text x={12} y={3} fontSize={8} fill="var(--color-text-muted)" fontWeight={500}>{cat}</text>
</g>
))}
</g>
{/* Node type legend (top right) */}
<g transform={`translate(${dims.w - 130}, 12)`}>
<text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">NODES</text>
<g transform="translate(0, 14)">
<circle cx={5} cy={0} r={4} fill="#3b82f622" stroke="#3b82f6" strokeWidth={1} />
<text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Market (circle)</text>
</g>
<g transform="translate(0, 28)">
<rect x={1} y={-4} width={8} height={8} rx={2} fill="var(--color-surface-el)" stroke="var(--color-border)" strokeWidth={1} />
<text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Petition (rect)</text>
</g>
<g transform="translate(0, 42)">
<rect x={2} y={-3} width={6} height={6} rx={1} fill={`${TRENDING_COLOR}25`} stroke={TRENDING_COLOR} strokeWidth={1}
transform="rotate(45, 5, 0)" />
<text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Trending (diamond)</text>
</g>
</g>
{/* Shift+click hint */}
<text x={dims.w - 12} y={dims.h - 8} textAnchor="end"
fontSize={8} fill="var(--color-text-muted)" opacity={0.5}>
Drag to move | Scroll to zoom | Shift+click for Alliance
</text>
</svg>
{/* Zoom controls (top-left overlay) */}
<div style={{
position: 'absolute', top: 8, left: 8, zIndex: 10,
display: 'flex', flexDirection: 'column', gap: 2,
}}>
{[
{ icon: <ZoomIn size={13} />, action: handleZoomIn, title: 'Zoom In' },
{ icon: <ZoomOut size={13} />, action: handleZoomOut, title: 'Zoom Out' },
{ icon: <Maximize2 size={13} />, action: handleZoomReset, title: 'Reset View' },
].map(({ icon, action, title }) => (
<button
key={title}
onClick={action}
title={title}
style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 6, border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text-muted)', cursor: 'pointer',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.color = '#a78bfa'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-text-muted)'; }}
>
{icon}
</button>
))}
</div>
{/* Zoom level indicator */}
{zoom !== 1 && (
<div style={{
position: 'absolute', top: 8, left: 42,
padding: '4px 8px', borderRadius: 6,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
fontSize: 9, fontWeight: 600, color: '#a78bfa',
}}>
{Math.round(zoom * 100)}%
</div>
)}
{/* Reset Layout button */}
{Object.keys(posOverrides).length > 0 && (
<button
onClick={() => setPosOverrides({})}
style={{
position: 'absolute', bottom: 8, left: 12,
padding: '3px 10px', borderRadius: 6,
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text-muted)',
fontSize: 9, fontWeight: 600, cursor: 'pointer',
zIndex: 10, transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.color = '#a78bfa'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-text-muted)'; }}
>
Reset Layout
</button>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════════════════
Pulse Panel Sub-Component
═══════════════════════════════════════════════════════════════════════════ */
interface PulsePanelProps {
articles: RSSArticle[];
loading: boolean;
onRefresh: () => void;
refreshing: boolean;
}
function PulsePanel({ articles, loading, onRefresh, refreshing }: PulsePanelProps) {
const [feedFilter, setFeedFilter] = useState<string>('all');
const filters = ['all', 'national', 'local', 'wire'];
const filtered = useMemo(() => {
if (feedFilter === 'all') return articles;
return articles.filter(a => a.feed_category === feedFilter);
}, [articles, feedFilter]);
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid var(--color-border)', flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Rss size={14} style={{ color: '#a78bfa' }} />
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)' }}>Pulse Feed</span>
<span style={{
fontSize: 9, padding: '1px 6px', borderRadius: 99,
backgroundColor: 'rgba(124, 58, 237, 0.15)', color: '#a78bfa',
fontWeight: 600,
}}>
{articles.length}
</span>
</div>
<button
onClick={onRefresh}
disabled={refreshing}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px',
borderRadius: 6, border: '1px solid var(--color-border)',
backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
cursor: 'pointer', fontSize: 10, fontWeight: 600,
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
{refreshing
? <Loader2 size={11} style={{ animation: 'spin 1s linear infinite' }} />
: <RefreshCw size={11} />
}
Refresh
</button>
</div>
{/* Filter chips */}
<div style={{
padding: '8px 14px', display: 'flex', gap: 4, flexShrink: 0,
borderBottom: '1px solid var(--color-border)',
}}>
{filters.map(f => (
<button
key={f}
onClick={() => setFeedFilter(f)}
style={{
padding: '3px 10px', borderRadius: 99, border: 'none',
fontSize: 10, fontWeight: 600, cursor: 'pointer',
backgroundColor: feedFilter === f ? 'rgba(124, 58, 237, 0.2)' : 'var(--color-surface-el)',
color: feedFilter === f ? '#a78bfa' : 'var(--color-text-muted)',
transition: 'all 150ms ease',
textTransform: 'capitalize',
}}
>
{f}
</button>
))}
</div>
{/* Articles list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '6px 10px' }}>
{loading && (
<div style={{ padding: 20, textAlign: 'center' }}>
<Loader2 size={18} style={{ color: 'var(--color-text-muted)', animation: 'spin 1s linear infinite' }} />
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 11 }}>
No articles found for this filter.
</div>
)}
{!loading && filtered.map(article => {
const srcName = article.source || article.feed_name || 'Unknown';
const srcColor = SOURCE_COLORS[srcName] || SOURCE_COLORS.default;
const sentColor = sentimentColor(article.sentiment || 'neutral');
return (
<div
key={article.id}
style={{
padding: '8px 10px 8px 12px', borderRadius: 8, marginBottom: 4,
borderLeft: `2px solid ${srcColor}40`,
transition: 'all 150ms ease', cursor: 'default',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
{/* Source badge */}
<span style={{
fontSize: 8, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
backgroundColor: `${srcColor}20`, color: srcColor,
border: `1px solid ${srcColor}40`,
textTransform: 'uppercase', letterSpacing: '0.03em',
}}>
{srcName}
</span>
{/* Time */}
<span style={{ fontSize: 9, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
{relativeTime(article.published_at)}
</span>
</div>
{/* Title */}
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 11, fontWeight: 600, color: 'var(--color-text)',
textDecoration: 'none', lineHeight: 1.35,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{decodeEntities(article.title)}
</a>
{/* Bottom row: sentiment (only if non-neutral) + tickers */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{article.sentiment && article.sentiment !== 'neutral' && (
<span style={{
fontSize: 8, fontWeight: 600, padding: '1px 5px', borderRadius: 4,
backgroundColor: `${sentColor}20`, color: sentColor,
textTransform: 'capitalize',
}}>
{article.sentiment}
</span>
)}
{(article.matched_tickers || article.matched_markets || []).map(t => (
<span key={t} style={{
fontSize: 8, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
backgroundColor: 'rgba(124, 58, 237, 0.12)', color: '#a78bfa',
letterSpacing: '0.02em',
}}>
{t}
</span>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════════════════
Actions Panel Sub-Component
═══════════════════════════════════════════════════════════════════════════ */
interface ActionsPanelProps {
selectedMarkets: KalshiMarket[];
selectedPetition: Petition | null;
onClearSelection: () => void;
onRefresh: () => void;
}
function ActionsPanel({ selectedMarkets, selectedPetition, onClearSelection, onRefresh }: ActionsPanelProps) {
const [tone, setTone] = useState('urgent');
const [generating, setGenerating] = useState(false);
const [generated, setGenerated] = useState<GeneratedPetition | null>(null);
const [copiedPlatform, setCopiedPlatform] = useState<string | null>(null);
const [actionsExpanded, setActionsExpanded] = useState(true);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' | 'info' } | null>(null);
const [saving, setSaving] = useState(false);
const [batchLinking, setBatchLinking] = useState(false);
function showToast(msg: string, type: 'success' | 'error' | 'info' = 'info') {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
}
// Derived: first selected market (for single-market mode)
const selectedMarket = selectedMarkets[0] || null;
// Alliance mode: 2 markets from different categories
const isAllianceMode = selectedMarkets.length === 2 &&
selectedMarkets[0].category !== selectedMarkets[1].category;
async function handleGenerate() {
if (!selectedMarket) return;
setGenerating(true);
setGenerated(null);
try {
const res = await fetch('/api/orbit/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
market_id: selectedMarket.id,
market_title: selectedMarket.title,
market_ticker: selectedMarket.ticker,
market_yes_price: selectedMarket.yes_price,
tone,
}),
});
if (res.ok) {
const data = await res.json();
setGenerated(data.petition || data);
} else {
setGenerated({
title: `Action Needed: ${selectedMarket.title}`,
summary: `Based on Kalshi market ${selectedMarket.ticker} showing ${Math.round(selectedMarket.yes_price * 100)}% probability, we urge immediate action on this critical issue.`,
body_html: `<p>We, the undersigned, call for immediate attention to the issue tracked by prediction market ${selectedMarket.ticker}.</p>`,
body_text: `We, the undersigned, call for immediate attention to the issue tracked by prediction market ${selectedMarket.ticker}.`,
suggested_target: 'U.S. Congress',
suggested_tags: [(selectedMarket.category || 'Other').toLowerCase(), 'prediction-market', 'action'],
suggested_goal: 10000,
});
}
} catch {
setGenerated({
title: `Action Needed: ${selectedMarket.title}`,
summary: `Based on Kalshi market ${selectedMarket.ticker} showing ${Math.round(selectedMarket.yes_price * 100)}% probability, we urge immediate action.`,
body_html: `<p>We call for immediate attention to this issue.</p>`,
body_text: `We call for immediate attention to this issue.`,
suggested_target: 'U.S. Congress',
suggested_tags: [(selectedMarket.category || 'Other').toLowerCase()],
suggested_goal: 10000,
});
} finally {
setGenerating(false);
}
}
async function handleAllianceGenerate() {
if (selectedMarkets.length !== 2) return;
setGenerating(true);
setGenerated(null);
try {
const res = await fetch('/api/orbit/suggest-alliance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
market_tickers: [selectedMarkets[0].ticker, selectedMarkets[1].ticker],
tone,
}),
});
if (res.ok) {
const data = await res.json();
setGenerated(data.petition || data);
} else {
// Fallback alliance mock
setGenerated({
title: `United Action: ${selectedMarkets[0].category} + ${selectedMarkets[1].category}`,
summary: `An unlikely alliance petition bridging ${selectedMarkets[0].title} and ${selectedMarkets[1].title} for maximum political impact.`,
body_html: `<p>We, a coalition spanning ${selectedMarkets[0].category} and ${selectedMarkets[1].category} advocates, demand unified action.</p>`,
body_text: `We, a coalition spanning ${selectedMarkets[0].category} and ${selectedMarkets[1].category} advocates, demand unified action.`,
suggested_target: 'U.S. Congress',
suggested_tags: [selectedMarkets[0].category.toLowerCase(), selectedMarkets[1].category.toLowerCase(), 'alliance'],
suggested_goal: 25000,
alliance_insight: `This alliance between ${selectedMarkets[0].category} and ${selectedMarkets[1].category} groups creates cross-partisan momentum that neither group could achieve alone.`,
group_a_appeal: `${selectedMarkets[0].category} supporters care because it directly impacts ${selectedMarkets[0].title}.`,
group_b_appeal: `${selectedMarkets[1].category} supporters care because it connects to ${selectedMarkets[1].title}.`,
impact_multiplier: `Cross-partisan petitions historically achieve 3-5x higher engagement and are more likely to receive legislative attention.`,
});
}
} catch {
setGenerated({
title: `United Action: ${selectedMarkets[0].category} + ${selectedMarkets[1].category}`,
summary: `Alliance petition bridging two issue groups.`,
body_html: `<p>Coalition petition spanning multiple constituencies.</p>`,
body_text: `Coalition petition spanning multiple constituencies.`,
suggested_target: 'U.S. Congress',
suggested_tags: ['alliance'],
suggested_goal: 25000,
});
} finally {
setGenerating(false);
}
}
function handleCopyAndOpen(platformKey: string) {
const platform = PLATFORMS[platformKey];
if (!platform || !generated) return;
const text = `${generated.title}\n\n${generated.body_text || generated.summary}`;
// Clipboard with fallback for HTTP contexts
const doCopy = () => {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}
// Fallback: textarea copy
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
return Promise.resolve();
};
doCopy().then(() => {
setCopiedPlatform(platformKey);
showToast(`Copied to clipboard! Opening ${platform.name}...`, 'success');
setTimeout(() => setCopiedPlatform(null), 2000);
window.open(platform.url, '_blank');
}).catch(() => {
showToast('Copy failed — opening platform anyway', 'error');
window.open(platform.url, '_blank');
});
}
async function handleSaveDraft() {
if (!generated) return;
setSaving(true);
try {
const res = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: generated.title,
summary: generated.summary,
body_html: generated.body_html || `<p>${generated.summary}</p>`,
body_text: generated.body_text || generated.summary,
target: generated.suggested_target,
tags: generated.suggested_tags,
signature_goal: generated.suggested_goal || 10000,
status: 'draft',
}),
});
if (res.ok) {
showToast('Petition saved as draft!', 'success');
setGenerated(null);
onClearSelection();
onRefresh();
} else {
const err = await res.json().catch(() => ({ error: 'Save failed' }));
showToast(err.error || 'Failed to save petition', 'error');
}
} catch {
showToast('Network error — petition not saved', 'error');
} finally {
setSaving(false);
}
}
async function handleBatchLink() {
setBatchLinking(true);
try {
const res = await fetch('/api/orbit/batch-link', { method: 'POST' });
if (res.ok) {
const data = await res.json();
showToast(`AI linked ${data.links_inserted} connections, created ${data.petitions_created} petitions`, 'success');
} else {
showToast('AI linking failed — try again', 'error');
}
} catch {
showToast('Network error during AI linking', 'error');
} finally {
setBatchLinking(false);
}
}
async function handleActivatePetition(petitionId: string) {
try {
const res = await fetch('/api/petitions', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: petitionId, status: 'active' }),
});
if (res.ok) { showToast('Petition activated!', 'success'); onRefresh(); }
else showToast('Failed to activate', 'error');
} catch {
showToast('Network error', 'error');
}
}
async function handleArchivePetition(petitionId: string) {
try {
const res = await fetch('/api/petitions', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: petitionId, status: 'closed' }),
});
if (res.ok) {
showToast('Petition archived', 'info');
onClearSelection();
onRefresh();
} else showToast('Failed to archive', 'error');
} catch {
showToast('Network error', 'error');
}
}
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, overflow: 'hidden',
position: 'relative',
}}>
{/* Header */}
<div
style={{
padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid var(--color-border)', flexShrink: 0, cursor: 'pointer',
}}
onClick={() => setActionsExpanded(!actionsExpanded)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Zap size={14} style={{ color: '#a78bfa' }} />
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)' }}>Actions</span>
</div>
{actionsExpanded ? <ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
</div>
{/* Toast notification */}
{toast && (
<div style={{
position: 'absolute', bottom: 12, left: '50%', transform: 'translateX(-50%)',
zIndex: 100, padding: '8px 16px', borderRadius: 8,
backgroundColor: toast.type === 'success' ? 'rgba(34, 197, 94, 0.9)' : toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(124, 58, 237, 0.9)',
color: '#fff', fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
animation: 'fadeInUp 200ms ease',
}}>
{toast.msg}
</div>
)}
{actionsExpanded && (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 14px' }}>
{/* No selection — Quick Actions */}
{selectedMarkets.length === 0 && !selectedPetition && !generated && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ textAlign: 'center', padding: '12px 10px' }}>
<div style={{
width: 40, height: 40, borderRadius: 12, margin: '0 auto 10px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
border: '1px solid rgba(124, 58, 237, 0.2)',
}}>
<Target size={18} style={{ color: '#a78bfa' }} />
</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
Select a Node
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
Click a market to generate a petition. Shift+click two from different categories for Alliance mode.
</div>
</div>
{/* Quick Action Buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 9, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>
Quick Actions
</div>
<button
onClick={handleBatchLink}
disabled={batchLinking}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px', borderRadius: 8,
border: '1px solid rgba(167, 139, 250, 0.3)',
backgroundColor: 'rgba(167, 139, 250, 0.06)',
color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
transition: 'all 150ms ease', textAlign: 'left',
opacity: batchLinking ? 0.7 : 1,
}}
onMouseEnter={e => { if (!batchLinking) e.currentTarget.style.backgroundColor = 'rgba(167, 139, 250, 0.12)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(167, 139, 250, 0.06)'; }}
>
{batchLinking
? <Loader2 size={13} style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }} />
: <Sparkles size={13} style={{ flexShrink: 0 }} />}
<span style={{ flex: 1 }}>AI Auto-Link Markets & Petitions</span>
</button>
<button
onClick={async () => {
showToast('Discovering trending topics...', 'info');
try {
const res = await fetch('/api/trending/discover', { method: 'POST' });
if (res.ok) {
const data = await res.json();
showToast(`Discovered ${data.count || 0} trending topics!`, 'success');
} else showToast('Discovery failed', 'error');
} catch { showToast('Network error', 'error'); }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px', borderRadius: 8,
border: '1px solid rgba(34, 197, 94, 0.3)',
backgroundColor: 'rgba(34, 197, 94, 0.06)',
color: '#22c55e', fontSize: 10, fontWeight: 700, cursor: 'pointer',
transition: 'all 150ms ease', textAlign: 'left',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(34, 197, 94, 0.12)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(34, 197, 94, 0.06)'; }}
>
<TrendingUp size={13} style={{ flexShrink: 0 }} />
<span style={{ flex: 1 }}>Discover Trending Topics</span>
</button>
<button
onClick={() => {
showToast('Opening Pulse Agent...', 'info');
window.open('http://45.61.58.125:9845/', '_blank');
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px', borderRadius: 8,
border: '1px solid rgba(6, 182, 212, 0.3)',
backgroundColor: 'rgba(6, 182, 212, 0.06)',
color: '#06b6d4', fontSize: 10, fontWeight: 700, cursor: 'pointer',
transition: 'all 150ms ease', textAlign: 'left',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(6, 182, 212, 0.12)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(6, 182, 212, 0.06)'; }}
>
<Globe size={13} style={{ flexShrink: 0 }} />
<span style={{ flex: 1 }}>Open Pulse of America</span>
</button>
</div>
</div>
)}
{/* Alliance mode — 2 markets from different categories */}
{isAllianceMode && !generated && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Alliance badge */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '6px 12px', borderRadius: 8,
background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.05))',
border: '1px solid rgba(245, 158, 11, 0.3)',
}}>
<Zap size={12} style={{ color: '#f59e0b' }} />
<span style={{ fontSize: 10, fontWeight: 700, color: '#f59e0b' }}>
Alliance Detected: {selectedMarkets[0].category} + {selectedMarkets[1].category}
</span>
</div>
{/* Both markets stacked */}
{selectedMarkets.map((m, idx) => (
<div key={m.id} style={{
padding: '8px 12px', borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: `1px solid ${CATEGORY_COLORS[m.category] || 'var(--color-border)'}33`,
}}>
<div style={{ fontSize: 9, fontWeight: 600, color: CATEGORY_COLORS[m.category] || '#71717a', marginBottom: 2, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Group {String.fromCharCode(65 + idx)}: {m.category}
</div>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.3 }}>
{m.title}
</div>
<div style={{ fontSize: 9, color: '#22c55e', fontWeight: 600, marginTop: 2 }}>
{Math.round((m.yes_price || 0) * 100)}% probability
</div>
</div>
))}
{/* Tone selector */}
<div>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Tone</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['formal', 'urgent', 'passionate', 'grassroots'].map(t => (
<button key={t} onClick={() => setTone(t)}
style={{
padding: '3px 10px', borderRadius: 99, border: 'none',
fontSize: 10, fontWeight: 600, cursor: 'pointer',
backgroundColor: tone === t ? 'rgba(245, 158, 11, 0.2)' : 'var(--color-surface-el)',
color: tone === t ? '#f59e0b' : 'var(--color-text-muted)',
textTransform: 'capitalize', transition: 'all 150ms ease',
}}
>{t}</button>
))}
</div>
</div>
<button
onClick={handleAllianceGenerate}
disabled={generating}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 12px', borderRadius: 8, border: 'none',
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
color: '#000', fontSize: 11, fontWeight: 700,
cursor: generating ? 'wait' : 'pointer',
opacity: generating ? 0.7 : 1,
transition: 'opacity 150ms ease',
}}
>
{generating
? <><Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> Generating Alliance...</>
: <><Zap size={13} /> Generate Alliance Petition</>
}
</button>
</div>
)}
{/* Single market selected — Generate petition */}
{selectedMarkets.length >= 1 && !isAllianceMode && !generated && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
padding: '10px 12px', borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Selected Market
</div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6, lineHeight: 1.3 }}>
{selectedMarket!.title}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 9, color: '#22c55e', fontWeight: 700 }}>
Yes: {Math.round((selectedMarket!.yes_price || 0) * 100)}%
</span>
<span style={{ fontSize: 9, color: '#ef4444', fontWeight: 700 }}>
No: {Math.round((selectedMarket!.no_price || 0) * 100)}%
</span>
<span style={{ fontSize: 9, color: 'var(--color-text-muted)' }}>
Vol: {((selectedMarket!.volume || 0) / 1000).toFixed(0)}k
</span>
</div>
</div>
{/* Tone selector */}
<div>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Tone</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['formal', 'urgent', 'passionate', 'grassroots'].map(t => (
<button key={t} onClick={() => setTone(t)}
style={{
padding: '3px 10px', borderRadius: 99, border: 'none',
fontSize: 10, fontWeight: 600, cursor: 'pointer',
backgroundColor: tone === t ? 'rgba(124, 58, 237, 0.2)' : 'var(--color-surface-el)',
color: tone === t ? '#a78bfa' : 'var(--color-text-muted)',
textTransform: 'capitalize', transition: 'all 150ms ease',
}}
>{t}</button>
))}
</div>
</div>
<button
onClick={handleGenerate}
disabled={generating}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 12px', borderRadius: 8, border: 'none',
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff', fontSize: 11, fontWeight: 700,
cursor: generating ? 'wait' : 'pointer',
opacity: generating ? 0.7 : 1,
transition: 'opacity 150ms ease',
}}
>
{generating
? <><Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> Generating...</>
: <><Sparkles size={13} /> Generate Petition</>
}
</button>
</div>
)}
{/* Generated petition preview */}
{generated && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
padding: '10px 12px', borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: `1px solid ${generated.alliance_insight ? 'rgba(245, 158, 11, 0.3)' : 'rgba(124, 58, 237, 0.2)'}`,
}}>
<div style={{ fontSize: 10, fontWeight: 600, color: generated.alliance_insight ? '#f59e0b' : '#a78bfa', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{generated.alliance_insight ? 'Alliance Petition' : 'Generated Preview'}
</div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4, lineHeight: 1.3 }}>
{generated.title}
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
{generated.summary}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(generated.suggested_tags || []).map(tag => (
<span key={tag} style={{
fontSize: 8, padding: '1px 6px', borderRadius: 99,
backgroundColor: generated.alliance_insight ? 'rgba(245, 158, 11, 0.1)' : 'rgba(124, 58, 237, 0.1)',
color: generated.alliance_insight ? '#f59e0b' : '#a78bfa',
}}>
{tag}
</span>
))}
</div>
</div>
{/* Alliance insight blocks */}
{generated.alliance_insight && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
padding: '8px 10px', borderRadius: 8,
backgroundColor: 'rgba(245, 158, 11, 0.06)',
border: '1px solid rgba(245, 158, 11, 0.15)',
}}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#f59e0b', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Why This Alliance Works
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{generated.alliance_insight}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{generated.group_a_appeal && (
<div style={{ flex: 1, padding: '6px 8px', borderRadius: 6, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 8, fontWeight: 700, color: CATEGORY_COLORS[selectedMarkets[0]?.category] || '#71717a', marginBottom: 2 }}>GROUP A</div>
<div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.group_a_appeal}</div>
</div>
)}
{generated.group_b_appeal && (
<div style={{ flex: 1, padding: '6px 8px', borderRadius: 6, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 8, fontWeight: 700, color: CATEGORY_COLORS[selectedMarkets[1]?.category] || '#71717a', marginBottom: 2 }}>GROUP B</div>
<div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.group_b_appeal}</div>
</div>
)}
</div>
{generated.impact_multiplier && (
<div style={{
padding: '6px 10px', borderRadius: 6,
backgroundColor: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.15)',
}}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#22c55e', marginBottom: 2 }}>IMPACT MULTIPLIER</div>
<div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.impact_multiplier}</div>
</div>
)}
</div>
)}
{/* Save / Regenerate / Activate */}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={handleSaveDraft}
disabled={saving}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
padding: '7px 10px', borderRadius: 8, border: 'none',
background: saving ? '#555' : 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff', fontSize: 10, fontWeight: 700,
cursor: saving ? 'wait' : 'pointer',
opacity: saving ? 0.7 : 1,
transition: 'all 150ms ease',
}}
>
{saving
? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Saving...</>
: <><FileText size={12} /> Save as Draft</>
}
</button>
<button
onClick={() => setGenerated(null)}
style={{
padding: '7px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
fontSize: 10, fontWeight: 600, cursor: 'pointer',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
Redo
</button>
</div>
{/* Cross-Platform Posting */}
<div>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Cross-Platform Post
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.entries(PLATFORMS).map(([key, platform]) => (
<button
key={key}
onClick={() => handleCopyAndOpen(key)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
backgroundColor: 'transparent', color: 'var(--color-text)',
fontSize: 10, fontWeight: 600, cursor: 'pointer',
transition: 'all 150ms ease', textAlign: 'left',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; e.currentTarget.style.borderColor = platform.color; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.borderColor = 'var(--color-border)'; }}
>
<div style={{
width: 22, height: 22, borderRadius: 6, flexShrink: 0,
backgroundColor: `${platform.color}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ExternalLink size={11} style={{ color: platform.color }} />
</div>
<span style={{ flex: 1 }}>{platform.name}</span>
{copiedPlatform === key
? <Check size={12} style={{ color: '#22c55e' }} />
: <Copy size={12} style={{ color: 'var(--color-text-muted)' }} />
}
</button>
))}
</div>
</div>
{/* Email Campaign Link */}
<button
onClick={async () => {
if (!generated) return;
// Save petition first, then redirect to campaigns
setSaving(true);
try {
const res = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: generated.title,
summary: generated.summary,
body_html: generated.body_html || `<p>${generated.summary}</p>`,
body_text: generated.body_text || generated.summary,
target: generated.suggested_target,
tags: generated.suggested_tags,
signature_goal: generated.suggested_goal || 10000,
status: 'active',
}),
});
if (res.ok) {
showToast('Petition saved & ready for campaign!', 'success');
// Navigate to campaigns tab after short delay
setTimeout(() => {
const event = new CustomEvent('patty-navigate', { detail: { tab: 'campaigns' } });
window.dispatchEvent(event);
}, 500);
} else {
showToast('Failed to save petition for campaign', 'error');
}
} catch {
showToast('Network error', 'error');
} finally {
setSaving(false);
}
}}
disabled={saving}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '7px 10px', borderRadius: 8,
border: '1px solid rgba(124, 58, 237, 0.3)',
backgroundColor: 'rgba(124, 58, 237, 0.08)',
color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.15)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.08)'; }}
>
<Mail size={12} /> Create Email Campaign
</button>
</div>
)}
{/* Petition selected — show linked markets + cross-post */}
{selectedPetition && selectedMarkets.length === 0 && !generated && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
padding: '10px 12px', borderRadius: 8,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Selected Petition
</div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4, lineHeight: 1.3 }}>
{selectedPetition.title}
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
{selectedPetition.summary}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{
fontSize: 9, padding: '2px 6px', borderRadius: 4,
backgroundColor: STATUS_STYLES[selectedPetition.status]?.bg,
color: STATUS_STYLES[selectedPetition.status]?.text,
fontWeight: 600, textTransform: 'capitalize',
}}>
{selectedPetition.status}
</span>
<span style={{ fontSize: 9, color: 'var(--color-text-muted)' }}>
{(selectedPetition.signature_count ?? 0).toLocaleString()} / {(selectedPetition.signature_goal ?? 1).toLocaleString()} sigs
</span>
</div>
</div>
{/* Cross-Platform Posting for existing petition */}
<div>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Post to Platform
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.entries(PLATFORMS).map(([key, platform]) => (
<button
key={key}
onClick={() => {
const text = `${selectedPetition.title}\n\n${selectedPetition.summary}`;
navigator.clipboard.writeText(text).then(() => {
setCopiedPlatform(key);
setTimeout(() => setCopiedPlatform(null), 2000);
window.open(platform.url, '_blank');
});
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
backgroundColor: 'transparent', color: 'var(--color-text)',
fontSize: 10, fontWeight: 600, cursor: 'pointer',
transition: 'all 150ms ease', textAlign: 'left',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{
width: 22, height: 22, borderRadius: 6, flexShrink: 0,
backgroundColor: `${platform.color}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ExternalLink size={11} style={{ color: platform.color }} />
</div>
<span style={{ flex: 1 }}>Copy & Open {platform.name}</span>
{copiedPlatform === key
? <Check size={12} style={{ color: '#22c55e' }} />
: <Copy size={12} style={{ color: 'var(--color-text-muted)' }} />
}
</button>
))}
</div>
</div>
{/* Activate / Archive buttons */}
<div style={{ display: 'flex', gap: 6 }}>
{selectedPetition.status === 'draft' && (
<button
onClick={() => handleActivatePetition(selectedPetition.id)}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
padding: '7px 10px', borderRadius: 8, border: 'none',
background: 'linear-gradient(135deg, #22c55e, #16a34a)',
color: '#fff', fontSize: 10, fontWeight: 700, cursor: 'pointer',
transition: 'all 150ms ease',
}}
>
<Zap size={11} /> Activate
</button>
)}
<button
onClick={() => handleArchivePetition(selectedPetition.id)}
style={{
padding: '7px 10px', borderRadius: 8,
border: '1px solid rgba(239, 68, 68, 0.3)',
backgroundColor: 'rgba(239, 68, 68, 0.06)',
color: '#ef4444', fontSize: 10, fontWeight: 600, cursor: 'pointer',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.12)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.06)'; }}
>
<X size={11} /> Archive
</button>
</div>
<button
onClick={() => {
showToast('Opening campaign builder...', 'info');
const event = new CustomEvent('patty-navigate', { detail: { tab: 'campaigns' } });
window.dispatchEvent(event);
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '7px 10px', borderRadius: 8,
border: '1px solid rgba(124, 58, 237, 0.3)',
backgroundColor: 'rgba(124, 58, 237, 0.08)',
color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.15)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.08)'; }}
>
<Mail size={12} /> Create Campaign from Petition
</button>
{/* Share Link */}
<button
onClick={() => {
const shareUrl = `${window.location.origin}/petition/${selectedPetition.id}`;
const doCopy = () => {
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(shareUrl);
const ta = document.createElement('textarea');
ta.value = shareUrl;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
return Promise.resolve();
};
doCopy().then(() => showToast('Petition link copied!', 'success'))
.catch(() => showToast('Failed to copy link', 'error'));
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '7px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
backgroundColor: 'transparent',
color: 'var(--color-text-secondary)', fontSize: 10, fontWeight: 600, cursor: 'pointer',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Link2 size={11} /> Share Petition Link
</button>
</div>
)}
</div>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════════════════
Main OrbitTab Component
═══════════════════════════════════════════════════════════════════════════ */
interface OrbitTabProps {
pulseTopic?: string | null;
onPulseTopicConsumed?: () => void;
}
export default function OrbitTab({ pulseTopic, onPulseTopicConsumed }: OrbitTabProps = {}) {
/* ── Data state ───────────────────────────────────────────────────────── */
const [markets, setMarkets] = useState<KalshiMarket[]>(MOCK_MARKETS);
const [petitions, setPetitions] = useState<Petition[]>(MOCK_PETITIONS);
const [links, setLinks] = useState<OrbitLink[]>(MOCK_LINKS);
const [articles, setArticles] = useState<RSSArticle[]>(MOCK_ARTICLES);
const [stats, setStats] = useState<OrbitStats>(MOCK_STATS);
const [trendingTopics, setTrendingTopics] = useState<TrendingTopic[]>([]);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [loading, setLoading] = useState(false);
const [rssLoading, setRssLoading] = useState(false);
const [pulseGenerating, setPulseGenerating] = useState(false);
const [pulseResult, setPulseResult] = useState<{ petition: any; influence_analysis: string } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [usingMock, setUsingMock] = useState(true);
/* ── Interaction state ────────────────────────────────────────────────── */
const [hoveredMarket, setHoveredMarket] = useState<string | null>(null);
const [hoveredPetition, setHoveredPetition] = useState<string | null>(null);
const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
const [selectedPetition, setSelectedPetition] = useState<string | null>(null);
// Market selection handler: normal click = single select, shift = multi (max 2)
const handleSelectMarket = useCallback((id: string | null, shift?: boolean) => {
if (!id) {
setSelectedMarkets([]);
return;
}
setSelectedMarkets(prev => {
// Deselect if already selected
if (prev.includes(id)) return prev.filter(x => x !== id);
// Shift+click: add to selection (max 2)
if (shift && prev.length < 2) return [...prev, id];
if (shift && prev.length >= 2) return [prev[1], id]; // drop oldest
// Normal click: replace
return [id];
});
setSelectedPetition(null);
}, []);
/* ── Fetch orbit data ─────────────────────────────────────────────────── */
const fetchOrbitData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/orbit');
if (res.ok) {
const data = await res.json();
if (data.markets?.length > 0) {
// Map DB fields to client-expected fields
const mappedMarkets = data.markets.map((m: any) => {
// DB stores prices as integers 0-100; client expects floats 0-1
const rawYes = m.yes_price ?? m.last_price ?? m.yes_bid ?? 0;
const yesPrice = rawYes > 1 ? rawYes / 100 : rawYes;
const rawNo = m.no_price ?? m.no_bid ?? (rawYes != null ? (rawYes > 1 ? 100 : 1) - rawYes : 0);
const noPrice = rawNo > 1 ? rawNo / 100 : rawNo;
return {
...m,
yes_price: yesPrice,
no_price: noPrice,
volume: m.volume ?? 0,
category: m.category || 'Other',
};
});
setMarkets(mappedMarkets);
setPetitions(data.petitions || []);
setLinks(data.links || []);
if (data.trending?.length > 0) setTrendingTopics(data.trending);
setStats({
...MOCK_STATS,
...data.stats,
articles_today: data.stats?.articles_today ?? data.stats?.recent_articles ?? MOCK_STATS.articles_today,
avg_sentiment: data.stats?.avg_sentiment ?? MOCK_STATS.avg_sentiment,
});
setUsingMock(false);
}
}
} catch {
// Keep mock data on failure
} finally {
setLoading(false);
}
}, []);
/* ── Fetch RSS data ───────────────────────────────────────────────────── */
const fetchRSSData = useCallback(async () => {
setRssLoading(true);
try {
const res = await fetch('/api/orbit/rss');
if (res.ok) {
const data = await res.json();
if (data.articles?.length > 0) {
setArticles(data.articles);
if (data.stats) {
setStats(prev => ({
...prev,
active_feeds: data.stats.active_feeds ?? prev.active_feeds,
articles_today: data.stats.articles_today ?? prev.articles_today,
avg_sentiment: data.stats.avg_sentiment ?? prev.avg_sentiment,
}));
}
setUsingMock(false);
}
}
} catch {
// Keep mock data
} finally {
setRssLoading(false);
}
}, []);
/* ── Fetch agent status ──────────────────────────────────────────────── */
const fetchAgentStatus = useCallback(async () => {
try {
const res = await fetch('/api/orbit/agents');
if (res.ok) {
const data = await res.json();
setAgents(data.agents || []);
}
} catch { /* ignore */ }
}, []);
const handleRefreshFeeds = useCallback(async () => {
setRefreshing(true);
await fetchRSSData();
setRefreshing(false);
}, [fetchRSSData]);
useEffect(() => {
fetchOrbitData();
fetchRSSData();
fetchAgentStatus();
}, [fetchOrbitData, fetchRSSData, fetchAgentStatus]);
/* ── Auto-generate petition from Pulse topic ───────────────────────── */
useEffect(() => {
if (!pulseTopic) return;
setPulseGenerating(true);
setPulseResult(null);
fetch('/api/orbit/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_title: pulseTopic, tone: 'urgent' }),
})
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
setPulseResult({ petition: data.petition, influence_analysis: data.influence_analysis || '' });
// Refresh data to show new petition in mind map
fetchOrbitData();
})
.catch(err => {
console.error('[OrbitTab] Pulse petition generation failed:', err);
setPulseResult(null);
})
.finally(() => {
setPulseGenerating(false);
onPulseTopicConsumed?.();
});
}, [pulseTopic, onPulseTopicConsumed, fetchOrbitData]);
/* ── Derived selection objects ─────────────────────────────────────────── */
const selectedMarketObjs = selectedMarkets.map(id => markets.find(m => m.id === id)).filter(Boolean) as KalshiMarket[];
const selectedPetitionObj = selectedPetition ? petitions.find(p => p.id === selectedPetition) || null : null;
const sentimentInfo = sentimentLabel(stats.avg_sentiment);
/* ─── Render ──────────────────────────────────────────────────────────── */
return (
<div style={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
{/* ── Stats Bar ────────────────────────────────────────────────────── */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
padding: '10px 16px', borderRadius: 12,
background: 'linear-gradient(135deg, var(--color-surface), var(--color-surface-el))',
border: '1px solid var(--color-border)',
flexShrink: 0,
}}>
{usingMock && (
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '3px 10px', borderRadius: 99,
backgroundColor: 'rgba(245, 158, 11, 0.15)',
border: '1px solid rgba(245, 158, 11, 0.3)',
}}>
<AlertCircle size={11} style={{ color: '#f59e0b' }} />
<span style={{ fontSize: 9, fontWeight: 600, color: '#f59e0b' }}>Demo Data</span>
</div>
)}
<StatPill icon={<BarChart3 size={12} />} label="Markets" value={stats.total_markets} color="#3b82f6" />
<StatPill icon={<Link2 size={12} />} label="Linked" value={stats.linked_petitions} color="#a78bfa" />
<StatPill icon={<Rss size={12} />} label="Feeds" value={stats.active_feeds} color="#22c55e" />
<StatPill icon={<Newspaper size={12} />} label="Today" value={stats.articles_today} color="#06b6d4" />
<StatPill icon={<TrendingUp size={12} />} label="Trending" value={trendingTopics.length} color={TRENDING_COLOR} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>Sentiment</span>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
backgroundColor: `${sentimentInfo.color}20`, color: sentimentInfo.color,
}}>
{sentimentInfo.label} ({(stats.avg_sentiment ?? 0).toFixed(2)})
</span>
</div>
{/* Agent Status Indicators */}
{agents.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 99,
backgroundColor: 'rgba(124,58,237,0.08)', border: '1px solid rgba(124,58,237,0.15)' }}
title={agents.map(a => `${a.name}: ${a.status}`).join(', ')}
>
<Activity size={10} style={{ color: '#a78bfa' }} />
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--color-text-muted)' }}>Agents</span>
{agents.map(a => (
<div key={a.name} title={`${a.name} (${a.role}) — ${a.status}`}
style={{
width: 7, height: 7, borderRadius: '50%',
backgroundColor: a.status === 'online' ? '#22c55e' : a.status === 'error' ? '#f59e0b' : '#ef4444',
border: '1px solid rgba(255,255,255,0.15)',
transition: 'background-color 300ms ease',
}}
/>
))}
<span style={{ fontSize: 9, fontWeight: 700, color: '#22c55e' }}>
{agents.filter(a => a.status === 'online').length}/{agents.length}
</span>
</div>
)}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
{loading && <Loader2 size={13} style={{ color: 'var(--color-text-muted)', animation: 'spin 1s linear infinite' }} />}
<button
onClick={() => { fetchOrbitData(); fetchRSSData(); fetchAgentStatus(); }}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
borderRadius: 6, border: '1px solid var(--color-border)',
backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
cursor: 'pointer', fontSize: 10, fontWeight: 600,
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<RefreshCw size={11} /> Sync
</button>
</div>
</div>
{/* ── Pulse Topic Banner ──────────────────────────────────────────── */}
{(pulseGenerating || pulseResult) && (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 16px', borderRadius: 10,
background: pulseGenerating
? 'linear-gradient(135deg, rgba(124,58,237,0.15), rgba(59,130,246,0.15))'
: 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(124,58,237,0.12))',
border: `1px solid ${pulseGenerating ? 'rgba(124,58,237,0.3)' : 'rgba(34,197,94,0.3)'}`,
flexShrink: 0,
}}>
{pulseGenerating ? (
<>
<Loader2 size={16} style={{ color: '#a78bfa', animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: 12, fontWeight: 600, color: '#a78bfa' }}>
Generating petition from Pulse topic...
</span>
</>
) : pulseResult ? (
<>
<Sparkles size={16} style={{ color: '#22c55e' }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)' }}>
Petition created: “{pulseResult.petition?.title}”
</span>
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', marginLeft: 4 }}>
{pulseResult.influence_analysis?.slice(0, 120)}...
</span>
<button
onClick={() => setPulseResult(null)}
style={{
marginLeft: 'auto', padding: '2px 8px', borderRadius: 4,
border: '1px solid var(--color-border)', backgroundColor: 'transparent',
color: 'var(--color-text-muted)', fontSize: 10, cursor: 'pointer',
}}
>
<X size={12} />
</button>
</>
) : null}
</div>
)}
{/* ── Main Layout: Mind Map | Right Panels ─────────────────────────── */}
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{/* Mind Map (left, 60%) */}
<div style={{
flex: '0 0 60%', minWidth: 0,
height: 'calc(100vh - 140px)',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, overflow: 'hidden', position: 'relative',
}}>
{loading && !usingMock ? (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%',
flexDirection: 'column', gap: 8,
}}>
<Loader2 size={24} style={{ color: '#a78bfa', animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>Loading orbit data...</span>
</div>
) : (
<MindMap
markets={markets.slice(0, 14)}
petitions={petitions.slice(0, 6)}
links={links}
trendingTopics={trendingTopics.slice(0, 6)}
hoveredMarket={hoveredMarket}
hoveredPetition={hoveredPetition}
selectedMarkets={selectedMarkets}
selectedPetition={selectedPetition}
onHoverMarket={setHoveredMarket}
onHoverPetition={setHoveredPetition}
onSelectMarket={handleSelectMarket}
onSelectPetition={(id) => { setSelectedPetition(id); if (id) setSelectedMarkets([]); }}
onSelectTrending={(id) => {
// When a trending topic is clicked, auto-generate a petition from it
if (id) {
const topic = trendingTopics.find(t => t.id === id);
if (topic) {
window.dispatchEvent(new CustomEvent('patty-navigate', { detail: { tab: 'trending' } }));
}
}
}}
/>
)}
{/* Watermark */}
<div style={{
position: 'absolute', bottom: 8, right: 12,
fontSize: 9, color: 'var(--color-text-muted)', opacity: 0.5,
fontWeight: 600, letterSpacing: '0.04em',
}}>
ORBIT MIND MAP
</div>
</div>
{/* Right panels (40%, stacked) */}
<div style={{ flex: '0 0 40%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12, height: 'calc(100vh - 140px)' }}>
{/* Pulse panel (top 55%) */}
<div style={{ flex: '0 0 55%', minHeight: 0 }}>
<PulsePanel
articles={articles}
loading={rssLoading}
onRefresh={handleRefreshFeeds}
refreshing={refreshing}
/>
</div>
{/* Actions panel (bottom 45%) */}
<div style={{ flex: 1, minHeight: 0 }}>
<ActionsPanel
selectedMarkets={selectedMarketObjs}
selectedPetition={selectedPetitionObj}
onClearSelection={() => { setSelectedMarkets([]); setSelectedPetition(null); }}
onRefresh={fetchOrbitData}
/>
</div>
</div>
</div>
{/* Animation keyframes */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`}</style>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════════════════
StatPill Utility
═══════════════════════════════════════════════════════════════════════════ */
function StatPill({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: number; color: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<div style={{ color, display: 'flex', alignItems: 'center' }}>{icon}</div>
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>{label}</span>
<span style={{ fontSize: 11, fontWeight: 800, color: 'var(--color-text)' }}>{value}</span>
</div>
);
}