diff --git a/docker-compose.yml b/docker-compose.yml
index e7a14c91..b11e0552 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -84,6 +84,26 @@ services:
start_period: 15s
retries: 3
+ # ── Frontend ────────────────────────────────────────────────────────────────
+ # NEXT_PUBLIC_* values are baked in at build time. The browser calls the API
+ # on the host port, so the default points at http://localhost:3001.
+ web:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ args:
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
+ NEXT_PUBLIC_STELLAR_NETWORK: ${NEXT_PUBLIC_STELLAR_NETWORK:-testnet}
+ NEXT_PUBLIC_SOROBAN_RPC_URL: ${NEXT_PUBLIC_SOROBAN_RPC_URL:-https://soroban-testnet.stellar.org}
+ NEXT_PUBLIC_MARKET_CONTRACT_ID: ${NEXT_PUBLIC_MARKET_CONTRACT_ID:-}
+ NEXT_PUBLIC_ADMIN_ADDRESSES: ${NEXT_PUBLIC_ADMIN_ADDRESSES:-}
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ depends_on:
+ api:
+ condition: service_healthy
+
# ── Indexer ──────────────────────────────────────────────────────────────────
# B-54: Runs the Soroban event-polling loop as a managed process separate
# from the API server, sharing the same compiled Docker image.
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 00000000..2bc6cfe9
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+.next
+out
+coverage
+storybook-static
+test-results
+playwright-report
+.env*.local
+npm-debug.log*
+Dockerfile
+.dockerignore
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 00000000..74342f03
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,58 @@
+# syntax=docker/dockerfile:1
+# ── BOXMEOUT frontend production image ───────────────────────────────────────
+# Multi-stage build on the Next.js "standalone" output. The final image runs as
+# an unprivileged user and contains only the traced server bundle.
+#
+# NEXT_PUBLIC_* values are inlined into the JavaScript bundle at build time, so
+# they are passed as build args rather than runtime env. The defaults below
+# target the local docker-compose stack; override them for other environments:
+# docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .
+# ─────────────────────────────────────────────────────────────────────────────
+
+FROM node:20-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM node:20-alpine AS builder
+WORKDIR /app
+ENV NEXT_TELEMETRY_DISABLED=1
+
+ARG NEXT_PUBLIC_API_URL=http://localhost:3001
+ARG NEXT_PUBLIC_STELLAR_NETWORK=testnet
+ARG NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
+ARG NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
+ARG NEXT_PUBLIC_MARKET_CONTRACT_ID=
+ARG NEXT_PUBLIC_ADMIN_ADDRESSES=
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
+ NEXT_PUBLIC_STELLAR_NETWORK=$NEXT_PUBLIC_STELLAR_NETWORK \
+ NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE=$NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE \
+ NEXT_PUBLIC_SOROBAN_RPC_URL=$NEXT_PUBLIC_SOROBAN_RPC_URL \
+ NEXT_PUBLIC_MARKET_CONTRACT_ID=$NEXT_PUBLIC_MARKET_CONTRACT_ID \
+ NEXT_PUBLIC_ADMIN_ADDRESSES=$NEXT_PUBLIC_ADMIN_ADDRESSES
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN mkdir -p public && npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production \
+ NEXT_TELEMETRY_DISABLED=1 \
+ PORT=3000 \
+ HOSTNAME=0.0.0.0
+
+RUN addgroup --system --gid 1001 nodejs \
+ && adduser --system --uid 1001 nextjs
+
+COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+EXPOSE 3000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+ CMD wget -qO- http://127.0.0.1:3000/ > /dev/null || exit 1
+
+CMD ["node", "server.js"]
diff --git a/frontend/__tests__/lib/resolution.test.ts b/frontend/__tests__/lib/resolution.test.ts
new file mode 100644
index 00000000..86aac653
--- /dev/null
+++ b/frontend/__tests__/lib/resolution.test.ts
@@ -0,0 +1,46 @@
+import {
+ DISPUTE_WINDOW_MS,
+ getClaimBlockReason,
+ getResolutionStage,
+} from "@/lib/resolution";
+
+const RESOLVED_AT = "2026-10-01T12:00:00Z";
+const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime();
+
+function market(status: "Open" | "Locked" | "Resolved" | "Disputed" | "Cancelled", resolvedAt?: string) {
+ return { status, scheduledAt: "2026-09-30T00:00:00Z", resolvedAt };
+}
+
+describe("getResolutionStage", () => {
+ it("maps open, locked, disputed and cancelled markets directly", () => {
+ expect(getResolutionStage(market("Open"))).toBe("open");
+ expect(getResolutionStage(market("Locked"))).toBe("locked");
+ expect(getResolutionStage(market("Disputed", RESOLVED_AT))).toBe("disputed");
+ expect(getResolutionStage(market("Cancelled"))).toBe("cancelled");
+ });
+
+ it("keeps a resolved market in the dispute window until 24 hours have passed", () => {
+ const m = market("Resolved", RESOLVED_AT);
+ expect(getResolutionStage(m, RESOLVED_AT_MS + DISPUTE_WINDOW_MS - 1)).toBe("dispute-window");
+ expect(getResolutionStage(m, RESOLVED_AT_MS + DISPUTE_WINDOW_MS)).toBe("finalized");
+ });
+
+ it("falls back to scheduledAt when resolvedAt is missing", () => {
+ const m = market("Resolved");
+ const scheduledMs = new Date("2026-09-30T00:00:00Z").getTime();
+ expect(getResolutionStage(m, scheduledMs + 1000)).toBe("dispute-window");
+ expect(getResolutionStage(m, scheduledMs + DISPUTE_WINDOW_MS + 1)).toBe("finalized");
+ });
+});
+
+describe("getClaimBlockReason", () => {
+ it("explains the wait during the dispute window and on disputed markets", () => {
+ expect(getClaimBlockReason("dispute-window")).toMatch(/dispute window/);
+ expect(getClaimBlockReason("disputed")).toMatch(/admin/);
+ });
+
+ it("returns null when a claim can go ahead or the market is cancelled", () => {
+ expect(getClaimBlockReason("finalized")).toBeNull();
+ expect(getClaimBlockReason("cancelled")).toBeNull();
+ });
+});
diff --git a/frontend/__tests__/lib/security-headers.test.ts b/frontend/__tests__/lib/security-headers.test.ts
new file mode 100644
index 00000000..66d4226b
--- /dev/null
+++ b/frontend/__tests__/lib/security-headers.test.ts
@@ -0,0 +1,81 @@
+import {
+ buildConnectSrc,
+ buildSecurityHeaders,
+ HORIZON_URLS,
+} from "@/lib/security-headers";
+
+const API_URL = "http://localhost:3001";
+
+function headerValue(headers: { key: string; value: string }[], key: string): string {
+ const header = headers.find((h) => h.key === key);
+ if (!header) throw new Error(`missing header ${key}`);
+ return header.value;
+}
+
+function directive(csp: string, name: string): string[] {
+ const match = csp.split("; ").find((d) => d.startsWith(`${name} `));
+ if (!match) throw new Error(`missing directive ${name}`);
+ return match.split(" ").slice(1);
+}
+
+describe("buildConnectSrc", () => {
+ it("allows the API, Horizon for the network and the Soroban RPC", () => {
+ const sources = buildConnectSrc({
+ apiUrl: "https://api.boxmeout.example/v1",
+ sorobanRpcUrl: "https://soroban-testnet.stellar.org",
+ network: "testnet",
+ });
+
+ expect(sources).toEqual([
+ "'self'",
+ "https://api.boxmeout.example",
+ HORIZON_URLS.testnet,
+ "https://soroban-testnet.stellar.org",
+ ]);
+ });
+
+ it("omits the RPC origin when none is configured", () => {
+ const sources = buildConnectSrc({ apiUrl: API_URL, network: "mainnet" });
+
+ expect(sources).toEqual(["'self'", API_URL, HORIZON_URLS.mainnet]);
+ });
+
+ it("names the variable when the API URL is invalid", () => {
+ expect(() =>
+ buildConnectSrc({ apiUrl: "not a url", network: "testnet" })
+ ).toThrow("NEXT_PUBLIC_API_URL must be a valid URL");
+ });
+});
+
+describe("buildSecurityHeaders", () => {
+ it("sets the clickjacking, referrer, sniffing and permissions headers", () => {
+ const headers = buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" });
+
+ expect(headerValue(headers, "X-Frame-Options")).toBe("DENY");
+ expect(headerValue(headers, "Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ expect(headerValue(headers, "X-Content-Type-Options")).toBe("nosniff");
+ expect(headerValue(headers, "Permissions-Policy")).toContain("camera=()");
+ });
+
+ it("builds connect-src from the configured API origin", () => {
+ const headers = buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" });
+ const csp = headerValue(headers, "Content-Security-Policy");
+
+ expect(directive(csp, "connect-src")).toContain("http://localhost:3001");
+ expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]);
+ });
+
+ it("allows eval in scripts only in development", () => {
+ const prod = headerValue(
+ buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" }),
+ "Content-Security-Policy"
+ );
+ const dev = headerValue(
+ buildSecurityHeaders({ isDev: true, apiUrl: API_URL, network: "testnet" }),
+ "Content-Security-Policy"
+ );
+
+ expect(directive(prod, "script-src")).not.toContain("'unsafe-eval'");
+ expect(directive(dev, "script-src")).toContain("'unsafe-eval'");
+ });
+});
diff --git a/frontend/__tests__/lib/sitemap.test.ts b/frontend/__tests__/lib/sitemap.test.ts
new file mode 100644
index 00000000..7ee5e7d8
--- /dev/null
+++ b/frontend/__tests__/lib/sitemap.test.ts
@@ -0,0 +1,42 @@
+import { buildSitemapEntries } from "@/lib/sitemap";
+
+const NOW = new Date("2026-10-19T00:00:00Z");
+
+describe("buildSitemapEntries", () => {
+ it("lists the home page first, at the site origin", () => {
+ const entries = buildSitemapEntries("https://boxmeout.example/some/path", [], NOW);
+
+ expect(entries).toEqual([
+ { url: "https://boxmeout.example/", lastModified: NOW, changeFrequency: "hourly", priority: 1 },
+ ]);
+ });
+
+ it("adds one absolute URL per open market", () => {
+ const entries = buildSitemapEntries(
+ "https://boxmeout.example",
+ [{ id: "m-1", scheduledAt: "2026-11-01T00:00:00Z" }],
+ NOW
+ );
+
+ expect(entries[1]).toEqual({
+ url: "https://boxmeout.example/markets/m-1",
+ lastModified: new Date("2026-11-01T00:00:00Z"),
+ changeFrequency: "hourly",
+ priority: 0.8,
+ });
+ });
+
+ it("encodes market ids so they cannot change the path", () => {
+ const entries = buildSitemapEntries(
+ "https://boxmeout.example",
+ [{ id: "a/b?c", scheduledAt: "2026-11-01T00:00:00Z" }],
+ NOW
+ );
+
+ expect(entries[1].url).toBe("https://boxmeout.example/markets/a%2Fb%3Fc");
+ });
+
+ it("rejects a site URL that is not absolute", () => {
+ expect(() => buildSitemapEntries("boxmeout.example", [], NOW)).toThrow();
+ });
+});
diff --git a/frontend/app/icon.svg b/frontend/app/icon.svg
new file mode 100644
index 00000000..d5498235
--- /dev/null
+++ b/frontend/app/icon.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/app/markets/[id]/opengraph-image.tsx b/frontend/app/markets/[id]/opengraph-image.tsx
index c51223a3..2e524ab8 100644
--- a/frontend/app/markets/[id]/opengraph-image.tsx
+++ b/frontend/app/markets/[id]/opengraph-image.tsx
@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */
-// @ts-expect-error -- next/og types resolve after npm install
import { ImageResponse } from "next/og";
import { fetchMarketById } from "@/lib/api";
diff --git a/frontend/components/BetForm.tsx b/frontend/components/BetForm.tsx
index 90fee44d..fca4f4c4 100644
--- a/frontend/components/BetForm.tsx
+++ b/frontend/components/BetForm.tsx
@@ -2,7 +2,8 @@
import { useEffect, useId, useMemo, useState } from "react";
import { Bet, BetSide, Market } from "@/lib/api";
import { BetAmountInput } from "./BetAmountInput";
-import { TransactionStatusModal, TransactionStatus } from "./TransactionStatusModal";
+import { TransactionStatusModal } from "./TransactionStatusModal";
+import type { TransactionStatus } from "@/lib/stellar";
import { usePlaceBet } from "@/hooks/usePlaceBet";
const MIN_AMOUNT_XLM = 1;
diff --git a/frontend/components/ClaimButton.resolution.test.tsx b/frontend/components/ClaimButton.resolution.test.tsx
new file mode 100644
index 00000000..807ab8d5
--- /dev/null
+++ b/frontend/components/ClaimButton.resolution.test.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { ClaimButton } from "./ClaimButton";
+import { Bet, Market, MarketStatus, Outcome } from "@/lib/api";
+
+jest.mock("@/hooks/useClaimWinnings", () => ({
+ useClaimWinnings: () => ({ claim: jest.fn() }),
+}));
+jest.mock("@/hooks/useClaimRefund", () => ({
+ useClaimRefund: () => ({ claimRefund: jest.fn() }),
+}));
+
+const RESOLVED_AT = "2026-10-01T12:00:00Z";
+const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime();
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function makeMarket(status: MarketStatus, outcome: Outcome | null): Market {
+ return {
+ id: "m-1",
+ contractAddress: "C1",
+ fighterA: { name: "Maya Chen", record: "10-0", nationality: "US", weightClass: "Welter" },
+ fighterB: { name: "Rico Alvarez", record: "9-1", nationality: "MX", weightClass: "Welter" },
+ scheduledAt: "2026-09-30T00:00:00Z",
+ bettingEndsAt: "2026-09-29T23:00:00Z",
+ status,
+ outcome,
+ poolA: "0",
+ poolB: "0",
+ totalPool: "0",
+ oracleAddress: "G1",
+ createdBy: "G2",
+ resolvedAt: RESOLVED_AT,
+ };
+}
+
+const winningBet: Bet = {
+ id: "b-1",
+ marketId: "m-1",
+ bettor: "GBETTOR",
+ side: "FighterA",
+ amount: "100",
+ placedAt: "2026-09-29T00:00:00Z",
+ claimed: false,
+ payout: null,
+};
+
+describe("ClaimButton", () => {
+ it("renders nothing for a losing bet on a resolved market", () => {
+ const { container } = render(
+
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("shows a disabled claim with the reason during the dispute window", () => {
+ jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000);
+ render();
+
+ expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
+ expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument();
+ jest.restoreAllMocks();
+ });
+
+ it("shows a disabled claim with the reason while the result is disputed", () => {
+ render();
+
+ expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
+ expect(screen.getByText(/decided by an admin/)).toBeInTheDocument();
+ });
+
+ it("enables the claim once the dispute window has passed", () => {
+ jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1);
+ render();
+
+ expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled();
+ expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument();
+ jest.restoreAllMocks();
+ });
+
+ it("allows refunds on a cancelled market straight away", () => {
+ render();
+
+ expect(screen.getByRole("button", { name: "Claim Refund" })).toBeEnabled();
+ });
+
+ it("shows a claimed bet as claimed", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Claimed" })).toBeDisabled();
+ });
+});
diff --git a/frontend/components/ClaimButton.tsx b/frontend/components/ClaimButton.tsx
index 958095b9..d15ca056 100644
--- a/frontend/components/ClaimButton.tsx
+++ b/frontend/components/ClaimButton.tsx
@@ -3,6 +3,7 @@ import { useState } from "react";
import { Bet, Market } from "@/lib/api";
import { useClaimWinnings } from "@/hooks/useClaimWinnings";
import { useClaimRefund } from "@/hooks/useClaimRefund";
+import { getClaimBlockReason, getResolutionStage } from "@/lib/resolution";
export interface ClaimReceipt {
betId: string;
@@ -25,10 +26,13 @@ export function ClaimButton({ bet, market, onClaimed }: ClaimButtonProps): JSX.E
// Can't determine claim eligibility without market data
if (!market) return null;
- const isWinner = market.status === "Resolved" && market.outcome === bet.side;
+ // A disputed market still has an outcome; its claim is blocked by getClaimBlockReason
+ const hasOutcome = market.status === "Resolved" || market.status === "Disputed";
+ const isWinner = hasOutcome && market.outcome === bet.side;
const isRefundable =
(market.status === "Cancelled" || market.outcome === "NoContest") && !bet.claimed;
const showButton = (isWinner || isRefundable) && !bet.claimed;
+ const blockReason = showButton ? getClaimBlockReason(getResolutionStage(market)) : null;
if (bet.claimed) {
return (
@@ -45,6 +49,20 @@ export function ClaimButton({ bet, market, onClaimed }: ClaimButtonProps): JSX.E
const label = isRefundable ? "Claim Refund" : "Claim Winnings";
+ if (blockReason) {
+ return (
+
+
+
{blockReason}
+
+ );
+ }
+
async function handleClaim() {
if (loading || !market) return;
setLoading(true);
diff --git a/frontend/components/MarketDetailClient.tsx b/frontend/components/MarketDetailClient.tsx
index b52ef9c7..9ecd20bb 100644
--- a/frontend/components/MarketDetailClient.tsx
+++ b/frontend/components/MarketDetailClient.tsx
@@ -15,6 +15,8 @@ import { CountdownTimer } from "@/components/CountdownTimer";
import { DisputeModal } from "@/components/DisputeModal";
import { useWallet } from "@/hooks/useWallet";
import { LoadingSkeleton } from "@/components/LoadingSkeleton";
+import { ResolutionTimeline } from "@/components/ResolutionTimeline";
+import { getResolutionStage } from "@/lib/resolution";
import { truncateAddress, stellarExpertContractUrl } from "@/lib/stellar";
// recharts is only needed on this page; keep it out of the shared bundle
@@ -27,15 +29,6 @@ const MarketOddsChart = dynamic(
{ ssr: false, loading: () => }
);
-const DISPUTE_WINDOW_SEC = 24 * 60 * 60; // 24 h
-
-function isInDisputeWindow(market: Market): boolean {
- if (market.status !== "Resolved") return false;
- // scheduledAt is the closest proxy for resolvedAt we have on the type
- const resolvedAt = new Date(market.scheduledAt).getTime();
- return Date.now() - resolvedAt < DISPUTE_WINDOW_SEC * 1000;
-}
-
function BetHistoryRow({ bet }: { bet: Bet }): JSX.Element {
const xlm = (Number(BigInt(bet.amount)) / 1e7).toFixed(2);
return (
@@ -274,6 +267,8 @@ export function MarketDetailClient({
expiredLabel="Locked"
/>
+
+
{/* Fighter cards */}
@@ -317,7 +312,7 @@ export function MarketDetailClient({
)}
{/* Dispute section */}
- {isInDisputeWindow(m) && !showDispute && (
+ {getResolutionStage(m) === "dispute-window" && !showDispute && (