From ea3c00feb464dbc95748ff7d949335d5af987c4c Mon Sep 17 00:00:00 2001 From: afurious <120628710+afurious@users.noreply.github.com> Date: Wed, 27 May 2026 21:27:35 +0000 Subject: [PATCH] feat(frontend): add market list filter/sort controls, pool bar, and odds display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MarketFilters: status tabs (All/Open/Locked/Resolved), debounced fighter search (300ms), sort dropdown (Newest/Ending Soon/Biggest Pool), all state synced to URL query params with page reset on filter change - PoolProportionBar: three-segment bar (red/gray/blue for A/Draw/B), widths proportional to pool sizes with percentage labels, smooth CSS transition, equal-thirds fallback when all pools are empty - OddsDisplay: parimutuel multiplier (e.g. 2.50x) and implied probability per outcome, favorite highlighted with amber ring + FAV label, shows — on zero pool - api.ts: add search field to MarketFilters for fighter name filtering --- .../src/components/market/MarketFilters.tsx | 124 ++++++++++++++++++ .../src/components/market/OddsDisplay.tsx | 80 +++++++++++ .../components/market/PoolProportionBar.tsx | 55 ++++++++ frontend/src/services/api.ts | 2 + 4 files changed, 261 insertions(+) create mode 100644 frontend/src/components/market/MarketFilters.tsx create mode 100644 frontend/src/components/market/OddsDisplay.tsx create mode 100644 frontend/src/components/market/PoolProportionBar.tsx diff --git a/frontend/src/components/market/MarketFilters.tsx b/frontend/src/components/market/MarketFilters.tsx new file mode 100644 index 00000000..1a49cbc6 --- /dev/null +++ b/frontend/src/components/market/MarketFilters.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +export type SortOption = 'newest' | 'ending_soon' | 'biggest_pool'; + +const STATUS_TABS = [ + { label: 'All', value: '' }, + { label: 'Open', value: 'open' }, + { label: 'Locked', value: 'locked' }, + { label: 'Resolved', value: 'resolved' }, +] as const; + +const SORT_OPTIONS: { label: string; value: SortOption }[] = [ + { label: 'Newest', value: 'newest' }, + { label: 'Ending Soon', value: 'ending_soon' }, + { label: 'Biggest Pool', value: 'biggest_pool' }, +]; + +export interface MarketFilterValues { + status: string; + search: string; + sort: SortOption; +} + +interface MarketFiltersProps { + /** Called whenever any filter/sort value changes */ + onChange?: (values: MarketFilterValues) => void; +} + +export function MarketFilters({ onChange }: MarketFiltersProps): JSX.Element { + const router = useRouter(); + const searchParams = useSearchParams(); + + const status = searchParams.get('status') ?? ''; + const sort = (searchParams.get('sort') ?? 'newest') as SortOption; + const searchParam = searchParams.get('search') ?? ''; + + // Local state for the search input (debounced before hitting URL) + const [searchInput, setSearchInput] = useState(searchParam); + const debounceRef = useRef | null>(null); + + const setParam = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') { + params.delete(key); + } else { + params.set(key, value); + } + } + // Reset to page 1 on any filter/sort change + params.delete('page'); + router.replace(`?${params.toString()}`); + }, + [router, searchParams], + ); + + // Sync local input when URL param changes externally + useEffect(() => { + setSearchInput(searchParam); + }, [searchParam]); + + // Debounce search input → URL + const handleSearchChange = (value: string) => { + setSearchInput(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setParam({ search: value || null }); + }, 300); + }; + + // Notify parent of current values + useEffect(() => { + onChange?.({ status, search: searchParam, sort }); + }, [status, searchParam, sort, onChange]); + + return ( +
+ {/* Status tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {/* Fighter name search */} + handleSearchChange(e.target.value)} + placeholder="Search fighters…" + aria-label="Search fighters" + className="min-h-[44px] bg-gray-800 text-white text-sm rounded-lg px-3 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 w-48" + /> + + {/* Sort dropdown */} + +
+ ); +} diff --git a/frontend/src/components/market/OddsDisplay.tsx b/frontend/src/components/market/OddsDisplay.tsx new file mode 100644 index 00000000..23fde994 --- /dev/null +++ b/frontend/src/components/market/OddsDisplay.tsx @@ -0,0 +1,80 @@ +interface OddsDisplayProps { + pool_a: string; + pool_b: string; + pool_draw: string; + fee_bps: number; + fighter_a: string; + fighter_b: string; +} + +interface Outcome { + label: string; + pool: bigint; + color: string; +} + +/** + * Parimutuel multiplier = (total_pool * (1 - fee)) / outcome_pool + * Returns null when pool is zero. + */ +function multiplier(outcomePool: bigint, totalPool: bigint, feeBps: number): number | null { + if (outcomePool === 0n || totalPool === 0n) return null; + const net = totalPool * BigInt(10000 - feeBps); + return Number(net) / Number(outcomePool) / 10000; +} + +export function OddsDisplay({ + pool_a, + pool_b, + pool_draw, + fee_bps, + fighter_a, + fighter_b, +}: OddsDisplayProps): JSX.Element { + const a = BigInt(pool_a); + const b = BigInt(pool_b); + const d = BigInt(pool_draw); + const total = a + b + d; + + const outcomes: Outcome[] = [ + { label: fighter_a, pool: a, color: 'text-red-400' }, + { label: 'Draw', pool: d, color: 'text-gray-400' }, + { label: fighter_b, pool: b, color: 'text-blue-400' }, + ]; + + // Favorite = outcome with the largest pool (highest implied probability) + const maxPool = outcomes.reduce((max, o) => (o.pool > max ? o.pool : max), 0n); + const hasFavorite = total > 0n; + + return ( +
+ {outcomes.map(({ label, pool, color }) => { + const mult = multiplier(pool, total, fee_bps); + const impliedPct = total > 0n && pool > 0n + ? ((Number(pool) / Number(total)) * 100).toFixed(0) + : null; + const isFavorite = hasFavorite && pool === maxPool; + + return ( +
+ {label} + + {mult !== null ? `${mult.toFixed(2)}x` : '—'} + + + {impliedPct !== null ? `${impliedPct}%` : '—'} + + {isFavorite && ( + FAV + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/market/PoolProportionBar.tsx b/frontend/src/components/market/PoolProportionBar.tsx new file mode 100644 index 00000000..32cc264e --- /dev/null +++ b/frontend/src/components/market/PoolProportionBar.tsx @@ -0,0 +1,55 @@ +interface PoolProportionBarProps { + pool_a: string; + pool_b: string; + pool_draw: string; + fighter_a: string; + fighter_b: string; +} + +const EQUAL = 100 / 3; + +function pct(pool: bigint, total: bigint): number { + return total === 0n ? EQUAL : Number((pool * 10000n) / total) / 100; +} + +export function PoolProportionBar({ + pool_a, + pool_b, + pool_draw, + fighter_a, + fighter_b, +}: PoolProportionBarProps): JSX.Element { + const a = BigInt(pool_a); + const b = BigInt(pool_b); + const d = BigInt(pool_draw); + const total = a + b + d; + + const pA = pct(a, total); + const pD = pct(d, total); + // Assign remainder to B so segments always sum to 100 + const pB = Math.max(0, 100 - pA - pD); + + const segments = [ + { label: fighter_a, pct: pA, bg: 'bg-red-600' }, + { label: 'Draw', pct: pD, bg: 'bg-gray-500' }, + { label: fighter_b, pct: pB, bg: 'bg-blue-600' }, + ]; + + return ( +
+ {segments.map(({ label, pct: p, bg }) => ( +
+ {p >= 10 && ( + + {p.toFixed(0)}% + + )} +
+ ))} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 968f593d..82b709eb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -37,6 +37,7 @@ async function apiFetch(path: string): Promise { export interface MarketFilters { status?: string; weight_class?: string; + search?: string; } export interface PaginationParams { @@ -63,6 +64,7 @@ export async function fetchMarkets( const params = new URLSearchParams(); if (filters?.status) params.set('status', filters.status); if (filters?.weight_class) params.set('weight_class', filters.weight_class); + if (filters?.search) params.set('search', filters.search); if (pagination?.page) params.set('page', pagination.page.toString()); if (pagination?.limit) params.set('limit', pagination.limit.toString()); const qs = params.toString();