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
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default function MarketDetailContent({ market_id }: { market_id: string }
{market.fighter_a} <span className="text-gray-500">vs</span> {market.fighter_b}
</h1>
<p className="text-sm text-gray-400">{market.venue}</p>
<CountdownTimer scheduled_at={market.scheduled_at} label="Starts in" />
<CountdownTimer targetDate={market.scheduled_at} label="Starts in" />
</div>

{/* Odds bar + pool sizes */}
Expand Down
158 changes: 100 additions & 58 deletions frontend/src/app/portfolio/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{stats.map(({ label, value, color }) => (
<div key={label} className="bg-gray-900 rounded-xl p-4 text-center">
<p className="text-xs text-gray-400">{label}</p>
<p className={`text-base font-semibold mt-1 break-words ${color ?? 'text-white'}`}>{value}</p>
</div>
))}
</div>
);
}

// ─── 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 (
Expand All @@ -50,62 +93,61 @@ export default function PortfolioPage(): JSX.Element {
return <main className="text-center mt-20 text-gray-400">Loading portfolio…</main>;
}

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 (
<main className="text-center mt-20 space-y-3 px-4">
<p className="text-4xl">🥊</p>
<p className="text-gray-400">No bets yet — find a fight to bet on</p>
<Link href="/" className="text-amber-400 hover:text-amber-300 text-sm">Browse markets →</Link>
<Link href="/" className="inline-block text-amber-400 hover:text-amber-300 text-sm">
Browse markets →
</Link>
</main>
);
}

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 (
<main className="max-w-4xl mx-auto px-4 py-8 space-y-8">
{/* Stats — 2 cols on mobile, 4 on sm+ */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 }) => (
<div key={label} className="bg-gray-900 rounded-xl p-4 text-center">
<p className="text-xs text-gray-400">{label}</p>
<p className="text-base font-semibold text-white mt-1 break-words">{value}</p>
</div>
))}
{/* Header */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<h1 className="text-xl font-bold text-white">My Portfolio</h1>
{pendingCount > 0 && (
<button
onClick={handleClaimAll}
disabled={claimingAll || claimTxStatus.status === 'pending'}
className="min-h-[44px] px-5 rounded-xl bg-amber-500 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed font-semibold text-black text-sm"
>
{claimingAll ? 'Claiming…' : `Claim All (${pendingCount})`}
</button>
)}
</div>

{portfolio!.pending_claims.length > 0 && (
<section>
<h2 className="text-amber-400 font-semibold mb-3">Pending Claims</h2>
<BetHistoryTable bets={portfolio!.pending_claims} onClaim={claimWinnings} onRefund={claimRefund} />
</section>
)}

{portfolio!.active_bets.length > 0 && (
<section>
<h2 className="text-white font-semibold mb-3">Active Bets</h2>
<BetHistoryTable bets={portfolio!.active_bets} onClaim={claimWinnings} onRefund={claimRefund} />
</section>
)}
{/* Stats */}
<BettorStats
totalStaked={portfolio!.total_staked_xlm}
totalWon={portfolio!.total_won_xlm}
totalLost={portfolio!.total_lost_xlm}
pendingClaimsCount={pendingCount}
/>

{/* Bet history */}
<section>
<h2 className="text-white font-semibold mb-3">Bet History</h2>
<BetHistoryTable bets={portfolio!.past_bets} onClaim={claimWinnings} onRefund={claimRefund} />
<BetHistoryTable
bets={allBets}
markets={marketsMap}
onClaim={claimWinnings}
onRefund={claimRefund}
/>
</section>

<TxStatusToast txStatus={displayStatus} onDismiss={handleDismiss} />
<TxStatusToast
txStatus={displayStatus}
onDismiss={() => setDismissedToast(true)}
/>
</main>
);
}
Loading
Loading