BOXMEOUT
@@ -58,6 +59,7 @@ export function Header(): JSX.Element {
{IS_MAINNET ? 'MAINNET' : 'TESTNET'}
+
{/* Mobile hamburger */}
diff --git a/frontend/src/components/layout/ThemeToggle.tsx b/frontend/src/components/layout/ThemeToggle.tsx
new file mode 100644
index 00000000..77fb009b
--- /dev/null
+++ b/frontend/src/components/layout/ThemeToggle.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+const LS_KEY = 'boxmeout_theme';
+
+function getInitialTheme(): 'dark' | 'light' {
+ if (typeof window === 'undefined') return 'dark';
+ const stored = localStorage.getItem(LS_KEY);
+ if (stored === 'dark' || stored === 'light') return stored;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+export function ThemeToggle(): JSX.Element {
+ const [theme, setTheme] = useState<'dark' | 'light'>('dark');
+
+ useEffect(() => {
+ const initial = getInitialTheme();
+ setTheme(initial);
+ document.documentElement.classList.toggle('dark', initial === 'dark');
+ }, []);
+
+ const toggle = () => {
+ const next = theme === 'dark' ? 'light' : 'dark';
+ setTheme(next);
+ localStorage.setItem(LS_KEY, next);
+ document.documentElement.classList.toggle('dark', next === 'dark');
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/TransactionStatus.tsx b/frontend/src/components/ui/TransactionStatus.tsx
new file mode 100644
index 00000000..e1a6aed7
--- /dev/null
+++ b/frontend/src/components/ui/TransactionStatus.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import type { TxStatus } from '../../types';
+import { TX_PENDING_STATES } from '../../types';
+import { stellarExplorerUrl } from '../../services/wallet';
+
+const PENDING_LABELS: Record
= {
+ signing: 'Waiting for signature…',
+ broadcasting: 'Broadcasting to network…',
+ confirming: 'Confirming on Stellar…',
+};
+
+interface TransactionStatusProps {
+ txStatus: TxStatus;
+ onRetry?: () => void;
+ onDismiss?: () => void;
+}
+
+export function TransactionStatus({ txStatus, onRetry, onDismiss }: TransactionStatusProps): JSX.Element | null {
+ const { status, hash, error } = txStatus;
+
+ if (status === 'idle') return null;
+
+ const isPending = (TX_PENDING_STATES as readonly string[]).includes(status);
+
+ return (
+
+ {isPending && (
+ <>
+
+
{PENDING_LABELS[status]}
+ >
+ )}
+
+ {status === 'success' && (
+ <>
+
+
+ {onDismiss && (
+
+ )}
+ >
+ )}
+
+ {status === 'error' && (
+ <>
+
+
+
Transaction failed
+ {error &&
{error}
}
+ {onRetry && (
+
+ )}
+
+ {onDismiss && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/TxStatusToast.tsx b/frontend/src/components/ui/TxStatusToast.tsx
index 0b0dc071..e6b1017b 100644
--- a/frontend/src/components/ui/TxStatusToast.tsx
+++ b/frontend/src/components/ui/TxStatusToast.tsx
@@ -18,12 +18,19 @@ export function TxStatusToast({ txStatus, onDismiss }: TxStatusToastProps): JSX.
if (txStatus.status === 'idle') return <>>;
+ const isPending = ['signing', 'broadcasting', 'confirming'].includes(txStatus.status);
+ const pendingLabel = txStatus.status === 'signing'
+ ? 'Waiting for signature…'
+ : txStatus.status === 'broadcasting'
+ ? 'Broadcasting…'
+ : 'Confirming on Stellar…';
+
return (
- {txStatus.status === 'pending' && (
+ {isPending && (
<>
⏳
-
Transaction pending…
+
{pendingLabel}
>
)}
{txStatus.status === 'success' && (
diff --git a/frontend/src/hooks/useBet.ts b/frontend/src/hooks/useBet.ts
index 8409d17b..05b16b4f 100644
--- a/frontend/src/hooks/useBet.ts
+++ b/frontend/src/hooks/useBet.ts
@@ -54,7 +54,7 @@ export function useBet(market: Market): UseBetResult {
if (!xlm || xlm <= 0) return;
setError(null);
setIsSubmitting(true);
- setTxStatus({ hash: null, status: 'pending', error: null });
+ setTxStatus({ hash: null, status: 'signing', error: null });
try {
const hash = await submitBet(market.market_id, side, xlm);
setTxStatus({ hash, status: 'success', error: null });
diff --git a/frontend/src/hooks/useClaimWinnings.ts b/frontend/src/hooks/useClaimWinnings.ts
new file mode 100644
index 00000000..90d4a621
--- /dev/null
+++ b/frontend/src/hooks/useClaimWinnings.ts
@@ -0,0 +1,63 @@
+// ============================================================
+// BOXMEOUT — useClaimWinnings Hook
+// ============================================================
+
+import { useState, useCallback } from 'react';
+import type { TxStatus } from '../types';
+import { submitClaimWithStages } from '../services/wallet';
+import { useAppStore } from '../store';
+
+export interface UseClaimWinningsResult {
+ claimWinnings: (marketId: string) => Promise
;
+ txStatus: TxStatus;
+ txHash: string | null;
+ error: string | null;
+ reset: () => void;
+}
+
+const IDLE: TxStatus = { hash: null, status: 'idle', error: null };
+
+export function useClaimWinnings(): UseClaimWinningsResult {
+ const [txStatus, setTxStatus] = useState(IDLE);
+ const [txHash, setTxHash] = useState(null);
+ const [error, setError] = useState(null);
+ const { setTxStatus: setStoreTxStatus } = useAppStore();
+
+ const claimWinnings = useCallback(async (marketId: string) => {
+ setError(null);
+ setTxHash(null);
+
+ const update = (status: TxStatus) => {
+ setTxStatus(status);
+ setStoreTxStatus(status);
+ };
+
+ update({ hash: null, status: 'signing', error: null });
+
+ try {
+ const hash = await submitClaimWithStages(marketId, (stage) => {
+ update({ hash: null, status: stage, error: null });
+ });
+
+ setTxHash(hash);
+ update({ hash, status: 'success', error: null });
+
+ // Invalidate caches so useBets and useMarket refetch fresh data
+ // Both hooks use useEffect with no external cache — trigger by dispatching a custom event
+ window.dispatchEvent(new CustomEvent('boxmeout:claim_success', { detail: { marketId } }));
+ } catch (e: any) {
+ const msg = e?.message ?? 'Claim failed';
+ setError(msg);
+ update({ hash: null, status: 'error', error: msg });
+ }
+ }, [setStoreTxStatus]);
+
+ const reset = useCallback(() => {
+ setTxStatus(IDLE);
+ setStoreTxStatus(IDLE);
+ setTxHash(null);
+ setError(null);
+ }, [setStoreTxStatus]);
+
+ return { claimWinnings, txStatus, txHash, error, reset };
+}
diff --git a/frontend/src/hooks/useMarket.ts b/frontend/src/hooks/useMarket.ts
index 236057e4..27e84edc 100644
--- a/frontend/src/hooks/useMarket.ts
+++ b/frontend/src/hooks/useMarket.ts
@@ -63,5 +63,22 @@ export function useMarket(market_id: string): UseMarketResult {
};
}, [market_id]);
+ // Refresh when a claim succeeds for this market
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (detail?.marketId === market_id) {
+ setMarket(null);
+ setIsLoading(true);
+ fetchMarketById(market_id)
+ .then(setMarket)
+ .catch(setError)
+ .finally(() => setIsLoading(false));
+ }
+ };
+ window.addEventListener('boxmeout:claim_success', handler);
+ return () => window.removeEventListener('boxmeout:claim_success', handler);
+ }, [market_id]);
+
return { market, isLoading, error };
}
diff --git a/frontend/src/hooks/usePortfolio.ts b/frontend/src/hooks/usePortfolio.ts
index 3016bc44..79f78926 100644
--- a/frontend/src/hooks/usePortfolio.ts
+++ b/frontend/src/hooks/usePortfolio.ts
@@ -50,8 +50,15 @@ export function usePortfolio(): UsePortfolioResult {
useEffect(() => { load(); }, [load]);
+ // Refresh when useClaimWinnings hook fires a successful claim
+ useEffect(() => {
+ const handler = () => { load(); };
+ window.addEventListener('boxmeout:claim_success', handler);
+ return () => window.removeEventListener('boxmeout:claim_success', handler);
+ }, [load]);
+
const runClaim = useCallback(async (fn: () => Promise) => {
- setClaimTxStatus({ hash: null, status: 'pending', error: null });
+ setClaimTxStatus({ hash: null, status: 'signing', error: null });
try {
const hash = await fn();
setClaimTxStatus({ hash, status: 'success', error: null });
diff --git a/frontend/src/services/wallet.ts b/frontend/src/services/wallet.ts
index 9f7aecca..c031e5c3 100644
--- a/frontend/src/services/wallet.ts
+++ b/frontend/src/services/wallet.ts
@@ -195,6 +195,79 @@ export async function submitClaim(market_contract_address: string): Promise void;
+
+/**
+ * Like submitClaim but calls onStage at each phase so the UI can show granular status.
+ */
+export async function submitClaimWithStages(
+ market_contract_address: string,
+ onStage: TxStageCallback,
+): Promise {
+ const address = getConnectedAddress();
+ if (!address) throw new Error('WalletNotConnected');
+ const token = process.env.NEXT_PUBLIC_XLM_TOKEN_ADDRESS;
+ if (!token) throw new Error('NEXT_PUBLIC_XLM_TOKEN_ADDRESS not set');
+
+ const server = new SorobanRpc.Server(SOROBAN_RPC_URL);
+ const account = await server.getAccount(address);
+ const contract = new Contract(market_contract_address);
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ contract.call(
+ 'claim_winnings',
+ new Address(address).toScVal(),
+ new Address(token).toScVal(),
+ ),
+ )
+ .setTimeout(30)
+ .build();
+
+ const preparedTx = await server.prepareTransaction(tx);
+ const txXdr = preparedTx.toXDR();
+
+ const freighter = (window as any).freighter;
+ if (!freighter) throw new WalletNotInstalledError();
+
+ onStage('signing');
+ let signedTxXdr: string;
+ try {
+ const result = await freighter.signTransaction(txXdr, { networkPassphrase: NETWORK_PASSPHRASE });
+ signedTxXdr = result.signedTxXdr;
+ } catch (error) {
+ throw new WalletSignError(error instanceof Error ? error.message : 'User rejected transaction signing');
+ }
+
+ onStage('broadcasting');
+ const submitRes = await server.sendTransaction(
+ TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE),
+ );
+
+ if (submitRes.status === 'ERROR') {
+ throw new TxSubmissionError(
+ `Network rejected transaction: ${submitRes.errorResult?.toString() || 'Unknown error'}`,
+ submitRes.errorResult,
+ );
+ }
+
+ onStage('confirming');
+ let getRes = await server.getTransaction(submitRes.hash);
+ for (let i = 0; i < 20 && getRes.status === 'NOT_FOUND'; i++) {
+ await new Promise((r) => setTimeout(r, 1500));
+ getRes = await server.getTransaction(submitRes.hash);
+ }
+
+ if (getRes.status !== 'SUCCESS') {
+ throw new TxSubmissionError(`Transaction failed with status: ${getRes.status}`, getRes);
+ }
+
+ return submitRes.hash;
+}
+
export async function submitRefund(market_contract_address: string): Promise {
const bettor = getConnectedAddress();
if (!bettor) throw new Error('WalletNotConnected');
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index cba88816..8197b6aa 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -78,10 +78,14 @@ export interface MarketStats {
export interface TxStatus {
hash: string | null;
- status: 'idle' | 'pending' | 'success' | 'error';
+ status: 'idle' | 'signing' | 'broadcasting' | 'confirming' | 'success' | 'error';
error: string | null;
}
+/** Pending states that show a spinner */
+export const TX_PENDING_STATES = ['signing', 'broadcasting', 'confirming'] as const;
+export type TxPendingState = (typeof TX_PENDING_STATES)[number];
+
// ─── Governance ──────────────────────────────────────────────────────────────
export type ProposalType =
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index 22328f57..c964e8c7 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -2,6 +2,7 @@ import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
+ darkMode: 'class',
theme: { extend: {} },
plugins: [],
};