โ† Back
โ˜†
import { useState, useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import KPIBar from '../components/screener/KPIBar';
import MarketsTable from '../components/screener/MarketsTable';
import EventsList from '../components/screener/EventsList';
import { useMarkets, useStats } from '../hooks/useMarkets';
import { useEvents } from '../hooks/useEvents';
import { useWebSocket } from '../hooks/useWebSocket';
import { useT } from '../i18n/LanguageContext';

type View = 'events' | 'markets';

const CATEGORIES = ['All', 'Sports', 'Politics', 'Crypto', 'Weather', 'Tech', 'Finance', 'Culture', 'Geopolitics', 'Elections'];
const PAGE_SIZE = 50;

// Visible sort selector โ€” the screener's core power, otherwise only reachable
// via desktop table headers (absent on mobile cards). Each option carries a
// sensible default direction (tight spread / soonest ending = ascending).
type SortOpt = { key: string; labelKey: string; dir: 'asc' | 'desc' };
const MARKET_SORTS: SortOpt[] = [
  { key: 'volume', labelKey: 'mkt.sort.volume', dir: 'desc' },
  { key: 'liquidity', labelKey: 'mkt.sort.liquidity', dir: 'desc' },
  { key: 'spread', labelKey: 'mkt.sort.spread', dir: 'asc' },
  { key: 'end_date', labelKey: 'mkt.sort.endDate', dir: 'asc' },
  { key: 'updated_at', labelKey: 'mkt.sort.updated', dir: 'desc' },
];
const EVENT_SORTS: SortOpt[] = [
  { key: 'volume', labelKey: 'mkt.sort.volume', dir: 'desc' },
  { key: 'liquidity', labelKey: 'mkt.sort.liquidity', dir: 'desc' },
  { key: 'outcomes', labelKey: 'mkt.sort.outcomes', dir: 'desc' },
  { key: 'end_date', labelKey: 'mkt.sort.endDate', dir: 'asc' },
];

type FOpt = { label: string; value: number; labelKey?: string };
const VOLUME_OPTIONS: FOpt[] = [
  { label: 'Any', value: 0, labelKey: 'mkt.anyM' },
  { label: '$1K+', value: 1000 },
  { label: '$10K+', value: 10000 },
  { label: '$100K+', value: 100000 },
  { label: '$1M+', value: 1000000 },
];

const LIQUIDITY_OPTIONS: FOpt[] = [
  { label: 'Any', value: 0, labelKey: 'mkt.anyF' },
  { label: '$10K+', value: 10000 },
  { label: '$100K+', value: 100000 },
  { label: '$1M+', value: 1000000 },
];

type EOpt = { label: string; value: string; labelKey?: string };
const ENDING_OPTIONS: EOpt[] = [
  { label: 'Anytime', value: '', labelKey: 'mkt.anytime' },
  { label: '24h', value: 'today', labelKey: 'mkt.24h' },
  { label: 'Week', value: 'week', labelKey: 'mkt.week' },
];

export default function ScreenerPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  // Init from URL
  const [category, setCategory] = useState(searchParams.get('cat') || 'All');
  const [search, setSearch] = useState(searchParams.get('q') || '');
  const [searchInput, setSearchInput] = useState(searchParams.get('q') || '');
  const [minVolume, setMinVolume] = useState(Number(searchParams.get('vol')) || 0);
  const [minLiquidity, setMinLiquidity] = useState(Number(searchParams.get('liq')) || 0);
  const [ending, setEnding] = useState(searchParams.get('end') || '');
  const [minPrice, setMinPrice] = useState(Number(searchParams.get('pmin')) || 0);
  const [maxPrice, setMaxPrice] = useState(Number(searchParams.get('pmax')) || 1);
  const [sortCol, setSortCol] = useState(searchParams.get('sort') || 'volume');
  const [sortDir, setSortDir] = useState(searchParams.get('dir') || 'desc');
  const [page, setPage] = useState(Number(searchParams.get('p')) || 0);
  const [showFilters, setShowFilters] = useState(false);
  const [view, setView] = useState<View>(searchParams.get('view') === 'markets' ? 'markets' : 'events');

  const sorts = view === 'events' ? EVENT_SORTS : MARKET_SORTS;
  const { t } = useT();

  const ws = useWebSocket();
  const { stats, loading: statsLoading } = useStats();
  const { markets, meta, loading } = useMarkets({
    category,
    search,
    min_volume: minVolume,
    min_liquidity: minLiquidity,
    min_price: minPrice,
    max_price: maxPrice,
    ending,
    sort: sortCol,
    order: sortDir,
    limit: PAGE_SIZE,
    offset: page * PAGE_SIZE,
  });
  const { events, meta: eventsMeta, loading: eventsLoading } = useEvents({
    category,
    search,
    sort: sortCol,
    order: sortDir,
    limit: PAGE_SIZE,
    offset: page * PAGE_SIZE,
    enabled: view === 'events',
  });

  const activeMeta = view === 'events' ? eventsMeta : meta;
  const activeLoading = view === 'events' ? eventsLoading : loading;

  const switchView = useCallback((v: View) => {
    setView(v);
    setSortCol('volume');
    setSortDir('desc');
    setPage(0);
    setShowFilters(false);
  }, []);

  // Sync state โ†’ URL
  useEffect(() => {
    const p = new URLSearchParams();
    if (category !== 'All') p.set('cat', category);
    if (search) p.set('q', search);
    if (minVolume > 0) p.set('vol', String(minVolume));
    if (minLiquidity > 0) p.set('liq', String(minLiquidity));
    if (ending) p.set('end', ending);
    if (minPrice > 0) p.set('pmin', String(minPrice));
    if (maxPrice < 1) p.set('pmax', String(maxPrice));
    if (sortCol !== 'volume') p.set('sort', sortCol);
    if (sortDir !== 'desc') p.set('dir', sortDir);
    if (page > 0) p.set('p', String(page));
    if (view !== 'events') p.set('view', view);
    setSearchParams(p, { replace: true });
  }, [category, search, minVolume, minLiquidity, ending, minPrice, maxPrice, sortCol, sortDir, page, view, setSearchParams]);

  const handleSort = useCallback((col: string) => {
    if (col === sortCol) {
      setSortDir(d => d === 'desc' ? 'asc' : 'desc');
    } else {
      setSortCol(col);
      setSortDir('desc');
    }
    setPage(0);
  }, [sortCol]);

  const applySort = useCallback((key: string, defDir: 'asc' | 'desc') => {
    if (key === sortCol) {
      setSortDir(d => (d === 'desc' ? 'asc' : 'desc'));
    } else {
      setSortCol(key);
      setSortDir(defDir);
    }
    setPage(0);
  }, [sortCol]);

  const handleCategoryChange = useCallback((cat: string) => {
    setCategory(cat);
    setSearch('');
    setSearchInput('');
    setPage(0);
  }, []);

  const handleSearch = useCallback(() => {
    setSearch(searchInput);
    setPage(0);
  }, [searchInput]);

  const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      setSearch(searchInput);
      setPage(0);
    }
  }, [searchInput]);

  const hasActiveFilters = minVolume > 0 || minLiquidity > 0 || minPrice > 0 || maxPrice < 1 || ending !== '';

  const resetFilters = useCallback(() => {
    setMinVolume(0);
    setMinLiquidity(0);
    setEnding('');
    setMinPrice(0);
    setMaxPrice(1);
    setPage(0);
  }, []);

  return (
    <div className="screener-page">
      <KPIBar stats={stats} loading={statsLoading} />

      {/* View toggle: events (grouped, default) vs flat markets */}
      <div className="view-toggle">
        <button
          className={`view-tab ${view === 'events' ? 'view-tab-active' : ''}`}
          onClick={() => switchView('events')}
        >
          {t('mkt.events')}
        </button>
        <button
          className={`view-tab ${view === 'markets' ? 'view-tab-active' : ''}`}
          onClick={() => switchView('markets')}
        >
          {t('mkt.allMarkets')}
        </button>
        {ws.connected && (
          <span className="ws-live"><span className="ws-live-dot" /> Live</span>
        )}
      </div>

      {/* Category pills */}
      <div className="category-pills">
        {CATEGORIES.map(cat => (
          <button
            key={cat}
            className={`pill ${category === cat ? 'pill-active' : ''}`}
            onClick={() => handleCategoryChange(cat)}
          >
            {cat}
          </button>
        ))}
      </div>

      {/* Search bar */}
      <div className="search-bar">
        <svg className="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
          <circle cx="11" cy="11" r="8" />
          <path d="M21 21l-4.35-4.35" />
        </svg>
        <input
          type="text"
          placeholder="Search markets..."
          value={searchInput}
          onChange={e => setSearchInput(e.target.value)}
          onKeyDown={handleSearchKeyDown}
          className="search-input"
        />
        {searchInput && (
          <button className="search-clear" onClick={() => { setSearchInput(''); setSearch(''); setPage(0); }}>
            โœ•
          </button>
        )}
        {view === 'markets' && (
          <button
            className={`filter-toggle ${showFilters ? 'filter-toggle-active' : ''} ${hasActiveFilters ? 'filter-has-active' : ''}`}
            onClick={() => setShowFilters(v => !v)}
            title="Filters"
          >
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
              <path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
            </svg>
          </button>
        )}
        <button className="search-btn" onClick={handleSearch}>Search</button>
      </div>

      {/* Expandable filters */}
      {showFilters && (
        <div className="filters-panel">
          <div className="filter-group">
            <label className="filter-label">{t('mkt.minVolume')}</label>
            <div className="filter-options">
              {VOLUME_OPTIONS.map(opt => (
                <button
                  key={opt.value}
                  className={`pill pill-sm ${minVolume === opt.value ? 'pill-active' : ''}`}
                  onClick={() => { setMinVolume(opt.value); setPage(0); }}
                >
                  {opt.labelKey ? t(opt.labelKey) : opt.label}
                </button>
              ))}
            </div>
          </div>
          <div className="filter-group">
            <label className="filter-label">{t('mkt.minLiquidity')}</label>
            <div className="filter-options">
              {LIQUIDITY_OPTIONS.map(opt => (
                <button
                  key={opt.value}
                  className={`pill pill-sm ${minLiquidity === opt.value ? 'pill-active' : ''}`}
                  onClick={() => { setMinLiquidity(opt.value); setPage(0); }}
                >
                  {opt.labelKey ? t(opt.labelKey) : opt.label}
                </button>
              ))}
            </div>
          </div>
          <div className="filter-group">
            <label className="filter-label">{t('mkt.endsLabel')}</label>
            <div className="filter-options">
              {ENDING_OPTIONS.map(opt => (
                <button
                  key={opt.value || 'any'}
                  className={`pill pill-sm ${ending === opt.value ? 'pill-active' : ''}`}
                  onClick={() => { setEnding(opt.value); setPage(0); }}
                >
                  {opt.labelKey ? t(opt.labelKey) : opt.label}
                </button>
              ))}
            </div>
          </div>
          <div className="filter-group">
            <label className="filter-label">{t('mkt.yesRange')}</label>
            <div className="price-range">
              <input
                type="range"
                min="0" max="100" step="5"
                value={minPrice * 100}
                onChange={e => { setMinPrice(Number(e.target.value) / 100); setPage(0); }}
                className="range-input"
              />
              <span className="range-value">{Math.round(minPrice * 100)}ยข</span>
              <span className="range-sep">โ€”</span>
              <input
                type="range"
                min="0" max="100" step="5"
                value={maxPrice * 100}
                onChange={e => { setMaxPrice(Number(e.target.value) / 100); setPage(0); }}
                className="range-input"
              />
              <span className="range-value">{Math.round(maxPrice * 100)}ยข</span>
            </div>
          </div>
          {hasActiveFilters && (
            <button className="filter-reset" onClick={resetFilters}>{t('mkt.resetFilters')}</button>
          )}
        </div>
      )}

      {/* Sort bar โ€” visible on mobile where table headers aren't */}
      <div className="sort-bar">
        <span className="sort-bar-label">{t('mkt.sortLabel')}</span>
        <div className="sort-bar-pills">
          {sorts.map(s => (
            <button
              key={s.key}
              className={`pill pill-sm ${sortCol === s.key ? 'pill-active' : ''}`}
              onClick={() => applySort(s.key, s.dir)}
            >
              {t(s.labelKey)}{sortCol === s.key && (sortDir === 'desc' ? ' โ†“' : ' โ†‘')}
            </button>
          ))}
        </div>
      </div>

      {/* Results count */}
      {activeMeta && (
        <div className="results-count">
          {activeMeta.total.toLocaleString()} {view === 'events' ? t('mkt.eventsWord') : t('mkt.marketsWord')}
          {search && <span> {t('mkt.bySearch', { q: search })}</span>}
          {category !== 'All' && <span> {t('mkt.inCat', { cat: category })}</span>}
          {view === 'markets' && hasActiveFilters && <span> {t('mkt.filtered')}</span>}
        </div>
      )}

      {/* List */}
      {view === 'events' ? (
        <EventsList events={events} loading={eventsLoading} />
      ) : (
        <MarketsTable
          markets={markets}
          loading={loading}
          sortCol={sortCol}
          sortDir={sortDir}
          onSort={handleSort}
          livePrices={ws.prices}
          changedIds={ws.changed}
        />
      )}

      {/* Pagination */}
      {activeMeta && activeMeta.total > PAGE_SIZE && (
        <div className="pagination">
          <button
            className="page-btn"
            disabled={page === 0 || activeLoading}
            onClick={() => setPage(p => p - 1)}
          >
            {t('mkt.back')}
          </button>
          <span className="page-info">
            {page * PAGE_SIZE + 1}โ€“{Math.min((page + 1) * PAGE_SIZE, activeMeta.total)} {t('mkt.pageOf')} {activeMeta.total.toLocaleString()}
          </span>
          <button
            className="page-btn"
            disabled={!activeMeta.has_more || activeLoading}
            onClick={() => setPage(p => p + 1)}
          >
            {t('mkt.next')}
          </button>
        </div>
      )}
    </div>
  );
}

๐Ÿ“œ Git History

6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...