diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..0859140b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.next/
+out/
+dist/
+build/
+.env
+.env.local
+.env.*.local
+*.log
+.DS_Store
+coverage/
diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs
index 57001752..91501f70 100644
--- a/contracts/treasury/src/lib.rs
+++ b/contracts/treasury/src/lib.rs
@@ -1,5 +1,5 @@
#![no_std]
-use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Vec};
+use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, Vec};
// ─── STORAGE KEYS ─────────────────────────────────────────────────────────────
// ADMIN -> Address
@@ -8,6 +8,18 @@ use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Vec};
// TOTAL_FEES_EARNED -> i128
// WITHDRAWAL_LOG -> Vec<(Address, i128, u64)>
+#[contracttype]
+#[derive(Clone, Debug)]
+pub struct ProtocolConfig {
+ pub admin: Address,
+ pub fee_collector: Address,
+ pub default_fee_bp: u32,
+ pub min_bet_amount: i128,
+ pub max_bet_amount: i128,
+ pub dispute_window_sec: u64,
+ pub paused: bool,
+}
+
#[contract]
pub struct Treasury;
@@ -42,7 +54,42 @@ impl Treasury {
/// Logs the drain. Emits EmergencyDrain event.
/// Returns total amount drained in stroops.
pub fn emergency_drain(env: Env, admin: Address, recipient: Address) -> i128 {
- todo!("implement: require_auth(admin), verify protocol is paused, transfer full BALANCE, set BALANCE=0, log, emit event, return drained amount")
+ // 1. Authentication
+ admin.require_auth();
+
+ // 2. State Check — protocol must be paused
+ let factory: Address = env.storage().persistent().get(&symbol_short!("FACTORY")).unwrap();
+ let config: ProtocolConfig = env.invoke_contract(&factory, &symbol_short!("get_config"), soroban_sdk::vec![&env]);
+ if !config.paused {
+ panic!("protocol is not paused");
+ }
+
+ // 3. Funds Transfer — drain full balance
+ let amount: i128 = env.storage().persistent().get(&symbol_short!("BALANCE")).unwrap_or(0);
+ let token: Address = env.storage().persistent().get(&symbol_short!("TOKEN")).unwrap();
+ soroban_sdk::token::Client::new(&env, &token).transfer(
+ &env.current_contract_address(),
+ &recipient,
+ &amount,
+ );
+ env.storage().persistent().set(&symbol_short!("BALANCE"), &0_i128);
+
+ // 4. Logging & Events
+ let mut log: Vec<(Address, i128, u64)> = env
+ .storage()
+ .persistent()
+ .get(&symbol_short!("WLOG"))
+ .unwrap_or(soroban_sdk::vec![&env]);
+ log.push_back((recipient.clone(), amount, env.ledger().timestamp()));
+ env.storage().persistent().set(&symbol_short!("WLOG"), &log);
+
+ env.events().publish(
+ (symbol_short!("EmrgDrain"), recipient),
+ amount,
+ );
+
+ // 5. Return drained amount
+ amount
}
/// Returns current treasury XLM balance in stroops.
@@ -60,3 +107,141 @@ impl Treasury {
todo!("implement: read WITHDRAWAL_LOG from storage and return")
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use soroban_sdk::{
+ testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation, Events, Ledger},
+ vec, IntoVal, Symbol,
+ };
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ /// Registers a Treasury contract and pre-seeds its storage so that
+ /// `emergency_drain` has the data it needs without calling `initialize`
+ /// (which is still a `todo!`).
+ fn setup(
+ env: &Env,
+ paused: bool,
+ balance: i128,
+ ) -> (TreasuryClient, Address, Address, Address) {
+ let admin = Address::generate(env);
+ let recipient = Address::generate(env);
+
+ // Deploy a mock token
+ let token_admin = Address::generate(env);
+ let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address();
+ let token = soroban_sdk::token::StellarAssetClient::new(env, &token_id);
+
+ // Deploy a mock factory whose `get_config` returns a ProtocolConfig
+ let factory_id = env.register(MockFactory, (admin.clone(), paused));
+
+ // Deploy Treasury and seed its storage
+ let treasury_id = env.register(Treasury, ());
+ env.as_contract(&treasury_id, || {
+ env.storage().persistent().set(&symbol_short!("FACTORY"), &factory_id);
+ env.storage().persistent().set(&symbol_short!("TOKEN"), &token_id);
+ env.storage().persistent().set(&symbol_short!("ADMIN"), &admin);
+ env.storage().persistent().set(&symbol_short!("BALANCE"), &balance);
+ });
+
+ // Mint `balance` tokens into the treasury contract
+ token.mint(&treasury_id, &balance);
+
+ let client = TreasuryClient::new(env, &treasury_id);
+ (client, admin, recipient, token_id)
+ }
+
+ // ── mock factory ─────────────────────────────────────────────────────────
+
+ #[contract]
+ struct MockFactory;
+
+ #[contractimpl]
+ impl MockFactory {
+ pub fn __constructor(env: Env, admin: Address, paused: bool) {
+ env.storage().persistent().set(&symbol_short!("admin"), &admin);
+ env.storage().persistent().set(&symbol_short!("paused"), &paused);
+ }
+
+ pub fn get_config(env: Env) -> ProtocolConfig {
+ let admin: Address = env.storage().persistent().get(&symbol_short!("admin")).unwrap();
+ let paused: bool = env.storage().persistent().get(&symbol_short!("paused")).unwrap();
+ ProtocolConfig {
+ admin: admin.clone(),
+ fee_collector: admin,
+ default_fee_bp: 200,
+ min_bet_amount: 1_000_000,
+ max_bet_amount: 100_000_000,
+ dispute_window_sec: 86_400,
+ paused,
+ }
+ }
+ }
+
+ // ── tests ─────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_emergency_drain_success() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let balance = 50_000_000_i128;
+ let (client, admin, recipient, token_id) = setup(&env, true, balance);
+
+ let drained = client.emergency_drain(&admin, &recipient);
+
+ // Return value
+ assert_eq!(drained, balance);
+
+ // BALANCE set to 0
+ let stored_balance: i128 = env.as_contract(&client.address, || {
+ env.storage().persistent().get(&symbol_short!("BALANCE")).unwrap()
+ });
+ assert_eq!(stored_balance, 0);
+
+ // Token actually transferred
+ let token = soroban_sdk::token::Client::new(&env, &token_id);
+ assert_eq!(token.balance(&recipient), balance);
+ assert_eq!(token.balance(&client.address), 0);
+
+ // Withdrawal log updated
+ let log: Vec<(Address, i128, u64)> = env.as_contract(&client.address, || {
+ env.storage().persistent().get(&symbol_short!("WLOG")).unwrap()
+ });
+ assert_eq!(log.len(), 1);
+ let (log_recipient, log_amount, _) = log.get(0).unwrap();
+ assert_eq!(log_recipient, recipient);
+ assert_eq!(log_amount, balance);
+
+ // EmergencyDrain event emitted
+ let events = env.events().all();
+ let found = events.iter().any(|(_, topics, data)| {
+ topics.contains(&symbol_short!("EmrgDrain").into_val(&env))
+ && data == balance.into_val(&env)
+ });
+ assert!(found, "EmergencyDrain event not found");
+ }
+
+ #[test]
+ #[should_panic(expected = "protocol is not paused")]
+ fn test_emergency_drain_fails_when_not_paused() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let (client, admin, recipient, _) = setup(&env, false, 10_000_000);
+ client.emergency_drain(&admin, &recipient);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_emergency_drain_fails_when_unauthorized() {
+ let env = Env::default();
+ // Do NOT mock auths — a non-admin call must fail auth check.
+
+ let (client, _admin, recipient, _) = setup(&env, true, 10_000_000);
+ let attacker = Address::generate(&env);
+ client.emergency_drain(&attacker, &recipient);
+ }
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 00000000..6d1c6e64
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,19 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body {
+ overflow-x: hidden;
+ max-width: 100vw;
+}
+
+/* Ensure all interactive elements meet 44px touch target minimum */
+@layer base {
+ button,
+ a,
+ [role="button"] {
+ min-height: 44px;
+ min-width: 44px;
+ }
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 00000000..b2357030
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,24 @@
+import type { Metadata, Viewport } from "next";
+import "./globals.css";
+import { Navbar } from "@/components/Navbar";
+
+export const metadata: Metadata = {
+ title: "BOXMEOUT — Boxing Prediction Market",
+ description: "Decentralized boxing prediction market on Stellar",
+};
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/app/markets/[id]/page.tsx b/frontend/app/markets/[id]/page.tsx
index 994ca30a..99526e2a 100644
--- a/frontend/app/markets/[id]/page.tsx
+++ b/frontend/app/markets/[id]/page.tsx
@@ -4,16 +4,73 @@ import { MarketOddsBar } from "@/components/MarketOddsBar";
import { MarketOddsChart } from "@/components/MarketOddsChart";
import { MarketStatusBadge } from "@/components/MarketStatusBadge";
import { CountdownTimer } from "@/components/CountdownTimer";
+import { Market } from "@/lib/api";
interface Props {
params: { id: string };
}
-/**
- * Market detail page — server component.
- * Renders fighter cards, live odds bar, betting interface, and bet history.
- * Passes market_id down to client components for real-time polling.
- */
+/** Placeholder market for layout rendering until API is wired up. */
+function placeholderMarket(id: string): Market {
+ return {
+ id,
+ contractAddress: "",
+ fighterA: { name: "Fighter A", record: "20-0", nationality: "USA", weightClass: "Heavyweight" },
+ fighterB: { name: "Fighter B", record: "18-2", nationality: "UK", weightClass: "Heavyweight" },
+ scheduledAt: new Date(Date.now() + 86400000).toISOString(),
+ bettingEndsAt: new Date(Date.now() + 43200000).toISOString(),
+ status: "Open",
+ outcome: null,
+ poolA: "0",
+ poolB: "0",
+ totalPool: "0",
+ oracleAddress: "",
+ createdBy: "",
+ };
+}
+
export default async function MarketDetailPage({ params }: Props): Promise {
- throw new Error("Not implemented");
+ const market = placeholderMarket(params.id);
+ const poolA = BigInt(market.poolA);
+ const poolB = BigInt(market.poolB);
+ const total = poolA + poolB;
+ const oddsA = total === BigInt(0) ? 50 : Number((poolA * BigInt(100)) / total);
+ const oddsB = 100 - oddsA;
+
+ return (
+
+ {/* Header */}
+
+
+ {market.fighterA.name} vs {market.fighterB.name}
+
+
+
+
+
+
+ {/* Fighter cards — stack on mobile, side-by-side on md+ */}
+
+
+
+
+
+ {/* Odds bar */}
+
+
+ {/* Chart — full width */}
+
+
+ {/* Betting interface — full width below chart */}
+
{}} />
+
+ );
}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index acbccb60..2421bef7 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,8 +1,40 @@
-/**
- * Home page — server component.
- * Fetches active markets on the server and passes them to MarketList.
- * Shows three tabs: Active, Upcoming, Resolved.
- */
-export default async function HomePage(): Promise {
- throw new Error("Not implemented");
+"use client";
+import { useState } from "react";
+import { MarketList } from "@/components/MarketList";
+import { MarketStatus } from "@/lib/api";
+
+const TABS: { label: string; status: MarketStatus }[] = [
+ { label: "Active", status: "Open" },
+ { label: "Upcoming", status: "Locked" },
+ { label: "Resolved", status: "Resolved" },
+];
+
+export default function HomePage(): JSX.Element {
+ const [tab, setTab] = useState("Open");
+
+ return (
+
+
Boxing Markets
+
+ {/* Tabs — full-width on mobile, auto-width on desktop */}
+
+ {TABS.map(({ label, status }) => (
+
+ ))}
+
+
+ {/* MarketList handles the responsive grid (1-col mobile, 2-col md, 3-col lg) */}
+
+
+ );
}
diff --git a/frontend/app/portfolio/page.tsx b/frontend/app/portfolio/page.tsx
index a0f6a80e..3438525d 100644
--- a/frontend/app/portfolio/page.tsx
+++ b/frontend/app/portfolio/page.tsx
@@ -1,14 +1,35 @@
"use client";
import { PortfolioTable } from "@/components/PortfolioTable";
-import { ClaimButton } from "@/components/ClaimButton";
import { usePortfolio } from "@/hooks/usePortfolio";
import { useWallet } from "@/hooks/useWallet";
-/**
- * Portfolio page — client component (requires wallet).
- * Shows user's full bet history, pending claims, and portfolio stats.
- * Redirects to home if no wallet is connected.
- */
export default function PortfolioPage(): JSX.Element {
- throw new Error("Not implemented");
+ const { address } = useWallet();
+ const { bets, markets, isLoading } = usePortfolio(address);
+
+ if (!address) {
+ return (
+
+
👛
+
Connect your wallet to view your portfolio.
+
+ );
+ }
+
+ return (
+
+
My Portfolio
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : (
+ /* PortfolioTable wraps its own overflow-x-auto — no page-level scroll */
+
+ )}
+
+ );
}
diff --git a/frontend/components/BetAmountInput.tsx b/frontend/components/BetAmountInput.tsx
index fa796c41..2bd7b3c7 100644
--- a/frontend/components/BetAmountInput.tsx
+++ b/frontend/components/BetAmountInput.tsx
@@ -6,13 +6,34 @@ export interface BetAmountInputProps {
min: number;
max: number;
estimatedPayout: bigint | null;
+ disabled?: boolean;
}
-/**
- * Controlled XLM amount input with min/max validation.
- * Shows estimated payout below the input updated in real time.
- * Displays inline validation error when value is out of [min, max] range.
- */
-export function BetAmountInput(_props: BetAmountInputProps): JSX.Element {
- throw new Error("Not implemented");
+export function BetAmountInput({ value, onChange, min, max, estimatedPayout, disabled }: BetAmountInputProps): JSX.Element {
+ const num = parseFloat(value);
+ const error = value && (isNaN(num) || num < min || num > max)
+ ? `Enter an amount between ${min} and ${max} XLM`
+ : null;
+
+ return (
+
+
+
onChange(e.target.value)}
+ min={min}
+ max={max}
+ disabled={disabled}
+ className="w-full bg-gray-700 text-white rounded-lg px-3 h-11 border border-gray-600 focus:border-amber-400 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
+ placeholder={`${min}–${max} XLM`}
+ />
+ {error &&
{error}
}
+ {estimatedPayout !== null && !error && (
+
+ Est. payout: {(Number(estimatedPayout) / 1e7).toFixed(2)} XLM
+
+ )}
+
+ );
}
diff --git a/frontend/components/BettingInterface.tsx b/frontend/components/BettingInterface.tsx
index 385d0e62..f6c55e77 100644
--- a/frontend/components/BettingInterface.tsx
+++ b/frontend/components/BettingInterface.tsx
@@ -1,17 +1,98 @@
"use client";
-import { Bet, Market } from "@/lib/api";
+import { useState } from "react";
+import { Bet, BetSide, Market } from "@/lib/api";
+import { BetAmountInput } from "./BetAmountInput";
+import { usePlaceBet } from "@/hooks/usePlaceBet";
+import { useToast } from "@/hooks/useToast";
export interface BettingInterfaceProps {
market: Market;
onBetPlaced: (bet: Bet) => void;
}
-/**
- * Main betting UI on the market detail page.
- * Renders two side-select buttons (Fighter A / Fighter B) and a BetAmountInput.
- * Builds and submits the place_bet Soroban transaction via connected wallet.
- * Entire component is disabled when market.status !== "Open".
- */
-export function BettingInterface(_props: BettingInterfaceProps): JSX.Element {
- throw new Error("Not implemented");
+export function BettingInterface({ market, onBetPlaced }: BettingInterfaceProps): JSX.Element {
+ const [side, setSide] = useState(null);
+ const [amount, setAmount] = useState("");
+ const { placeBet, isLoading } = usePlaceBet(market.id);
+ const { showToast } = useToast();
+
+ const marketClosed = market.status !== "Open";
+ // All controls disabled while market is closed OR a tx is in-flight
+ const allDisabled = marketClosed || isLoading;
+
+ async function handleSubmit() {
+ if (!side || !amount || allDisabled) return;
+ const xlmUnits = BigInt(Math.round(parseFloat(amount) * 1e7));
+ showToast("Transaction submitted. Waiting for ledger confirmation...", "info");
+ try {
+ const bet = await placeBet(side, xlmUnits);
+ showToast("Bet confirmed successfully!", "success");
+ onBetPlaced(bet);
+ setSide(null);
+ setAmount("");
+ } catch (e) {
+ showToast(e instanceof Error ? e.message : "Transaction failed.", "error");
+ }
+ }
+
+ return (
+
+
Place Bet
+
+ {marketClosed && !isLoading && (
+
Betting is {market.status.toLowerCase()}.
+ )}
+
+ {/* In-flight feedback */}
+ {isLoading && (
+
+ {/* Spinner */}
+
+ Confirming transaction...
+
+ )}
+
+ {/* Fighter select */}
+
+
+
+
+
+
{ if (!allDisabled) setAmount(v); }}
+ min={1}
+ max={10000}
+ estimatedPayout={null}
+ disabled={allDisabled}
+ />
+
+
+
+ );
}
diff --git a/frontend/components/ClaimButton.tsx b/frontend/components/ClaimButton.tsx
index 51baddba..6ae4259d 100644
--- a/frontend/components/ClaimButton.tsx
+++ b/frontend/components/ClaimButton.tsx
@@ -14,12 +14,17 @@ export interface ClaimButtonProps {
onClaimed: (receipt: ClaimReceipt) => void;
}
-/**
- * Renders "Claim Winnings" or "Claim Refund" based on market outcome and bet side.
- * Submits claim_winnings() or claim_refund() on-chain via wallet.
- * Disabled when bet.claimed=true or market is not Resolved/Cancelled.
- * Shows loading spinner while the transaction is in-flight.
- */
-export function ClaimButton(_props: ClaimButtonProps): JSX.Element {
- throw new Error("Not implemented");
+export function ClaimButton({ bet, market }: ClaimButtonProps): JSX.Element {
+ const claimable =
+ !bet.claimed && (market.status === "Resolved" || market.status === "Cancelled");
+ const label = market.status === "Cancelled" ? "Claim Refund" : "Claim Winnings";
+
+ return (
+
+ );
}
diff --git a/frontend/components/CountdownTimer.tsx b/frontend/components/CountdownTimer.tsx
index c59b238c..7d81ee21 100644
--- a/frontend/components/CountdownTimer.tsx
+++ b/frontend/components/CountdownTimer.tsx
@@ -1,15 +1,41 @@
"use client";
+import { useEffect, useState } from "react";
export interface CountdownTimerProps {
- targetTimestamp: number; // Unix seconds
- label: string; // e.g. "Betting closes in"
+ targetTimestamp: number;
+ label: string;
}
-/**
- * Live countdown to a Unix timestamp, updated every second.
- * Displays HH:MM:SS format with the label prefix.
- * Switches to "LIVE" text once targetTimestamp is reached.
- */
-export function CountdownTimer(_props: CountdownTimerProps): JSX.Element {
- throw new Error("Not implemented");
+function formatRemaining(seconds: number): string {
+ if (seconds <= 0) return "LIVE";
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = seconds % 60;
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
+}
+
+export function CountdownTimer({ targetTimestamp, label }: CountdownTimerProps): JSX.Element {
+ const [remaining, setRemaining] = useState(() =>
+ Math.max(0, targetTimestamp - Math.floor(Date.now() / 1000))
+ );
+
+ useEffect(() => {
+ if (remaining <= 0) return;
+ const id = setInterval(() => {
+ setRemaining(Math.max(0, targetTimestamp - Math.floor(Date.now() / 1000)));
+ }, 1000);
+ return () => clearInterval(id);
+ }, [targetTimestamp, remaining]);
+
+ const display = formatRemaining(remaining);
+
+ return (
+
+ {display === "LIVE" ? (
+ LIVE
+ ) : (
+ <>{label}: {display}>
+ )}
+
+ );
}
diff --git a/frontend/components/FighterCard.tsx b/frontend/components/FighterCard.tsx
index ac15393e..ab31bea2 100644
--- a/frontend/components/FighterCard.tsx
+++ b/frontend/components/FighterCard.tsx
@@ -4,13 +4,25 @@ export interface FighterCardProps {
fighter: Fighter;
side: "A" | "B";
poolAmount: bigint;
- impliedOdds: number; // 0–100 percentage
+ impliedOdds: number;
}
-/**
- * Displays one fighter's info: name, record, weight class, nationality.
- * Shows current pool size in XLM and implied win probability as a percentage.
- */
-export function FighterCard(_props: FighterCardProps): JSX.Element {
- throw new Error("Not implemented");
+export function FighterCard({ fighter, side, poolAmount, impliedOdds }: FighterCardProps): JSX.Element {
+ const accent = side === "A" ? "border-blue-500 text-blue-400" : "border-red-500 text-red-400";
+ const xlm = (Number(poolAmount) / 1e7).toFixed(2);
+
+ return (
+
+
+
+ Fighter {side}
+
+ {impliedOdds.toFixed(1)}%
+
+
{fighter.name}
+
{fighter.record} · {fighter.weightClass}
+
{fighter.nationality}
+
Pool: {xlm} XLM
+
+ );
}
diff --git a/frontend/components/LoadingSkeleton.tsx b/frontend/components/LoadingSkeleton.tsx
index 2c2bc840..39a0c638 100644
--- a/frontend/components/LoadingSkeleton.tsx
+++ b/frontend/components/LoadingSkeleton.tsx
@@ -3,11 +3,29 @@ export interface LoadingSkeletonProps {
count?: number;
}
-/**
- * Animated placeholder rendered while data loads.
- * Each variant matches the dimensions of its real counterpart
- * (MarketCard, PortfolioTable row, MarketOddsChart) to prevent layout shift.
- */
-export function LoadingSkeleton(_props: LoadingSkeletonProps): JSX.Element {
- throw new Error("Not implemented");
+export function LoadingSkeleton({ variant, count = 1 }: LoadingSkeletonProps): JSX.Element {
+ const items = Array.from({ length: count });
+
+ if (variant === "card") {
+ return (
+
+ {items.map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (variant === "table") {
+ return (
+
+ {items.map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ // chart
+ return ;
}
diff --git a/frontend/components/MarketCard.tsx b/frontend/components/MarketCard.tsx
index bad60119..142431e0 100644
--- a/frontend/components/MarketCard.tsx
+++ b/frontend/components/MarketCard.tsx
@@ -1,15 +1,39 @@
+import Link from "next/link";
import { Market } from "@/lib/api";
+import { MarketStatusBadge } from "./MarketStatusBadge";
+import { MarketOddsBar } from "./MarketOddsBar";
export interface MarketCardProps {
market: Market;
showOdds: boolean;
}
-/**
- * Displays a compact preview of one boxing market.
- * Shows fighter names, scheduled date, pool sizes, and implied odds bar.
- * Clicking the card navigates to /markets/[id].
- */
export default function MarketCard({ market, showOdds }: MarketCardProps): JSX.Element {
- throw new Error("Not implemented");
+ const poolA = BigInt(market.poolA);
+ const poolB = BigInt(market.poolB);
+
+ return (
+
+
+
+ {market.fighterA.name} vs {market.fighterB.name}
+
+
+
+
+ {market.fighterA.weightClass} · {new Date(market.scheduledAt).toLocaleDateString()}
+
+ {showOdds && (
+
+ )}
+
+ );
}
diff --git a/frontend/components/MarketList.tsx b/frontend/components/MarketList.tsx
index 413b3b9d..eb06a700 100644
--- a/frontend/components/MarketList.tsx
+++ b/frontend/components/MarketList.tsx
@@ -1,4 +1,6 @@
import { Market, MarketStatus } from "@/lib/api";
+import MarketCard from "./MarketCard";
+import { LoadingSkeleton } from "./LoadingSkeleton";
export interface MarketListProps {
markets: Market[];
@@ -6,11 +8,25 @@ export interface MarketListProps {
filter?: MarketStatus;
}
-/**
- * Renders a responsive grid of MarketCard components.
- * Shows LoadingSkeleton variants when isLoading=true.
- * Shows an empty state message when markets array is empty.
- */
-export function MarketList(_props: MarketListProps): JSX.Element {
- throw new Error("Not implemented");
+export function MarketList({ markets, isLoading, filter }: MarketListProps): JSX.Element {
+ if (isLoading) return ;
+
+ const filtered = filter ? markets.filter((m) => m.status === filter) : markets;
+
+ if (filtered.length === 0) {
+ return (
+
+
🥊
+
No {filter?.toLowerCase() ?? ""} markets yet.
+
+ );
+ }
+
+ return (
+
+ {filtered.map((m) => (
+
+ ))}
+
+ );
}
diff --git a/frontend/components/MarketOddsBar.tsx b/frontend/components/MarketOddsBar.tsx
index 7a9707e5..155edf6b 100644
--- a/frontend/components/MarketOddsBar.tsx
+++ b/frontend/components/MarketOddsBar.tsx
@@ -5,10 +5,21 @@ export interface MarketOddsBarProps {
fighterBName: string;
}
-/**
- * Visual proportional bar showing Fighter A vs Fighter B pool split.
- * Color-coded with percentage labels. Falls back to 50/50 when both pools are 0.
- */
-export function MarketOddsBar(_props: MarketOddsBarProps): JSX.Element {
- throw new Error("Not implemented");
+export function MarketOddsBar({ poolA, poolB, fighterAName, fighterBName }: MarketOddsBarProps): JSX.Element {
+ const total = poolA + poolB;
+ const pctA = total === BigInt(0) ? 50 : Number((poolA * BigInt(100)) / total);
+ const pctB = 100 - pctA;
+
+ return (
+
+
+ {fighterAName} {pctA}%
+ {pctB}% {fighterBName}
+
+
+
+ );
}
diff --git a/frontend/components/MarketOddsChart.tsx b/frontend/components/MarketOddsChart.tsx
index ea3a45ec..b52ad8c0 100644
--- a/frontend/components/MarketOddsChart.tsx
+++ b/frontend/components/MarketOddsChart.tsx
@@ -1,4 +1,5 @@
"use client";
+import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import { OddsSnapshot } from "@/lib/api";
export interface MarketOddsChartProps {
@@ -6,11 +7,32 @@ export interface MarketOddsChartProps {
historicalOdds: OddsSnapshot[];
}
-/**
- * Line chart of historical implied odds over time (Recharts).
- * Shows how the Fighter A / B split shifted as bets came in.
- * Handles single-data-point gracefully — no chart crash.
- */
-export function MarketOddsChart(_props: MarketOddsChartProps): JSX.Element {
- throw new Error("Not implemented");
+export function MarketOddsChart({ historicalOdds }: MarketOddsChartProps): JSX.Element {
+ if (historicalOdds.length < 2) {
+ return (
+
+ Not enough data to display chart
+
+ );
+ }
+
+ const data = historicalOdds.map((s) => ({
+ t: new Date(s.timestamp).toLocaleTimeString(),
+ A: s.oddsA,
+ B: s.oddsB,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/frontend/components/MarketStatusBadge.tsx b/frontend/components/MarketStatusBadge.tsx
index 11a88aab..8eeb5a25 100644
--- a/frontend/components/MarketStatusBadge.tsx
+++ b/frontend/components/MarketStatusBadge.tsx
@@ -4,10 +4,18 @@ export interface MarketStatusBadgeProps {
status: MarketStatus;
}
-/**
- * Color-coded pill badge for a market's status.
- * Open=green, Locked=yellow, Resolved=blue, Disputed=red, Cancelled=gray.
- */
-export function MarketStatusBadge(_props: MarketStatusBadgeProps): JSX.Element {
- throw new Error("Not implemented");
+const COLORS: Record = {
+ Open: "bg-green-700 text-green-100",
+ Locked: "bg-yellow-700 text-yellow-100",
+ Resolved: "bg-blue-700 text-blue-100",
+ Disputed: "bg-red-700 text-red-100",
+ Cancelled: "bg-gray-600 text-gray-100",
+};
+
+export function MarketStatusBadge({ status }: MarketStatusBadgeProps): JSX.Element {
+ return (
+
+ {status}
+
+ );
}
diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx
new file mode 100644
index 00000000..c2ab7a7c
--- /dev/null
+++ b/frontend/components/Navbar.tsx
@@ -0,0 +1,74 @@
+"use client";
+import { useState } from "react";
+import Link from "next/link";
+
+const NAV_LINKS = [
+ { href: "/", label: "Markets" },
+ { href: "/portfolio", label: "Portfolio" },
+ { href: "/create", label: "Create Market" },
+];
+
+export function Navbar() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ );
+}
diff --git a/frontend/components/PortfolioTable.tsx b/frontend/components/PortfolioTable.tsx
index 86117a38..c8b5b622 100644
--- a/frontend/components/PortfolioTable.tsx
+++ b/frontend/components/PortfolioTable.tsx
@@ -1,4 +1,5 @@
"use client";
+import { useState } from "react";
import { Bet, Market } from "@/lib/api";
export interface PortfolioTableProps {
@@ -6,11 +7,91 @@ export interface PortfolioTableProps {
markets: Record;
}
-/**
- * Table of user bets: Fight, Side, Amount (XLM), Status, Payout columns.
- * Sortable by any column. Filterable by bet status.
- * Shows empty-state illustration when bets array is empty.
- */
-export function PortfolioTable(_props: PortfolioTableProps): JSX.Element {
- throw new Error("Not implemented");
+type SortKey = "fight" | "side" | "amount" | "status" | "payout";
+
+export function PortfolioTable({ bets, markets }: PortfolioTableProps): JSX.Element {
+ const [sortKey, setSortKey] = useState("fight");
+ const [asc, setAsc] = useState(true);
+
+ function toggleSort(key: SortKey) {
+ if (sortKey === key) setAsc((a) => !a);
+ else { setSortKey(key); setAsc(true); }
+ }
+
+ if (bets.length === 0) {
+ return (
+
+
📋
+
No bets yet. Head to a market to place your first bet!
+
+ );
+ }
+
+ const sorted = [...bets].sort((a, b) => {
+ let cmp = 0;
+ if (sortKey === "fight") {
+ const mA = markets[a.marketId];
+ const mB = markets[b.marketId];
+ cmp = (mA ? `${mA.fighterA.name} vs ${mA.fighterB.name}` : a.marketId)
+ .localeCompare(mB ? `${mB.fighterA.name} vs ${mB.fighterB.name}` : b.marketId);
+ } else if (sortKey === "side") {
+ cmp = a.side.localeCompare(b.side);
+ } else if (sortKey === "amount") {
+ cmp = Number(BigInt(a.amount) - BigInt(b.amount));
+ } else if (sortKey === "status") {
+ cmp = String(a.claimed).localeCompare(String(b.claimed));
+ } else if (sortKey === "payout") {
+ cmp = Number(BigInt(a.payout ?? "0") - BigInt(b.payout ?? "0"));
+ }
+ return asc ? cmp : -cmp;
+ });
+
+ function Th({ label, sk }: { label: string; sk: SortKey }) {
+ return (
+ toggleSort(sk)}
+ className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer select-none whitespace-nowrap hover:text-white"
+ >
+ {label}{sortKey === sk ? (asc ? " ↑" : " ↓") : ""}
+ |
+ );
+ }
+
+ return (
+ /* overflow-x-auto scoped to this container — prevents page-level horizontal scroll */
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+ {sorted.map((bet) => {
+ const m = markets[bet.marketId];
+ const fight = m ? `${m.fighterA.name} vs ${m.fighterB.name}` : bet.marketId;
+ const xlm = (Number(BigInt(bet.amount)) / 1e7).toFixed(2);
+ const payout = bet.payout ? (Number(BigInt(bet.payout)) / 1e7).toFixed(2) : "—";
+ return (
+
+ | {fight} |
+ {bet.side} |
+ {xlm} XLM |
+
+
+ {bet.claimed ? "Claimed" : "Pending"}
+
+ |
+ {payout} |
+
+ );
+ })}
+
+
+
+ );
}
diff --git a/frontend/components/WalletConnectButton.tsx b/frontend/components/WalletConnectButton.tsx
index cc1a9b80..b6e15c73 100644
--- a/frontend/components/WalletConnectButton.tsx
+++ b/frontend/components/WalletConnectButton.tsx
@@ -1,14 +1,41 @@
"use client";
+import { useState } from "react";
export interface WalletConnectButtonProps {
onConnected: (address: string) => void;
}
-/**
- * Connect / disconnect wallet button supporting Freighter, Albedo, and xBull.
- * Shows truncated Stellar address (GABCD…XYZ) when connected.
- * Renders a Freighter install link when the extension is not detected.
- */
-export function WalletConnectButton(_props: WalletConnectButtonProps): JSX.Element {
- throw new Error("Not implemented");
+function truncate(addr: string) {
+ return `${addr.slice(0, 5)}…${addr.slice(-3)}`;
+}
+
+export function WalletConnectButton({ onConnected }: WalletConnectButtonProps): JSX.Element {
+ const [address, setAddress] = useState(null);
+
+ async function connect() {
+ // Freighter API integration point
+ const addr = "GABCDEFGHIJKLMNOPQRSTUVWXYZ"; // placeholder
+ setAddress(addr);
+ onConnected(addr);
+ }
+
+ function disconnect() {
+ setAddress(null);
+ }
+
+ return address ? (
+
+ ) : (
+
+ );
}
diff --git a/frontend/hooks/useMarkets.ts b/frontend/hooks/useMarkets.ts
index 7eca434e..4619d33a 100644
--- a/frontend/hooks/useMarkets.ts
+++ b/frontend/hooks/useMarkets.ts
@@ -1,7 +1,6 @@
-import { Market, MarketFilters } from "@/lib/api";
+import { Market, MarketQueryParams } from "@/lib/api";
-// Re-export MarketFilters so callers import from one place
-export type { MarketFilters };
+export type MarketFilters = MarketQueryParams;
export interface UseMarketsResult {
markets: Market[];
@@ -15,6 +14,6 @@ export interface UseMarketsResult {
* Polls automatically every 30 seconds for live updates.
* Returns loading and error states for the caller to handle.
*/
-export function useMarkets(filters?: MarketFilters): UseMarketsResult {
- throw new Error("Not implemented");
+export function useMarkets(_filters?: MarketFilters): UseMarketsResult {
+ return { markets: [], isLoading: false, error: null, refetch: () => {} };
}
diff --git a/frontend/hooks/usePortfolio.ts b/frontend/hooks/usePortfolio.ts
index 7b70bb45..9a5140cc 100644
--- a/frontend/hooks/usePortfolio.ts
+++ b/frontend/hooks/usePortfolio.ts
@@ -1,17 +1,20 @@
-import { Bet, PortfolioSummary } from "@/lib/api";
+import { Bet, Market, PortfolioSummary } from "@/lib/api";
export interface UsePortfolioResult {
bets: Bet[];
+ markets: Record;
summary: PortfolioSummary | null;
isLoading: boolean;
refetch: () => void;
}
-/**
- * Fetches all bets and portfolio summary for the given Stellar address.
- * Returns empty bets and null summary when address is null (wallet not connected).
- * Does not activate any network requests until address is non-null.
- */
export function usePortfolio(address: string | null): UsePortfolioResult {
- throw new Error("Not implemented");
+ // Data fetching wired up once API layer is implemented
+ return {
+ bets: [],
+ markets: {},
+ summary: null,
+ isLoading: false,
+ refetch: () => {},
+ };
}
diff --git a/frontend/hooks/useWallet.ts b/frontend/hooks/useWallet.ts
index 7b19811f..1f85dd65 100644
--- a/frontend/hooks/useWallet.ts
+++ b/frontend/hooks/useWallet.ts
@@ -1,3 +1,6 @@
+"use client";
+import { useState } from "react";
+
export interface UseWalletResult {
address: string | null;
isConnected: boolean;
@@ -6,11 +9,21 @@ export interface UseWalletResult {
signTransaction: (xdr: string) => Promise;
}
-/**
- * Manages Stellar wallet state using Freighter or compatible wallet APIs.
- * Abstracts Freighter/Albedo/xBull behind a common interface.
- * connect() throws a descriptive error if no wallet extension is installed.
- */
export function useWallet(): UseWalletResult {
- throw new Error("Not implemented");
+ const [address, setAddress] = useState(null);
+
+ async function connect() {
+ // Freighter integration point — stub returns placeholder
+ setAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ");
+ }
+
+ function disconnect() {
+ setAddress(null);
+ }
+
+ async function signTransaction(_xdr: string): Promise {
+ throw new Error("Wallet not connected");
+ }
+
+ return { address, isConnected: address !== null, connect, disconnect, signTransaction };
}
diff --git a/frontend/next.config.js b/frontend/next.config.js
new file mode 100644
index 00000000..658404ac
--- /dev/null
+++ b/frontend/next.config.js
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+module.exports = nextConfig;
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 00000000..12a703d9
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 00000000..79e597cd
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,15 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./hooks/**/*.{ts,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 00000000..79e3578e
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}