x
Some checks failed
Deploy to Cloudflare Workers / deploy (push) Has been cancelled

This commit is contained in:
Kirubel-Kibru-Yaltopia 2026-05-24 21:46:10 +03:00
parent feaa5f142a
commit 89440985f1
180 changed files with 24406 additions and 0 deletions

7
.cursor/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"plugins": {
"supabase": {
"enabled": true
}
}
}

8
.dev.vars.example Normal file
View File

@ -0,0 +1,8 @@
# Copy to .dev.vars for local `npm run preview` (Workers runtime)
# Do not commit .dev.vars
NEXTJS_ENV=development
# Optional: mirror .env.local for preview (NEXT_PUBLIC_* are inlined at build time)
# NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

17
.env.local.example Normal file
View File

@ -0,0 +1,17 @@
NEXT_PUBLIC_SUPABASE_URL=https://vcxpcyafnlyiyqmapyyy.supabase.co
# Use EITHER publishable (sb_publishable_...) OR legacy anon JWT (eyJ...)
# Dashboard → Project Settings → API
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-or-publishable-key
# Auth: Dashboard → Authentication → URL configuration
# Site URL: http://localhost:3000
# Redirect URLs (include password reset):
# http://localhost:3000/**
# http://localhost:3000/auth/callback
# http://localhost:3000/reset-password
# For CLI migrations (IPv4 pooler — required if db push fails with IPv6 error)
# Dashboard → Connect → Session mode (port 5432)
SUPABASE_DB_PASSWORD=your-database-password
SUPABASE_DB_URL=postgresql://postgres.vcxpcyafnlyiyqmapyyy:YOUR_PASSWORD@aws-0-YOUR_REGION.pooler.supabase.com:5432/postgres

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
*.sh text eol=lf
*.mjs text eol=lf
*.sql text eol=lf

