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"] +}