diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..54ec55a --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "supabase": { + "enabled": true + } + } +} diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..7527660 --- /dev/null +++ b/.dev.vars.example @@ -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 diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..946bd2c --- /dev/null +++ b/.env.local.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..54c6917 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +*.mjs text eol=lf +*.sql text eol=lf diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml new file mode 100644 index 0000000..468c7ea --- /dev/null +++ b/.github/workflows/deploy-cloudflare.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5bd586 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/actions/leagues.ts b/actions/leagues.ts new file mode 100644 index 0000000..8d26774 --- /dev/null +++ b/actions/leagues.ts @@ -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`); +} diff --git a/actions/matches.ts b/actions/matches.ts new file mode 100644 index 0000000..ad9d522 --- /dev/null +++ b/actions/matches.ts @@ -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"); +} diff --git a/actions/players.ts b/actions/players.ts new file mode 100644 index 0000000..6efca96 --- /dev/null +++ b/actions/players.ts @@ -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" + ); +} diff --git a/actions/teams.ts b/actions/teams.ts new file mode 100644 index 0000000..01f2fd6 --- /dev/null +++ b/actions/teams.ts @@ -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"); +} diff --git a/app/(auth)/forgot-password/manager/page.tsx b/app/(auth)/forgot-password/manager/page.tsx new file mode 100644 index 0000000..4290a98 --- /dev/null +++ b/app/(auth)/forgot-password/manager/page.tsx @@ -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 ( + <> + + + +
+ +
+ + ); +} diff --git a/app/(auth)/forgot-password/master/page.tsx b/app/(auth)/forgot-password/master/page.tsx new file mode 100644 index 0000000..c4ddf21 --- /dev/null +++ b/app/(auth)/forgot-password/master/page.tsx @@ -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 ( + <> + + + +
+ +
+ + ); +} diff --git a/app/(auth)/login/manager/page.tsx b/app/(auth)/login/manager/page.tsx new file mode 100644 index 0000000..1489e25 --- /dev/null +++ b/app/(auth)/login/manager/page.tsx @@ -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 ( + <> + + No account?{" "} + + Create account + + {" · "} + + Home + +

+ } + > + +
+
+ +
+ + ); +} diff --git a/app/(auth)/login/master/page.tsx b/app/(auth)/login/master/page.tsx new file mode 100644 index 0000000..765689b --- /dev/null +++ b/app/(auth)/login/master/page.tsx @@ -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 ( + <> + + + Create master account + +

+ } + > + +
+
+ +
+ + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..653be9c --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function LoginPage() { + redirect("/"); +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..130ecec --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -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

Loading…

; +} + +export default function ResetPasswordPage() { + return ( + <> + + }> + + + +
+ +
+ + ); +} diff --git a/app/(auth)/signup/manager/page.tsx b/app/(auth)/signup/manager/page.tsx new file mode 100644 index 0000000..65037af --- /dev/null +++ b/app/(auth)/signup/manager/page.tsx @@ -0,0 +1,11 @@ +import { SignupForm } from "@/components/auth/signup-form"; + +export default function ManagerSignupPage() { + return ( + + ); +} diff --git a/app/(auth)/signup/master/page.tsx b/app/(auth)/signup/master/page.tsx new file mode 100644 index 0000000..6491d18 --- /dev/null +++ b/app/(auth)/signup/master/page.tsx @@ -0,0 +1,11 @@ +import { SignupForm } from "@/components/auth/signup-form"; + +export default function MasterSignupPage() { + return ( + + ); +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..1e76709 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function SignupPage() { + redirect("/signup/manager"); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..4af9998 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,9 @@ +import { AppShell } from "@/components/layout/AppShell"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/admin/results/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/admin/results/page.tsx new file mode 100644 index 0000000..b66600e --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/admin/results/page.tsx @@ -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 ( +
+

Results admin

+

+ Approve or resolve match results as league manager +

+ + +
    + {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 ( +
  • + +
    + + vs + +
    + + {m.result_status} + + +
  • + ); + })} + {(!pending || pending.length === 0) && ( +

    No pending results

    + )} +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/fixtures/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/fixtures/page.tsx new file mode 100644 index 0000000..47d77ae --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/fixtures/page.tsx @@ -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 ( +
+

Fixtures

+ +
    + {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 ( +
  • + +
    + + + {m.status === "completed" + ? `${m.home_score} – ${m.away_score}` + : "vs"} + + +
    +
    + {m.matchday && MD {m.matchday} · } + {m.status.replace(/_/g, " ")} + {m.venue &&

    {m.venue}

    } +
    + +
  • + ); + })} +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/layout.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/layout.tsx new file mode 100644 index 0000000..201d7e5 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/layout.tsx @@ -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 ( +
+
+ +
+
{children}
+ +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/matches/[matchId]/match-actions.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/matches/[matchId]/match-actions.tsx new file mode 100644 index 0000000..27e7cd0 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/matches/[matchId]/match-actions.tsx @@ -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(null); + + async function run(fn: () => Promise) { + setLoading(true); + setError(null); + try { + await fn(); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error"); + } finally { + setLoading(false); + } + } + + return ( +
+ {error &&

{error}

} + + {userTeamId && status !== "completed" && ( + + {proposedAt && ( +

+ Proposed: {new Date(proposedAt).toLocaleString()} +

+ )} +
{ + 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() + ); + }); + }} + > +
+ + +
+ +
+ +
+ )} + + {userTeamId && status !== "completed" && ( + +
{ + 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")), + }) + ); + }} + > +
+ + +
+
+ + +
+ +
+
+ )} + + {isLeagueManager && resultStatus === "pending_approval" && ( + + + + )} + + {isLeagueManager && ( + +
{ + 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, + }) + ); + }} + > + + + + +
+
+ )} +
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/matches/[matchId]/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/matches/[matchId]/page.tsx new file mode 100644 index 0000000..6899fb0 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/matches/[matchId]/page.tsx @@ -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 ( +
+ +
+ +
+

+ {match.status === "completed" + ? `${match.home_score} – ${match.away_score}` + : "vs"} +

+

+ {match.status.replace(/_/g, " ")} +

+ {match.venue && ( +

{match.venue}

+ )} +
+ +
+
+ +
+ +
    + {signatures?.map((s) => ( +
  • + ✓ {(s.teams as { name: string })?.name} signed +
  • + ))} + {(!signatures || signatures.length === 0) && ( +
  • No signatures yet
  • + )} +
+
+ + +
    + {submissions?.map((s) => ( +
  • + {(s.teams as { name: string })?.name}: {s.home_score}–{s.away_score} +
  • + ))} + {(!submissions || submissions.length === 0) && ( +
  • No submissions yet
  • + )} +
+
+
+ + +
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/my-team/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/my-team/page.tsx new file mode 100644 index 0000000..d343da3 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/my-team/page.tsx @@ -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 ( +

+ You are not a team manager in this competition. +

+ ); + } + + 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 ( +
+ + + + +
+ d.value > 0)} /> + + + +
+ + + + + + + + + + + + + + {playerStats?.map((p) => ( + + + + + + + + ))} + +
PlayerAppsGAG+A
{p.player_name}{p.appearances}{p.goals}{p.assists} + {p.goals + p.assists} +
+
+ + +
    + {results + ?.slice() + .reverse() + .slice(0, 5) + .map((r) => ( +
  • + vs {r.opponent_name} + + {r.goals_for}–{r.goals_against}{" "} + + {r.result} + + +
  • + ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/page.tsx new file mode 100644 index 0000000..9ddd800 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/page.tsx @@ -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 ( +
+
+

{competition.name}

+
+ + {competition.status === "draft" && ( + + )} + + {competition.tournament_mode === "league" && standings && standings.length > 0 && ( + + + + )} + + +
    + {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 ( +
  • + +
    + + vs + +
    + + {m.status.replace(/_/g, " ")} + + +
  • + ); + })} + {(!upcoming || upcoming.length === 0) && ( +

    No upcoming fixtures

    + )} +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/playoffs/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/playoffs/page.tsx new file mode 100644 index 0000000..3eb18d1 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/playoffs/page.tsx @@ -0,0 +1,17 @@ +import { GlassCard } from "@/components/ui/glass-card"; + +export default function PlayoffsPage() { + return ( +
+

Champions League playoffs

+ +

+ End-of-season qualification playoffs activate when the competition + status is set to playoffs{" "} + after all league matches are completed. Top teams auto-qualify per + league rules; remaining teams play in for the final CL spots. +

+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/teams/[teamId]/settings/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/teams/[teamId]/settings/page.tsx new file mode 100644 index 0000000..b230017 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/teams/[teamId]/settings/page.tsx @@ -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 ( +
+

Team settings — {team.name}

+ +
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/teams/[teamId]/settings/team-settings-form.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/teams/[teamId]/settings/team-settings-form.tsx new file mode 100644 index 0000000..7445d07 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/teams/[teamId]/settings/team-settings-form.tsx @@ -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( + [...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) { + 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 ( +
+ + + + + +
+ setStadium(e.target.value)} + placeholder="Arena name" + className="flex-1" + /> + +
+
+ + +
+ {DAY_NAMES.map((name, i) => ( + + ))} +
+ +
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/transfers/page.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/transfers/page.tsx new file mode 100644 index 0000000..8bae7a0 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/transfers/page.tsx @@ -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 ( +
+

Transfers

+ + +
    + {transfers?.map((t) => ( +
  • + + {(t.player as { display_name: string })?.display_name} + {" "} + {(t.from_team as { name: string })?.name} →{" "} + {(t.to_team as { name: string })?.name} +
  • + ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/transfers/transfers-panel.tsx b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/transfers/transfers-panel.tsx new file mode 100644 index 0000000..a58ba05 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/competitions/[competitionId]/transfers/transfers-panel.tsx @@ -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 ( +
+ +
{ + 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(); + }} + > +
+ + +
+
+ + +
+ +
+
+ + +
{ + 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(); + }} + > +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/page.tsx b/app/(dashboard)/leagues/[leagueId]/page.tsx new file mode 100644 index 0000000..7e07ce6 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/page.tsx @@ -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 ( +
+
+
+

{league.name}

+

{league.description}

+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ {competitions?.map((c) => ( + + +
+
+

{c.name}

+

+ {c.tournament_mode} · {c.status} +

+
+ + {c.status} + +
+
+ + ))} +
+
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/rules/page.tsx b/app/(dashboard)/leagues/[leagueId]/rules/page.tsx new file mode 100644 index 0000000..54e0fe7 --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/rules/page.tsx @@ -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 ( +
+
+

League rules

+

+ {league.name} · version {latest?.version ?? 0} +

+
+ +
+ ); +} diff --git a/app/(dashboard)/leagues/[leagueId]/rules/rules-form.tsx b/app/(dashboard)/leagues/[leagueId]/rules/rules-form.tsx new file mode 100644 index 0000000..4e6f23b --- /dev/null +++ b/app/(dashboard)/leagues/[leagueId]/rules/rules-form.tsx @@ -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 ( + +
+
+ + + setRules({ ...rules, points_win: Number(e.target.value) }) + } + className="mt-1" + /> +
+
+ + + setRules({ ...rules, points_draw: Number(e.target.value) }) + } + className="mt-1" + /> +
+
+ + +
+
+ + + setRules({ + ...rules, + auto_qualify_count: Number(e.target.value), + }) + } + className="mt-1" + /> +
+
+ +
+ ); +} diff --git a/app/(dashboard)/leagues/page.tsx b/app/(dashboard)/leagues/page.tsx new file mode 100644 index 0000000..216292b --- /dev/null +++ b/app/(dashboard)/leagues/page.tsx @@ -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 ( +
+
+
+

Leagues

+

+ Create and manage tournament leagues +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ {leagues?.map((league) => ( + + +
+
+ +
+
+

{league.name}

+

+ {league.description || "No description"} +

+
+
+
+ + ))} + {(!leagues || leagues.length === 0) && ( +

+ No leagues yet. Create your first league above. +

+ )} +
+
+ ); +} diff --git a/app/(dashboard)/players/page.tsx b/app/(dashboard)/players/page.tsx new file mode 100644 index 0000000..5c818d6 --- /dev/null +++ b/app/(dashboard)/players/page.tsx @@ -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 ( +
+
+

Player registry

+

+ Load players before roster adds or transfers +

+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+ + + + + + + + + + + {players?.map((p) => ( + + + + + + + ))} + +
NameExternal IDStatusActions
{p.display_name} + {p.external_id || "—"} + + + {p.status} + + +
+ +
+
+
+
+
+ ); +} diff --git a/app/(manager)/layout.tsx b/app/(manager)/layout.tsx new file mode 100644 index 0000000..f2669bf --- /dev/null +++ b/app/(manager)/layout.tsx @@ -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 {children}; +} diff --git a/app/(manager)/manager/calendar/page.tsx b/app/(manager)/manager/calendar/page.tsx new file mode 100644 index 0000000..b7da6ae --- /dev/null +++ b/app/(manager)/manager/calendar/page.tsx @@ -0,0 +1,11 @@ +import { MatchCalendar } from "@/components/calendar/match-calendar"; + +export default function ManagerCalendarPage() { + return ( + + ); +} diff --git a/app/(manager)/manager/cups/page.tsx b/app/(manager)/manager/cups/page.tsx new file mode 100644 index 0000000..4d315a6 --- /dev/null +++ b/app/(manager)/manager/cups/page.tsx @@ -0,0 +1,11 @@ +import { ManagerCompetitionsTable } from "@/components/manager/manager-competitions-table"; + +export default function ManagerCupsPage() { + return ( + + ); +} diff --git a/app/(manager)/manager/faq/page.tsx b/app/(manager)/manager/faq/page.tsx new file mode 100644 index 0000000..4844bd8 --- /dev/null +++ b/app/(manager)/manager/faq/page.tsx @@ -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 ( +
+ +
+ {MANAGER_FAQ.map((item) => ( + +

{item.a}

+
+ ))} +
+
+ ); +} diff --git a/app/(manager)/manager/issues/page.tsx b/app/(manager)/manager/issues/page.tsx new file mode 100644 index 0000000..c683371 --- /dev/null +++ b/app/(manager)/manager/issues/page.tsx @@ -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(); + 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 ; +} diff --git a/app/(manager)/manager/leagues/page.tsx b/app/(manager)/manager/leagues/page.tsx new file mode 100644 index 0000000..d7e2b9a --- /dev/null +++ b/app/(manager)/manager/leagues/page.tsx @@ -0,0 +1,11 @@ +import { ManagerCompetitionsTable } from "@/components/manager/manager-competitions-table"; + +export default function ManagerLeaguesPage() { + return ( + + ); +} diff --git a/app/(manager)/manager/page.tsx b/app/(manager)/manager/page.tsx new file mode 100644 index 0000000..dfa8bbc --- /dev/null +++ b/app/(manager)/manager/page.tsx @@ -0,0 +1,5 @@ +import { ManagerDashboardClient } from "@/components/manager/manager-dashboard-client"; + +export default function ManagerDashboardPage() { + return ; +} diff --git a/app/(manager)/manager/rules/page.tsx b/app/(manager)/manager/rules/page.tsx new file mode 100644 index 0000000..e7d9043 --- /dev/null +++ b/app/(manager)/manager/rules/page.tsx @@ -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(); + const leagueNames = new Map(); + + 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 ( +
+ + {rulesBlocks.length === 0 ? ( + +

+ No league rules available yet. You need to be assigned as a team manager. +

+
+ ) : ( + rulesBlocks.map((block) => ( + +
+
+
Win points
+
{block.rules.points_win}
+
+
+
Draw points
+
{block.rules.points_draw}
+
+
+
Format
+
{block.rules.round_robin_format}
+
+
+
Auto-qualify (CL)
+
{block.rules.auto_qualify_count}
+
+
+ + View full rules page + +
+ )) + )} +
+ ); +} diff --git a/app/(master)/layout.tsx b/app/(master)/layout.tsx new file mode 100644 index 0000000..3540518 --- /dev/null +++ b/app/(master)/layout.tsx @@ -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 {children}; +} diff --git a/app/(master)/master/calendar/page.tsx b/app/(master)/master/calendar/page.tsx new file mode 100644 index 0000000..b2b007f --- /dev/null +++ b/app/(master)/master/calendar/page.tsx @@ -0,0 +1,11 @@ +import { MatchCalendar } from "@/components/calendar/match-calendar"; + +export default function MasterCalendarPage() { + return ( + + ); +} diff --git a/app/(master)/master/issues/page.tsx b/app/(master)/master/issues/page.tsx new file mode 100644 index 0000000..33a3ec7 --- /dev/null +++ b/app/(master)/master/issues/page.tsx @@ -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 ( + + ); +} diff --git a/app/(master)/master/leagues/[leagueId]/competitions/new/page.tsx b/app/(master)/master/leagues/[leagueId]/competitions/new/page.tsx new file mode 100644 index 0000000..cc51193 --- /dev/null +++ b/app/(master)/master/leagues/[leagueId]/competitions/new/page.tsx @@ -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(null); + + return ( +
+ + +
{ + 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); + } + }} + > +
+ + +
+
+ + +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/app/(master)/master/leagues/[leagueId]/page.tsx b/app/(master)/master/leagues/[leagueId]/page.tsx new file mode 100644 index 0000000..b3e0501 --- /dev/null +++ b/app/(master)/master/leagues/[leagueId]/page.tsx @@ -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 ( +
+ + Edit rules + + } + /> + + +
    + {league.competitions?.map( + (c: { id: string; name: string; status: string; tournament_mode: string }) => ( +
  • + + {c.name} + + {c.tournament_mode} · {c.status} + + +
  • + ) + )} + {(!league.competitions || league.competitions.length === 0) && ( +

    No competitions yet

    + )} +
+ +
+
+ ); +} diff --git a/app/(master)/master/leagues/page.tsx b/app/(master)/master/leagues/page.tsx new file mode 100644 index 0000000..12681e7 --- /dev/null +++ b/app/(master)/master/leagues/page.tsx @@ -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 ; +} diff --git a/app/(master)/master/page.tsx b/app/(master)/master/page.tsx new file mode 100644 index 0000000..26373bc --- /dev/null +++ b/app/(master)/master/page.tsx @@ -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 ( +
+ + Manage leagues + + } + /> + +
+ + + + +
+ +
+ + + +
    + {openIssues?.map((issue) => { + const league = issue.leagues as { name: string } | null; + return ( +
  • +

    {issue.subject}

    +

    + {league?.name} · {new Date(issue.created_at).toLocaleDateString()} +

    +
  • + ); + })} + {(!openIssues || openIssues.length === 0) && ( +

    No open issues

    + )} +
+ + View all issues + +
+
+
+ ); +} diff --git a/app/(master)/master/players/page.tsx b/app/(master)/master/players/page.tsx new file mode 100644 index 0000000..e137b4b --- /dev/null +++ b/app/(master)/master/players/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..22c5a0a --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -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 } + ); + } +} diff --git a/app/api/competitions/[competitionId]/activate/route.ts b/app/api/competitions/[competitionId]/activate/route.ts new file mode 100644 index 0000000..791d2ff --- /dev/null +++ b/app/api/competitions/[competitionId]/activate/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/dashboard/route.ts b/app/api/competitions/[competitionId]/dashboard/route.ts new file mode 100644 index 0000000..eecdc69 --- /dev/null +++ b/app/api/competitions/[competitionId]/dashboard/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/fixtures/route.ts b/app/api/competitions/[competitionId]/fixtures/route.ts new file mode 100644 index 0000000..1d736f4 --- /dev/null +++ b/app/api/competitions/[competitionId]/fixtures/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/matches/route.ts b/app/api/competitions/[competitionId]/matches/route.ts new file mode 100644 index 0000000..995fca7 --- /dev/null +++ b/app/api/competitions/[competitionId]/matches/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/pending-results/route.ts b/app/api/competitions/[competitionId]/pending-results/route.ts new file mode 100644 index 0000000..7d05188 --- /dev/null +++ b/app/api/competitions/[competitionId]/pending-results/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/roster/route.ts b/app/api/competitions/[competitionId]/roster/route.ts new file mode 100644 index 0000000..42b15fa --- /dev/null +++ b/app/api/competitions/[competitionId]/roster/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/route.ts b/app/api/competitions/[competitionId]/route.ts new file mode 100644 index 0000000..0a06a61 --- /dev/null +++ b/app/api/competitions/[competitionId]/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/standings/route.ts b/app/api/competitions/[competitionId]/standings/route.ts new file mode 100644 index 0000000..2c2507c --- /dev/null +++ b/app/api/competitions/[competitionId]/standings/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/teams/route.ts b/app/api/competitions/[competitionId]/teams/route.ts new file mode 100644 index 0000000..368d6a5 --- /dev/null +++ b/app/api/competitions/[competitionId]/teams/route.ts @@ -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); + } +} diff --git a/app/api/competitions/[competitionId]/transfers/route.ts b/app/api/competitions/[competitionId]/transfers/route.ts new file mode 100644 index 0000000..efaabaf --- /dev/null +++ b/app/api/competitions/[competitionId]/transfers/route.ts @@ -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); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..3123155 --- /dev/null +++ b/app/api/health/route.ts @@ -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); + } +} diff --git a/app/api/issues/route.ts b/app/api/issues/route.ts new file mode 100644 index 0000000..b6011fa --- /dev/null +++ b/app/api/issues/route.ts @@ -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); + } +} diff --git a/app/api/leagues/[leagueId]/competitions/route.ts b/app/api/leagues/[leagueId]/competitions/route.ts new file mode 100644 index 0000000..006b5ca --- /dev/null +++ b/app/api/leagues/[leagueId]/competitions/route.ts @@ -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); + } +} diff --git a/app/api/leagues/[leagueId]/masters/route.ts b/app/api/leagues/[leagueId]/masters/route.ts new file mode 100644 index 0000000..46d2a1b --- /dev/null +++ b/app/api/leagues/[leagueId]/masters/route.ts @@ -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); + } +} diff --git a/app/api/leagues/[leagueId]/route.ts b/app/api/leagues/[leagueId]/route.ts new file mode 100644 index 0000000..f7da814 --- /dev/null +++ b/app/api/leagues/[leagueId]/route.ts @@ -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); + } +} diff --git a/app/api/leagues/[leagueId]/rules/route.ts b/app/api/leagues/[leagueId]/rules/route.ts new file mode 100644 index 0000000..fcd6d10 --- /dev/null +++ b/app/api/leagues/[leagueId]/rules/route.ts @@ -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); + } +} diff --git a/app/api/leagues/route.ts b/app/api/leagues/route.ts new file mode 100644 index 0000000..f65aeaf --- /dev/null +++ b/app/api/leagues/route.ts @@ -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); + } +} diff --git a/app/api/manager/calendar/route.ts b/app/api/manager/calendar/route.ts new file mode 100644 index 0000000..b56a332 --- /dev/null +++ b/app/api/manager/calendar/route.ts @@ -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); + } +} diff --git a/app/api/manager/competitions/route.ts b/app/api/manager/competitions/route.ts new file mode 100644 index 0000000..afa54a0 --- /dev/null +++ b/app/api/manager/competitions/route.ts @@ -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); + } +} diff --git a/app/api/manager/dashboard/route.ts b/app/api/manager/dashboard/route.ts new file mode 100644 index 0000000..474378b --- /dev/null +++ b/app/api/manager/dashboard/route.ts @@ -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); + } +} diff --git a/app/api/master/calendar/route.ts b/app/api/master/calendar/route.ts new file mode 100644 index 0000000..c9ceff7 --- /dev/null +++ b/app/api/master/calendar/route.ts @@ -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); + } +} diff --git a/app/api/matches/[matchId]/results/route.ts b/app/api/matches/[matchId]/results/route.ts new file mode 100644 index 0000000..2d566a9 --- /dev/null +++ b/app/api/matches/[matchId]/results/route.ts @@ -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); + } +} diff --git a/app/api/matches/[matchId]/route.ts b/app/api/matches/[matchId]/route.ts new file mode 100644 index 0000000..82c9052 --- /dev/null +++ b/app/api/matches/[matchId]/route.ts @@ -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); + } +} diff --git a/app/api/matches/[matchId]/schedule/route.ts b/app/api/matches/[matchId]/schedule/route.ts new file mode 100644 index 0000000..3bbaf92 --- /dev/null +++ b/app/api/matches/[matchId]/schedule/route.ts @@ -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); + } +} diff --git a/app/api/players/[playerId]/route.ts b/app/api/players/[playerId]/route.ts new file mode 100644 index 0000000..3d6ea8f --- /dev/null +++ b/app/api/players/[playerId]/route.ts @@ -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); + } +} diff --git a/app/api/players/route.ts b/app/api/players/route.ts new file mode 100644 index 0000000..57f6aba --- /dev/null +++ b/app/api/players/route.ts @@ -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); + } +} diff --git a/app/api/teams/[teamId]/availability/route.ts b/app/api/teams/[teamId]/availability/route.ts new file mode 100644 index 0000000..6d1018f --- /dev/null +++ b/app/api/teams/[teamId]/availability/route.ts @@ -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); + } +} diff --git a/app/api/teams/[teamId]/route.ts b/app/api/teams/[teamId]/route.ts new file mode 100644 index 0000000..65574ed --- /dev/null +++ b/app/api/teams/[teamId]/route.ts @@ -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); + } +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..ab434cf --- /dev/null +++ b/app/auth/callback/route.ts @@ -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}`); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..fc3e3b3 --- /dev/null +++ b/app/globals.css @@ -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); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..b9ba9c3 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..64472f3 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+
+
+
+ +
+

