diff --git a/frontend/src/pages/PremiumPage.tsx b/frontend/src/pages/PremiumPage.tsx
new file mode 100644
index 0000000..db64144
--- /dev/null
+++ b/frontend/src/pages/PremiumPage.tsx
@@ -0,0 +1,217 @@
+import { useEffect, useState } from "react";
+import { Crown, Check, Zap, Star, TrendingUp, Shield, X } from "lucide-react";
+import { useAuth } from "../auth-context";
+import { api } from "../api";
+import "../styles/features.css";
+
+export function PremiumPage() {
+ const { user } = useAuth();
+ const [status, setStatus] = useState
(null);
+ const [expiryDate, setExpiryDate] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [processingPayment, setProcessingPayment] = useState(false);
+
+ useEffect(() => {
+ if (user) {
+ loadSubscriptionStatus();
+ }
+ }, [user]);
+
+ async function loadSubscriptionStatus() {
+ if (!user) return;
+ try {
+ const result = await api.getSubscriptionStatus(user.id);
+ setStatus(result.status);
+ setExpiryDate(result.expiryDate || null);
+ } catch (err) {
+ console.error("Failed to load subscription status:", err);
+ }
+ }
+
+ async function handleSubscribe() {
+ if (!user) {
+ setError("Please log in to subscribe");
+ return;
+ }
+
+ if (user.role !== "organizer") {
+ setError("Premium is currently only available for organizers");
+ return;
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+ setProcessingPayment(true);
+
+ const result = await api.createSubscription("price_premium_monthly");
+
+ // In a real app, this would redirect to Stripe Checkout with the clientSecret
+ // For demo purposes, we'll simulate a successful payment
+ setTimeout(() => {
+ setProcessingPayment(false);
+ setStatus("active");
+ loadSubscriptionStatus();
+ }, 2000);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create subscription");
+ setProcessingPayment(false);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const isActive = status === "active";
+ const isPending = status === "pending";
+
+ return (
+
+
+
+
+
+ PREMIUM
+
+
Unlock Your Tournament Potential
+
Advanced features for serious organizers and competitors
+
+
+
+ {!user ? (
+
+ Please log in to view premium subscription options
+
+ ) : user.role !== "organizer" ? (
+
+ Premium subscriptions are currently only available for tournament organizers
+
+ ) : null}
+
+ {isActive && (
+
+
+
+ Premium Active
+ {expiryDate && (
+
+ • Renews {new Date(expiryDate).toLocaleDateString()}
+
+ )}
+
+
+ )}
+
+ {isPending && (
+
+
+ Payment processing...
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
$9.99
+
per month
+ {!isActive && user && user.role === "organizer" && (
+
+ )}
+
+
+
+
+
+
+
+
+
Priority Match Notifications
+
Get instant alerts for your upcoming matches with real-time updates
+
+
+
+
+
+
+
+
+
Advanced Analytics
+
Deep insights into player performance, match statistics, and tournament trends
+
+
+
+
+
+
+
+
+
Custom Branding
+
Personalize your tournaments with custom logos, colors, and themes
+
+
+
+
+
+
+
+
+
Premium Support
+
24/7 priority support from our tournament operations team
+
+
+
+
+
+
+
+
+
Unlimited Replays
+
Store and share unlimited match replays with no file size restrictions
+
+
+
+
+
+
+
+
+
Early Access
+
Be the first to try new features and beta functionality
+
+
+
+
+ {isActive && (
+
+
+ Need to make changes? Contact support or manage your subscription in settings.
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/ReplayUploadPage.tsx b/frontend/src/pages/ReplayUploadPage.tsx
new file mode 100644
index 0000000..bd835df
--- /dev/null
+++ b/frontend/src/pages/ReplayUploadPage.tsx
@@ -0,0 +1,201 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { Upload, Film, AlertCircle, CheckCircle } from "lucide-react";
+import { useAuth } from "../auth-context";
+import { api } from "../api";
+import "../styles/features.css";
+
+export function ReplayUploadPage() {
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+
+ const [tournamentId, setTournamentId] = useState("");
+ const [videoUrl, setVideoUrl] = useState("");
+ const [title, setTitle] = useState("");
+ const [player1, setPlayer1] = useState("");
+ const [player2, setPlayer2] = useState("");
+ const [game, setGame] = useState("");
+ const [fileSize, setFileSize] = useState("");
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setSuccess(false);
+
+ if (!user) {
+ setError("You must be logged in to upload replays");
+ return;
+ }
+
+ if (user.role !== "organizer") {
+ setError("Only organizers can upload replays");
+ return;
+ }
+
+ const fileSizeBytes = parseFloat(fileSize) * 1024 * 1024; // Convert MB to bytes
+ if (fileSizeBytes > 2 * 1024 * 1024 * 1024) {
+ setError("File size cannot exceed 2GB");
+ return;
+ }
+
+ try {
+ setLoading(true);
+ await api.uploadReplay({
+ tournamentId,
+ videoUrl,
+ title,
+ playerNames: [player1, player2].filter(Boolean),
+ game,
+ fileSizeBytes: Math.floor(fileSizeBytes),
+ });
+ setSuccess(true);
+ setTimeout(() => navigate("/replays"), 2000);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to upload replay");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (!user) {
+ return (
+
+
+
+ Please log in to upload replays
+
+
+ );
+ }
+
+ if (user.role !== "organizer") {
+ return (
+
+
+
+ Only tournament organizers can upload replays
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Upload Replay
+
+
+ Share match footage with the community
+
+
+
+ {error && (
+
+ )}
+
+ {success && (
+
+
+ Replay uploaded successfully! Redirecting...
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/ReplaysPage.tsx b/frontend/src/pages/ReplaysPage.tsx
new file mode 100644
index 0000000..35ec255
--- /dev/null
+++ b/frontend/src/pages/ReplaysPage.tsx
@@ -0,0 +1,225 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { Film, Search, Gamepad2, Calendar, Users, Play } from "lucide-react";
+import { api } from "../api";
+import "../styles/features.css";
+
+interface Replay {
+ id: string;
+ tournamentId: string;
+ videoUrl: string;
+ title: string;
+ playerNames: string[];
+ game: string;
+ fileSizeBytes: number;
+ uploadedAt: string;
+}
+
+export function ReplaysPage() {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [replays, setReplays] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [total, setTotal] = useState(0);
+ const pageSize = 12;
+
+ const gameFilter = searchParams.get("game") || "";
+ const playerFilter = searchParams.get("player") || "";
+
+ useEffect(() => {
+ loadReplays();
+ }, [page, gameFilter, playerFilter]);
+
+ async function loadReplays() {
+ try {
+ setLoading(true);
+ setError(null);
+ const params: any = { page, page_size: pageSize };
+ if (gameFilter) params.game = gameFilter;
+ if (playerFilter) params.player_name = playerFilter;
+
+ const result = await api.searchReplays(params);
+ setReplays(result.replays);
+ setTotal(result.total);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load replays");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function handleGameFilter(game: string) {
+ const params = new URLSearchParams(searchParams);
+ if (game) {
+ params.set("game", game);
+ } else {
+ params.delete("game");
+ }
+ setSearchParams(params);
+ setPage(1);
+ }
+
+ function handlePlayerSearch(e: React.FormEvent) {
+ e.preventDefault();
+ const formData = new FormData(e.currentTarget);
+ const player = formData.get("player") as string;
+ const params = new URLSearchParams(searchParams);
+ if (player) {
+ params.set("player", player);
+ } else {
+ params.delete("player");
+ }
+ setSearchParams(params);
+ setPage(1);
+ }
+
+ function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+ }
+
+ function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+ }
+
+ const totalPages = Math.ceil(total / pageSize);
+
+ return (
+
+
+
+
+
+ Match Replays
+
+
Relive the greatest moments. Study the best plays. Learn from the pros.
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ Error: {error}
+
+ )}
+
+ {loading ? (
+
+ ) : replays.length === 0 ? (
+
+
+
+
+
No replays found
+
Try adjusting your filters or check back later for new content
+
+ ) : (
+ <>
+
+ {replays.map((replay) => (
+
window.open(replay.videoUrl, "_blank")}
+ >
+
+
+
{replay.title}
+
+
+
+ {replay.game}
+
+
+
+ {replay.playerNames.join(" vs ")}
+
+
+
+
+
+ {formatDate(replay.uploadedAt)}
+
+ {formatFileSize(replay.fileSizeBytes)}
+
+
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+
+
+ Page {page} of {totalPages}
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/TournamentBracketPage.tsx b/frontend/src/pages/TournamentBracketPage.tsx
index a44b5c8..53334cb 100644
--- a/frontend/src/pages/TournamentBracketPage.tsx
+++ b/frontend/src/pages/TournamentBracketPage.tsx
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useOutletContext, useParams } from "react-router-dom";
-import { Check, Download, Share2, X } from "lucide-react";
+import { Check, Download, Share2, X, Trophy, Save } from "lucide-react";
import type { BracketMatch, BracketResponse, BracketRound } from "../api";
import { api, type TournamentDetail } from "../api";
+import { useAuth } from "../auth-context";
import "../styles/bracket-view-page.css";
+import "../styles/features.css";
type OutletCtx = { setCurrentEventTitle: (t: string | null) => void };
@@ -226,12 +228,23 @@ export function nextMatchLabel(bracket: BracketResponse, liveId: string | null):
export function TournamentBracketPage() {
const { id } = useParams<{ id: string }>();
const { setCurrentEventTitle } = useOutletContext();
+ const { user } = useAuth();
const [detail, setDetail] = useState(null);
const [bracket, setBracket] = useState(null);
const [err, setErr] = useState(null);
const [toast, setToast] = useState(true);
const [copied, setCopied] = useState(false);
+ // Score tracking state
+ const [selectedMatch, setSelectedMatch] = useState(null);
+ const [player1Score, setPlayer1Score] = useState("");
+ const [player2Score, setPlayer2Score] = useState("");
+ const [scoreLoading, setScoreLoading] = useState(false);
+ const [scoreError, setScoreError] = useState(null);
+ const [scoreSuccess, setScoreSuccess] = useState(false);
+
+ const isOrganizer = user?.role === "organizer";
+
const load = useCallback(async () => {
if (!id) return;
const [d, b] = await Promise.all([api.getTournament(id), api.getTournamentBracket(id)]);
@@ -290,6 +303,50 @@ export function TournamentBracketPage() {
URL.revokeObjectURL(a.href);
};
+ async function handleScoreSubmit() {
+ if (!selectedMatch || !selectedMatch.player1 || !selectedMatch.player2) return;
+
+ const p1Score = parseInt(player1Score);
+ const p2Score = parseInt(player2Score);
+
+ if (isNaN(p1Score) || isNaN(p2Score)) {
+ setScoreError("Please enter valid scores");
+ return;
+ }
+
+ if (p1Score === p2Score) {
+ setScoreError("Scores cannot be tied");
+ return;
+ }
+
+ const winnerUserId = p1Score > p2Score ? selectedMatch.player1.userId : selectedMatch.player2.userId;
+
+ try {
+ setScoreLoading(true);
+ setScoreError(null);
+ await api.reportMatchScore(selectedMatch.id, {
+ player1Score: p1Score,
+ player2Score: p2Score,
+ winnerUserId,
+ });
+ setScoreSuccess(true);
+ setTimeout(() => {
+ setScoreSuccess(false);
+ setSelectedMatch(null);
+ setPlayer1Score("");
+ setPlayer2Score("");
+ }, 2000);
+ } catch (err) {
+ setScoreError(err instanceof Error ? err.message : "Failed to report score");
+ } finally {
+ setScoreLoading(false);
+ }
+ }
+
+ const activeBracketMatches = bracket?.rounds.flatMap((r) =>
+ r.matches.filter((m) => m.player1 && m.player2 && m.status !== "complete")
+ ) || [];
+
if (err && !detail) {
return (
@@ -403,6 +460,116 @@ export function TournamentBracketPage() {
{nextMatchLabel(bracket, liveId)}
+
+ {/* Score Tracking Panel (Organizers Only) */}
+ {isOrganizer && activeBracketMatches.length > 0 && (
+
+
+
+
+ Report Match Score
+
+
+
+ {scoreSuccess && (
+
+
+ Score reported successfully!
+
+ )}
+
+ {scoreError && (
+
+
+ {scoreError}
+
+ )}
+
+ {!selectedMatch ? (
+
+
+
+
+ ) : (
+ <>
+
+
+
{selectedMatch.player1?.displayName}
+
setPlayer1Score(e.target.value)}
+ placeholder="0"
+ min="0"
+ />
+
+
VS
+
+
{selectedMatch.player2?.displayName}
+
setPlayer2Score(e.target.value)}
+ placeholder="0"
+ min="0"
+ />
+
+
+
+
+
+
+
+ >
+ )}
+