29
.github/workflows/deploy-cloudflare.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: Deploy to Cloudflare Workers
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Build and deploy
run: npm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# supabase CLI (keep project-ref for linked remote)
supabase/.temp/*
!supabase/.temp/project-ref
# vercel
.vercel
# cloudflare / opennext
.open-next
.dev.vars
.wrangler
# typescript
*.tsbuildinfo
next-env.d.ts

77
actions/leagues.ts Normal file
View File

@ -0,0 +1,77 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import * as leagues from "@/lib/services/leagues";
import * as teams from "@/lib/services/teams";
export async function createLeague(formData: FormData) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
const league = await leagues.createLeague(supabase, user.id, {
name: formData.get("name") as string,
description: (formData.get("description") as string) || undefined,
});
revalidatePath("/leagues");
return league;
}
export async function createCompetition(leagueId: string, formData: FormData) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
const data = await leagues.createCompetition(supabase, user.id, leagueId, {
name: formData.get("name") as string,
tournament_mode: formData.get("tournament_mode") as "league" | "cup",
timezone: (formData.get("timezone") as string) || "UTC",
});
revalidatePath(`/leagues/${leagueId}`);
return data;
}
export async function activateCompetition(competitionId: string) {
const supabase = await createClient();
await leagues.activateCompetition(supabase, competitionId);
revalidatePath("/leagues");
}
export async function generateFixtures(competitionId: string, mode: string) {
const supabase = await createClient();
await leagues.generateFixtures(
supabase,
competitionId,
mode as "league" | "cup"
);
revalidatePath("/leagues");
}
export async function createTeam(competitionId: string, formData: FormData) {
const supabase = await createClient();
const data = await teams.createTeam(supabase, competitionId, {
name: formData.get("name") as string,
nickname: (formData.get("nickname") as string) || undefined,
icon: (formData.get("icon") as string) || undefined,
});
revalidatePath("/leagues");
return data;
}
export async function saveLeagueRules(leagueId: string, rules: object) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
await leagues.saveLeagueRules(supabase, user.id, leagueId, rules);
revalidatePath(`/leagues/${leagueId}/rules`);
}

51
actions/matches.ts Normal file
View File

@ -0,0 +1,51 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import * as matches from "@/lib/services/matches";
export async function proposeSchedule(matchId: string, scheduledAt: string) {
const supabase = await createClient();
await matches.proposeSchedule(supabase, matchId, scheduledAt);
revalidatePath("/leagues");
}
export async function signSchedule(matchId: string, teamId: string) {
const supabase = await createClient();
await matches.signSchedule(supabase, matchId, teamId);
revalidatePath("/leagues");
}
export async function submitResult(
matchId: string,
teamId: string,
homeScore: number,
awayScore: number
) {
const supabase = await createClient();
await matches.submitResult(supabase, matchId, teamId, homeScore, awayScore);
revalidatePath("/leagues");
}
export async function approveResult(matchId: string) {
const supabase = await createClient();
await matches.approveResult(supabase, matchId);
revalidatePath("/leagues");
}
export async function setResultByManager(
matchId: string,
homeScore: number,
awayScore: number,
note?: string
) {
const supabase = await createClient();
await matches.setResultByManager(
supabase,
matchId,
homeScore,
awayScore,
note
);
revalidatePath("/leagues");
}

39
actions/players.ts Normal file
View File

@ -0,0 +1,39 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import * as players from "@/lib/services/players";
export async function createPlayer(formData: FormData) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
await players.createPlayer(supabase, user.id, {
display_name: formData.get("display_name") as string,
external_id: (formData.get("external_id") as string) || undefined,
});
revalidatePath("/players");
}
export async function updatePlayerStatus(
playerId: string,
status: "active" | "inactive"
) {
const supabase = await createClient();
await players.updatePlayerStatus(supabase, playerId, status);
revalidatePath("/players");
}
export async function togglePlayerStatus(
playerId: string,
currentStatus: string
) {
await updatePlayerStatus(
playerId,
currentStatus === "active" ? "inactive" : "active"
);
}

64
actions/teams.ts Normal file
View File

@ -0,0 +1,64 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import * as teams from "@/lib/services/teams";
import * as players from "@/lib/services/players";
export async function updateTeamProfile(
teamId: string,
data: { home_stadium_name?: string; logo_path?: string }
) {
const supabase = await createClient();
await teams.updateTeam(supabase, teamId, data);
revalidatePath("/leagues");
}
export async function setTeamAvailability(
teamId: string,
windows: { day_of_week: number; start_time?: string; end_time?: string }[]
) {
const supabase = await createClient();
await teams.setAvailability(supabase, teamId, windows);
revalidatePath("/leagues");
}
export async function addToRoster(
teamId: string,
competitionId: string,
playerId: string
) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
await players.addToRoster(supabase, user.id, {
teamId,
competitionId,
playerId,
});
revalidatePath("/leagues");
}
export async function registerTransfer(
competitionId: string,
playerId: string,
fromTeamId: string,
toTeamId: string
) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
await players.registerTransfer(supabase, user.id, {
competitionId,
playerId,
fromTeamId,
toTeamId,
});
revalidatePath("/leagues");
}

View File

@ -0,0 +1,19 @@
import { LoginPageShell } from "@/components/auth/login-form";
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
export default function ManagerForgotPasswordPage() {
return (
<>
<LoginPageShell
title="Reset password"
subtitle="Team Manager — we will email you a secure link"
>
<ForgotPasswordForm portal="manager" />
</LoginPageShell>
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
<YaltopiaFooter />
</div>
</>
);
}

View File

@ -0,0 +1,19 @@
import { LoginPageShell } from "@/components/auth/login-form";
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
export default function MasterForgotPasswordPage() {
return (
<>
<LoginPageShell
title="Reset password"
subtitle="League Master — we will email you a secure link"
>
<ForgotPasswordForm portal="league_master" />
</LoginPageShell>
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
<YaltopiaFooter />
</div>
</>
);
}

View File

@ -0,0 +1,34 @@
import Link from "next/link";
import { LoginForm, LoginPageShell } from "@/components/auth/login-form";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
export default function ManagerLoginPage() {
return (
<>
<LoginPageShell
title="Welcome back"
subtitle="Sign in to your team manager dashboard"
footer={
<p className="text-center text-sm text-muted-foreground">
No account?{" "}
<Link
href="/signup/manager"
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Create account
</Link>
{" · "}
<Link href="/" className="hover:underline">
Home
</Link>
</p>
}
>
<LoginForm expectedRole="manager" />
</LoginPageShell>
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
<YaltopiaFooter />
</div>
</>
);
}

View File

@ -0,0 +1,29 @@
import Link from "next/link";
import { LoginForm, LoginPageShell } from "@/components/auth/login-form";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
export default function MasterLoginPage() {
return (
<>
<LoginPageShell
title="League Master"
subtitle="Administrative access — not shown on the public site"
footer={
<p className="text-center text-sm text-muted-foreground">
<Link
href="/signup/master"
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Create master account
</Link>
</p>
}
>
<LoginForm expectedRole="league_master" />
</LoginPageShell>
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
<YaltopiaFooter />
</div>
</>
);
}

View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function LoginPage() {
redirect("/");
}

View File

@ -0,0 +1,26 @@
import { Suspense } from "react";
import { LoginPageShell } from "@/components/auth/login-form";
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
function ResetPasswordFallback() {
return <p className="text-sm text-muted-foreground">Loading</p>;
}
export default function ResetPasswordPage() {
return (
<>
<LoginPageShell
title="Set new password"
subtitle="Choose a strong password for your account"
>
<Suspense fallback={<ResetPasswordFallback />}>
<ResetPasswordForm />
</Suspense>
</LoginPageShell>
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
<YaltopiaFooter />
</div>
</>
);
}

View File

@ -0,0 +1,11 @@
import { SignupForm } from "@/components/auth/signup-form";
export default function ManagerSignupPage() {
return (
<SignupForm
portalRole="manager"
title="Team Manager signup"
description="For team managers in Yaltopia FIFA tournaments."
/>
);
}

View File

@ -0,0 +1,11 @@
import { SignupForm } from "@/components/auth/signup-form";
export default function MasterSignupPage() {
return (
<SignupForm
portalRole="league_master"
title="League Master signup"
description="Create an admin account to manage leagues, fixtures, and competitions."
/>
);
}

View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SignupPage() {
redirect("/signup/manager");
}

View File

@ -0,0 +1,9 @@
import { AppShell } from "@/components/layout/AppShell";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AppShell>{children}</AppShell>;
}

View File

@ -0,0 +1,65 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { GlassCard } from "@/components/ui/glass-card";
import { TeamBadge } from "@/components/teams/TeamBadge";
export default async function AdminResultsPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string }>;
}) {
const { leagueId, competitionId } = await params;
const supabase = await createClient();
const { data: pending } = await supabase
.from("matches")
.select(
`*, home:home_team_id(name, logo_path), away:away_team_id(name, logo_path)`
)
.eq("competition_id", competitionId)
.in("result_status", ["pending_approval", "disputed"]);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Results admin</h1>
<p className="text-sm text-[var(--color-muted)]">
Approve or resolve match results as league manager
</p>
<GlassCard title="Pending approval & disputes">
<ul className="space-y-3">
{pending?.map((m) => {
const home = m.home as { name: string; logo_path: string | null };
const away = m.away as { name: string; logo_path: string | null };
return (
<li key={m.id}>
<Link
href={`/leagues/${leagueId}/competitions/${competitionId}/matches/${m.id}`}
className="flex items-center justify-between rounded-lg border border-white/10 p-4 hover:bg-white/5"
>
<div className="flex items-center gap-3">
<TeamBadge name={home?.name} logoPath={home?.logo_path} size="sm" />
<span className="text-[var(--color-muted)]">vs</span>
<TeamBadge name={away?.name} logoPath={away?.logo_path} size="sm" />
</div>
<span
className={
m.result_status === "disputed"
? "text-red-400"
: "text-amber-400"
}
>
{m.result_status}
</span>
</Link>
</li>
);
})}
{(!pending || pending.length === 0) && (
<p className="text-sm text-[var(--color-muted)]">No pending results</p>
)}
</ul>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,59 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { GlassCard } from "@/components/ui/glass-card";
import { TeamBadge } from "@/components/teams/TeamBadge";
export default async function FixturesPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string }>;
}) {
const { leagueId, competitionId } = await params;
const supabase = await createClient();
const { data: matches } = await supabase
.from("matches")
.select(
`*, home:home_team_id(id, name, logo_path), away:away_team_id(id, name, logo_path)`
)
.eq("competition_id", competitionId)
.order("matchday")
.order("round");
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Fixtures</h1>
<GlassCard>
<ul className="divide-y divide-white/10">
{matches?.map((m) => {
const home = m.home as { id: string; name: string; logo_path: string | null };
const away = m.away as { id: string; name: string; logo_path: string | null };
return (
<li key={m.id} className="py-4">
<Link
href={`/leagues/${leagueId}/competitions/${competitionId}/matches/${m.id}`}
className="flex flex-wrap items-center justify-between gap-4 hover:opacity-90"
>
<div className="flex items-center gap-4">
<TeamBadge name={home?.name} logoPath={home?.logo_path} />
<span className="text-lg font-semibold">
{m.status === "completed"
? `${m.home_score} ${m.away_score}`
: "vs"}
</span>
<TeamBadge name={away?.name} logoPath={away?.logo_path} />
</div>
<div className="text-right text-xs text-[var(--color-muted)]">
{m.matchday && <span>MD {m.matchday} · </span>}
<span className="capitalize">{m.status.replace(/_/g, " ")}</span>
{m.venue && <p className="mt-0.5">{m.venue}</p>}
</div>
</Link>
</li>
);
})}
</ul>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,75 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { CompetitionSidebar } from "@/components/layout/Sidebar";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
export default async function CompetitionLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ leagueId: string; competitionId: string }>;
}) {
const { leagueId, competitionId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: competition } = await supabase
.from("competitions")
.select("*, leagues(name)")
.eq("id", competitionId)
.single();
if (!competition) notFound();
let showMyTeam = false;
if (user) {
const { data: compTeams } = await supabase
.from("teams")
.select("id")
.eq("competition_id", competitionId);
const ids = compTeams?.map((t) => t.id) ?? [];
if (ids.length > 0) {
const { count } = await supabase
.from("team_members")
.select("id", { count: "exact", head: true })
.eq("user_id", user.id)
.eq("role", "manager")
.in("team_id", ids);
showMyTeam = (count ?? 0) > 0;
}
}
return (
<div className="flex min-h-screen flex-col">
<div className="flex flex-1">
<aside className="flex w-56 flex-col border-r border-white/10 bg-black/20">
<div className="border-b border-white/10 p-4">
<Link
href={`/leagues/${leagueId}`}
className="text-xs text-cyan-400 hover:underline"
>
{(competition.leagues as { name: string })?.name}
</Link>
<h2 className="mt-1 font-semibold">{competition.name}</h2>
<p className="text-xs capitalize text-[var(--color-muted)]">
{competition.tournament_mode} · {competition.status}
</p>
</div>
<CompetitionSidebar
leagueId={leagueId}
competitionId={competitionId}
showMyTeam={showMyTeam}
/>
</aside>
<div className="flex flex-1 flex-col">
<main className="flex-1 p-6">{children}</main>
<YaltopiaFooter />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function MatchActions({
matchId,
homeTeamId,
awayTeamId,
userTeamId,
status,
resultStatus,
isLeagueManager,
proposedAt,
}: {
matchId: string;
homeTeamId: string;
awayTeamId: string;
userTeamId: string | null;
status: string;
resultStatus: string | null;
isLeagueManager: boolean;
proposedAt: string | null;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(fn: () => Promise<void>) {
setLoading(true);
setError(null);
try {
await fn();
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Error");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
{error && <p className="text-sm text-red-400">{error}</p>}
{userTeamId && status !== "completed" && (
<GlassCard title="Schedule">
{proposedAt && (
<p className="mb-3 text-sm text-[var(--color-muted)]">
Proposed: {new Date(proposedAt).toLocaleString()}
</p>
)}
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
run(async () => {
const raw = fd.get("scheduled_at") as string;
await api.matches.proposeSchedule(
matchId,
new Date(raw).toISOString()
);
});
}}
>
<div>
<Label htmlFor="scheduled_at">Propose kickoff</Label>
<Input
id="scheduled_at"
name="scheduled_at"
type="datetime-local"
className="mt-1"
required
/>
</div>
<Button type="submit" disabled={loading}>
Propose
</Button>
</form>
<Button
className="mt-3"
variant="secondary"
disabled={loading || !userTeamId}
onClick={() =>
run(() => api.matches.signSchedule(matchId, userTeamId!))
}
>
Sign schedule
</Button>
</GlassCard>
)}
{userTeamId && status !== "completed" && (
<GlassCard title="Submit result">
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
run(() =>
api.matches.submitResult(matchId, {
teamId: userTeamId!,
homeScore: Number(fd.get("home_score")),
awayScore: Number(fd.get("away_score")),
})
);
}}
>
<div>
<Label>Home score</Label>
<Input name="home_score" type="number" min={0} required className="mt-1 w-20" />
</div>
<div>
<Label>Away score</Label>
<Input name="away_score" type="number" min={0} required className="mt-1 w-20" />
</div>
<Button type="submit" disabled={loading}>
Submit result
</Button>
</form>
</GlassCard>
)}
{isLeagueManager && resultStatus === "pending_approval" && (
<GlassCard title="League manager" highlight>
<Button
disabled={loading}
onClick={() => run(() => api.matches.approveResult(matchId))}
>
Approve matching result
</Button>
</GlassCard>
)}
{isLeagueManager && (
<GlassCard title="Set official result">
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
run(() =>
api.matches.setResult(matchId, {
homeScore: Number(fd.get("home_score")),
awayScore: Number(fd.get("away_score")),
note: (fd.get("note") as string) || undefined,
})
);
}}
>
<Input name="home_score" type="number" min={0} placeholder="H" className="w-20" required />
<Input name="away_score" type="number" min={0} placeholder="A" className="w-20" required />
<Input name="note" placeholder="Note (optional)" className="flex-1 min-w-[120px]" />
<Button type="submit" disabled={loading}>
Set result
</Button>
</form>
</GlassCard>
)}
</div>
);
}

View File

@ -0,0 +1,124 @@
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { MatchActions } from "./match-actions";
import { GlassCard } from "@/components/ui/glass-card";
import { TeamBadge } from "@/components/teams/TeamBadge";
export default async function MatchPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string; matchId: string }>;
}) {
const { leagueId, competitionId, matchId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: match } = await supabase
.from("matches")
.select(
`*, home:home_team_id(id, name, logo_path), away:away_team_id(id, name, logo_path)`
)
.eq("id", matchId)
.single();
if (!match) notFound();
const home = match.home as { id: string; name: string; logo_path: string | null };
const away = match.away as { id: string; name: string; logo_path: string | null };
const { data: submissions } = await supabase
.from("match_result_submissions")
.select("*, teams(name)")
.eq("match_id", matchId);
const { data: signatures } = await supabase
.from("match_signatures")
.select("team_id, teams(name)")
.eq("match_id", matchId);
let userTeamId: string | null = null;
if (user) {
const { data: tm } = await supabase
.from("team_members")
.select("team_id")
.eq("user_id", user.id)
.in("team_id", [home.id, away.id])
.limit(1)
.single();
userTeamId = tm?.team_id ?? null;
}
const { data: isLeagueManager } = user
? await supabase
.from("competition_league_managers")
.select("id")
.eq("competition_id", competitionId)
.eq("user_id", user.id)
.maybeSingle()
: { data: null };
return (
<div className="space-y-6">
<GlassCard>
<div className="flex flex-wrap items-center justify-center gap-8 py-4">
<TeamBadge name={home.name} logoPath={home.logo_path} size="lg" />
<div className="text-center">
<p className="text-3xl font-bold">
{match.status === "completed"
? `${match.home_score} ${match.away_score}`
: "vs"}
</p>
<p className="mt-1 text-sm capitalize text-[var(--color-muted)]">
{match.status.replace(/_/g, " ")}
</p>
{match.venue && (
<p className="mt-1 text-xs text-[var(--color-muted)]">{match.venue}</p>
)}
</div>
<TeamBadge name={away.name} logoPath={away.logo_path} size="lg" />
</div>
</GlassCard>
<div className="grid gap-4 lg:grid-cols-2">
<GlassCard title="Schedule signatures">
<ul className="text-sm">
{signatures?.map((s) => (
<li key={s.team_id} className="text-emerald-400">
{(s.teams as { name: string })?.name} signed
</li>
))}
{(!signatures || signatures.length === 0) && (
<li className="text-[var(--color-muted)]">No signatures yet</li>
)}
</ul>
</GlassCard>
<GlassCard title="Result submissions">
<ul className="space-y-2 text-sm">
{submissions?.map((s) => (
<li key={s.team_id}>
{(s.teams as { name: string })?.name}: {s.home_score}{s.away_score}
</li>
))}
{(!submissions || submissions.length === 0) && (
<li className="text-[var(--color-muted)]">No submissions yet</li>
)}
</ul>
</GlassCard>
</div>
<MatchActions
matchId={matchId}
homeTeamId={home.id}
awayTeamId={away.id}
userTeamId={userTeamId}
status={match.status}
resultStatus={match.result_status}
isLeagueManager={!!isLeagueManager}
proposedAt={match.proposed_scheduled_at}
/>
</div>
);
}

View File

@ -0,0 +1,200 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import {
FormDonut,
GoalsTrendChart,
TopScorersChart,
StatCards,
} from "@/components/manager/TeamCharts";
import { GlassCard } from "@/components/ui/glass-card";
import { TeamBadge } from "@/components/teams/TeamBadge";
export default async function MyTeamPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string }>;
}) {
const { leagueId, competitionId } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: teamsInComp } = await supabase
.from("teams")
.select("id")
.eq("competition_id", competitionId);
const teamIds = teamsInComp?.map((t) => t.id) ?? [];
const { data: membership } = await supabase
.from("team_members")
.select("team_id, teams(*)")
.eq("user_id", user.id)
.eq("role", "manager")
.in("team_id", teamIds.length ? teamIds : ["00000000-0000-0000-0000-000000000000"])
.limit(1)
.maybeSingle();
if (!membership) {
return (
<p className="text-[var(--color-muted)]">
You are not a team manager in this competition.
</p>
);
}
const team = membership.teams as {
id: string;
name: string;
logo_path: string | null;
};
const { data: results } = await supabase
.from("team_match_results")
.select("*")
.eq("team_id", team.id)
.order("matchday", { ascending: true });
const { data: playerStats } = await supabase
.from("player_competition_stats")
.select("*")
.eq("team_id", team.id)
.eq("competition_id", competitionId)
.order("goals", { ascending: false });
const formCounts = { W: 0, D: 0, L: 0 };
results?.slice(-10).forEach((r) => {
if (r.result in formCounts) formCounts[r.result as keyof typeof formCounts]++;
});
const formData = Object.entries(formCounts).map(([name, value]) => ({
name,
value,
}));
const goalsTrend =
results?.map((r, i) => ({
matchday: r.matchday ?? i + 1,
gf: r.goals_for ?? 0,
ga: r.goals_against ?? 0,
})) ?? [];
const topScorers =
playerStats?.slice(0, 8).map((p) => ({
name: p.player_name,
goals: p.goals,
})) ?? [];
const topAssists =
playerStats
?.slice()
.sort((a, b) => b.assists - a.assists)
.slice(0, 8)
.map((p) => ({
name: p.player_name,
assists: p.assists,
})) ?? [];
const played = results?.length ?? 0;
const won = results?.filter((r) => r.result === "W").length ?? 0;
const drawn = results?.filter((r) => r.result === "D").length ?? 0;
const lost = results?.filter((r) => r.result === "L").length ?? 0;
const gf = results?.reduce((s, r) => s + (r.goals_for ?? 0), 0) ?? 0;
const ga = results?.reduce((s, r) => s + (r.goals_against ?? 0), 0) ?? 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<TeamBadge name={team.name} logoPath={team.logo_path} size="lg" />
<a
href={`/leagues/${leagueId}/competitions/${competitionId}/teams/${team.id}/settings`}
className="text-sm text-cyan-400 hover:underline"
>
Team settings
</a>
</div>
<StatCards
stats={[
{ label: "Played", value: played },
{ label: "Won", value: won },
{ label: "GF", value: gf },
{ label: "GD", value: gf - ga },
]}
/>
<div className="grid gap-4 lg:grid-cols-2">
<FormDonut data={formData.filter((d) => d.value > 0)} />
<GoalsTrendChart data={goalsTrend} />
<TopScorersChart
data={topScorers}
dataKey="goals"
title="Top scorers"
/>
<TopScorersChart
data={topAssists}
dataKey="assists"
title="Top assists"
/>
</div>
<GlassCard title="Squad stats">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-[var(--color-muted)]">
<th className="pb-2">Player</th>
<th className="pb-2 text-center">Apps</th>
<th className="pb-2 text-center">G</th>
<th className="pb-2 text-center">A</th>
<th className="pb-2 text-center">G+A</th>
</tr>
</thead>
<tbody>
{playerStats?.map((p) => (
<tr key={p.player_id} className="border-b border-white/5">
<td className="py-2">{p.player_name}</td>
<td className="py-2 text-center">{p.appearances}</td>
<td className="py-2 text-center">{p.goals}</td>
<td className="py-2 text-center">{p.assists}</td>
<td className="py-2 text-center text-cyan-400">
{p.goals + p.assists}
</td>
</tr>
))}
</tbody>
</table>
</GlassCard>
<GlassCard title="Recent results">
<ul className="space-y-2">
{results
?.slice()
.reverse()
.slice(0, 5)
.map((r) => (
<li
key={r.match_id}
className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2 text-sm"
>
<span>vs {r.opponent_name}</span>
<span>
{r.goals_for}{r.goals_against}{" "}
<span
className={
r.result === "W"
? "text-emerald-400"
: r.result === "L"
? "text-red-400"
: "text-amber-400"
}
>
{r.result}
</span>
</span>
</li>
))}
</ul>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,95 @@
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { GlassCard } from "@/components/ui/glass-card";
import { CompetitionDraftPanel } from "@/components/competitions/competition-draft-panel";
import { StandingsTable } from "@/components/standings/StandingsTable";
import { TeamBadge } from "@/components/teams/TeamBadge";
import Link from "next/link";
export default async function CompetitionPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string }>;
}) {
const { leagueId, competitionId } = await params;
const supabase = await createClient();
const { data: competition } = await supabase
.from("competitions")
.select("*")
.eq("id", competitionId)
.single();
if (!competition) notFound();
const { data: teams } = await supabase
.from("teams")
.select("*")
.eq("competition_id", competitionId)
.order("name");
const { data: standings } = await supabase
.from("competition_standings")
.select("*")
.eq("competition_id", competitionId);
const { data: upcoming } = await supabase
.from("matches")
.select(
`*, home:home_team_id(name, logo_path), away:away_team_id(name, logo_path)`
)
.eq("competition_id", competitionId)
.in("status", ["scheduled", "schedule_pending", "schedule_confirmed"])
.order("scheduled_at")
.limit(5);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{competition.name}</h1>
</div>
{competition.status === "draft" && (
<CompetitionDraftPanel
competitionId={competitionId}
initialTeams={teams ?? []}
/>
)}
{competition.tournament_mode === "league" && standings && standings.length > 0 && (
<GlassCard title="Standings">
<StandingsTable standings={standings} />
</GlassCard>
)}
<GlassCard title="Upcoming fixtures">
<ul className="space-y-3">
{upcoming?.map((m) => {
const home = m.home as { name: string; logo_path: string | null };
const away = m.away as { name: string; logo_path: string | null };
return (
<li key={m.id}>
<Link
href={`/leagues/${leagueId}/competitions/${competitionId}/matches/${m.id}`}
className="flex items-center justify-between rounded-lg border border-white/10 p-3 transition-colors hover:bg-white/5"
>
<div className="flex items-center gap-4">
<TeamBadge name={home?.name} logoPath={home?.logo_path} size="sm" />
<span className="text-[var(--color-muted)]">vs</span>
<TeamBadge name={away?.name} logoPath={away?.logo_path} size="sm" />
</div>
<span className="text-xs text-[var(--color-muted)] capitalize">
{m.status.replace(/_/g, " ")}
</span>
</Link>
</li>
);
})}
{(!upcoming || upcoming.length === 0) && (
<p className="text-sm text-[var(--color-muted)]">No upcoming fixtures</p>
)}
</ul>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { GlassCard } from "@/components/ui/glass-card";
export default function PlayoffsPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Champions League playoffs</h1>
<GlassCard highlight>
<p className="text-sm text-[var(--color-muted)]">
End-of-season qualification playoffs activate when the competition
status is set to <strong className="text-foreground">playoffs</strong>{" "}
after all league matches are completed. Top teams auto-qualify per
league rules; remaining teams play in for the final CL spots.
</p>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { TeamSettingsForm } from "./team-settings-form";
export default async function TeamSettingsPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string; teamId: string }>;
}) {
const { teamId } = await params;
const supabase = await createClient();
const { data: team } = await supabase
.from("teams")
.select("*")
.eq("id", teamId)
.single();
if (!team) notFound();
const { data: availability } = await supabase
.from("team_availability")
.select("*")
.eq("team_id", teamId);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Team settings {team.name}</h1>
<TeamSettingsForm team={team} availability={availability ?? []} />
</div>
);
}

View File

@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { createClient } from "@/lib/supabase/client";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { DAY_NAMES } from "@/lib/utils";
export function TeamSettingsForm({
team,
availability,
}: {
team: { id: string; name: string; home_stadium_name: string | null; logo_path: string | null; competition_id: string };
availability: { day_of_week: number; start_time: string | null; end_time: string | null }[];
}) {
const router = useRouter();
const [stadium, setStadium] = useState(team.home_stadium_name ?? "");
const [days, setDays] = useState<number[]>(
[...new Set(availability.map((a) => a.day_of_week))]
);
const [loading, setLoading] = useState(false);
async function saveStadium() {
setLoading(true);
await api.teams.update(team.id, { home_stadium_name: stadium });
setLoading(false);
router.refresh();
}
async function uploadLogo(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const supabase = createClient();
const ext = file.name.split(".").pop();
const path = `${team.competition_id}/${team.id}/logo.${ext}`;
const { error } = await supabase.storage
.from("team-logos")
.upload(path, file, { upsert: true });
if (error) {
alert(error.message);
return;
}
await api.teams.update(team.id, { logo_path: path });
router.refresh();
}
async function saveAvailability() {
setLoading(true);
await api.teams.setAvailability(
team.id,
days.map((d) => ({ day_of_week: d }))
);
setLoading(false);
router.refresh();
}
function toggleDay(d: number) {
setDays((prev) =>
prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d]
);
}
return (
<div className="space-y-6">
<GlassCard title="Team logo">
<Input type="file" accept="image/*" onChange={uploadLogo} />
</GlassCard>
<GlassCard title="Home stadium">
<div className="flex gap-3">
<Input
value={stadium}
onChange={(e) => setStadium(e.target.value)}
placeholder="Arena name"
className="flex-1"
/>
<Button onClick={saveStadium} disabled={loading}>
Save
</Button>
</div>
</GlassCard>
<GlassCard title="Playable days">
<div className="flex flex-wrap gap-2">
{DAY_NAMES.map((name, i) => (
<button
key={name}
type="button"
onClick={() => toggleDay(i)}
className={`rounded-lg px-3 py-2 text-sm ${
days.includes(i)
? "bg-cyan-500/20 text-cyan-400"
: "bg-white/5 text-[var(--color-muted)]"
}`}
>
{name}
</button>
))}
</div>
<Button className="mt-4" onClick={saveAvailability} disabled={loading}>
Save availability
</Button>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { createClient } from "@/lib/supabase/server";
import { TransfersPanel } from "./transfers-panel";
import { GlassCard } from "@/components/ui/glass-card";
export default async function TransfersPage({
params,
}: {
params: Promise<{ leagueId: string; competitionId: string }>;
}) {
const { competitionId } = await params;
const supabase = await createClient();
const { data: transfers } = await supabase
.from("transfers")
.select(
`*, player:players(display_name), from_team:from_team_id(name), to_team:to_team_id(name)`
)
.eq("competition_id", competitionId)
.order("created_at", { ascending: false })
.limit(20);
const { data: teams } = await supabase
.from("teams")
.select("id, name")
.eq("competition_id", competitionId);
const { data: players } = await supabase
.from("players")
.select("id, display_name")
.eq("status", "active");
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Transfers</h1>
<TransfersPanel
competitionId={competitionId}
teams={teams ?? []}
players={players ?? []}
/>
<GlassCard title="Transfer history">
<ul className="space-y-2 text-sm">
{transfers?.map((t) => (
<li key={t.id} className="rounded-lg bg-white/5 px-3 py-2">
<span className="font-medium">
{(t.player as { display_name: string })?.display_name}
</span>{" "}
{(t.from_team as { name: string })?.name} {" "}
{(t.to_team as { name: string })?.name}
</li>
))}
</ul>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
export function TransfersPanel({
competitionId,
teams,
players,
}: {
competitionId: string;
teams: { id: string; name: string }[];
players: { id: string; display_name: string }[];
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
return (
<div className="grid gap-4 lg:grid-cols-2">
<GlassCard title="Add to roster">
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setLoading(true);
await api.competitions.addRoster(competitionId, {
teamId: fd.get("team_id") as string,
playerId: fd.get("player_id") as string,
});
setLoading(false);
router.refresh();
}}
>
<div>
<Label>Team</Label>
<select
name="team_id"
required
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
{teams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div>
<Label>Player (registry)</Label>
<select
name="player_id"
required
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
{players.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
</option>
))}
</select>
</div>
<Button type="submit" disabled={loading}>
Add to roster
</Button>
</form>
</GlassCard>
<GlassCard title="Inter-team transfer">
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setLoading(true);
await api.competitions.transfer(competitionId, {
playerId: fd.get("player_id") as string,
fromTeamId: fd.get("from_team_id") as string,
toTeamId: fd.get("to_team_id") as string,
});
setLoading(false);
router.refresh();
}}
>
<div>
<Label>Player</Label>
<select
name="player_id"
required
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
{players.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
</option>
))}
</select>
</div>
<div>
<Label>From</Label>
<select
name="from_team_id"
required
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
{teams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div>
<Label>To</Label>
<select
name="to_team_id"
required
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
{teams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<Button type="submit" disabled={loading}>
Register transfer
</Button>
</form>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,89 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { createCompetition } from "@/actions/leagues";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default async function LeaguePage({
params,
}: {
params: Promise<{ leagueId: string }>;
}) {
const { leagueId } = await params;
const supabase = await createClient();
const { data: league } = await supabase
.from("leagues")
.select("*")
.eq("id", leagueId)
.single();
if (!league) notFound();
const { data: competitions } = await supabase
.from("competitions")
.select("*")
.eq("league_id", leagueId)
.order("created_at", { ascending: false });
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{league.name}</h1>
<p className="text-sm text-[var(--color-muted)]">{league.description}</p>
</div>
<Button variant="outline" asChild>
<Link href={`/leagues/${leagueId}/rules`}>Edit rules</Link>
</Button>
</div>
<GlassCard title="New competition">
<form
action={createCompetition.bind(null, leagueId)}
className="flex flex-wrap items-end gap-4"
>
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" required className="mt-1" placeholder="Season 2025" />
</div>
<div>
<Label htmlFor="tournament_mode">Mode</Label>
<select
id="tournament_mode"
name="tournament_mode"
className="mt-1 flex h-10 rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
<option value="league">League (round-robin)</option>
<option value="cup">Cup (knockout)</option>
</select>
</div>
<Button type="submit">Create competition</Button>
</form>
</GlassCard>
<div className="grid gap-4 sm:grid-cols-2">
{competitions?.map((c) => (
<Link key={c.id} href={`/leagues/${leagueId}/competitions/${c.id}`}>
<GlassCard className="hover:border-cyan-400/30">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{c.name}</h3>
<p className="text-xs text-[var(--color-muted)] capitalize">
{c.tournament_mode} · {c.status}
</p>
</div>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs capitalize">
{c.status}
</span>
</div>
</GlassCard>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { parseLeagueRules, defaultLeagueRules } from "@/lib/rules/schema";
import { RulesForm } from "./rules-form";
export default async function RulesPage({
params,
}: {
params: Promise<{ leagueId: string }>;
}) {
const { leagueId } = await params;
const supabase = await createClient();
const { data: league } = await supabase
.from("leagues")
.select("name")
.eq("id", leagueId)
.single();
if (!league) notFound();
const { data: latest } = await supabase
.from("league_rules")
.select("rules, version")
.eq("league_id", leagueId)
.order("version", { ascending: false })
.limit(1)
.single();
const rules = latest?.rules
? parseLeagueRules(latest.rules)
: defaultLeagueRules;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">League rules</h1>
<p className="text-sm text-[var(--color-muted)]">
{league.name} · version {latest?.version ?? 0}
</p>
</div>
<RulesForm leagueId={leagueId} initialRules={rules} />
</div>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { LeagueRules } from "@/lib/rules/schema";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api/client";
export function RulesForm({
leagueId,
initialRules,
}: {
leagueId: string;
initialRules: LeagueRules;
}) {
const router = useRouter();
const [rules, setRules] = useState(initialRules);
const [loading, setLoading] = useState(false);
async function handleSave() {
setLoading(true);
try {
await api.leagues.saveRules(leagueId, rules);
router.refresh();
} finally {
setLoading(false);
}
}
return (
<GlassCard title="Scoring & format">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Points for win</Label>
<Input
type="number"
value={rules.points_win}
onChange={(e) =>
setRules({ ...rules, points_win: Number(e.target.value) })
}
className="mt-1"
/>
</div>
<div>
<Label>Points for draw</Label>
<Input
type="number"
value={rules.points_draw}
onChange={(e) =>
setRules({ ...rules, points_draw: Number(e.target.value) })
}
className="mt-1"
/>
</div>
<div>
<Label>Round robin</Label>
<select
value={rules.round_robin_format}
onChange={(e) =>
setRules({
...rules,
round_robin_format: e.target.value as "single" | "double",
})
}
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
<option value="single">Single</option>
<option value="double">Double</option>
</select>
</div>
<div>
<Label>Auto-qualify (CL)</Label>
<Input
type="number"
value={rules.auto_qualify_count}
onChange={(e) =>
setRules({
...rules,
auto_qualify_count: Number(e.target.value),
})
}
className="mt-1"
/>
</div>
</div>
<Button className="mt-6" onClick={handleSave} disabled={loading}>
Save new rules version
</Button>
</GlassCard>
);
}

View File

@ -0,0 +1,71 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { createLeague } from "@/actions/leagues";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Trophy, Plus } from "lucide-react";
export default async function LeaguesPage() {
const supabase = await createClient();
const { data: leagues } = await supabase
.from("leagues")
.select("*")
.order("created_at", { ascending: false });
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Leagues</h1>
<p className="text-sm text-[var(--color-muted)]">
Create and manage tournament leagues
</p>
</div>
</div>
<GlassCard title="New league">
<form action={createLeague} className="flex flex-wrap items-end gap-4">
<div className="min-w-[200px] flex-1">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" required className="mt-1" placeholder="Sunday League" />
</div>
<div className="min-w-[200px] flex-1">
<Label htmlFor="description">Description</Label>
<Input id="description" name="description" className="mt-1" placeholder="Optional" />
</div>
<Button type="submit">
<Plus className="h-4 w-4" />
Create league
</Button>
</form>
</GlassCard>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{leagues?.map((league) => (
<Link key={league.id} href={`/leagues/${league.id}`}>
<GlassCard className="transition-colors hover:border-cyan-400/30">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-cyan-500/15 p-2">
<Trophy className="h-5 w-5 text-cyan-400" />
</div>
<div>
<h3 className="font-semibold">{league.name}</h3>
<p className="mt-1 text-xs text-[var(--color-muted)] line-clamp-2">
{league.description || "No description"}
</p>
</div>
</div>
</GlassCard>
</Link>
))}
{(!leagues || leagues.length === 0) && (
<p className="col-span-full text-center text-[var(--color-muted)]">
No leagues yet. Create your first league above.
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
import { createClient } from "@/lib/supabase/server";
import { createPlayer, togglePlayerStatus } from "@/actions/players";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default async function PlayersPage() {
const supabase = await createClient();
const { data: players } = await supabase
.from("players")
.select("*")
.order("display_name");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Player registry</h1>
<p className="text-sm text-[var(--color-muted)]">
Load players before roster adds or transfers
</p>
</div>
<GlassCard title="Add player">
<form action={createPlayer} className="flex flex-wrap items-end gap-4">
<div>
<Label htmlFor="display_name">Name</Label>
<Input id="display_name" name="display_name" required className="mt-1" />
</div>
<div>
<Label htmlFor="external_id">External ID</Label>
<Input id="external_id" name="external_id" className="mt-1" />
</div>
<Button type="submit">Add player</Button>
</form>
</GlassCard>
<GlassCard title="All players">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-[var(--color-muted)]">
<th className="pb-3 pr-4">Name</th>
<th className="pb-3 pr-4">External ID</th>
<th className="pb-3 pr-4">Status</th>
<th className="pb-3">Actions</th>
</tr>
</thead>
<tbody>
{players?.map((p) => (
<tr key={p.id} className="border-b border-white/5">
<td className="py-3 pr-4 font-medium">{p.display_name}</td>
<td className="py-3 pr-4 text-[var(--color-muted)]">
{p.external_id || "—"}
</td>
<td className="py-3 pr-4">
<span
className={
p.status === "active"
? "text-emerald-400"
: "text-[var(--color-muted)]"
}
>
{p.status}
</span>
</td>
<td className="py-3">
<form action={togglePlayerStatus.bind(null, p.id, p.status)}>
<Button type="submit" variant="ghost" size="sm">
Toggle status
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</GlassCard>
</div>
);
}

21
app/(manager)/layout.tsx Normal file
View File

@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { requirePortalRole } from "@/lib/auth/profile";
import { ManagerShell } from "@/components/layout/ManagerShell";
export default async function ManagerLayout({
children,
}: {
children: React.ReactNode;
}) {
const gate = await requirePortalRole("manager");
if (!gate.ok) {
redirect(gate.reason === "auth" ? "/login/manager" : "/login/master");
}
const user = {
name: gate.profile?.display_name ?? gate.user.email?.split("@")[0] ?? "Manager",
email: gate.user.email ?? "",
};
return <ManagerShell user={user}>{children}</ManagerShell>;
}

View File

@ -0,0 +1,11 @@
import { MatchCalendar } from "@/components/calendar/match-calendar";
export default function ManagerCalendarPage() {
return (
<MatchCalendar
apiPath="/api/manager/calendar"
title="Match calendar"
description="Your team fixtures across leagues and cups — click a day to see kickoffs."
/>
);
}

View File

@ -0,0 +1,11 @@
import { ManagerCompetitionsTable } from "@/components/manager/manager-competitions-table";
export default function ManagerCupsPage() {
return (
<ManagerCompetitionsTable
title="Cups"
description="Knockout competitions you manage"
mode="cup"
/>
);
}

View File

@ -0,0 +1,21 @@
import { PageHeader } from "@/components/dashboard/page-header";
import { GlassCard } from "@/components/ui/glass-card";
import { MANAGER_FAQ } from "@/lib/content/faq";
export default function ManagerFaqPage() {
return (
<div className="space-y-6">
<PageHeader
title="FAQ"
description="Common questions for team managers"
/>
<div className="space-y-4">
{MANAGER_FAQ.map((item) => (
<GlassCard key={item.q} title={item.q}>
<p className="text-sm text-[var(--color-muted)]">{item.a}</p>
</GlassCard>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { createClient } from "@/lib/supabase/server";
import { getCurrentProfile } from "@/lib/auth/profile";
import { IssuesPanel } from "@/components/issues/issues-panel";
export default async function ManagerIssuesPage() {
const supabase = await createClient();
const ctx = await getCurrentProfile();
const { data: memberships } = await supabase
.from("team_members")
.select("teams(competitions(league_id, leagues(id, name)))")
.eq("user_id", ctx!.user.id);
const leagueMap = new Map<string, string>();
memberships?.forEach((m) => {
const team = m.teams as {
competitions: { leagues: { id: string; name: string } | null } | null;
} | null;
const league = team?.competitions?.leagues;
if (league) leagueMap.set(league.id, league.name);
});
const leagues = [...leagueMap.entries()].map(([id, name]) => ({ id, name }));
return <IssuesPanel leagues={leagues} asMaster={false} />;
}

View File

@ -0,0 +1,11 @@
import { ManagerCompetitionsTable } from "@/components/manager/manager-competitions-table";
export default function ManagerLeaguesPage() {
return (
<ManagerCompetitionsTable
title="Leagues"
description="Round-robin competitions you manage — points and goal difference"
mode="league"
/>
);
}

View File

@ -0,0 +1,5 @@
import { ManagerDashboardClient } from "@/components/manager/manager-dashboard-client";
export default function ManagerDashboardPage() {
return <ManagerDashboardClient />;
}

View File

@ -0,0 +1,96 @@
import { createClient } from "@/lib/supabase/server";
import { getCurrentProfile } from "@/lib/auth/profile";
import { PageHeader } from "@/components/dashboard/page-header";
import { GlassCard } from "@/components/ui/glass-card";
import Link from "next/link";
import { parseLeagueRules, defaultLeagueRules } from "@/lib/rules/schema";
export default async function ManagerRulesPage() {
const supabase = await createClient();
const ctx = await getCurrentProfile();
const { data: memberships } = await supabase
.from("team_members")
.select("teams(competition_id, competitions(league_id, name, leagues(name)))")
.eq("user_id", ctx!.user.id)
.eq("role", "manager");
const leagueIds = new Set<string>();
const leagueNames = new Map<string, string>();
memberships?.forEach((m) => {
const team = m.teams as {
competitions: { league_id: string; leagues: { name: string } | null } | null;
} | null;
const leagueId = team?.competitions?.league_id;
const name = team?.competitions?.leagues?.name;
if (leagueId) {
leagueIds.add(leagueId);
if (name) leagueNames.set(leagueId, name);
}
});
const rulesBlocks = await Promise.all(
[...leagueIds].map(async (leagueId) => {
const { data: latest } = await supabase
.from("league_rules")
.select("rules, version")
.eq("league_id", leagueId)
.order("version", { ascending: false })
.limit(1)
.single();
const rules = latest?.rules
? parseLeagueRules(latest.rules)
: defaultLeagueRules;
return { leagueId, name: leagueNames.get(leagueId) ?? "League", rules, version: latest?.version ?? 0 };
})
);
return (
<div className="space-y-6">
<PageHeader
title="Rules"
description="Scoring and format for leagues you participate in"
/>
{rulesBlocks.length === 0 ? (
<GlassCard>
<p className="text-sm text-[var(--color-muted)]">
No league rules available yet. You need to be assigned as a team manager.
</p>
</GlassCard>
) : (
rulesBlocks.map((block) => (
<GlassCard
key={block.leagueId}
title={`${block.name} · v${block.version}`}
>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-[var(--color-muted)]">Win points</dt>
<dd className="font-medium">{block.rules.points_win}</dd>
</div>
<div>
<dt className="text-[var(--color-muted)]">Draw points</dt>
<dd className="font-medium">{block.rules.points_draw}</dd>
</div>
<div>
<dt className="text-[var(--color-muted)]">Format</dt>
<dd className="font-medium capitalize">{block.rules.round_robin_format}</dd>
</div>
<div>
<dt className="text-[var(--color-muted)]">Auto-qualify (CL)</dt>
<dd className="font-medium">{block.rules.auto_qualify_count}</dd>
</div>
</dl>
<Link
href={`/leagues/${block.leagueId}/rules`}
className="mt-4 inline-block text-sm text-cyan-400 hover:underline"
>
View full rules page
</Link>
</GlassCard>
))
)}
</div>
);
}

21
app/(master)/layout.tsx Normal file
View File

@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { requirePortalRole } from "@/lib/auth/profile";
import { MasterShell } from "@/components/layout/MasterShell";
export default async function MasterLayout({
children,
}: {
children: React.ReactNode;
}) {
const gate = await requirePortalRole("league_master");
if (!gate.ok) {
redirect(gate.reason === "auth" ? "/login/master" : "/login/manager");
}
const user = {
name: gate.profile?.display_name ?? gate.user.email?.split("@")[0] ?? "Master",
email: gate.user.email ?? "",
};
return <MasterShell user={user}>{children}</MasterShell>;
}

View File

@ -0,0 +1,11 @@
import { MatchCalendar } from "@/components/calendar/match-calendar";
export default function MasterCalendarPage() {
return (
<MatchCalendar
apiPath="/api/master/calendar"
title="Fixture calendar"
description="All matches in leagues you manage — schedule and track from one retro grid."
/>
);
}

View File

@ -0,0 +1,11 @@
import { createClient } from "@/lib/supabase/server";
import { IssuesPanel } from "@/components/issues/issues-panel";
export default async function MasterIssuesPage() {
const supabase = await createClient();
const { data: leagues } = await supabase.from("leagues").select("id, name");
return (
<IssuesPanel leagues={leagues ?? []} asMaster />
);
}

View File

@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { api } from "@/lib/api/client";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/dashboard/page-header";
export default function NewCompetitionPage() {
const router = useRouter();
const params = useParams();
const leagueId = params.leagueId as string;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<div className="space-y-6">
<PageHeader title="New competition" description="Add a league season or cup" />
<GlassCard>
<form
className="space-y-4 max-w-md"
onSubmit={async (e) => {
e.preventDefault();
if (loading) return;
const fd = new FormData(e.currentTarget);
setLoading(true);
setError(null);
try {
const comp = (await api.leagues.createCompetition(leagueId, {
name: fd.get("name") as string,
tournament_mode: fd.get("tournament_mode") as "league" | "cup",
})) as { id: string };
router.push(`/leagues/${leagueId}/competitions/${comp.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
}}
>
<div>
<Label>Name</Label>
<Input name="name" required className="mt-1" />
</div>
<div>
<Label>Mode</Label>
<select
name="tournament_mode"
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
<option value="league">League</option>
<option value="cup">Cup</option>
</select>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<Button type="submit" disabled={loading}>
Create competition
</Button>
</form>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,65 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { PageHeader } from "@/components/dashboard/page-header";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
export default async function MasterLeagueDetailPage({
params,
}: {
params: Promise<{ leagueId: string }>;
}) {
const { leagueId } = await params;
const supabase = await createClient();
const { data: league } = await supabase
.from("leagues")
.select("*, competitions(*)")
.eq("id", leagueId)
.single();
if (!league) notFound();
return (
<div className="space-y-6">
<PageHeader
title={league.name}
description={league.description ?? "League administration"}
actions={
<Button variant="outline" asChild>
<Link href={`/leagues/${leagueId}/rules`}>Edit rules</Link>
</Button>
}
/>
<GlassCard title="Competitions">
<ul className="space-y-2">
{league.competitions?.map(
(c: { id: string; name: string; status: string; tournament_mode: string }) => (
<li key={c.id}>
<Link
href={`/leagues/${leagueId}/competitions/${c.id}`}
className="flex items-center justify-between rounded-lg border border-white/10 px-4 py-3 hover:bg-white/5"
>
<span className="font-medium">{c.name}</span>
<span className="text-xs capitalize text-[var(--color-muted)]">
{c.tournament_mode} · {c.status}
</span>
</Link>
</li>
)
)}
{(!league.competitions || league.competitions.length === 0) && (
<p className="text-sm text-[var(--color-muted)]">No competitions yet</p>
)}
</ul>
<Button className="mt-4" asChild>
<Link href={`/master/leagues/${leagueId}/competitions/new`}>
Add competition
</Link>
</Button>
</GlassCard>
</div>
);
}

View File

@ -0,0 +1,18 @@
import { createClient } from "@/lib/supabase/server";
import { getCurrentProfile } from "@/lib/auth/profile";
import { listLeaguesForMaster } from "@/lib/services/leagues";
import { MasterLeaguesClient } from "@/components/master/master-leagues-client";
export default async function MasterLeaguesPage() {
const supabase = await createClient();
const ctx = await getCurrentProfile();
const isGlobal = ctx?.profile?.portal_role === "league_master";
const leagues = await listLeaguesForMaster(
supabase,
ctx!.user.id,
!!isGlobal
);
return <MasterLeaguesClient initialLeagues={leagues} />;
}

View File

@ -0,0 +1,103 @@
import { createClient } from "@/lib/supabase/server";
import { PageHeader } from "@/components/dashboard/page-header";
import { StatCard } from "@/components/dashboard/stat-card";
import { GlassCard } from "@/components/ui/glass-card";
import { Trophy, Users, Inbox, Shield } from "lucide-react";
import Link from "next/link";
import { MasterAssignPanel } from "@/components/master/master-assign-panel";
export default async function MasterDashboardPage() {
const supabase = await createClient();
const [{ count: leagues }, { count: players }, { data: openIssues }] =
await Promise.all([
supabase.from("leagues").select("*", { count: "exact", head: true }),
supabase.from("players").select("*", { count: "exact", head: true }),
supabase
.from("support_issues")
.select("id, subject, status, created_at, leagues(name)")
.eq("status", "open")
.order("created_at", { ascending: false })
.limit(5),
]);
return (
<div className="space-y-6">
<PageHeader
title="League Master Dashboard"
description="Overview of leagues, players, and open issues"
actions={
<Link
href="/master/leagues"
className="inline-flex h-10 items-center rounded-lg bg-cyan-500 px-4 text-sm font-medium text-slate-950 hover:bg-cyan-400"
>
Manage leagues
</Link>
}
/>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
title="Leagues"
value={leagues ?? 0}
subtitle="Total tournaments"
icon={Trophy}
accent="cyan"
/>
<StatCard
title="Players"
value={players ?? 0}
subtitle="Global registry"
icon={Users}
accent="green"
/>
<StatCard
title="Open issues"
value={openIssues?.length ?? 0}
subtitle="Needs attention"
icon={Inbox}
accent="amber"
/>
<StatCard
title="Portal"
value="Master"
subtitle="Full admin access"
icon={Shield}
accent="pink"
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<MasterAssignPanel />
<GlassCard title="Recent issues">
<ul className="space-y-3">
{openIssues?.map((issue) => {
const league = issue.leagues as { name: string } | null;
return (
<li
key={issue.id}
className="rounded-lg border border-white/10 px-3 py-2 text-sm"
>
<p className="font-medium">{issue.subject}</p>
<p className="text-xs text-[var(--color-muted)]">
{league?.name} · {new Date(issue.created_at).toLocaleDateString()}
</p>
</li>
);
})}
{(!openIssues || openIssues.length === 0) && (
<p className="text-sm text-[var(--color-muted)]">No open issues</p>
)}
</ul>
<Link
href="/master/issues"
className="mt-4 inline-block text-sm text-cyan-400 hover:underline"
>
View all issues
</Link>
</GlassCard>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { createClient } from "@/lib/supabase/server";
import { PageHeader } from "@/components/dashboard/page-header";
import { PlayersRegistry } from "@/components/players/players-registry";
export default async function MasterPlayersPage() {
const supabase = await createClient();
const { data: players } = await supabase
.from("players")
.select("*")
.order("display_name");
return (
<div className="space-y-6">
<PageHeader
title="Player registry"
description="Global players used for rosters and transfers"
/>
<PlayersRegistry initialPlayers={players ?? []} />
</div>
);
}

View File

@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { createAdminClient, hasAdminClient } from "@/lib/supabase/admin";
import { formatSignupError } from "@/lib/supabase/auth-errors";
import type { PortalRole } from "@/lib/auth/roles";
type Body = {
email?: string;
password?: string;
displayName?: string;
portalRole?: PortalRole;
};
function parsePortalRole(value: unknown): PortalRole {
return value === "league_master" ? "league_master" : "manager";
}
export async function POST(request: Request) {
if (!hasAdminClient()) {
return NextResponse.json(
{
success: false,
error:
"Server signup is not configured. Add SUPABASE_SERVICE_ROLE_KEY to .env.local or use client signup.",
useClient: true,
},
{ status: 503 }
);
}
let body: Body;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ success: false, error: "Invalid JSON body" },
{ status: 400 }
);
}
const email = body.email?.trim().toLowerCase();
const password = body.password;
const displayName = body.displayName?.trim() || email?.split("@")[0] || "User";
const portalRole = parsePortalRole(body.portalRole);
if (!email || !password) {
return NextResponse.json(
{ success: false, error: "Email and password are required" },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ success: false, error: "Password must be at least 6 characters" },
{ status: 400 }
);
}
const autoConfirm =
process.env.SUPABASE_AUTO_CONFIRM_EMAIL === "true" ||
process.env.NODE_ENV === "development";
try {
const admin = createAdminClient();
const { data: created, error: createError } =
await admin.auth.admin.createUser({
email,
password,
email_confirm: autoConfirm,
user_metadata: {
display_name: displayName,
portal_role: portalRole,
},
});
if (createError) {
const message = formatSignupError(createError.message);
const status = createError.status === 422 ? 422 : 500;
return NextResponse.json({ success: false, error: message }, { status });
}
const userId = created.user?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: "User was not created" },
{ status: 500 }
);
}
const { error: profileError } = await admin.from("profiles").upsert(
{
id: userId,
display_name: displayName,
portal_role: portalRole,
},
{ onConflict: "id" }
);
if (profileError) {
return NextResponse.json(
{
success: false,
error: formatSignupError(
profileError.message.includes("portal_role")
? "Database error saving new user"
: profileError.message
),
},
{ status: 500 }
);
}
return NextResponse.json({
success: true,
userId,
needsEmailConfirmation: !autoConfirm,
portalRole,
});
} catch (e) {
const message = e instanceof Error ? e.message : "Signup failed";
return NextResponse.json(
{ success: false, error: formatSignupError(message) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
await leagues.activateCompetition(supabase, competitionId);
return apiSuccess({ activated: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,32 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { ApiError, apiError, apiSuccess } from "@/lib/api/errors";
import * as teams from "@/lib/services/teams";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
const user = await requireUser(supabase);
const membership = await teams.getManagerTeam(
supabase,
user.id,
competitionId
);
if (!membership) {
throw new ApiError(404, "You are not a manager in this competition");
}
const teamId = membership.team_id;
const dashboard = await teams.getTeamDashboard(
supabase,
teamId,
competitionId
);
return apiSuccess({ team: membership.teams, ...dashboard });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,24 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const comp = await leagues.getCompetition(supabase, competitionId);
const count = await leagues.generateFixtures(
supabase,
competitionId,
comp.tournament_mode as "league" | "cup"
);
return apiSuccess({ matchesCreated: count });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as matches from "@/lib/services/matches";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await matches.listMatches(supabase, competitionId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as matchService from "@/lib/services/matches";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await matchService.listPendingResults(supabase, competitionId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,24 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as players from "@/lib/services/players";
export async function POST(
request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{ teamId: string; playerId: string }>(request);
await players.addToRoster(supabase, user.id, {
teamId: body.teamId,
competitionId,
playerId: body.playerId,
});
return apiSuccess({ added: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await leagues.getCompetition(supabase, competitionId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await leagues.getStandings(supabase, competitionId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,39 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as teams from "@/lib/services/teams";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await teams.listTeams(supabase, competitionId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const body = await parseJson<{
name: string;
nickname?: string;
icon?: string;
}>(request);
const data = await teams.createTeam(supabase, competitionId, body);
return apiSuccess(data, 201);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,42 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as players from "@/lib/services/players";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await players.listTransfers(supabase, competitionId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ competitionId: string }> }
) {
try {
const { competitionId } = await params;
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{
playerId: string;
fromTeamId: string;
toTeamId: string;
}>(request);
await players.registerTransfer(supabase, user.id, {
competitionId,
...body,
});
return apiSuccess({ transferred: true });
} catch (e) {
return apiError(e);
}
}

31
app/api/health/route.ts Normal file
View File

@ -0,0 +1,31 @@
import { createClient } from "@/lib/supabase/server";
import { getSupabaseEnv } from "@/lib/supabase/env";
import { apiError, apiSuccess } from "@/lib/api/errors";
export async function GET() {
try {
let urlOk = false;
try {
const { url } = getSupabaseEnv();
urlOk = url.includes(".supabase.co");
} catch (e) {
return apiSuccess({
status: "misconfigured",
supabase: false,
message: e instanceof Error ? e.message : "Invalid env",
});
}
const supabase = await createClient();
const { error } = await supabase.from("leagues").select("id").limit(1);
return apiSuccess({
status: error ? "degraded" : "ok",
supabase: !error,
dbError: error?.message,
urlOk,
});
} catch (e) {
return apiError(e);
}
}

43
app/api/issues/route.ts Normal file
View File

@ -0,0 +1,43 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as issues from "@/lib/services/issues";
export async function GET(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const { searchParams } = new URL(request.url);
const asMaster = searchParams.get("as") === "master";
const data = await issues.listIssuesForUser(
supabase,
user.id,
asMaster
);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function POST(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{
leagueId: string;
competitionId?: string;
subject: string;
body: string;
}>(request);
const data = await issues.createIssue(supabase, user.id, {
leagueId: body.leagueId,
competitionId: body.competitionId,
subject: body.subject,
body: body.body,
});
return apiSuccess(data, 201);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,24 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function POST(
request: Request,
{ params }: { params: Promise<{ leagueId: string }> }
) {
try {
const { leagueId } = await params;
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{
name: string;
tournament_mode: "league" | "cup";
timezone?: string;
}>(request);
const data = await leagues.createCompetition(supabase, user.id, leagueId, body);
return apiSuccess(data, 201);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,44 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { ApiError, apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as masters from "@/lib/services/masters";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ leagueId: string }> }
) {
try {
const { leagueId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await masters.listLeagueMasters(supabase, leagueId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ leagueId: string }> }
) {
try {
const { leagueId } = await params;
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{ email: string }>(request);
const targetId = await masters.getUserIdByEmail(supabase, body.email);
if (!targetId) {
throw new ApiError(404, "No user found with that email");
}
await masters.assignLeagueMaster(
supabase,
leagueId,
targetId,
user.id
);
return apiSuccess({ assigned: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,34 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ leagueId: string }> }
) {
try {
const { leagueId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await leagues.getLeague(supabase, leagueId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ leagueId: string }> }
) {
try {
const { leagueId } = await params;
const supabase = await createClient();
await requireUser(supabase);
await leagues.deleteLeague(supabase, leagueId);
return apiSuccess({ deleted: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,20 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function POST(
request: Request,
{ params }: { params: Promise<{ leagueId: string }> }
) {
try {
const { leagueId } = await params;
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{ rules: object }>(request);
await leagues.saveLeagueRules(supabase, user.id, leagueId, body.rules);
return apiSuccess({ saved: true });
} catch (e) {
return apiError(e);
}
}

30
app/api/leagues/route.ts Normal file
View File

@ -0,0 +1,30 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as leagues from "@/lib/services/leagues";
export async function GET() {
try {
const supabase = await createClient();
await requireUser(supabase);
const data = await leagues.listLeagues(supabase);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function POST(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{ name: string; description?: string }>(request);
if (!body.name?.trim()) {
return apiError(new Error("name is required"));
}
const data = await leagues.createLeague(supabase, user.id, body);
return apiSuccess(data, 201);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,21 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import { getManagerCalendarMatches } from "@/lib/services/calendar";
export async function GET(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return apiError(new Error("from and to query params required (ISO dates)"));
}
const data = await getManagerCalendarMatches(supabase, user.id, from, to);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,23 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as manager from "@/lib/services/manager";
export async function GET(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const mode = new URL(request.url).searchParams.get("mode") as
| "league"
| "cup"
| null;
const data = await manager.getManagerCompetitions(
supabase,
user.id,
mode ?? undefined
);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,15 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as manager from "@/lib/services/manager";
export async function GET() {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const data = await manager.getManagerDashboard(supabase, user.id);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,32 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import { getMasterCalendarMatches } from "@/lib/services/calendar";
import { resolvePortalRole } from "@/lib/auth/resolve-portal-role";
export async function GET(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const { role } = await resolvePortalRole(supabase, user);
if (role !== "league_master") {
return apiError(new Error("League master access required"));
}
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return apiError(new Error("from and to query params required"));
}
const data = await getMasterCalendarMatches(
supabase,
user.id,
false,
from,
to
);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,58 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { ApiError, apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as matches from "@/lib/services/matches";
export async function POST(
request: Request,
{ params }: { params: Promise<{ matchId: string }> }
) {
try {
const { matchId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const body = await parseJson<{
action: "submit" | "approve" | "set";
teamId?: string;
homeScore?: number;
awayScore?: number;
note?: string;
}>(request);
switch (body.action) {
case "submit":
if (body.teamId == null || body.homeScore == null || body.awayScore == null) {
throw new ApiError(400, "teamId, homeScore, awayScore required");
}
await matches.submitResult(
supabase,
matchId,
body.teamId,
body.homeScore,
body.awayScore
);
break;
case "approve":
await matches.approveResult(supabase, matchId);
break;
case "set":
if (body.homeScore == null || body.awayScore == null) {
throw new ApiError(400, "homeScore, awayScore required");
}
await matches.setResultByManager(
supabase,
matchId,
body.homeScore,
body.awayScore,
body.note
);
break;
default:
throw new ApiError(400, "action must be submit, approve, or set");
}
return apiSuccess({ ok: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess } from "@/lib/api/errors";
import * as matches from "@/lib/services/matches";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ matchId: string }> }
) {
try {
const { matchId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const data = await matches.getMatchDetails(supabase, matchId);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,34 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { ApiError, apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as matches from "@/lib/services/matches";
export async function POST(
request: Request,
{ params }: { params: Promise<{ matchId: string }> }
) {
try {
const { matchId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const body = await parseJson<{
action: "propose" | "sign";
scheduledAt?: string;
teamId?: string;
}>(request);
if (body.action === "propose") {
if (!body.scheduledAt) throw new ApiError(400, "scheduledAt required");
await matches.proposeSchedule(supabase, matchId, body.scheduledAt);
} else if (body.action === "sign") {
if (!body.teamId) throw new ApiError(400, "teamId required");
await matches.signSchedule(supabase, matchId, body.teamId);
} else {
throw new ApiError(400, "action must be propose or sign");
}
return apiSuccess({ ok: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,20 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as players from "@/lib/services/players";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ playerId: string }> }
) {
try {
const { playerId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const body = await parseJson<{ status: "active" | "inactive" }>(request);
await players.updatePlayerStatus(supabase, playerId, body.status);
return apiSuccess({ updated: true });
} catch (e) {
return apiError(e);
}
}

29
app/api/players/route.ts Normal file
View File

@ -0,0 +1,29 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as players from "@/lib/services/players";
export async function GET() {
try {
const supabase = await createClient();
await requireUser(supabase);
const data = await players.listPlayers(supabase);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function POST(request: Request) {
try {
const supabase = await createClient();
const user = await requireUser(supabase);
const body = await parseJson<{ display_name: string; external_id?: string }>(
request
);
const data = await players.createPlayer(supabase, user.id, body);
return apiSuccess(data, 201);
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,22 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as teams from "@/lib/services/teams";
export async function PUT(
request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const body = await parseJson<{
windows: { day_of_week: number; start_time?: string; end_time?: string }[];
}>(request);
await teams.setAvailability(supabase, teamId, body.windows ?? []);
return apiSuccess({ saved: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,40 @@
import { createClient } from "@/lib/supabase/server";
import { requireUser } from "@/lib/api/auth";
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
import * as teams from "@/lib/services/teams";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId } = await params;
const supabase = await createClient();
await requireUser(supabase);
const body = await parseJson<{
home_stadium_name?: string;
logo_path?: string;
nickname?: string;
icon?: string;
}>(request);
const data = await teams.updateTeam(supabase, teamId, body);
return apiSuccess(data);
} catch (e) {
return apiError(e);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId } = await params;
const supabase = await createClient();
await requireUser(supabase);
await teams.deleteTeam(supabase, teamId);
return apiSuccess({ deleted: true });
} catch (e) {
return apiError(e);
}
}

View File

@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/";
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
const errUrl = new URL("/reset-password", origin);
errUrl.searchParams.set("error", error.message);
return NextResponse.redirect(errUrl);
}
}
const safeNext = next.startsWith("/") ? next : "/";
return NextResponse.redirect(`${origin}${safeNext}`);
}

155
app/globals.css Normal file
View File

@ -0,0 +1,155 @@
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-neon: var(--neon);
--color-neon-muted: var(--neon-muted);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-barlow), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-barlow-condensed), var(--font-barlow), sans-serif;
}
:root {
--radius: 0.75rem;
--background: #030303;
--foreground: #f4f4f5;
--card: #0a0a0c;
--card-foreground: #f4f4f5;
--popover: #0c0c0f;
--popover-foreground: #f4f4f5;
--primary: #c8ff4a;
--primary-foreground: #050505;
--secondary: #141416;
--secondary-foreground: #e4e4e7;
--muted: #18181b;
--muted-foreground: #8b8b96;
--accent: #1a1a1f;
--accent-foreground: #c8ff4a;
--destructive: #ff4d6d;
--border: #252528;
--input: #1c1c20;
--ring: #c8ff4a;
--neon: #c8ff4a;
--neon-muted: #7cb342;
--chart-1: #c8ff4a;
--chart-2: #a855f7;
--chart-3: #ff9124;
--chart-4: #22d3ee;
--chart-5: #f472b6;
}
* {
box-sizing: border-box;
border-color: var(--border);
}
html {
color-scheme: dark;
}
body {
background: var(--background);
color: var(--foreground);
min-height: 100vh;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
.font-display {
font-family: var(--font-display);
letter-spacing: 0.02em;
}
/* Retro grid + scanline atmosphere */
.retro-grid {
position: relative;
isolation: isolate;
}
.retro-grid::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
background-image:
linear-gradient(color-mix(in oklab, var(--neon) 4%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in oklab, var(--neon) 4%, transparent) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse 80% 70% at 50% 0%, black 20%, transparent 75%);
pointer-events: none;
}
.retro-grid::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background: radial-gradient(
ellipse 120% 80% at 50% -20%,
color-mix(in oklab, var(--neon) 8%, transparent),
transparent 55%
);
pointer-events: none;
}
.retro-card {
background: color-mix(in oklab, var(--card) 92%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow:
0 0 0 1px color-mix(in oklab, var(--neon) 6%, transparent) inset,
0 8px 32px -12px rgba(0, 0, 0, 0.65);
backdrop-filter: blur(8px);
}
.retro-card-glow {
border-color: color-mix(in oklab, var(--neon) 35%, var(--border));
box-shadow:
0 0 0 1px color-mix(in oklab, var(--neon) 12%, transparent) inset,
0 0 24px -6px color-mix(in oklab, var(--neon) 25%, transparent);
}
.neon-text {
color: var(--neon);
text-shadow: 0 0 20px color-mix(in oklab, var(--neon) 45%, transparent);
}
.neon-dot {
width: 5px;
height: 5px;
border-radius: 9999px;
background: var(--neon);
box-shadow: 0 0 8px var(--neon);
}
.glass-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.glass-card-highlight {
background: var(--card);
border: 1px solid color-mix(in oklab, var(--neon) 35%, var(--border));
border-radius: var(--radius-lg);
}

37
app/layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Barlow, Barlow_Condensed } from "next/font/google";
import "./globals.css";
const barlow = Barlow({
variable: "--font-barlow",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const barlowCondensed = Barlow_Condensed({
variable: "--font-barlow-condensed",
subsets: ["latin"],
weight: ["600", "700", "800", "900"],
});
export const metadata: Metadata = {
title: "Yaltopia FIFA — Tournament System",
description: "FIFA tournament holding system by Yaltopia Tech",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body
className={`${barlow.variable} ${barlowCondensed.variable} font-sans antialiased`}
suppressHydrationWarning
>
{children}
</body>
</html>
);
}

73
app/page.tsx Normal file
View File

@ -0,0 +1,73 @@
import Link from "next/link";
import { Trophy, ArrowRight, CalendarDays } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
export default function HomePage() {
return (
<div className="retro-grid flex min-h-screen flex-col items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 flex flex-col items-center text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary shadow-[0_0_32px_-8px_var(--neon)]">
<Trophy className="h-7 w-7 text-primary-foreground" />
</div>
<p className="font-display text-[10px] font-bold uppercase tracking-[0.25em] text-neon">
Yaltopia · FIFA
</p>
<h1 className="font-display mt-2 text-4xl font-black uppercase tracking-tight">
Tournament OS
</h1>
<p className="mt-3 max-w-sm text-sm text-muted-foreground">
Retro-grade fixtures, standings, and squad tools built for team
managers who want the hype without the clutter.
</p>
</div>
<Card className="retro-card border-border/80">
<CardHeader>
<CardTitle className="font-display text-lg font-bold uppercase tracking-wide">
Team Manager
</CardTitle>
<CardDescription>
Sign in to view leagues, cups, your match calendar, and submit
issues.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button asChild variant="neon" className="w-full" size="lg">
<Link href="/login/manager">
Sign in
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<p className="text-center text-sm text-muted-foreground">
New manager?{" "}
<Link
href="/signup/manager"
className="font-medium text-neon underline-offset-4 hover:underline"
>
Create account
</Link>
</p>
</CardContent>
</Card>
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5 text-neon-muted" />
<span>Match calendar inside the manager portal</span>
</div>
</div>
<div className="mt-10 w-full max-w-lg">
<YaltopiaFooter />
</div>
</div>
);
}

18
components.json Normal file
View File

@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
}
}

