← back to Ken
kalshi-dash/src/pages/Markets.jsx
428 lines
import React, { useState, useEffect } from 'react';
import { post } from '../api';
/* ─── Orderbook sub-row inside a ContractRow ─── */
function ContractRow({ m }) {
const [ob, setOb] = useState(null);
const [obOpen, setObOpen] = useState(false);
const [obLoading, setObLoading] = useState(false);
async function loadOb() {
if (obOpen) { setObOpen(false); return; }
setObOpen(true);
setObLoading(true);
try {
const r = await post('/kalshi', { action: 'get_orderbook', ticker: m.ticker });
if (r.success) setOb(r.orderbook);
} catch {}
setObLoading(false);
}
return (
<>
<tr style={{ background: 'rgba(0,240,255,0.02)' }}>
{/* Contract title */}
<td className="pl-8 max-w-[250px]">
<span className="truncate block text-sm" style={{ color: 'var(--text-dim)' }}>
{m.title || m.subtitle || m.ticker}
</span>
</td>
{/* Ticker link */}
<td>
<a
href={`https://kalshi.com/search?query=${encodeURIComponent((m.ticker || '').split('-')[0])}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs hover:underline transition"
style={{ color: 'var(--cyan)' }}
>
{m.ticker}
</a>
</td>
{/* Yes Bid */}
<td>
<span className="mono text-sm" style={{ color: 'var(--green)' }}>
{m.yes_bid ?? '—'}¢
</span>
</td>
{/* Yes Ask */}
<td>
<span className="mono text-sm" style={{ color: 'var(--red)' }}>
{m.yes_ask ?? '—'}¢
</span>
</td>
{/* Volume */}
<td>
<span className="mono text-xs" style={{ color: 'var(--text-dim)' }}>
{(m.volume || 0).toLocaleString()}
</span>
</td>
{/* Open Interest */}
<td>
<span className="mono text-xs" style={{ color: 'var(--text-muted)' }}>
{m.open_interest ? m.open_interest.toLocaleString() : '—'}
</span>
</td>
{/* Orderbook toggle */}
<td>
<button
onClick={loadOb}
className="btn btn-ghost btn-sm"
style={{ color: obOpen ? 'var(--cyan)' : 'var(--text-muted)' }}
>
{obOpen ? '▾ Book' : '▸ Book'}
</button>
</td>
</tr>
{/* Orderbook detail row */}
{obOpen && (
<tr style={{ background: 'rgba(0,240,255,0.01)' }}>
<td colSpan={7} className="p-4">
{obLoading ? (
<div className="flex items-center justify-center h-16">
<div
className="animate-spin w-5 h-5 border-2 border-t-transparent rounded-full"
style={{ borderColor: 'var(--cyan)', borderTopColor: 'transparent' }}
/>
</div>
) : ob ? (
<div className="grid grid-cols-2 gap-6 max-w-lg">
{/* YES bids */}
<div>
<p className="stat-label mb-2" style={{ color: 'var(--green)' }}>YES Bids</p>
<div className="space-y-1">
{(ob.yes || []).slice(0, 8).map((b, i) => (
<div key={i} className="relative flex justify-between text-xs px-2 py-1 rounded overflow-hidden" style={{ background: 'var(--surface-el)' }}>
<div
className="absolute inset-0 rounded"
style={{ width: `${Math.min((b[1] || 0) * 2, 100)}%`, background: 'rgba(0,195,137,0.08)' }}
/>
<span className="mono relative z-10" style={{ color: 'var(--green)' }}>{b[0]}¢</span>
<span className="mono relative z-10" style={{ color: 'var(--text-muted)' }}>{b[1]}</span>
</div>
))}
{(!ob.yes || ob.yes.length === 0) && (
<p className="text-xs text-center py-2" style={{ color: 'var(--text-muted)' }}>Empty</p>
)}
</div>
</div>
{/* NO bids */}
<div>
<p className="stat-label mb-2" style={{ color: 'var(--red)' }}>NO Bids</p>
<div className="space-y-1">
{(ob.no || []).slice(0, 8).map((a, i) => (
<div key={i} className="relative flex justify-between text-xs px-2 py-1 rounded overflow-hidden" style={{ background: 'var(--surface-el)' }}>
<div
className="absolute inset-0 rounded"
style={{ width: `${Math.min((a[1] || 0) * 2, 100)}%`, background: 'rgba(255,92,92,0.08)' }}
/>
<span className="mono relative z-10" style={{ color: 'var(--red)' }}>{a[0]}¢</span>
<span className="mono relative z-10" style={{ color: 'var(--text-muted)' }}>{a[1]}</span>
</div>
))}
{(!ob.no || ob.no.length === 0) && (
<p className="text-xs text-center py-2" style={{ color: 'var(--text-muted)' }}>Empty</p>
)}
</div>
</div>
</div>
) : (
<p className="text-xs text-center py-2" style={{ color: 'var(--text-muted)' }}>No orderbook data</p>
)}
</td>
</tr>
)}
</>
);
}
/* ─── Expandable event row ─── */
function EventRow({ e }) {
const [expanded, setExpanded] = useState(false);
const [contracts, setContracts] = useState([]);
const [loading, setLoading] = useState(false);
async function toggle() {
if (expanded) { setExpanded(false); return; }
setExpanded(true);
if (contracts.length > 0) return;
setLoading(true);
try {
const r = await post('/kalshi', { action: 'get_markets', params: { event_ticker: e.event_ticker } });
setContracts(r.markets || []);
} catch {}
setLoading(false);
}
return (
<>
{/* Event header row */}
<tr
className="cursor-pointer transition"
onClick={toggle}
style={{ background: expanded ? 'rgba(0,240,255,0.04)' : undefined }}
onMouseEnter={ev => {
if (!expanded) {
ev.currentTarget.style.background = 'var(--cyan-glow)';
}
}}
onMouseLeave={ev => {
if (!expanded) ev.currentTarget.style.background = '';
}}
>
<td className="max-w-[300px]">
<div className="flex items-center gap-2">
<span
className="text-xs transition-transform inline-block"
style={{
color: 'var(--text-muted)',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
▶
</span>
<span className="truncate block font-medium heading" style={{ color: 'var(--text)' }}>
{e.title}
</span>
</div>
</td>
<td>
<span className="badge bg-orange">{e.category || '—'}</span>
</td>
<td>
<a
href={`https://kalshi.com/search?query=${encodeURIComponent((e.event_ticker || '').split('-')[0])}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs hover:underline transition"
style={{ color: 'var(--cyan)' }}
onClick={ev => ev.stopPropagation()}
>
{e.event_ticker}
</a>
</td>
<td>
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
{e.mutually_exclusive ? (
<span className="badge bg-blue">ME</span>
) : '—'}
</span>
</td>
<td onClick={ev => ev.stopPropagation()}>
{expanded ? (
<span className="text-xs mono" style={{ color: 'var(--cyan)' }}>
{contracts.length || '...'} contracts
</span>
) : (
<span className="text-xs transition" style={{ color: 'var(--text-muted)' }}>
View Contracts
</span>
)}
</td>
</tr>
{/* Contracts sub-table */}
{expanded && (
<tr>
<td colSpan={5} className="p-0">
<div
className="ml-6 fade-in"
style={{
borderLeft: '2px solid rgba(0,240,255,0.2)',
background: 'rgba(0,240,255,0.015)',
}}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div
className="animate-spin w-5 h-5 border-2 border-t-transparent rounded-full"
style={{ borderColor: 'var(--cyan)', borderTopColor: 'transparent' }}
/>
</div>
) : contracts.length === 0 ? (
<div className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>
No contracts found
</div>
) : (
<table className="w-full">
<thead>
<tr>
<th className="pl-8">Contract</th>
<th>Ticker</th>
<th>Yes Bid</th>
<th>Yes Ask</th>
<th>Volume</th>
<th>Open Int.</th>
<th>Book</th>
</tr>
</thead>
<tbody>
{contracts.map(m => <ContractRow key={m.ticker} m={m} />)}
</tbody>
</table>
)}
</div>
</td>
</tr>
)}
</>
);
}
/* ─── Main Markets page ─── */
export default function Markets() {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
useEffect(() => { loadEvents(); }, []);
async function loadEvents() {
setLoading(true);
try {
const r = await post('/kalshi', { action: 'get_events', params: { status: 'open', limit: 100 } });
setEvents(r.events || []);
} catch {}
setLoading(false);
}
const categories = [...new Set(events.map(e => e.category).filter(Boolean))].sort();
const filtered = events.filter(e => {
const matchesText = !filter ||
(e.title || '').toLowerCase().includes(filter.toLowerCase()) ||
(e.category || '').toLowerCase().includes(filter.toLowerCase());
const matchesCat = !categoryFilter || e.category === categoryFilter;
return matchesText && matchesCat;
});
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div
className="animate-spin w-8 h-8 border-2 border-t-transparent rounded-full pulse-cyan"
style={{ borderColor: 'var(--cyan)', borderTopColor: 'transparent' }}
/>
</div>
);
}
return (
<div className="space-y-6 fade-in">
{/* ── Page Header ── */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="heading text-3xl font-bold" style={{ color: 'var(--text)' }}>
Markets
</h1>
<span className="badge bg-cyan">
{events.length} events
</span>
{filtered.length !== events.length && (
<span className="badge bg-orange">
{filtered.length} filtered
</span>
)}
</div>
<p className="section-subtitle mt-1">Live Kalshi prediction markets</p>
</div>
<button onClick={loadEvents} className="btn btn-o btn-sm">
↻ Refresh
</button>
</div>
{/* ── Search Bar ── */}
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm pointer-events-none"
style={{ color: 'var(--text-muted)' }}
>
⌕
</span>
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Search markets by name or category..."
className="input pl-8"
/>
</div>
{/* ── Category Filter Pills ── */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
onClick={() => setCategoryFilter('')}
className="badge transition cursor-pointer"
style={{
background: !categoryFilter ? 'var(--cyan-dim)' : 'rgba(42,42,53,0.6)',
color: !categoryFilter ? 'var(--cyan)' : 'var(--text-muted)',
border: !categoryFilter ? '1px solid rgba(0,240,255,0.3)' : '1px solid var(--border)',
}}
>
All
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat === categoryFilter ? '' : cat)}
className="badge transition cursor-pointer"
style={{
background: categoryFilter === cat ? 'var(--orange-dim)' : 'rgba(42,42,53,0.6)',
color: categoryFilter === cat ? 'var(--orange)' : 'var(--text-muted)',
border: categoryFilter === cat ? '1px solid rgba(247,147,26,0.3)' : '1px solid var(--border)',
}}
>
{cat}
</button>
))}
</div>
)}
{/* ── Events Table ── */}
<div className="card overflow-x-auto" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<th className="pl-5">Event</th>
<th>Category</th>
<th>Ticker</th>
<th>Type</th>
<th>Contracts</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-12" style={{ color: 'var(--text-muted)' }}>
<div className="flex flex-col items-center gap-2">
<span className="text-2xl">⌕</span>
<span className="text-sm">No markets match your filter</span>
</div>
</td>
</tr>
) : (
filtered.map(e => <EventRow key={e.event_ticker} e={e} />)
)}
</tbody>
</table>
</div>
</div>
);
}