diff --git a/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx b/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx index 2b535b26..593ef016 100644 --- a/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx +++ b/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx @@ -74,7 +74,7 @@ export default function MarketDetailContent({ market_id }: { market_id: string } {market.fighter_a} vs {market.fighter_b}

{market.venue}

- + {/* Odds bar + pool sizes */} diff --git a/frontend/src/app/portfolio/page.tsx b/frontend/src/app/portfolio/page.tsx index e3ddc657..2ef71392 100644 --- a/frontend/src/app/portfolio/page.tsx +++ b/frontend/src/app/portfolio/page.tsx @@ -2,41 +2,84 @@ // ============================================================ // BOXMEOUT — Portfolio Page (/portfolio) -// Shows the connected user's betting history and pending claims. // ============================================================ -'use client'; - -import { useState, useEffect } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import Link from 'next/link'; import { useWallet } from '../../hooks/useWallet'; import { usePortfolio } from '../../hooks/usePortfolio'; +import { useMarkets } from '../../hooks/useMarkets'; import { ConnectPrompt } from '../../components/ui/ConnectPrompt'; import { BetHistoryTable } from '../../components/bet/BetHistoryTable'; import { TxStatusToast } from '../../components/ui/TxStatusToast'; +import type { Bet } from '../../types'; + +// ─── BettorStats ───────────────────────────────────────────────────────────── + +interface BettorStatsProps { + totalStaked: number; + totalWon: number; + totalLost: number; + pendingClaimsCount: number; +} + +function BettorStats({ totalStaked, totalWon, totalLost, pendingClaimsCount }: BettorStatsProps) { + const winRate = totalStaked > 0 ? ((totalWon / totalStaked) * 100).toFixed(1) : '0.0'; + const stats = [ + { label: 'Total Staked', value: `${totalStaked.toFixed(2)} XLM` }, + { label: 'Total Won', value: `${totalWon.toFixed(2)} XLM`, color: 'text-green-400' }, + { label: 'Total Lost', value: `${totalLost.toFixed(2)} XLM`, color: 'text-red-400' }, + { label: 'Win Rate', value: `${winRate}%` }, + { label: 'Pending Claims', value: String(pendingClaimsCount), color: pendingClaimsCount > 0 ? 'text-amber-400' : undefined }, + ]; + + return ( +
+ {stats.map(({ label, value, color }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ ); +} + +// ─── Page ──────────────────────────────────────────────────────────────────── export default function PortfolioPage(): JSX.Element { const { isConnected } = useWallet(); const { portfolio, isLoading, claimTxStatus, claimWinnings, claimRefund } = usePortfolio(); - const [dismissedError, setDismissedError] = useState(false); - - // Reset dismissedError when status changes from error - useEffect(() => { - if (dismissedError && claimTxStatus.status !== 'error') { - setDismissedError(false); - } - }, [claimTxStatus.status, dismissedError]); + const { markets } = useMarkets(); + const [claimingAll, setClaimingAll] = useState(false); + const [dismissedToast, setDismissedToast] = useState(false); - // Show toast unless it's an error and user dismissed it - const displayStatus = dismissedError && claimTxStatus.status === 'error' - ? { hash: null, status: 'idle' as const, error: null } - : claimTxStatus; + const marketsMap = useMemo( + () => Object.fromEntries(markets.map((m) => [m.market_id, m])), + [markets], + ); - const handleDismiss = () => { - if (claimTxStatus.status === 'error') { - setDismissedError(true); + const allBets: Bet[] = useMemo(() => { + if (!portfolio) return []; + return [ + ...portfolio.pending_claims, + ...portfolio.active_bets, + ...portfolio.past_bets, + ]; + }, [portfolio]); + + const handleClaimAll = useCallback(async () => { + if (!portfolio || claimingAll) return; + setClaimingAll(true); + // Deduplicate by market_id — one tx per claimable market + const uniqueMarkets = [...new Set(portfolio.pending_claims.map((b) => b.market_id))]; + for (const market_id of uniqueMarkets) { + await claimWinnings(market_id); } - }; + setClaimingAll(false); + }, [portfolio, claimWinnings, claimingAll]); + + const displayStatus = dismissedToast ? { hash: null, status: 'idle' as const, error: null } : claimTxStatus; if (!isConnected) { return ( @@ -50,62 +93,61 @@ export default function PortfolioPage(): JSX.Element { return
Loading portfolio…
; } - const empty = !portfolio || ( - portfolio.active_bets.length === 0 && - portfolio.past_bets.length === 0 && - portfolio.pending_claims.length === 0 - ); + const isEmpty = !portfolio || allBets.length === 0; - if (empty) { + if (isEmpty) { return (
+

🥊

No bets yet — find a fight to bet on

- Browse markets → + + Browse markets → +
); } - const winRate = portfolio!.total_staked_xlm > 0 - ? ((portfolio!.total_won_xlm / portfolio!.total_staked_xlm) * 100).toFixed(1) - : '0.0'; + const pendingCount = portfolio!.pending_claims.length; return (
- {/* Stats — 2 cols on mobile, 4 on sm+ */} -
- {[ - { label: 'Total Staked', value: `${portfolio!.total_staked_xlm.toFixed(2)} XLM` }, - { label: 'Total Won', value: `${portfolio!.total_won_xlm.toFixed(2)} XLM` }, - { label: 'Total Lost', value: `${portfolio!.total_lost_xlm.toFixed(2)} XLM` }, - { label: 'Win Rate', value: `${winRate}%` }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} + {/* Header */} +
+

My Portfolio

+ {pendingCount > 0 && ( + + )}
- {portfolio!.pending_claims.length > 0 && ( -
-

Pending Claims

- -
- )} - - {portfolio!.active_bets.length > 0 && ( -
-

Active Bets

- -
- )} + {/* Stats */} + + {/* Bet history */}

Bet History

- +
- + setDismissedToast(true)} + />
); } diff --git a/frontend/src/components/bet/BetHistoryTable.tsx b/frontend/src/components/bet/BetHistoryTable.tsx index 456c6bdf..05789f29 100644 --- a/frontend/src/components/bet/BetHistoryTable.tsx +++ b/frontend/src/components/bet/BetHistoryTable.tsx @@ -1,115 +1,236 @@ +'use client'; + // ============================================================ // BOXMEOUT — BetHistoryTable Component // ============================================================ -import { stellarExplorerUrl } from '../../services/wallet'; -import type { Bet } from '../../types'; +import { useState, useMemo } from 'react'; +import type { Bet, Market } from '../../types'; + +type FilterTab = 'All' | 'Active' | 'Won' | 'Lost' | 'Pending Claim'; +type SortKey = 'placed_at' | 'amount_xlm'; +type SortDir = 'asc' | 'desc'; + +const PAGE_SIZE = 10; +const TABS: FilterTab[] = ['All', 'Active', 'Won', 'Lost', 'Pending Claim']; + +function getBetStatus(bet: Bet): 'Active' | 'Won' | 'Lost' | 'Pending Claim' | 'Claimed' { + if (bet.claimed) return 'Claimed'; + const payout = bet.payout !== null ? parseFloat(bet.payout) : null; + if (payout === null) return 'Active'; + if (payout > 0) return 'Pending Claim'; + return 'Lost'; +} interface BetHistoryTableProps { bets: Bet[]; - /** Called when user clicks "Claim" on an eligible row */ - onClaim: (market_contract_address: string) => void; - /** Called when user clicks "Refund" on a cancelled market row */ - onRefund: (market_contract_address: string) => void; + /** Optional map of market_id → Market for displaying fighter names */ + markets?: Record; + onClaim: (market_id: string) => void; + onRefund: (market_id: string) => void; } -/** - * Table of bets, typically shown on the Portfolio page. - * - * Columns: Market | Side | Amount (XLM) | Status | Payout (XLM) | Action - * - * Action column rules: - * - Bet is on winning side + unclaimed → show "Claim" button - * - Market is cancelled + unclaimed → show "Refund" button - * - Already claimed → show payout amount in green - * - Bet lost → show "-" (no action) - * - Market not yet resolved → show "Pending" - * - * Renders an empty state message when bets array is empty. - */ -export function BetHistoryTable({ - bets, - onClaim, - onRefund, -}: BetHistoryTableProps): JSX.Element { +export function BetHistoryTable({ bets, markets, onClaim, onRefund }: BetHistoryTableProps): JSX.Element { + const [tab, setTab] = useState('All'); + const [sortKey, setSortKey] = useState('placed_at'); + const [sortDir, setSortDir] = useState('desc'); + const [page, setPage] = useState(1); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('desc'); + } + setPage(1); + }; + + const filtered = useMemo(() => { + return bets.filter((bet) => { + if (tab === 'All') return true; + const status = getBetStatus(bet); + if (tab === 'Active') return status === 'Active'; + if (tab === 'Won') return status === 'Claimed'; + if (tab === 'Lost') return status === 'Lost'; + if (tab === 'Pending Claim') return status === 'Pending Claim'; + return true; + }); + }, [bets, tab]); + + const sorted = useMemo(() => { + return [...filtered].sort((a, b) => { + let cmp = 0; + if (sortKey === 'placed_at') { + cmp = new Date(a.placed_at).getTime() - new Date(b.placed_at).getTime(); + } else { + cmp = a.amount_xlm - b.amount_xlm; + } + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [filtered, sortKey, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const paginated = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const SortIcon = ({ col }: { col: SortKey }) => + sortKey === col ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ' ↕'; + if (bets.length === 0) { return

No bets yet.

; } return ( -
- - - - - - - - - - - - - {bets.map((bet) => { - const payout = bet.payout ? parseFloat(bet.payout) : null; - - let action: JSX.Element; - if (bet.claimed) { - action = {payout != null ? `${payout.toFixed(2)} XLM` : '—'}; - } else if (payout != null && payout > 0) { - action = ( - - ); - } else if (payout != null && payout < 0) { - action = ( - - ); - } else if (payout === 0) { - action = ; - } else { - action = Pending; - } - - return ( - - - - - + + + + + + + + + + ); + }) + )} + +
MarketSideAmount (XLM)StatusPayout (XLM)Action
- - {bet.market_id.slice(0, 8)}… - - {bet.side.replace('_', ' ')}{bet.amount_xlm} XLM - {bet.claimed - ? 'Claimed' - : payout != null - ? (payout > 0 ? 'Won' : payout < 0 ? 'Cancelled' : 'Lost') - : 'Active' - } +
+ {/* Filter tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Table */} +
+ + + + + + + + + + + + + + + {paginated.length === 0 ? ( + + - - - ); - })} - -
MarketMy Bet handleSort('amount_xlm')} + > + Amount + OddsStatusPayout handleSort('placed_at')} + > + Date + Action
+ No bets in this category. {payout != null ? `${payout.toFixed(2)} XLM` : '—'}{action}
+ ) : ( + paginated.map((bet) => { + const market = markets?.[bet.market_id]; + const marketLabel = market + ? `${market.fighter_a} vs ${market.fighter_b}` + : bet.market_id.slice(0, 8) + '…'; + const sideLabel = bet.side === 'fighter_a' + ? market?.fighter_a ?? 'Fighter A' + : bet.side === 'fighter_b' + ? market?.fighter_b ?? 'Fighter B' + : 'Draw'; + const payout = bet.payout !== null ? parseFloat(bet.payout) : null; + const status = getBetStatus(bet); + + // Odds: derive from market if available + const oddsVal = market + ? bet.side === 'fighter_a' + ? (market.odds_a / 100).toFixed(0) + '%' + : bet.side === 'fighter_b' + ? (market.odds_b / 100).toFixed(0) + '%' + : (market.odds_draw / 100).toFixed(0) + '%' + : '—'; + + let action: JSX.Element; + if (status === 'Pending Claim') { + action = ( + + ); + } else if (payout !== null && payout < 0) { + action = ( + + ); + } else { + action = ; + } + + const statusColor = + status === 'Claimed' ? 'text-green-400' + : status === 'Pending Claim' ? 'text-amber-400' + : status === 'Lost' ? 'text-red-400' + : 'text-gray-400'; + + return ( +
{marketLabel}{sideLabel}{bet.amount_xlm} XLM{oddsVal} + {status} + + {payout !== null && payout >= 0 ? `${payout.toFixed(2)} XLM` : '—'} + + {new Date(bet.placed_at).toLocaleDateString()} + {action}
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, sorted.length)} of {sorted.length} + +
+ + +
+
+ )} ); } diff --git a/frontend/src/components/market/FighterCard.tsx b/frontend/src/components/market/FighterCard.tsx new file mode 100644 index 00000000..61c64525 --- /dev/null +++ b/frontend/src/components/market/FighterCard.tsx @@ -0,0 +1,85 @@ +'use client'; + +// ============================================================ +// BOXMEOUT — FighterCard Component +// Used on the market detail page to display each fighter. +// ============================================================ + +import Image from 'next/image'; + +interface FighterCardProps { + name: string; + /** Implied probability in basis points (0–10000) */ + odds: number; + /** Pool amount in stroops (i128 string) */ + poolAmount: string; + /** Optional photo URL; falls back to placeholder */ + photoUrl?: string; + /** Highlight border when user has bet on this fighter */ + isUserBet?: boolean; + /** Show trophy icon if this fighter won */ + isWinner?: boolean; +} + +export function FighterCard({ + name, + odds, + poolAmount, + photoUrl, + isUserBet = false, + isWinner = false, +}: FighterCardProps): JSX.Element { + const oddsPercent = (odds / 100).toFixed(1); + const poolXlm = (parseInt(poolAmount, 10) / 1e7).toLocaleString(undefined, { + maximumFractionDigits: 0, + }); + + return ( +
+ {/* Avatar */} +
+ {photoUrl ? ( + {name} + ) : ( + 🥊 + )} + {isWinner && ( + + 🏆 + + )} +
+ + {/* Name */} +

