Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions frontend/src/components/market/MarketFilters.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | null>(null);

const setParam = useCallback(
(updates: Record<string, string | null>) => {
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 (
<div className="flex flex-wrap gap-3 items-center">
{/* Status tabs */}
<div className="flex rounded-lg overflow-hidden border border-gray-700">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setParam({ status: tab.value || null })}
className={`px-4 py-2 text-sm font-medium transition-colors ${
status === tab.value
? 'bg-amber-500 text-black'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>

{/* Fighter name search */}
<input
type="search"
value={searchInput}
onChange={(e) => 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 */}
<select
value={sort}
onChange={(e) => setParam({ sort: e.target.value })}
aria-label="Sort markets"
className="min-h-[44px] bg-gray-800 text-white text-sm rounded-lg px-3 focus:outline-none focus:ring-2 focus:ring-amber-500 ml-auto"
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
80 changes: 80 additions & 0 deletions frontend/src/components/market/OddsDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-2">
{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 (
<div
key={label}
className={`flex-1 flex flex-col items-center rounded-lg py-2 px-1 transition-colors ${
isFavorite ? 'bg-gray-700 ring-1 ring-amber-500/60' : 'bg-gray-800'
}`}
>
<span className="text-gray-400 text-xs truncate w-full text-center mb-1">{label}</span>
<span className={`font-bold text-sm transition-all duration-300 ${color}`}>
{mult !== null ? `${mult.toFixed(2)}x` : '—'}
</span>
<span className="text-gray-500 text-xs mt-0.5">
{impliedPct !== null ? `${impliedPct}%` : '—'}
</span>
{isFavorite && (
<span className="text-amber-400 text-[10px] mt-0.5 font-medium">FAV</span>
)}
</div>
);
})}
</div>
);
}
55 changes: 55 additions & 0 deletions frontend/src/components/market/PoolProportionBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full h-8 rounded overflow-hidden" role="img" aria-label="Pool proportion">
{segments.map(({ label, pct: p, bg }) => (
<div
key={label}
className={`${bg} flex items-center justify-center overflow-hidden transition-[width] duration-500 ease-in-out`}
style={{ width: `${p}%` }}
>
{p >= 10 && (
<span className="text-white text-xs font-semibold truncate px-1 select-none">
{p.toFixed(0)}%
</span>
)}
</div>
))}
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async function apiFetch<T>(path: string): Promise<T> {
export interface MarketFilters {
status?: string;
weight_class?: string;
search?: string;
}

export interface PaginationParams {
Expand All @@ -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();
Expand Down
Loading