+ Yaltopia · FIFA +

+

+ Tournament OS +

+

+ Retro-grade fixtures, standings, and squad tools — built for team + managers who want the hype without the clutter. +

+
+ + + + + Team Manager + + + Sign in to view leagues, cups, your match calendar, and submit + issues. + + + + +

+ New manager?{" "} + + Create account + +

+
+
+ +
+ + Match calendar inside the manager portal +
+
+ +
+ +
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..d06a9ae --- /dev/null +++ b/components.json @@ -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" + } +} diff --git a/components/auth/forgot-password-form.tsx b/components/auth/forgot-password-form.tsx new file mode 100644 index 0000000..7d100a6 --- /dev/null +++ b/components/auth/forgot-password-form.tsx @@ -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(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 ( +
+

+ If an account exists for{" "} + {email}, we sent a reset + link. Check your inbox and spam folder. +

+

+ The link opens a page to set a new password, then signs you into the{" "} + {portalLabel} portal. +

+

+ Did not get it? Wait at least an hour (Supabase email limits) before + trying again, or use Authentication → Users in the Supabase dashboard. +

+ +
+ ); + } + + return ( +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + placeholder="you@example.com" + /> +
+ {error && ( +

{error}

+ )} + {cooldownMs > 0 && !error && ( +

+ You can request another email in{" "} + {formatCooldownSeconds(cooldownMs)}s. +

+ )} + +

+ + Back to sign in + +

+
+ ); +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..a271b92 --- /dev/null +++ b/components/auth/login-form.tsx @@ -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(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 ( +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ {error && ( +

{error}

+ )} + +

+ + Forgot password? + +

+
+ ); +} + +export function LoginPageShell({ + title, + subtitle, + children, + footer, +}: { + title: string; + subtitle: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+
+

+ Yaltopia FIFA +

+

+ {title} +

+

{subtitle}

+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/components/auth/reset-password-form.tsx b/components/auth/reset-password-form.tsx new file mode 100644 index 0000000..637e78a --- /dev/null +++ b/components/auth/reset-password-form.tsx @@ -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(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 ( +
+

Password updated successfully.

+

Redirecting to your dashboard…

+
+ ); + } + + return ( +
+
+ + setPassword(e.target.value)} + required + minLength={6} + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + required + minLength={6} + autoComplete="new-password" + /> +
+ {error && ( +

{error}

+ )} + +

+ + Back to sign in + +

+
+ ); +} diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx new file mode 100644 index 0000000..45981bd --- /dev/null +++ b/components/auth/signup-form.tsx @@ -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(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 ( +
+ + +

+ Yaltopia FIFA +

+ + {title} + + {description} +
+ +
+
+ + setDisplayName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + required + minLength={6} + autoComplete="new-password" + /> +
+ {error && ( +

{error}

+ )} + +
+

+ + Sign in + + {portalRole === "manager" && ( + <> + {" · "} + + Home + + + )} +

+
+
+
+ ); +} diff --git a/components/calendar/match-calendar.tsx b/components/calendar/match-calendar.tsx new file mode 100644 index 0000000..3edb2c7 --- /dev/null +++ b/components/calendar/match-calendar.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const from = startOfMonth(month).toISOString(); + const to = endOfMonth(month).toISOString(); + const data = await apiFetch( + `${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(); + 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 ( +
+
+

+ Schedule +

+

+ {title} +

+

{description}

+
+ +
+
+
+
+ +

+ {formatMonthYear(month)} +

+
+
+ + + +
+
+ + {loading && ( +
+ + Loading fixtures… +
+ )} + + {error && !loading && ( +

{error}

+ )} + + {!loading && !error && ( + <> +
+ {WEEKDAYS.map((d) => ( +
+ {d} +
+ ))} +
+ +
+ {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 ( + + ); + })} +
+ +

+ {monthMatchCount}{" "} + scheduled in {formatMonthYear(month)} + {unscheduled.length > 0 && ( + <> + {" · "} + {unscheduled.length} awaiting + date + + )} +

+ + )} +
+ +
+
+

+ {selectedDate.toLocaleDateString(undefined, { + weekday: "long", + month: "short", + day: "numeric", + })} +

+

+ {selectedMatches.length === 0 ? "No matches" : `${selectedMatches.length} match${selectedMatches.length > 1 ? "es" : ""}`} +

+ +
    + {selectedMatches.length === 0 && ( +
  • + Pick a glowing day or schedule a fixture from your league. +
  • + )} + {selectedMatches.map((m) => ( + + ))} +
+
+ + {unscheduled.length > 0 && ( +
+
+ +

+ Date TBD +

+
+
    + {unscheduled.map((m) => ( + + ))} +
+
+ )} +
+
+
+ ); +} + +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 ( +
  • + +
    + + {match.status.replace(/_/g, " ")} + + {!compact && ( + {match.league_name} + )} +
    +

    + {match.home_name}{" "} + vs {match.away_name} +

    +

    {match.competition_name}

    +

    {matchDisplayDate(when)}

    + +
  • + ); +} diff --git a/components/competitions/competition-draft-panel.tsx b/components/competitions/competition-draft-panel.tsx new file mode 100644 index 0000000..5efe9f0 --- /dev/null +++ b/components/competitions/competition-draft-panel.tsx @@ -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(initialTeams); + const [name, setName] = useState(""); + const [nickname, setNickname] = useState(""); + const [icon, setIcon] = useState("shield"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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, 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 ( +
    + {error &&

    {error}

    } + +
    + + +
    + + +
    +
    +
    + + setName(e.target.value)} + placeholder="FC Example" + required + className="mt-1" + /> +
    +
    + + setNickname(e.target.value)} + placeholder="The Blues" + className="mt-1" + /> +
    +
    +
    + + +
    + +
    +
    + + + {teams.length === 0 ? ( +

    + No teams yet. Add your first team above. +

    + ) : ( +
    + + + + + + + + + {teams.map((t) => ( + + + + + + ))} + +
    TeamNickname +
    +
    + + {t.name} +
    +
    + {t.nickname || "—"} + + +
    +
    + )} +
    + + !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) + } + /> + + !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) + } + /> + + !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); + }} + /> +
    + ); +} diff --git a/components/dashboard/page-header.tsx b/components/dashboard/page-header.tsx new file mode 100644 index 0000000..74b37d0 --- /dev/null +++ b/components/dashboard/page-header.tsx @@ -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 ( +
    +
    +
    +

    + {title} +

    + {description && ( +

    {description}

    + )} +
    + {actions && ( +
    {actions}
    + )} +
    + {tabs && tabs.length > 0 && ( + + + {tabs.map((t) => ( + + {t.label} + + ))} + + + )} +
    + ); +} diff --git a/components/dashboard/stat-card.tsx b/components/dashboard/stat-card.tsx new file mode 100644 index 0000000..910838c --- /dev/null +++ b/components/dashboard/stat-card.tsx @@ -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 ( + + +

    {title}

    +
    + +
    +
    + +
    {value}
    +
    + {trend && trendLabel && ( + + {trend === "up" ? "+" : trend === "down" ? "−" : ""} + {trendLabel} + + )} + {subtitle && ( + + {subtitle} + + )} +
    +
    +
    + ); +} diff --git a/components/issues/issues-panel.tsx b/components/issues/issues-panel.tsx new file mode 100644 index 0000000..e307a3c --- /dev/null +++ b/components/issues/issues-panel.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [pending, setPending] = useState(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 ( +
    + + + {!asMaster && ( + +
    { + e.preventDefault(); + if (leagues.length === 0) return; + setPending(new FormData(e.currentTarget)); + setShowConfirm(true); + }} + > +
    + + +
    +
    + + +
    +
    + +