{name}

+ + {/* Odds */} +
+

{oddsPercent}%

+

implied odds

+
+ + {/* Pool */} +
+

{poolXlm} XLM

+

in pool

+
+ + {isUserBet && ( + + Your pick + + )} +
+ ); +} diff --git a/frontend/src/components/market/MarketCard.tsx b/frontend/src/components/market/MarketCard.tsx index de657cf1..b7712f53 100644 --- a/frontend/src/components/market/MarketCard.tsx +++ b/frontend/src/components/market/MarketCard.tsx @@ -49,7 +49,7 @@ export function MarketCard({ market }: MarketCardProps): JSX.Element { {/* Footer */}
- + {totalXlm} XLM pooled
diff --git a/frontend/src/components/ui/CountdownTimer.tsx b/frontend/src/components/ui/CountdownTimer.tsx index 65dcf06b..36e89d10 100644 --- a/frontend/src/components/ui/CountdownTimer.tsx +++ b/frontend/src/components/ui/CountdownTimer.tsx @@ -1,46 +1,49 @@ +'use client'; + // ============================================================ // BOXMEOUT — CountdownTimer Component // ============================================================ -import { useMarketCountdown } from '@/hooks/useMarketCountdown'; +import { useState, useEffect } from 'react'; interface CountdownTimerProps { - /** ISO 8601 timestamp of fight start */ - scheduled_at: string; - /** Optional label prefix (e.g. "Starts in") */ + /** ISO 8601 timestamp of target time */ + targetDate: string; + /** Optional label for context (e.g. "Betting closes in") */ label?: string; } -/** - * Renders a live countdown to the fight start time. - * Uses the useMarketCountdown hook internally. - * - * Display: - * "Starts in 2h 14m 32s" → while countdown is running - * "LIVE" → when fight has started (red pulsing badge) - * "ENDED" → after resolution window passed - */ -export function CountdownTimer({ - scheduled_at, - label = 'Starts in', -}: CountdownTimerProps): JSX.Element { - const state = useMarketCountdown(scheduled_at); - - if (state === 'LIVE') { - return ( - - LIVE - - ); - } +function formatDDHHMMSS(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const dd = Math.floor(totalSec / 86400); + const hh = Math.floor((totalSec % 86400) / 3600); + const mm = Math.floor((totalSec % 3600) / 60); + const ss = totalSec % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(dd)}:${pad(hh)}:${pad(mm)}:${pad(ss)}`; +} + +export function CountdownTimer({ targetDate, label }: CountdownTimerProps): JSX.Element { + const targetMs = new Date(targetDate).getTime(); + const [remaining, setRemaining] = useState(() => targetMs - Date.now()); + + useEffect(() => { + const id = setInterval(() => { + const r = targetMs - Date.now(); + setRemaining(r); + if (r <= 0) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }, [targetMs]); - if (state === 'ENDED') { - return ENDED; + if (remaining <= 0) { + return Betting Closed; } return ( - {label} {state} + {label && {label}} + {formatDDHHMMSS(remaining)} ); }