← 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>
  );
}