View File

@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { formatAuthNetworkError } from "@/lib/supabase/env";
import { formatAuthError, isAuthRateLimitError } from "@/lib/supabase/auth-errors";
import {
formatCooldownSeconds,
getResetEmailCooldownRemainingMs,
RESET_EMAIL_COOLDOWN_MS,
startResetEmailCooldown,
} from "@/lib/auth/reset-email-cooldown";
import type { PortalRole } from "@/lib/auth/roles";
import { loginPathForRole } from "@/lib/auth/roles";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function resetRedirectUrl(portal: PortalRole) {
const origin =
typeof window !== "undefined" ? window.location.origin : "";
const next = `/reset-password?portal=${portal}`;
return `${origin}/auth/callback?next=${encodeURIComponent(next)}`;
}
export function ForgotPasswordForm({ portal }: { portal: PortalRole }) {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const [cooldownMs, setCooldownMs] = useState(0);
const loginHref = loginPathForRole(portal);
const portalLabel = portal === "league_master" ? "League Master" : "Team Manager";
useEffect(() => {
setCooldownMs(getResetEmailCooldownRemainingMs());
const id = window.setInterval(() => {
const remaining = getResetEmailCooldownRemainingMs();
setCooldownMs(remaining);
if (remaining <= 0) window.clearInterval(id);
}, 1000);
return () => window.clearInterval(id);
}, [sent, error]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const remaining = getResetEmailCooldownRemainingMs();
if (remaining > 0) {
setError(
`Please wait ${formatCooldownSeconds(remaining)} seconds before requesting another email.`
);
return;
}
setLoading(true);
setError(null);
try {
const supabase = createClient();
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
email.trim(),
{ redirectTo: resetRedirectUrl(portal) }
);
if (resetError) {
setError(formatAuthError(resetError));
if (isAuthRateLimitError(resetError)) {
startResetEmailCooldown();
setCooldownMs(RESET_EMAIL_COOLDOWN_MS);
}
return;
}
startResetEmailCooldown();
setCooldownMs(RESET_EMAIL_COOLDOWN_MS);
setSent(true);
} catch (err) {
setError(formatAuthNetworkError(err));
} finally {
setLoading(false);
}
}
const submitDisabled = loading || cooldownMs > 0;
if (sent) {
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted-foreground">
If an account exists for{" "}
<strong className="text-foreground">{email}</strong>, we sent a reset
link. Check your inbox and spam folder.
</p>
<p className="text-xs text-muted-foreground">
The link opens a page to set a new password, then signs you into the{" "}
{portalLabel} portal.
</p>
<p className="text-xs text-muted-foreground">
Did not get it? Wait at least an hour (Supabase email limits) before
trying again, or use Authentication Users in the Supabase dashboard.
</p>
<Button variant="outline" className="w-full" asChild>
<Link href={loginHref}>Back to sign in</Link>
</Button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="you@example.com"
/>
</div>
{error && (
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
)}
{cooldownMs > 0 && !error && (
<p className="text-xs text-muted-foreground">
You can request another email in{" "}
{formatCooldownSeconds(cooldownMs)}s.
</p>
)}
<Button type="submit" className="w-full" disabled={submitDisabled}>
{loading
? "Sending…"
: cooldownMs > 0
? `Wait ${formatCooldownSeconds(cooldownMs)}s`
: "Send reset link"}
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href={loginHref} className="hover:underline">
Back to sign in
</Link>
</p>
</form>
);
}

