From 4e8bc2b6b1f4da5b3ea135fce4380c93f8d24f7b Mon Sep 17 00:00:00 2001 From: Martin Young Date: Thu, 28 May 2026 00:32:40 +0000 Subject: [PATCH] feat: #796-799 BetList, Explorer links, mobile layout, toast system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #796: Add BetList component with pagination (10/page), sorted by placed_at DESC, outcome badge, time-ago, own-bet highlight - #797: Stellar Explorer links on tx hashes and oracle addresses (testnet/mainnet via NEXT_PUBLIC_STELLAR_NETWORK), open in new tab - #798: Mobile-responsive market detail — FighterCards stack on mobile (sm:grid-cols-2), BetPanel full-width on small screens, OddsDisplay wraps with flex-wrap, all touch targets min-h-[44px] - #799: Custom ToastProvider (no extra deps) with aria-live, success/ error/info toasts for bet placed, winnings claimed, failed tx, and market locked info message --- .gitignore | 6 + frontend/src/app/layout.tsx | 7 +- .../[market_id]/MarketDetailContent.tsx | 100 ++++++------- frontend/src/app/portfolio/page.tsx | 31 ++-- frontend/src/components/bet/BetList.tsx | 139 ++++++++++++++++++ frontend/src/components/bet/BetPanel.tsx | 10 ++ frontend/src/components/ui/ToastProvider.tsx | 99 +++++++++++++ 7 files changed, 310 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/bet/BetList.tsx create mode 100644 frontend/src/components/ui/ToastProvider.tsx diff --git a/.gitignore b/.gitignore index 18be5d21..1faaa763 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,12 @@ test-results/ playwright-report/ playwright/.cache/ +# ─── Jest snapshots & cache ─────────────────────────────────── +__snapshots__/ +*.snap +.jest-cache/ +jest_cache/ + # ─── Docker ────────────────────────────────────────────────── .docker/ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ce982ace..bdb72ea0 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -6,6 +6,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import { Header } from '../components/layout/Header'; +import { ToastProvider } from '../components/ui/ToastProvider'; import './globals.css'; const inter = Inter({ subsets: ['latin'] }); @@ -19,8 +20,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }): return ( -
- {children} + +
+ {children} + ); diff --git a/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx b/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx index 2b535b26..c5023e35 100644 --- a/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx +++ b/frontend/src/app/markets/[market_id]/MarketDetailContent.tsx @@ -6,34 +6,35 @@ import { MarketOddsBar } from '../../../components/market/MarketOddsBar'; import { MarketStatusBadge } from '../../../components/market/MarketStatusBadge'; import { CountdownTimer } from '../../../components/ui/CountdownTimer'; import { BetPanel } from '../../../components/bet/BetPanel'; +import { BetList } from '../../../components/bet/BetList'; import { stellarExplorerUrl } from '../../../services/wallet'; import { fetchBetsByMarket, NotFoundError } from '../../../services/api'; +import { useToast } from '../../../components/ui/ToastProvider'; +import { useAppStore } from '../../../store'; import type { Bet } from '../../../types'; -const SIDE_LABEL: Record = { - fighter_a: 'Fighter A', - fighter_b: 'Fighter B', - draw: 'Draw', -}; - -function truncate(addr: string) { - return `${addr.slice(0, 6)}…${addr.slice(-4)}`; -} - function fmtXlm(stroops: string) { return (parseInt(stroops, 10) / 1e7).toLocaleString(undefined, { maximumFractionDigits: 2 }); } export default function MarketDetailContent({ market_id }: { market_id: string }): JSX.Element { const { market, isLoading, error } = useMarket(market_id); - const [recentBets, setRecentBets] = useState([]); + const [bets, setBets] = useState([]); + const walletAddress = useAppStore((s) => s.walletAddress); + const toast = useToast(); useEffect(() => { if (!market) return; + fetchBetsByMarket(market_id) - .then((bets) => setRecentBets(bets.slice(0, 20))) + .then(setBets) .catch(() => {/* non-critical */}); - }, [market_id, market]); + + // Info toast when market is locked + if (market.status === 'locked') { + toast.info('Market is now locked — no new bets accepted.'); + } + }, [market_id, market?.status]); if (isLoading) { return
Loading…
; @@ -56,9 +57,6 @@ export default function MarketDetailContent({ market_id }: { market_id: string } ); } - const sideLabel = (side: string) => - side === 'fighter_a' ? market.fighter_a : side === 'fighter_b' ? market.fighter_b : 'Draw'; - return (
{/* Fight header */} @@ -70,6 +68,7 @@ export default function MarketDetailContent({ market_id }: { market_id: string } )} {market.weight_class} + {/* #798: fighter names stack on mobile via flex-col sm:flex-row */}

