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...