View File

@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { formatAuthNetworkError } from "@/lib/supabase/env";
import type { PortalRole } from "@/lib/auth/roles";
import { PORTAL_ROUTES } from "@/lib/auth/roles";
import {
ensureUserProfile,
isPortalRoleSchemaError,
resolvePortalRole,
} from "@/lib/auth/resolve-portal-role";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
export function LoginForm({ expectedRole }: { expectedRole: PortalRole }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const supabase = createClient();
const { data: authData, error: authError } =
await supabase.auth.signInWithPassword({ email, password });
if (authError) {
setError(authError.message);
return;
}
const userId = authData.user?.id;
if (!userId) {
setError("Sign in failed");
return;
}
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
setError("Sign in failed");
return;
}
const { role, profileError } = await resolvePortalRole(supabase, user);
if (role !== expectedRole) {
await supabase.auth.signOut();
setError(
expectedRole === "manager"
? "This account is not a Team Manager. Use the League Master sign-in URL if you are an admin."
: "This account is not a League Master. Use the manager sign-in page."
);
return;
}
if (profileError && isPortalRoleSchemaError(profileError)) {
setError(
"Database is missing portal_role. Run: npm run db:push — then try again."
);
return;
}
if (profileError) {
await ensureUserProfile(supabase, user, expectedRole);
}
router.push(PORTAL_ROUTES[expectedRole]);
router.refresh();
} catch (err) {
setError(formatAuthNetworkError(err));
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && (
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</Button>
<p className="text-center text-sm">
<Link
href={
expectedRole === "league_master"
? "/forgot-password/master"
: "/forgot-password/manager"
}
className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Forgot password?
</Link>
</p>
</form>
);
}
export function LoginPageShell({
title,
subtitle,
children,
footer,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="retro-grid flex min-h-screen flex-col items-center justify-center px-4">
<div className="retro-card w-full max-w-md p-8">
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
Yaltopia FIFA
</p>
<h1 className="font-display mt-1 text-2xl font-black uppercase tracking-tight">
{title}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
<div className="mt-6">{children}</div>
{footer && <div className="mt-6">{footer}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { formatAuthNetworkError } from "@/lib/supabase/env";
import {
type PortalRole,
PORTAL_ROUTES,
loginPathForRole,
} from "@/lib/auth/roles";
import { Button } from "@/components/ui/button";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
function parsePortal(value: string | null): PortalRole {
return value === "league_master" ? "league_master" : "manager";
}
export function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const portal = parsePortal(searchParams.get("portal"));
const urlError = searchParams.get("error");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(urlError);
const [done, setDone] = useState(false);
const loginHref = loginPathForRole(portal);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (password.length < 6) {
setError("Password must be at least 6 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setLoading(true);
try {
const supabase = createClient();
const { error: updateError } = await supabase.auth.updateUser({
password,
});
if (updateError) {
setError(updateError.message);
return;
}
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
const { data: profile } = await supabase
.from("profiles")
.select("portal_role")
.eq("id", user.id)
.single();
const role = (profile?.portal_role as PortalRole) ?? portal;
if (role !== portal) {
setError(
`This account is a ${role === "league_master" ? "League Master" : "Team Manager"} account. Use the correct reset link or sign in at the other portal.`
);
return;
}
}
setDone(true);
setTimeout(() => {
router.push(PORTAL_ROUTES[portal]);
router.refresh();
}, 1500);
} catch (err) {
setError(formatAuthNetworkError(err));
} finally {
setLoading(false);
}
}
if (done) {
return (
<div className="space-y-4 text-center">
<p className="text-sm text-emerald-400">Password updated successfully.</p>
<p className="text-xs text-muted-foreground">Redirecting to your dashboard</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm password</Label>
<PasswordInput
id="confirm"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
</div>
{error && (
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Saving…" : "Set new password"}
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href={loginHref} className="hover:underline">
Back to sign in
</Link>
</p>
</form>
);
}

View File

@ -0,0 +1,221 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { formatAuthNetworkError } from "@/lib/supabase/env";
import { formatAuthError, formatSignupError } from "@/lib/supabase/auth-errors";
import { ensureUserProfile } from "@/lib/auth/resolve-portal-role";
import type { PortalRole } from "@/lib/auth/roles";
import { loginPathForRole, PORTAL_ROUTES } from "@/lib/auth/roles";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
async function signupViaApi(
email: string,
password: string,
displayName: string,
portalRole: PortalRole
) {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, displayName, portalRole }),
});
const json = (await res.json()) as {
success: boolean;
error?: string;
useClient?: boolean;
needsEmailConfirmation?: boolean;
};
return { ok: res.ok && json.success, status: res.status, ...json };
}
export function SignupForm({
portalRole,
title,
description,
}: {
portalRole: PortalRole;
title: string;
description: string;
}) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loginHref = loginPathForRole(portalRole);
const portalLabel =
portalRole === "league_master" ? "League Master" : "Team Manager";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const trimmedEmail = email.trim();
const api = await signupViaApi(
trimmedEmail,
password,
displayName,
portalRole
);
if (api.ok) {
if (api.needsEmailConfirmation) {
setError(
`Confirm your email, then sign in at ${portalLabel} login.`
);
return;
}
const supabase = createClient();
const { error: signInError } = await supabase.auth.signInWithPassword({
email: trimmedEmail,
password,
});
if (signInError) {
setError(
`Account created. Sign in at ${loginHref} (${signInError.message})`
);
return;
}
router.push(PORTAL_ROUTES[portalRole]);
router.refresh();
return;
}
if (api.status !== 503 && !api.useClient) {
setError(formatSignupError(api.error ?? "Signup failed"));
return;
}
const supabase = createClient();
const { data, error: authError } = await supabase.auth.signUp({
email: trimmedEmail,
password,
options: {
data: { display_name: displayName, portal_role: portalRole },
},
});
if (authError) {
setError(formatAuthError(authError));
return;
}
if (data.user && !data.session) {
setError(
`Confirm your email, then sign in at ${portalLabel} login.`
);
return;
}
if (data.user) {
const sync = await ensureUserProfile(
supabase,
data.user,
portalRole,
displayName
);
if (sync.error === "schema") {
setError(
"Account created but database needs an update. Run: npm run db:push — then sign in."
);
return;
}
if (!sync.ok && sync.error) {
setError(formatSignupError(sync.error));
return;
}
}
router.push(PORTAL_ROUTES[portalRole]);
router.refresh();
} catch (err) {
setError(formatAuthNetworkError(err));
} finally {
setLoading(false);
}
}
return (
<div className="retro-grid flex min-h-screen items-center justify-center px-4 py-12">
<Card className="retro-card w-full max-w-md border-border/80">
<CardHeader>
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
Yaltopia FIFA
</p>
<CardTitle className="font-display text-xl font-black uppercase tracking-wide">
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Display name</Label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label>Password</Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
</div>
{error && (
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
)}
<Button type="submit" variant="neon" className="w-full" disabled={loading}>
{loading ? "Creating account…" : "Create account"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
<Link href={loginHref} className="text-neon hover:underline">
Sign in
</Link>
{portalRole === "manager" && (
<>
{" · "}
<Link href="/" className="hover:underline">
Home
</Link>
</>
)}
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,324 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
CalendarDays,
Clock,
Loader2,
} from "lucide-react";
import type { CalendarMatch } from "@/lib/services/calendar";
import {
addMonths,
buildMonthGrid,
endOfMonth,
formatMonthYear,
matchDisplayDate,
parseDateKey,
sameDay,
startOfMonth,
toDateKey,
} from "@/lib/calendar/utils";
import { apiFetch } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
function matchWhen(m: CalendarMatch) {
return m.scheduled_at ?? m.proposed_scheduled_at;
}
function statusVariant(status: string) {
if (status === "completed" || status === "played") return "default" as const;
if (status === "scheduled" || status === "confirmed") return "secondary" as const;
return "outline" as const;
}
export function MatchCalendar({
apiPath,
title = "Match calendar",
description = "Plan around fixtures — days with matches glow on the grid.",
}: {
apiPath: "/api/manager/calendar" | "/api/master/calendar";
title?: string;
description?: string;
}) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const [selected, setSelected] = useState(() => toDateKey(new Date()));
const [matches, setMatches] = useState<CalendarMatch[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const from = startOfMonth(month).toISOString();
const to = endOfMonth(month).toISOString();
const data = await apiFetch<CalendarMatch[]>(
`${apiPath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
);
setMatches(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load matches");
} finally {
setLoading(false);
}
}, [apiPath, month]);
useEffect(() => {
void load();
}, [load]);
const byDate = useMemo(() => {
const map = new Map<string, CalendarMatch[]>();
for (const m of matches) {
const when = matchWhen(m);
if (!when) continue;
const key = toDateKey(new Date(when));
const list = map.get(key) ?? [];
list.push(m);
map.set(key, list);
}
return map;
}, [matches]);
const unscheduled = useMemo(
() => matches.filter((m) => !matchWhen(m)),
[matches]
);
const grid = useMemo(() => buildMonthGrid(month), [month]);
const selectedMatches = byDate.get(selected) ?? [];
const selectedDate = parseDateKey(selected);
const today = new Date();
const monthMatchCount = useMemo(() => {
let n = 0;
for (const [, list] of byDate) n += list.length;
return n;
}, [byDate]);
return (
<div className="space-y-6">
<div>
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
Schedule
</p>
<h1 className="font-display text-3xl font-black uppercase tracking-tight md:text-4xl">
{title}
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">{description}</p>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
<div className="retro-card p-4 md:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-neon" />
<h2 className="font-display text-xl font-bold uppercase tracking-wide">
{formatMonthYear(month)}
</h2>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="rounded-full border-border"
onClick={() => setMonth((m) => addMonths(m, -1))}
aria-label="Previous month"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => {
const now = startOfMonth(new Date());
setMonth(now);
setSelected(toDateKey(new Date()));
}}
>
Today
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full border-border"
onClick={() => setMonth((m) => addMonths(m, 1))}
aria-label="Next month"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{loading && (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin text-neon" />
Loading fixtures
</div>
)}
{error && !loading && (
<p className="py-8 text-center text-sm text-red-400">{error}</p>
)}
{!loading && !error && (
<>
<div className="mb-2 grid grid-cols-7 gap-1">
{WEEKDAYS.map((d) => (
<div
key={d}
className="py-1 text-center font-display text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{grid.map((day) => {
const key = toDateKey(day);
const inMonth = day.getMonth() === month.getMonth();
const isSelected = key === selected;
const isToday = sameDay(day, today);
const dayMatches = byDate.get(key) ?? [];
const hasMatches = dayMatches.length > 0;
return (
<button
key={key}
type="button"
onClick={() => setSelected(key)}
className={cn(
"group relative flex min-h-[52px] flex-col items-center justify-start rounded-xl border px-1 py-2 text-sm transition-all md:min-h-[64px]",
inMonth ? "border-border/80 bg-secondary/30" : "border-transparent bg-transparent opacity-35",
isSelected && "retro-card-glow border-neon/40 bg-neon/10",
!isSelected && hasMatches && "hover:border-neon/30 hover:bg-neon/5",
!isSelected && !hasMatches && inMonth && "hover:bg-secondary/50"
)}
>
<span
className={cn(
"font-display text-base font-bold tabular-nums",
isToday && "neon-text",
isSelected && !isToday && "text-foreground"
)}
>
{day.getDate()}
</span>
{hasMatches && (
<div className="mt-1 flex flex-wrap justify-center gap-0.5">
{dayMatches.slice(0, 3).map((m) => (
<span key={m.id} className="neon-dot" />
))}
{dayMatches.length > 3 && (
<span className="text-[9px] text-neon-muted">
+{dayMatches.length - 3}
</span>
)}
</div>
)}
</button>
);
})}
</div>
<p className="mt-4 text-xs text-muted-foreground">
<span className="neon-text font-semibold">{monthMatchCount}</span>{" "}
scheduled in {formatMonthYear(month)}
{unscheduled.length > 0 && (
<>
{" · "}
<span className="text-foreground">{unscheduled.length}</span> awaiting
date
</>
)}
</p>
</>
)}
</div>
<div className="space-y-4">
<div className="retro-card retro-card-glow p-4 md:p-5">
<p className="font-display text-[10px] font-bold uppercase tracking-[0.15em] text-neon">
{selectedDate.toLocaleDateString(undefined, {
weekday: "long",
month: "short",
day: "numeric",
})}
</p>
<h3 className="font-display mt-1 text-2xl font-black uppercase">
{selectedMatches.length === 0 ? "No matches" : `${selectedMatches.length} match${selectedMatches.length > 1 ? "es" : ""}`}
</h3>
<ul className="mt-4 space-y-3">
{selectedMatches.length === 0 && (
<li className="text-sm text-muted-foreground">
Pick a glowing day or schedule a fixture from your league.
</li>
)}
{selectedMatches.map((m) => (
<MatchCard key={m.id} match={m} />
))}
</ul>
</div>
{unscheduled.length > 0 && (
<div className="retro-card p-4">
<div className="mb-3 flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<h3 className="font-display text-sm font-bold uppercase tracking-wide">
Date TBD
</h3>
</div>
<ul className="max-h-64 space-y-2 overflow-y-auto">
{unscheduled.map((m) => (
<MatchCard key={m.id} match={m} compact />
))}
</ul>
</div>
)}
</div>
</div>
</div>
);
}
function MatchCard({ match, compact }: { match: CalendarMatch; compact?: boolean }) {
const when = matchWhen(match);
const href = `/leagues/${match.league_id}/competitions/${match.competition_id}/matches/${match.id}`;
return (
<li>
<Link
href={href}
className={cn(
"block rounded-xl border border-border/80 bg-background/60 p-3 transition-colors hover:border-neon/40 hover:bg-neon/5",
compact && "p-2.5"
)}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(match.status)} className="text-[10px] uppercase">
{match.status.replace(/_/g, " ")}
</Badge>
{!compact && (
<span className="text-[10px] text-muted-foreground">{match.league_name}</span>
)}
</div>
<p className={cn("mt-1 font-semibold", compact ? "text-sm" : "text-base")}>
{match.home_name}{" "}
<span className="text-muted-foreground font-normal">vs</span> {match.away_name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{match.competition_name}</p>
<p className="mt-1 text-xs text-neon-muted">{matchDisplayDate(when)}</p>
</Link>
</li>
);
}

View File

@ -0,0 +1,242 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TeamIconPicker } from "@/components/teams/team-icon-picker";
import { TeamIcon } from "@/components/teams/TeamIcon";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Trash2 } from "lucide-react";
export type DraftTeam = {
id: string;
name: string;
nickname: string | null;
icon: string | null;
logo_path: string | null;
};
export function CompetitionDraftPanel({
competitionId,
initialTeams,
}: {
competitionId: string;
initialTeams: DraftTeam[];
}) {
const router = useRouter();
const [teams, setTeams] = useState<DraftTeam[]>(initialTeams);
const [name, setName] = useState("");
const [nickname, setNickname] = useState("");
const [icon, setIcon] = useState("shield");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<
| { type: "activate" }
| { type: "fixtures" }
| { type: "delete-team"; team: DraftTeam }
| null
>(null);
const refreshTeams = useCallback(async () => {
const list = (await api.competitions.listTeams(competitionId)) as DraftTeam[];
setTeams(list);
}, [competitionId]);
async function run(fn: () => Promise<void>, refresh = true) {
setLoading(true);
setError(null);
try {
await fn();
if (refresh) {
await refreshTeams();
router.refresh();
}
} catch (e) {
setError(e instanceof Error ? e.message : "Error");
} finally {
setLoading(false);
setConfirmAction(null);
}
}
async function handleAddTeam(e: React.FormEvent) {
e.preventDefault();
if (loading) return;
const trimmed = name.trim();
if (!trimmed) return;
setLoading(true);
setError(null);
try {
const created = (await api.competitions.createTeam(competitionId, {
name: trimmed,
nickname: nickname.trim() || undefined,
icon,
})) as DraftTeam;
setTeams((prev) => [...prev, created]);
setName("");
setNickname("");
setIcon("shield");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add team");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
disabled={loading}
onClick={() => setConfirmAction({ type: "activate" })}
>
Activate competition
</Button>
<Button
disabled={loading}
onClick={() => setConfirmAction({ type: "fixtures" })}
>
Generate fixtures
</Button>
</div>
<GlassCard title="Add team">
<form onSubmit={handleAddTeam} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="team_name">Team name</Label>
<Input
id="team_name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="FC Example"
required
className="mt-1"
/>
</div>
<div>
<Label htmlFor="team_nickname">Nickname</Label>
<Input
id="team_nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="The Blues"
className="mt-1"
/>
</div>
</div>
<div>
<Label className="mb-2 block">Team icon</Label>
<TeamIconPicker value={icon} onChange={setIcon} />
</div>
<Button type="submit" disabled={loading}>
{loading ? "Adding…" : "Add team"}
</Button>
</form>
</GlassCard>
<GlassCard title={`Teams (${teams.length})`}>
{teams.length === 0 ? (
<p className="text-sm text-[var(--color-muted)]">
No teams yet. Add your first team above.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-[var(--color-muted)]">
<th className="pb-3 pr-4">Team</th>
<th className="pb-3 pr-4">Nickname</th>
<th className="pb-3 w-16" />
</tr>
</thead>
<tbody>
{teams.map((t) => (
<tr key={t.id} className="border-b border-white/5">
<td className="py-3 pr-4">
<div className="flex items-center gap-2 font-medium">
<TeamIcon
icon={t.icon}
className="h-5 w-5 text-cyan-400"
/>
{t.name}
</div>
</td>
<td className="py-3 pr-4 text-[var(--color-muted)]">
{t.nickname || "—"}
</td>
<td className="py-3 text-right">
<Button
type="button"
variant="ghost"
size="sm"
disabled={loading}
onClick={() =>
setConfirmAction({ type: "delete-team", team: t })
}
>
<Trash2 className="h-4 w-4 text-red-400" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</GlassCard>
<ConfirmDialog
open={confirmAction?.type === "activate"}
onOpenChange={(o) => !o && setConfirmAction(null)}
title="Activate competition?"
description="This snapshots league rules and opens the season. Teams cannot be added casually after activation without master access."
confirmLabel="Activate"
loading={loading}
variant="primary"
onConfirm={() =>
run(() => api.competitions.activate(competitionId), true)
}
/>
<ConfirmDialog
open={confirmAction?.type === "fixtures"}
onOpenChange={(o) => !o && setConfirmAction(null)}
title="Generate fixtures?"
description="This creates all matches for every team. Make sure your team list is final."
confirmLabel="Generate"
loading={loading}
variant="primary"
onConfirm={() =>
run(() => api.competitions.generateFixtures(competitionId), true)
}
/>
<ConfirmDialog
open={confirmAction?.type === "delete-team"}
onOpenChange={(o) => !o && setConfirmAction(null)}
title="Remove team?"
description={`Delete "${confirmAction?.type === "delete-team" ? confirmAction.team.name : ""}" from this competition? This cannot be undone.`}
confirmLabel="Delete team"
loading={loading}
onConfirm={() => {
if (confirmAction?.type !== "delete-team") return;
const id = confirmAction.team.id;
return run(async () => {
await api.teams.delete(id);
setTeams((prev) => prev.filter((t) => t.id !== id));
}, true);
}}
/>
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export function PageHeader({
title,
description,
actions,
tabs,
activeTab,
onTabChange,
}: {
title: string;
description?: string;
actions?: React.ReactNode;
tabs?: { id: string; label: string }[];
activeTab?: string;
onTabChange?: (id: string) => void;
}) {
return (
<div className="mb-6 space-y-4">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-display text-2xl font-black uppercase tracking-tight md:text-3xl">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
)}
</div>
{tabs && tabs.length > 0 && (
<Tabs
value={activeTab ?? tabs[0].id}
onValueChange={onTabChange}
className="w-full"
>
<TabsList>
{tabs.map((t) => (
<TabsTrigger key={t.id} value={t.id}>
{t.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export function StatCard({
title,
value,
subtitle,
icon: Icon,
trend,
trendLabel,
}: {
title: string;
value: string | number;
subtitle?: string;
icon: LucideIcon;
trend?: "up" | "down" | "neutral";
trendLabel?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="rounded-md border border-border bg-muted/50 p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tracking-tight">{value}</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{trend && trendLabel && (
<Badge
variant={
trend === "up"
? "success"
: trend === "down"
? "destructive"
: "secondary"
}
>
{trend === "up" ? "+" : trend === "down" ? "" : ""}
{trendLabel}
</Badge>
)}
{subtitle && (
<span className={cn("text-xs text-muted-foreground")}>
{subtitle}
</span>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { GlassCard } from "@/components/ui/glass-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/dashboard/page-header";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
type Issue = {
id: string;
subject: string;
body: string;
status: string;
master_reply: string | null;
created_at: string;
leagues: { name: string } | null;
};
export function IssuesPanel({
leagues,
asMaster,
}: {
leagues: { id: string; name: string }[];
asMaster: boolean;
}) {
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [pending, setPending] = useState<FormData | null>(null);
useEffect(() => {
api.issues.list(asMaster).then((d) => setIssues(d as Issue[]));
}, [asMaster]);
async function submit(fd: FormData) {
setLoading(true);
try {
await api.issues.create({
leagueId: fd.get("league_id") as string,
subject: fd.get("subject") as string,
body: fd.get("body") as string,
});
const updated = (await api.issues.list(asMaster)) as Issue[];
setIssues(updated);
} finally {
setLoading(false);
setShowConfirm(false);
setPending(null);
}
}
return (
<div className="space-y-6">
<PageHeader
title="Issues"
description={
asMaster
? "Messages from team managers"
: "Send problems to your league master"
}
/>
{!asMaster && (
<GlassCard title="New issue">
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (leagues.length === 0) return;
setPending(new FormData(e.currentTarget));
setShowConfirm(true);
}}
>
<div>
<Label>League</Label>
<select
name="league_id"
required
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
>
{leagues.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
<div>
<Label>Subject</Label>
<Input name="subject" required className="mt-1" />
</div>
<div>
<Label>Details</Label>
<textarea
name="body"
required
rows={4}
className="mt-1 flex w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm"
/>
</div>
<Button type="submit" disabled={loading || leagues.length === 0}>
Submit to league master
</Button>
</form>
</GlassCard>
)}
<GlassCard title={asMaster ? "All issues" : "Your issues"}>
<ul className="space-y-3">
{issues.map((issue) => (
<li
key={issue.id}
className="rounded-lg border border-white/10 p-3 text-sm"
>
<div className="flex justify-between gap-2">
<p className="font-medium">{issue.subject}</p>
<span className="text-xs capitalize text-[var(--color-muted)]">
{issue.status}
</span>
</div>
<p className="mt-1 text-xs text-cyan-400/70">
{issue.leagues?.name}
</p>
<p className="mt-2 text-[var(--color-muted)]">{issue.body}</p>
{issue.master_reply && (
<p className="mt-2 rounded-lg bg-white/5 p-2 text-foreground">
<span className="text-xs text-[var(--color-muted)]">Reply: </span>
{issue.master_reply}
</p>
)}
</li>
))}
{issues.length === 0 && (
<p className="text-[var(--color-muted)]">No issues</p>
)}
</ul>
</GlassCard>
<ConfirmDialog
open={showConfirm}
onOpenChange={setShowConfirm}
title="Send issue to league master?"
description="Your message will be visible to league masters for this league."
confirmLabel="Send"
variant="primary"
loading={loading}
onConfirm={() => pending && submit(pending)}
/>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { Sidebar } from "./Sidebar";
import { YaltopiaFooter } from "./YaltopiaFooter";
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<div className="flex flex-1">
<Sidebar />
<div className="flex flex-1 flex-col">
<main className="flex-1 p-6">{children}</main>
<YaltopiaFooter />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,156 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { YaltopiaFooter } from "./YaltopiaFooter";
import type { LucideIcon } from "lucide-react";
import {
Bell,
Search,
Settings,
Trophy,
LogOut,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
export type NavItem = { href: string; label: string; icon: LucideIcon };
export function DashboardShell({
brand,
navItems,
user,
children,
}: {
brand: string;
navItems: NavItem[];
user: { name: string; email: string };
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
async function signOut() {
const supabase = createClient();
await supabase.auth.signOut();
router.push("/");
router.refresh();
}
return (
<div className="retro-grid flex min-h-screen bg-background">
<aside className="hidden w-64 shrink-0 flex-col border-r border-border/80 bg-card/80 backdrop-blur-md md:flex">
<div className="flex h-14 items-center gap-2 border-b border-border/80 px-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary shadow-[0_0_20px_-4px_var(--neon)]">
<Trophy className="h-4 w-4 text-primary-foreground" />
</div>
<div className="min-w-0">
<p className="font-display truncate text-sm font-bold uppercase tracking-wide">
Yaltopia FIFA
</p>
<p className="truncate text-[10px] uppercase tracking-wider text-neon-muted">
{brand}
</p>
</div>
</div>
<nav className="flex-1 space-y-0.5 p-3">
<p className="font-display px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
Menu
</p>
{navItems.map(({ href, label, icon: Icon }) => {
const active =
pathname === href ||
(href !== "/" && pathname.startsWith(href + "/"));
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all",
active
? "border border-neon/25 bg-neon/10 text-foreground shadow-[inset_3px_0_0_0_var(--neon)]"
: "text-muted-foreground hover:bg-secondary/80 hover:text-foreground"
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0",
active ? "text-neon" : "opacity-70"
)}
/>
{label}
</Link>
);
})}
</nav>
<div className="border-t border-border/80 p-3">
<div className="flex items-center gap-3 rounded-xl px-2 py-2">
<Avatar className="h-9 w-9 border border-border">
<AvatarFallback className="bg-secondary text-xs font-bold">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="mt-1 w-full justify-start text-muted-foreground"
onClick={() => void signOut()}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-border/80 bg-background/80 px-4 backdrop-blur-md lg:px-6">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search…"
className="h-9 rounded-full border-border bg-muted/40 pl-9"
readOnly
/>
</div>
<div className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="icon" className="rounded-full text-muted-foreground">
<Bell className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="rounded-full text-muted-foreground">
<Settings className="h-4 w-4" />
</Button>
<div className="mx-1 h-6 w-px bg-border" aria-hidden />
<Avatar className="h-8 w-8 border border-border md:hidden">
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
</Avatar>
</div>
</header>
<main className="flex-1 p-4 lg:p-6">{children}</main>
<div className="border-t border-border/80 px-6 py-3">
<YaltopiaFooter />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import {
LayoutDashboard,
Trophy,
Medal,
BookOpen,
HelpCircle,
MessageSquare,
CalendarDays,
} from "lucide-react";
import { DashboardShell } from "./DashboardShell";
const nav = [
{ href: "/manager", label: "Dashboard", icon: LayoutDashboard },
{ href: "/manager/calendar", label: "Calendar", icon: CalendarDays },
{ href: "/manager/leagues", label: "Leagues", icon: Trophy },
{ href: "/manager/cups", label: "Cups", icon: Medal },
{ href: "/manager/rules", label: "Rules", icon: BookOpen },
{ href: "/manager/faq", label: "FAQ", icon: HelpCircle },
{ href: "/manager/issues", label: "Issues", icon: MessageSquare },
];
export function ManagerShell({
children,
user,
}: {
children: React.ReactNode;
user: { name: string; email: string };
}) {
return (
<DashboardShell brand="Team Manager" navItems={nav} user={user}>
{children}
</DashboardShell>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import {
LayoutDashboard,
Trophy,
Users,
Inbox,
CalendarDays,
} from "lucide-react";
import { DashboardShell } from "./DashboardShell";
const nav = [
{ href: "/master", label: "Dashboard", icon: LayoutDashboard },
{ href: "/master/calendar", label: "Calendar", icon: CalendarDays },
{ href: "/master/leagues", label: "Leagues", icon: Trophy },
{ href: "/master/players", label: "Players", icon: Users },
{ href: "/master/issues", label: "Issues", icon: Inbox },
];
export function MasterShell({
children,
user,
}: {
children: React.ReactNode;
user: { name: string; email: string };
}) {
return (
<DashboardShell brand="League Master" navItems={nav} user={user}>
{children}
</DashboardShell>
);
}

Some files were not shown because too many files have changed in this diff Show More