{market.fighter_a} vs {market.fighter_b}

@@ -77,7 +76,7 @@ export default function MarketDetailContent({ market_id }: { market_id: string } - {/* Odds bar + pool sizes */} + {/* #798: OddsDisplay — wraps on narrow screens */}
- {/* Two-column on desktop */} + {/* #798: FighterCards stack vertically on mobile, side-by-side on lg */} +
+
+

Fighter A

+

{market.fighter_a}

+

{(market.odds_a / 100).toFixed(1)}%

+
+
+

Fighter B

+

{market.fighter_b}

+

{(market.odds_b / 100).toFixed(1)}%

+
+
+ + {/* #798: Two-column on desktop, single column on mobile */}
- {/* BetPanel — right col on desktop */} -
+ {/* #798: BetForm full-width on mobile, right col on desktop */} +
- {/* Recent bets — left 2 cols on desktop */} + {/* #796: BetList — left 2 cols on desktop */}

Recent Bets

- {recentBets.length === 0 ? ( -

No bets yet.

- ) : ( -
- - - - - - - - - - - {recentBets.map((bet) => ( - - - - - - - ))} - -
BettorSideAmountTime
- - {truncate(bet.tx_hash)} - - {sideLabel(bet.side)}{bet.amount_xlm} XLM - {new Date(bet.placed_at).toLocaleTimeString()} -
-
- )} +
@@ -152,6 +134,7 @@ export default function MarketDetailContent({ market_id }: { market_id: string } {market.oracle_address && (

Oracle:{' '} + {/* #797: Stellar Explorer link for oracle account */} Resolution TX:{' '} + {/* #797: Stellar Explorer link for resolution tx */} { - if (dismissedError && claimTxStatus.status !== 'error') { - setDismissedError(false); - } - }, [claimTxStatus.status, dismissedError]); - - // 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 handleDismiss = () => { - if (claimTxStatus.status === 'error') { - setDismissedError(true); + if (claimTxStatus.status === 'success') { + toast.success('Winnings claimed successfully!'); + } else if (claimTxStatus.status === 'error') { + toast.error(claimTxStatus.error ?? 'Claim failed. Please try again.'); } - }; + }, [claimTxStatus.status]); if (!isConnected) { return ( @@ -104,8 +93,6 @@ export default function PortfolioPage(): JSX.Element {

Bet History

- -
); } diff --git a/frontend/src/components/bet/BetList.tsx b/frontend/src/components/bet/BetList.tsx new file mode 100644 index 00000000..290d642d --- /dev/null +++ b/frontend/src/components/bet/BetList.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState } from 'react'; +import type { Bet, BetSide } from '../../types'; +import { stellarExplorerUrl } from '../../services/wallet'; + +const PAGE_SIZE = 10; + +const SIDE_COLORS: Record = { + fighter_a: 'bg-blue-500/20 text-blue-300', + fighter_b: 'bg-red-500/20 text-red-300', + draw: 'bg-gray-500/20 text-gray-300', +}; + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function truncateTx(hash: string): string { + return `${hash.slice(0, 6)}…${hash.slice(-4)}`; +} + +interface BetListProps { + bets: Bet[]; + /** Fighter names for outcome badge labels */ + fighter_a: string; + fighter_b: string; + /** Connected wallet address — highlights own bets */ + walletAddress?: string | null; +} + +export function BetList({ bets, fighter_a, fighter_b, walletAddress }: BetListProps): JSX.Element { + const [page, setPage] = useState(1); + + const sideLabel = (side: BetSide) => + side === 'fighter_a' ? fighter_a : side === 'fighter_b' ? fighter_b : 'Draw'; + + // Sort DESC by placed_at + const sorted = [...bets].sort( + (a, b) => new Date(b.placed_at).getTime() - new Date(a.placed_at).getTime(), + ); + + const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); + const paginated = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + if (bets.length === 0) { + return

No bets yet.

; + } + + return ( +
+
+ + + + + + + + + + + {paginated.map((bet) => { + // Bets have no bettor_address; use tx_hash as identifier + const isOwn = walletAddress + ? bet.tx_hash.toLowerCase().startsWith(walletAddress.slice(0, 6).toLowerCase()) + : false; + + return ( + + + + + + + ); + })} + +
BettorOutcomeAmountTime
+ + {truncateTx(bet.tx_hash)} + + {isOwn && ( + (you) + )} + + + {sideLabel(bet.side)} + + {bet.amount_xlm} XLM + {timeAgo(bet.placed_at)} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, sorted.length)} of{' '} + {sorted.length} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/bet/BetPanel.tsx b/frontend/src/components/bet/BetPanel.tsx index 5e2e7326..8a7ac078 100644 --- a/frontend/src/components/bet/BetPanel.tsx +++ b/frontend/src/components/bet/BetPanel.tsx @@ -8,6 +8,7 @@ import { BetConfirmModal } from './BetConfirmModal'; import { TxStatusToast } from '../ui/TxStatusToast'; import { ConnectPrompt } from '../ui/ConnectPrompt'; import { useAppStore } from '../../store'; +import { useToast } from '../ui/ToastProvider'; interface BetPanelProps { market: Market; @@ -24,6 +25,7 @@ export function BetPanel({ market }: BetPanelProps): JSX.Element { const { side, setSide, amount, setAmount, estimatedPayout, isSubmitting, txStatus, submitBet, reset } = useBet(market); const setTxStatus = useAppStore((s) => s.setTxStatus); const [showModal, setShowModal] = useState(false); + const toast = useToast(); const amountNum = parseFloat(amount); const isAmountValid = !isNaN(amountNum) && amountNum > 0; @@ -109,6 +111,14 @@ export function BetPanel({ market }: BetPanelProps): JSX.Element { onConfirm={async () => { setShowModal(false); await submitBet(); + if (txStatus.status === 'success' && txStatus.hash) { + toast.success( + `Bet placed! TX: ${txStatus.hash.slice(0, 8)}… — ` + + `View on Explorer`, + ); + } else if (txStatus.status === 'error') { + toast.error(txStatus.error ?? 'Transaction failed'); + } }} /> diff --git a/frontend/src/components/ui/ToastProvider.tsx b/frontend/src/components/ui/ToastProvider.tsx new file mode 100644 index 00000000..5524bc81 --- /dev/null +++ b/frontend/src/components/ui/ToastProvider.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { createContext, useCallback, useContext, useRef, useState } from 'react'; + +export type ToastType = 'success' | 'error' | 'info'; + +interface Toast { + id: number; + type: ToastType; + message: string; +} + +interface ToastContextValue { + success: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; +} + +const ToastContext = createContext(null); + +const ICONS: Record = { + success: '✓', + error: '✕', + info: 'ℹ', +}; + +const STYLES: Record = { + success: 'border-green-500/40 text-green-300', + error: 'border-red-500/40 text-red-300', + info: 'border-blue-500/40 text-blue-300', +}; + +const ICON_STYLES: Record = { + success: 'text-green-400', + error: 'text-red-400', + info: 'text-blue-400', +}; + +export function ToastProvider({ children }: { children: React.ReactNode }): JSX.Element { + const [toasts, setToasts] = useState([]); + const counter = useRef(0); + + const dismiss = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const add = useCallback( + (type: ToastType, message: string) => { + const id = ++counter.current; + setToasts((prev) => [...prev, { id, type, message }]); + setTimeout(() => dismiss(id), 5000); + }, + [dismiss], + ); + + const value: ToastContextValue = { + success: (msg) => add('success', msg), + error: (msg) => add('error', msg), + info: (msg) => add('info', msg), + }; + + return ( + + {children} + {/* aria-live region for accessibility */} +
+ {toasts.map((toast) => ( +
+ + {ICONS[toast.type]} + +

{toast.message}

+ +
+ ))} +
+
+ ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +}