This commit is contained in:
parent
feaa5f142a
commit
89440985f1
7
.cursor/settings.json
Normal file
7
.cursor/settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"plugins": {
|
||||
"supabase": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
8
.dev.vars.example
Normal file
8
.dev.vars.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Copy to .dev.vars for local `npm run preview` (Workers runtime)
|
||||
# Do not commit .dev.vars
|
||||
|
||||
NEXTJS_ENV=development
|
||||
|
||||
# Optional: mirror .env.local for preview (NEXT_PUBLIC_* are inlined at build time)
|
||||
# NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
# NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
17
.env.local.example
Normal file
17
.env.local.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
NEXT_PUBLIC_SUPABASE_URL=https://vcxpcyafnlyiyqmapyyy.supabase.co
|
||||
|
||||
# Use EITHER publishable (sb_publishable_...) OR legacy anon JWT (eyJ...)
|
||||
# Dashboard → Project Settings → API
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-or-publishable-key
|
||||
|
||||
# Auth: Dashboard → Authentication → URL configuration
|
||||
# Site URL: http://localhost:3000
|
||||
# Redirect URLs (include password reset):
|
||||
# http://localhost:3000/**
|
||||
# http://localhost:3000/auth/callback
|
||||
# http://localhost:3000/reset-password
|
||||
|
||||
# For CLI migrations (IPv4 pooler — required if db push fails with IPv6 error)
|
||||
# Dashboard → Connect → Session mode (port 5432)
|
||||
SUPABASE_DB_PASSWORD=your-database-password
|
||||
SUPABASE_DB_URL=postgresql://postgres.vcxpcyafnlyiyqmapyyy:YOUR_PASSWORD@aws-0-YOUR_REGION.pooler.supabase.com:5432/postgres
|
||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
*.sh text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.sql text eol=lf
|
||||
29
.github/workflows/deploy-cloudflare.yml
vendored
Normal file
29
.github/workflows/deploy-cloudflare.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Deploy to Cloudflare Workers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Build and deploy
|
||||
run: npm run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# supabase CLI (keep project-ref for linked remote)
|
||||
supabase/.temp/*
|
||||
!supabase/.temp/project-ref
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# cloudflare / opennext
|
||||
.open-next
|
||||
.dev.vars
|
||||
.wrangler
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
77
actions/leagues.ts
Normal file
77
actions/leagues.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
import * as teams from "@/lib/services/teams";
|
||||
|
||||
export async function createLeague(formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
const league = await leagues.createLeague(supabase, user.id, {
|
||||
name: formData.get("name") as string,
|
||||
description: (formData.get("description") as string) || undefined,
|
||||
});
|
||||
|
||||
revalidatePath("/leagues");
|
||||
return league;
|
||||
}
|
||||
|
||||
export async function createCompetition(leagueId: string, formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
const data = await leagues.createCompetition(supabase, user.id, leagueId, {
|
||||
name: formData.get("name") as string,
|
||||
tournament_mode: formData.get("tournament_mode") as "league" | "cup",
|
||||
timezone: (formData.get("timezone") as string) || "UTC",
|
||||
});
|
||||
|
||||
revalidatePath(`/leagues/${leagueId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function activateCompetition(competitionId: string) {
|
||||
const supabase = await createClient();
|
||||
await leagues.activateCompetition(supabase, competitionId);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function generateFixtures(competitionId: string, mode: string) {
|
||||
const supabase = await createClient();
|
||||
await leagues.generateFixtures(
|
||||
supabase,
|
||||
competitionId,
|
||||
mode as "league" | "cup"
|
||||
);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function createTeam(competitionId: string, formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
const data = await teams.createTeam(supabase, competitionId, {
|
||||
name: formData.get("name") as string,
|
||||
nickname: (formData.get("nickname") as string) || undefined,
|
||||
icon: (formData.get("icon") as string) || undefined,
|
||||
});
|
||||
revalidatePath("/leagues");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function saveLeagueRules(leagueId: string, rules: object) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
await leagues.saveLeagueRules(supabase, user.id, leagueId, rules);
|
||||
revalidatePath(`/leagues/${leagueId}/rules`);
|
||||
}
|
||||
51
actions/matches.ts
Normal file
51
actions/matches.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import * as matches from "@/lib/services/matches";
|
||||
|
||||
export async function proposeSchedule(matchId: string, scheduledAt: string) {
|
||||
const supabase = await createClient();
|
||||
await matches.proposeSchedule(supabase, matchId, scheduledAt);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function signSchedule(matchId: string, teamId: string) {
|
||||
const supabase = await createClient();
|
||||
await matches.signSchedule(supabase, matchId, teamId);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function submitResult(
|
||||
matchId: string,
|
||||
teamId: string,
|
||||
homeScore: number,
|
||||
awayScore: number
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
await matches.submitResult(supabase, matchId, teamId, homeScore, awayScore);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function approveResult(matchId: string) {
|
||||
const supabase = await createClient();
|
||||
await matches.approveResult(supabase, matchId);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function setResultByManager(
|
||||
matchId: string,
|
||||
homeScore: number,
|
||||
awayScore: number,
|
||||
note?: string
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
await matches.setResultByManager(
|
||||
supabase,
|
||||
matchId,
|
||||
homeScore,
|
||||
awayScore,
|
||||
note
|
||||
);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
39
actions/players.ts
Normal file
39
actions/players.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import * as players from "@/lib/services/players";
|
||||
|
||||
export async function createPlayer(formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
await players.createPlayer(supabase, user.id, {
|
||||
display_name: formData.get("display_name") as string,
|
||||
external_id: (formData.get("external_id") as string) || undefined,
|
||||
});
|
||||
|
||||
revalidatePath("/players");
|
||||
}
|
||||
|
||||
export async function updatePlayerStatus(
|
||||
playerId: string,
|
||||
status: "active" | "inactive"
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
await players.updatePlayerStatus(supabase, playerId, status);
|
||||
revalidatePath("/players");
|
||||
}
|
||||
|
||||
export async function togglePlayerStatus(
|
||||
playerId: string,
|
||||
currentStatus: string
|
||||
) {
|
||||
await updatePlayerStatus(
|
||||
playerId,
|
||||
currentStatus === "active" ? "inactive" : "active"
|
||||
);
|
||||
}
|
||||
64
actions/teams.ts
Normal file
64
actions/teams.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import * as teams from "@/lib/services/teams";
|
||||
import * as players from "@/lib/services/players";
|
||||
|
||||
export async function updateTeamProfile(
|
||||
teamId: string,
|
||||
data: { home_stadium_name?: string; logo_path?: string }
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
await teams.updateTeam(supabase, teamId, data);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function setTeamAvailability(
|
||||
teamId: string,
|
||||
windows: { day_of_week: number; start_time?: string; end_time?: string }[]
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
await teams.setAvailability(supabase, teamId, windows);
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function addToRoster(
|
||||
teamId: string,
|
||||
competitionId: string,
|
||||
playerId: string
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
await players.addToRoster(supabase, user.id, {
|
||||
teamId,
|
||||
competitionId,
|
||||
playerId,
|
||||
});
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
|
||||
export async function registerTransfer(
|
||||
competitionId: string,
|
||||
playerId: string,
|
||||
fromTeamId: string,
|
||||
toTeamId: string
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
|
||||
await players.registerTransfer(supabase, user.id, {
|
||||
competitionId,
|
||||
playerId,
|
||||
fromTeamId,
|
||||
toTeamId,
|
||||
});
|
||||
revalidatePath("/leagues");
|
||||
}
|
||||
19
app/(auth)/forgot-password/manager/page.tsx
Normal file
19
app/(auth)/forgot-password/manager/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { LoginPageShell } from "@/components/auth/login-form";
|
||||
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
export default function ManagerForgotPasswordPage() {
|
||||
return (
|
||||
<>
|
||||
<LoginPageShell
|
||||
title="Reset password"
|
||||
subtitle="Team Manager — we will email you a secure link"
|
||||
>
|
||||
<ForgotPasswordForm portal="manager" />
|
||||
</LoginPageShell>
|
||||
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
app/(auth)/forgot-password/master/page.tsx
Normal file
19
app/(auth)/forgot-password/master/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { LoginPageShell } from "@/components/auth/login-form";
|
||||
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
export default function MasterForgotPasswordPage() {
|
||||
return (
|
||||
<>
|
||||
<LoginPageShell
|
||||
title="Reset password"
|
||||
subtitle="League Master — we will email you a secure link"
|
||||
>
|
||||
<ForgotPasswordForm portal="league_master" />
|
||||
</LoginPageShell>
|
||||
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
app/(auth)/login/manager/page.tsx
Normal file
34
app/(auth)/login/manager/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import Link from "next/link";
|
||||
import { LoginForm, LoginPageShell } from "@/components/auth/login-form";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
export default function ManagerLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<LoginPageShell
|
||||
title="Welcome back"
|
||||
subtitle="Sign in to your team manager dashboard"
|
||||
footer={
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
No account?{" "}
|
||||
<Link
|
||||
href="/signup/manager"
|
||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/" className="hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<LoginForm expectedRole="manager" />
|
||||
</LoginPageShell>
|
||||
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
app/(auth)/login/master/page.tsx
Normal file
29
app/(auth)/login/master/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import Link from "next/link";
|
||||
import { LoginForm, LoginPageShell } from "@/components/auth/login-form";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
export default function MasterLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<LoginPageShell
|
||||
title="League Master"
|
||||
subtitle="Administrative access — not shown on the public site"
|
||||
footer={
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/signup/master"
|
||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Create master account
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<LoginForm expectedRole="league_master" />
|
||||
</LoginPageShell>
|
||||
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
app/(auth)/login/page.tsx
Normal file
5
app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
redirect("/");
|
||||
}
|
||||
26
app/(auth)/reset-password/page.tsx
Normal file
26
app/(auth)/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Suspense } from "react";
|
||||
import { LoginPageShell } from "@/components/auth/login-form";
|
||||
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
function ResetPasswordFallback() {
|
||||
return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<>
|
||||
<LoginPageShell
|
||||
title="Set new password"
|
||||
subtitle="Choose a strong password for your account"
|
||||
>
|
||||
<Suspense fallback={<ResetPasswordFallback />}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</LoginPageShell>
|
||||
<div className="mx-auto -mt-4 mb-8 w-full max-w-md px-4">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/(auth)/signup/manager/page.tsx
Normal file
11
app/(auth)/signup/manager/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { SignupForm } from "@/components/auth/signup-form";
|
||||
|
||||
export default function ManagerSignupPage() {
|
||||
return (
|
||||
<SignupForm
|
||||
portalRole="manager"
|
||||
title="Team Manager signup"
|
||||
description="For team managers in Yaltopia FIFA tournaments."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
app/(auth)/signup/master/page.tsx
Normal file
11
app/(auth)/signup/master/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { SignupForm } from "@/components/auth/signup-form";
|
||||
|
||||
export default function MasterSignupPage() {
|
||||
return (
|
||||
<SignupForm
|
||||
portalRole="league_master"
|
||||
title="League Master signup"
|
||||
description="Create an admin account to manage leagues, fixtures, and competitions."
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
app/(auth)/signup/page.tsx
Normal file
5
app/(auth)/signup/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SignupPage() {
|
||||
redirect("/signup/manager");
|
||||
}
|
||||
9
app/(dashboard)/layout.tsx
Normal file
9
app/(dashboard)/layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { TeamBadge } from "@/components/teams/TeamBadge";
|
||||
|
||||
export default async function AdminResultsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string }>;
|
||||
}) {
|
||||
const { leagueId, competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: pending } = await supabase
|
||||
.from("matches")
|
||||
.select(
|
||||
`*, home:home_team_id(name, logo_path), away:away_team_id(name, logo_path)`
|
||||
)
|
||||
.eq("competition_id", competitionId)
|
||||
.in("result_status", ["pending_approval", "disputed"]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Results admin</h1>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Approve or resolve match results as league manager
|
||||
</p>
|
||||
|
||||
<GlassCard title="Pending approval & disputes">
|
||||
<ul className="space-y-3">
|
||||
{pending?.map((m) => {
|
||||
const home = m.home as { name: string; logo_path: string | null };
|
||||
const away = m.away as { name: string; logo_path: string | null };
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Link
|
||||
href={`/leagues/${leagueId}/competitions/${competitionId}/matches/${m.id}`}
|
||||
className="flex items-center justify-between rounded-lg border border-white/10 p-4 hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<TeamBadge name={home?.name} logoPath={home?.logo_path} size="sm" />
|
||||
<span className="text-[var(--color-muted)]">vs</span>
|
||||
<TeamBadge name={away?.name} logoPath={away?.logo_path} size="sm" />
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
m.result_status === "disputed"
|
||||
? "text-red-400"
|
||||
: "text-amber-400"
|
||||
}
|
||||
>
|
||||
{m.result_status}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{(!pending || pending.length === 0) && (
|
||||
<p className="text-sm text-[var(--color-muted)]">No pending results</p>
|
||||
)}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { TeamBadge } from "@/components/teams/TeamBadge";
|
||||
|
||||
export default async function FixturesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string }>;
|
||||
}) {
|
||||
const { leagueId, competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: matches } = await supabase
|
||||
.from("matches")
|
||||
.select(
|
||||
`*, home:home_team_id(id, name, logo_path), away:away_team_id(id, name, logo_path)`
|
||||
)
|
||||
.eq("competition_id", competitionId)
|
||||
.order("matchday")
|
||||
.order("round");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Fixtures</h1>
|
||||
<GlassCard>
|
||||
<ul className="divide-y divide-white/10">
|
||||
{matches?.map((m) => {
|
||||
const home = m.home as { id: string; name: string; logo_path: string | null };
|
||||
const away = m.away as { id: string; name: string; logo_path: string | null };
|
||||
return (
|
||||
<li key={m.id} className="py-4">
|
||||
<Link
|
||||
href={`/leagues/${leagueId}/competitions/${competitionId}/matches/${m.id}`}
|
||||
className="flex flex-wrap items-center justify-between gap-4 hover:opacity-90"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<TeamBadge name={home?.name} logoPath={home?.logo_path} />
|
||||
<span className="text-lg font-semibold">
|
||||
{m.status === "completed"
|
||||
? `${m.home_score} – ${m.away_score}`
|
||||
: "vs"}
|
||||
</span>
|
||||
<TeamBadge name={away?.name} logoPath={away?.logo_path} />
|
||||
</div>
|
||||
<div className="text-right text-xs text-[var(--color-muted)]">
|
||||
{m.matchday && <span>MD {m.matchday} · </span>}
|
||||
<span className="capitalize">{m.status.replace(/_/g, " ")}</span>
|
||||
{m.venue && <p className="mt-0.5">{m.venue}</p>}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { CompetitionSidebar } from "@/components/layout/Sidebar";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
export default async function CompetitionLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ leagueId: string; competitionId: string }>;
|
||||
}) {
|
||||
const { leagueId, competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const { data: competition } = await supabase
|
||||
.from("competitions")
|
||||
.select("*, leagues(name)")
|
||||
.eq("id", competitionId)
|
||||
.single();
|
||||
|
||||
if (!competition) notFound();
|
||||
|
||||
let showMyTeam = false;
|
||||
if (user) {
|
||||
const { data: compTeams } = await supabase
|
||||
.from("teams")
|
||||
.select("id")
|
||||
.eq("competition_id", competitionId);
|
||||
const ids = compTeams?.map((t) => t.id) ?? [];
|
||||
if (ids.length > 0) {
|
||||
const { count } = await supabase
|
||||
.from("team_members")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "manager")
|
||||
.in("team_id", ids);
|
||||
showMyTeam = (count ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<div className="flex flex-1">
|
||||
<aside className="flex w-56 flex-col border-r border-white/10 bg-black/20">
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<Link
|
||||
href={`/leagues/${leagueId}`}
|
||||
className="text-xs text-cyan-400 hover:underline"
|
||||
>
|
||||
← {(competition.leagues as { name: string })?.name}
|
||||
</Link>
|
||||
<h2 className="mt-1 font-semibold">{competition.name}</h2>
|
||||
<p className="text-xs capitalize text-[var(--color-muted)]">
|
||||
{competition.tournament_mode} · {competition.status}
|
||||
</p>
|
||||
</div>
|
||||
<CompetitionSidebar
|
||||
leagueId={leagueId}
|
||||
competitionId={competitionId}
|
||||
showMyTeam={showMyTeam}
|
||||
/>
|
||||
</aside>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function MatchActions({
|
||||
matchId,
|
||||
homeTeamId,
|
||||
awayTeamId,
|
||||
userTeamId,
|
||||
status,
|
||||
resultStatus,
|
||||
isLeagueManager,
|
||||
proposedAt,
|
||||
}: {
|
||||
matchId: string;
|
||||
homeTeamId: string;
|
||||
awayTeamId: string;
|
||||
userTeamId: string | null;
|
||||
status: string;
|
||||
resultStatus: string | null;
|
||||
isLeagueManager: boolean;
|
||||
proposedAt: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(fn: () => Promise<void>) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
{userTeamId && status !== "completed" && (
|
||||
<GlassCard title="Schedule">
|
||||
{proposedAt && (
|
||||
<p className="mb-3 text-sm text-[var(--color-muted)]">
|
||||
Proposed: {new Date(proposedAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
run(async () => {
|
||||
const raw = fd.get("scheduled_at") as string;
|
||||
await api.matches.proposeSchedule(
|
||||
matchId,
|
||||
new Date(raw).toISOString()
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="scheduled_at">Propose kickoff</Label>
|
||||
<Input
|
||||
id="scheduled_at"
|
||||
name="scheduled_at"
|
||||
type="datetime-local"
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Propose
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="secondary"
|
||||
disabled={loading || !userTeamId}
|
||||
onClick={() =>
|
||||
run(() => api.matches.signSchedule(matchId, userTeamId!))
|
||||
}
|
||||
>
|
||||
Sign schedule
|
||||
</Button>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{userTeamId && status !== "completed" && (
|
||||
<GlassCard title="Submit result">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
run(() =>
|
||||
api.matches.submitResult(matchId, {
|
||||
teamId: userTeamId!,
|
||||
homeScore: Number(fd.get("home_score")),
|
||||
awayScore: Number(fd.get("away_score")),
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label>Home score</Label>
|
||||
<Input name="home_score" type="number" min={0} required className="mt-1 w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Away score</Label>
|
||||
<Input name="away_score" type="number" min={0} required className="mt-1 w-20" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Submit result
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{isLeagueManager && resultStatus === "pending_approval" && (
|
||||
<GlassCard title="League manager" highlight>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => run(() => api.matches.approveResult(matchId))}
|
||||
>
|
||||
Approve matching result
|
||||
</Button>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{isLeagueManager && (
|
||||
<GlassCard title="Set official result">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
run(() =>
|
||||
api.matches.setResult(matchId, {
|
||||
homeScore: Number(fd.get("home_score")),
|
||||
awayScore: Number(fd.get("away_score")),
|
||||
note: (fd.get("note") as string) || undefined,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Input name="home_score" type="number" min={0} placeholder="H" className="w-20" required />
|
||||
<Input name="away_score" type="number" min={0} placeholder="A" className="w-20" required />
|
||||
<Input name="note" placeholder="Note (optional)" className="flex-1 min-w-[120px]" />
|
||||
<Button type="submit" disabled={loading}>
|
||||
Set result
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { MatchActions } from "./match-actions";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { TeamBadge } from "@/components/teams/TeamBadge";
|
||||
|
||||
export default async function MatchPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string; matchId: string }>;
|
||||
}) {
|
||||
const { leagueId, competitionId, matchId } = await params;
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const { data: match } = await supabase
|
||||
.from("matches")
|
||||
.select(
|
||||
`*, home:home_team_id(id, name, logo_path), away:away_team_id(id, name, logo_path)`
|
||||
)
|
||||
.eq("id", matchId)
|
||||
.single();
|
||||
|
||||
if (!match) notFound();
|
||||
|
||||
const home = match.home as { id: string; name: string; logo_path: string | null };
|
||||
const away = match.away as { id: string; name: string; logo_path: string | null };
|
||||
|
||||
const { data: submissions } = await supabase
|
||||
.from("match_result_submissions")
|
||||
.select("*, teams(name)")
|
||||
.eq("match_id", matchId);
|
||||
|
||||
const { data: signatures } = await supabase
|
||||
.from("match_signatures")
|
||||
.select("team_id, teams(name)")
|
||||
.eq("match_id", matchId);
|
||||
|
||||
let userTeamId: string | null = null;
|
||||
if (user) {
|
||||
const { data: tm } = await supabase
|
||||
.from("team_members")
|
||||
.select("team_id")
|
||||
.eq("user_id", user.id)
|
||||
.in("team_id", [home.id, away.id])
|
||||
.limit(1)
|
||||
.single();
|
||||
userTeamId = tm?.team_id ?? null;
|
||||
}
|
||||
|
||||
const { data: isLeagueManager } = user
|
||||
? await supabase
|
||||
.from("competition_league_managers")
|
||||
.select("id")
|
||||
.eq("competition_id", competitionId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle()
|
||||
: { data: null };
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GlassCard>
|
||||
<div className="flex flex-wrap items-center justify-center gap-8 py-4">
|
||||
<TeamBadge name={home.name} logoPath={home.logo_path} size="lg" />
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold">
|
||||
{match.status === "completed"
|
||||
? `${match.home_score} – ${match.away_score}`
|
||||
: "vs"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm capitalize text-[var(--color-muted)]">
|
||||
{match.status.replace(/_/g, " ")}
|
||||
</p>
|
||||
{match.venue && (
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">{match.venue}</p>
|
||||
)}
|
||||
</div>
|
||||
<TeamBadge name={away.name} logoPath={away.logo_path} size="lg" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<GlassCard title="Schedule signatures">
|
||||
<ul className="text-sm">
|
||||
{signatures?.map((s) => (
|
||||
<li key={s.team_id} className="text-emerald-400">
|
||||
✓ {(s.teams as { name: string })?.name} signed
|
||||
</li>
|
||||
))}
|
||||
{(!signatures || signatures.length === 0) && (
|
||||
<li className="text-[var(--color-muted)]">No signatures yet</li>
|
||||
)}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Result submissions">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{submissions?.map((s) => (
|
||||
<li key={s.team_id}>
|
||||
{(s.teams as { name: string })?.name}: {s.home_score}–{s.away_score}
|
||||
</li>
|
||||
))}
|
||||
{(!submissions || submissions.length === 0) && (
|
||||
<li className="text-[var(--color-muted)]">No submissions yet</li>
|
||||
)}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<MatchActions
|
||||
matchId={matchId}
|
||||
homeTeamId={home.id}
|
||||
awayTeamId={away.id}
|
||||
userTeamId={userTeamId}
|
||||
status={match.status}
|
||||
resultStatus={match.result_status}
|
||||
isLeagueManager={!!isLeagueManager}
|
||||
proposedAt={match.proposed_scheduled_at}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
FormDonut,
|
||||
GoalsTrendChart,
|
||||
TopScorersChart,
|
||||
StatCards,
|
||||
} from "@/components/manager/TeamCharts";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { TeamBadge } from "@/components/teams/TeamBadge";
|
||||
|
||||
export default async function MyTeamPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string }>;
|
||||
}) {
|
||||
const { leagueId, competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { data: teamsInComp } = await supabase
|
||||
.from("teams")
|
||||
.select("id")
|
||||
.eq("competition_id", competitionId);
|
||||
const teamIds = teamsInComp?.map((t) => t.id) ?? [];
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from("team_members")
|
||||
.select("team_id, teams(*)")
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "manager")
|
||||
.in("team_id", teamIds.length ? teamIds : ["00000000-0000-0000-0000-000000000000"])
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!membership) {
|
||||
return (
|
||||
<p className="text-[var(--color-muted)]">
|
||||
You are not a team manager in this competition.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const team = membership.teams as {
|
||||
id: string;
|
||||
name: string;
|
||||
logo_path: string | null;
|
||||
};
|
||||
|
||||
const { data: results } = await supabase
|
||||
.from("team_match_results")
|
||||
.select("*")
|
||||
.eq("team_id", team.id)
|
||||
.order("matchday", { ascending: true });
|
||||
|
||||
const { data: playerStats } = await supabase
|
||||
.from("player_competition_stats")
|
||||
.select("*")
|
||||
.eq("team_id", team.id)
|
||||
.eq("competition_id", competitionId)
|
||||
.order("goals", { ascending: false });
|
||||
|
||||
const formCounts = { W: 0, D: 0, L: 0 };
|
||||
results?.slice(-10).forEach((r) => {
|
||||
if (r.result in formCounts) formCounts[r.result as keyof typeof formCounts]++;
|
||||
});
|
||||
const formData = Object.entries(formCounts).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
const goalsTrend =
|
||||
results?.map((r, i) => ({
|
||||
matchday: r.matchday ?? i + 1,
|
||||
gf: r.goals_for ?? 0,
|
||||
ga: r.goals_against ?? 0,
|
||||
})) ?? [];
|
||||
|
||||
const topScorers =
|
||||
playerStats?.slice(0, 8).map((p) => ({
|
||||
name: p.player_name,
|
||||
goals: p.goals,
|
||||
})) ?? [];
|
||||
|
||||
const topAssists =
|
||||
playerStats
|
||||
?.slice()
|
||||
.sort((a, b) => b.assists - a.assists)
|
||||
.slice(0, 8)
|
||||
.map((p) => ({
|
||||
name: p.player_name,
|
||||
assists: p.assists,
|
||||
})) ?? [];
|
||||
|
||||
const played = results?.length ?? 0;
|
||||
const won = results?.filter((r) => r.result === "W").length ?? 0;
|
||||
const drawn = results?.filter((r) => r.result === "D").length ?? 0;
|
||||
const lost = results?.filter((r) => r.result === "L").length ?? 0;
|
||||
const gf = results?.reduce((s, r) => s + (r.goals_for ?? 0), 0) ?? 0;
|
||||
const ga = results?.reduce((s, r) => s + (r.goals_against ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<TeamBadge name={team.name} logoPath={team.logo_path} size="lg" />
|
||||
<a
|
||||
href={`/leagues/${leagueId}/competitions/${competitionId}/teams/${team.id}/settings`}
|
||||
className="text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
Team settings →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StatCards
|
||||
stats={[
|
||||
{ label: "Played", value: played },
|
||||
{ label: "Won", value: won },
|
||||
{ label: "GF", value: gf },
|
||||
{ label: "GD", value: gf - ga },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<FormDonut data={formData.filter((d) => d.value > 0)} />
|
||||
<GoalsTrendChart data={goalsTrend} />
|
||||
<TopScorersChart
|
||||
data={topScorers}
|
||||
dataKey="goals"
|
||||
title="Top scorers"
|
||||
/>
|
||||
<TopScorersChart
|
||||
data={topAssists}
|
||||
dataKey="assists"
|
||||
title="Top assists"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GlassCard title="Squad stats">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-[var(--color-muted)]">
|
||||
<th className="pb-2">Player</th>
|
||||
<th className="pb-2 text-center">Apps</th>
|
||||
<th className="pb-2 text-center">G</th>
|
||||
<th className="pb-2 text-center">A</th>
|
||||
<th className="pb-2 text-center">G+A</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{playerStats?.map((p) => (
|
||||
<tr key={p.player_id} className="border-b border-white/5">
|
||||
<td className="py-2">{p.player_name}</td>
|
||||
<td className="py-2 text-center">{p.appearances}</td>
|
||||
<td className="py-2 text-center">{p.goals}</td>
|
||||
<td className="py-2 text-center">{p.assists}</td>
|
||||
<td className="py-2 text-center text-cyan-400">
|
||||
{p.goals + p.assists}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Recent results">
|
||||
<ul className="space-y-2">
|
||||
{results
|
||||
?.slice()
|
||||
.reverse()
|
||||
.slice(0, 5)
|
||||
.map((r) => (
|
||||
<li
|
||||
key={r.match_id}
|
||||
className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<span>vs {r.opponent_name}</span>
|
||||
<span>
|
||||
{r.goals_for}–{r.goals_against}{" "}
|
||||
<span
|
||||
className={
|
||||
r.result === "W"
|
||||
? "text-emerald-400"
|
||||
: r.result === "L"
|
||||
? "text-red-400"
|
||||
: "text-amber-400"
|
||||
}
|
||||
>
|
||||
{r.result}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { CompetitionDraftPanel } from "@/components/competitions/competition-draft-panel";
|
||||
import { StandingsTable } from "@/components/standings/StandingsTable";
|
||||
import { TeamBadge } from "@/components/teams/TeamBadge";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function CompetitionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string }>;
|
||||
}) {
|
||||
const { leagueId, competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: competition } = await supabase
|
||||
.from("competitions")
|
||||
.select("*")
|
||||
.eq("id", competitionId)
|
||||
.single();
|
||||
|
||||
if (!competition) notFound();
|
||||
|
||||
const { data: teams } = await supabase
|
||||
.from("teams")
|
||||
.select("*")
|
||||
.eq("competition_id", competitionId)
|
||||
.order("name");
|
||||
|
||||
const { data: standings } = await supabase
|
||||
.from("competition_standings")
|
||||
.select("*")
|
||||
.eq("competition_id", competitionId);
|
||||
|
||||
const { data: upcoming } = await supabase
|
||||
.from("matches")
|
||||
.select(
|
||||
`*, home:home_team_id(name, logo_path), away:away_team_id(name, logo_path)`
|
||||
)
|
||||
.eq("competition_id", competitionId)
|
||||
.in("status", ["scheduled", "schedule_pending", "schedule_confirmed"])
|
||||
.order("scheduled_at")
|
||||
.limit(5);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">{competition.name}</h1>
|
||||
</div>
|
||||
|
||||
{competition.status === "draft" && (
|
||||
<CompetitionDraftPanel
|
||||
competitionId={competitionId}
|
||||
initialTeams={teams ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
{competition.tournament_mode === "league" && standings && standings.length > 0 && (
|
||||
<GlassCard title="Standings">
|
||||
<StandingsTable standings={standings} />
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
<GlassCard title="Upcoming fixtures">
|
||||
<ul className="space-y-3">
|
||||
{upcoming?.map((m) => {
|
||||
const home = m.home as { name: string; logo_path: string | null };
|
||||
const away = m.away as { name: string; logo_path: string | null };
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Link
|
||||
href={`/leagues/${leagueId}/competitions/${competitionId}/matches/${m.id}`}
|
||||
className="flex items-center justify-between rounded-lg border border-white/10 p-3 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<TeamBadge name={home?.name} logoPath={home?.logo_path} size="sm" />
|
||||
<span className="text-[var(--color-muted)]">vs</span>
|
||||
<TeamBadge name={away?.name} logoPath={away?.logo_path} size="sm" />
|
||||
</div>
|
||||
<span className="text-xs text-[var(--color-muted)] capitalize">
|
||||
{m.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{(!upcoming || upcoming.length === 0) && (
|
||||
<p className="text-sm text-[var(--color-muted)]">No upcoming fixtures</p>
|
||||
)}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
|
||||
export default function PlayoffsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Champions League playoffs</h1>
|
||||
<GlassCard highlight>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
End-of-season qualification playoffs activate when the competition
|
||||
status is set to <strong className="text-foreground">playoffs</strong>{" "}
|
||||
after all league matches are completed. Top teams auto-qualify per
|
||||
league rules; remaining teams play in for the final CL spots.
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { TeamSettingsForm } from "./team-settings-form";
|
||||
|
||||
export default async function TeamSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string; teamId: string }>;
|
||||
}) {
|
||||
const { teamId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: team } = await supabase
|
||||
.from("teams")
|
||||
.select("*")
|
||||
.eq("id", teamId)
|
||||
.single();
|
||||
|
||||
if (!team) notFound();
|
||||
|
||||
const { data: availability } = await supabase
|
||||
.from("team_availability")
|
||||
.select("*")
|
||||
.eq("team_id", teamId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Team settings — {team.name}</h1>
|
||||
<TeamSettingsForm team={team} availability={availability ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DAY_NAMES } from "@/lib/utils";
|
||||
|
||||
export function TeamSettingsForm({
|
||||
team,
|
||||
availability,
|
||||
}: {
|
||||
team: { id: string; name: string; home_stadium_name: string | null; logo_path: string | null; competition_id: string };
|
||||
availability: { day_of_week: number; start_time: string | null; end_time: string | null }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [stadium, setStadium] = useState(team.home_stadium_name ?? "");
|
||||
const [days, setDays] = useState<number[]>(
|
||||
[...new Set(availability.map((a) => a.day_of_week))]
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function saveStadium() {
|
||||
setLoading(true);
|
||||
await api.teams.update(team.id, { home_stadium_name: stadium });
|
||||
setLoading(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function uploadLogo(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const supabase = createClient();
|
||||
const ext = file.name.split(".").pop();
|
||||
const path = `${team.competition_id}/${team.id}/logo.${ext}`;
|
||||
const { error } = await supabase.storage
|
||||
.from("team-logos")
|
||||
.upload(path, file, { upsert: true });
|
||||
if (error) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
await api.teams.update(team.id, { logo_path: path });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function saveAvailability() {
|
||||
setLoading(true);
|
||||
await api.teams.setAvailability(
|
||||
team.id,
|
||||
days.map((d) => ({ day_of_week: d }))
|
||||
);
|
||||
setLoading(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
function toggleDay(d: number) {
|
||||
setDays((prev) =>
|
||||
prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d]
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GlassCard title="Team logo">
|
||||
<Input type="file" accept="image/*" onChange={uploadLogo} />
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Home stadium">
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={stadium}
|
||||
onChange={(e) => setStadium(e.target.value)}
|
||||
placeholder="Arena name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={saveStadium} disabled={loading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Playable days">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAY_NAMES.map((name, i) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => toggleDay(i)}
|
||||
className={`rounded-lg px-3 py-2 text-sm ${
|
||||
days.includes(i)
|
||||
? "bg-cyan-500/20 text-cyan-400"
|
||||
: "bg-white/5 text-[var(--color-muted)]"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button className="mt-4" onClick={saveAvailability} disabled={loading}>
|
||||
Save availability
|
||||
</Button>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { TransfersPanel } from "./transfers-panel";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
|
||||
export default async function TransfersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string; competitionId: string }>;
|
||||
}) {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: transfers } = await supabase
|
||||
.from("transfers")
|
||||
.select(
|
||||
`*, player:players(display_name), from_team:from_team_id(name), to_team:to_team_id(name)`
|
||||
)
|
||||
.eq("competition_id", competitionId)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
const { data: teams } = await supabase
|
||||
.from("teams")
|
||||
.select("id, name")
|
||||
.eq("competition_id", competitionId);
|
||||
|
||||
const { data: players } = await supabase
|
||||
.from("players")
|
||||
.select("id, display_name")
|
||||
.eq("status", "active");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Transfers</h1>
|
||||
<TransfersPanel
|
||||
competitionId={competitionId}
|
||||
teams={teams ?? []}
|
||||
players={players ?? []}
|
||||
/>
|
||||
<GlassCard title="Transfer history">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{transfers?.map((t) => (
|
||||
<li key={t.id} className="rounded-lg bg-white/5 px-3 py-2">
|
||||
<span className="font-medium">
|
||||
{(t.player as { display_name: string })?.display_name}
|
||||
</span>{" "}
|
||||
{(t.from_team as { name: string })?.name} →{" "}
|
||||
{(t.to_team as { name: string })?.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function TransfersPanel({
|
||||
competitionId,
|
||||
teams,
|
||||
players,
|
||||
}: {
|
||||
competitionId: string;
|
||||
teams: { id: string; name: string }[];
|
||||
players: { id: string; display_name: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<GlassCard title="Add to roster">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
setLoading(true);
|
||||
await api.competitions.addRoster(competitionId, {
|
||||
teamId: fd.get("team_id") as string,
|
||||
playerId: fd.get("player_id") as string,
|
||||
});
|
||||
setLoading(false);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label>Team</Label>
|
||||
<select
|
||||
name="team_id"
|
||||
required
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Player (registry)</Label>
|
||||
<select
|
||||
name="player_id"
|
||||
required
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
{players.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Add to roster
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Inter-team transfer">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
setLoading(true);
|
||||
await api.competitions.transfer(competitionId, {
|
||||
playerId: fd.get("player_id") as string,
|
||||
fromTeamId: fd.get("from_team_id") as string,
|
||||
toTeamId: fd.get("to_team_id") as string,
|
||||
});
|
||||
setLoading(false);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label>Player</Label>
|
||||
<select
|
||||
name="player_id"
|
||||
required
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
{players.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>From</Label>
|
||||
<select
|
||||
name="from_team_id"
|
||||
required
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>To</Label>
|
||||
<select
|
||||
name="to_team_id"
|
||||
required
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Register transfer
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
app/(dashboard)/leagues/[leagueId]/page.tsx
Normal file
89
app/(dashboard)/leagues/[leagueId]/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { createCompetition } from "@/actions/leagues";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default async function LeaguePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string }>;
|
||||
}) {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: league } = await supabase
|
||||
.from("leagues")
|
||||
.select("*")
|
||||
.eq("id", leagueId)
|
||||
.single();
|
||||
|
||||
if (!league) notFound();
|
||||
|
||||
const { data: competitions } = await supabase
|
||||
.from("competitions")
|
||||
.select("*")
|
||||
.eq("league_id", leagueId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{league.name}</h1>
|
||||
<p className="text-sm text-[var(--color-muted)]">{league.description}</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/leagues/${leagueId}/rules`}>Edit rules</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GlassCard title="New competition">
|
||||
<form
|
||||
action={createCompetition.bind(null, leagueId)}
|
||||
className="flex flex-wrap items-end gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" required className="mt-1" placeholder="Season 2025" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tournament_mode">Mode</Label>
|
||||
<select
|
||||
id="tournament_mode"
|
||||
name="tournament_mode"
|
||||
className="mt-1 flex h-10 rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
<option value="league">League (round-robin)</option>
|
||||
<option value="cup">Cup (knockout)</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit">Create competition</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{competitions?.map((c) => (
|
||||
<Link key={c.id} href={`/leagues/${leagueId}/competitions/${c.id}`}>
|
||||
<GlassCard className="hover:border-cyan-400/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{c.name}</h3>
|
||||
<p className="text-xs text-[var(--color-muted)] capitalize">
|
||||
{c.tournament_mode} · {c.status}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs capitalize">
|
||||
{c.status}
|
||||
</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
app/(dashboard)/leagues/[leagueId]/rules/page.tsx
Normal file
45
app/(dashboard)/leagues/[leagueId]/rules/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { parseLeagueRules, defaultLeagueRules } from "@/lib/rules/schema";
|
||||
import { RulesForm } from "./rules-form";
|
||||
|
||||
export default async function RulesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string }>;
|
||||
}) {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: league } = await supabase
|
||||
.from("leagues")
|
||||
.select("name")
|
||||
.eq("id", leagueId)
|
||||
.single();
|
||||
|
||||
if (!league) notFound();
|
||||
|
||||
const { data: latest } = await supabase
|
||||
.from("league_rules")
|
||||
.select("rules, version")
|
||||
.eq("league_id", leagueId)
|
||||
.order("version", { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
const rules = latest?.rules
|
||||
? parseLeagueRules(latest.rules)
|
||||
: defaultLeagueRules;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">League rules</h1>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{league.name} · version {latest?.version ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<RulesForm leagueId={leagueId} initialRules={rules} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
app/(dashboard)/leagues/[leagueId]/rules/rules-form.tsx
Normal file
94
app/(dashboard)/leagues/[leagueId]/rules/rules-form.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LeagueRules } from "@/lib/rules/schema";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
export function RulesForm({
|
||||
leagueId,
|
||||
initialRules,
|
||||
}: {
|
||||
leagueId: string;
|
||||
initialRules: LeagueRules;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [rules, setRules] = useState(initialRules);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.leagues.saveRules(leagueId, rules);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard title="Scoring & format">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Points for win</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={rules.points_win}
|
||||
onChange={(e) =>
|
||||
setRules({ ...rules, points_win: Number(e.target.value) })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Points for draw</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={rules.points_draw}
|
||||
onChange={(e) =>
|
||||
setRules({ ...rules, points_draw: Number(e.target.value) })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Round robin</Label>
|
||||
<select
|
||||
value={rules.round_robin_format}
|
||||
onChange={(e) =>
|
||||
setRules({
|
||||
...rules,
|
||||
round_robin_format: e.target.value as "single" | "double",
|
||||
})
|
||||
}
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
<option value="single">Single</option>
|
||||
<option value="double">Double</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Auto-qualify (CL)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={rules.auto_qualify_count}
|
||||
onChange={(e) =>
|
||||
setRules({
|
||||
...rules,
|
||||
auto_qualify_count: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="mt-6" onClick={handleSave} disabled={loading}>
|
||||
Save new rules version
|
||||
</Button>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
71
app/(dashboard)/leagues/page.tsx
Normal file
71
app/(dashboard)/leagues/page.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { createLeague } from "@/actions/leagues";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trophy, Plus } from "lucide-react";
|
||||
|
||||
export default async function LeaguesPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: leagues } = await supabase
|
||||
.from("leagues")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Leagues</h1>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Create and manage tournament leagues
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlassCard title="New league">
|
||||
<form action={createLeague} className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" required className="mt-1" placeholder="Sunday League" />
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" name="description" className="mt-1" placeholder="Optional" />
|
||||
</div>
|
||||
<Button type="submit">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create league
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{leagues?.map((league) => (
|
||||
<Link key={league.id} href={`/leagues/${league.id}`}>
|
||||
<GlassCard className="transition-colors hover:border-cyan-400/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-cyan-500/15 p-2">
|
||||
<Trophy className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{league.name}</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)] line-clamp-2">
|
||||
{league.description || "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</Link>
|
||||
))}
|
||||
{(!leagues || leagues.length === 0) && (
|
||||
<p className="col-span-full text-center text-[var(--color-muted)]">
|
||||
No leagues yet. Create your first league above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
app/(dashboard)/players/page.tsx
Normal file
82
app/(dashboard)/players/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { createPlayer, togglePlayerStatus } from "@/actions/players";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default async function PlayersPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: players } = await supabase
|
||||
.from("players")
|
||||
.select("*")
|
||||
.order("display_name");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Player registry</h1>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Load players before roster adds or transfers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlassCard title="Add player">
|
||||
<form action={createPlayer} className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor="display_name">Name</Label>
|
||||
<Input id="display_name" name="display_name" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="external_id">External ID</Label>
|
||||
<Input id="external_id" name="external_id" className="mt-1" />
|
||||
</div>
|
||||
<Button type="submit">Add player</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="All players">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-[var(--color-muted)]">
|
||||
<th className="pb-3 pr-4">Name</th>
|
||||
<th className="pb-3 pr-4">External ID</th>
|
||||
<th className="pb-3 pr-4">Status</th>
|
||||
<th className="pb-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players?.map((p) => (
|
||||
<tr key={p.id} className="border-b border-white/5">
|
||||
<td className="py-3 pr-4 font-medium">{p.display_name}</td>
|
||||
<td className="py-3 pr-4 text-[var(--color-muted)]">
|
||||
{p.external_id || "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span
|
||||
className={
|
||||
p.status === "active"
|
||||
? "text-emerald-400"
|
||||
: "text-[var(--color-muted)]"
|
||||
}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<form action={togglePlayerStatus.bind(null, p.id, p.status)}>
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
Toggle status
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/(manager)/layout.tsx
Normal file
21
app/(manager)/layout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { requirePortalRole } from "@/lib/auth/profile";
|
||||
import { ManagerShell } from "@/components/layout/ManagerShell";
|
||||
|
||||
export default async function ManagerLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const gate = await requirePortalRole("manager");
|
||||
if (!gate.ok) {
|
||||
redirect(gate.reason === "auth" ? "/login/manager" : "/login/master");
|
||||
}
|
||||
|
||||
const user = {
|
||||
name: gate.profile?.display_name ?? gate.user.email?.split("@")[0] ?? "Manager",
|
||||
email: gate.user.email ?? "",
|
||||
};
|
||||
|
||||
return <ManagerShell user={user}>{children}</ManagerShell>;
|
||||
}
|
||||
11
app/(manager)/manager/calendar/page.tsx
Normal file
11
app/(manager)/manager/calendar/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MatchCalendar } from "@/components/calendar/match-calendar";
|
||||
|
||||
export default function ManagerCalendarPage() {
|
||||
return (
|
||||
<MatchCalendar
|
||||
apiPath="/api/manager/calendar"
|
||||
title="Match calendar"
|
||||
description="Your team fixtures across leagues and cups — click a day to see kickoffs."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
app/(manager)/manager/cups/page.tsx
Normal file
11
app/(manager)/manager/cups/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ManagerCompetitionsTable } from "@/components/manager/manager-competitions-table";
|
||||
|
||||
export default function ManagerCupsPage() {
|
||||
return (
|
||||
<ManagerCompetitionsTable
|
||||
title="Cups"
|
||||
description="Knockout competitions you manage"
|
||||
mode="cup"
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
app/(manager)/manager/faq/page.tsx
Normal file
21
app/(manager)/manager/faq/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { MANAGER_FAQ } from "@/lib/content/faq";
|
||||
|
||||
export default function ManagerFaqPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="FAQ"
|
||||
description="Common questions for team managers"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{MANAGER_FAQ.map((item) => (
|
||||
<GlassCard key={item.q} title={item.q}>
|
||||
<p className="text-sm text-[var(--color-muted)]">{item.a}</p>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
app/(manager)/manager/issues/page.tsx
Normal file
26
app/(manager)/manager/issues/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { getCurrentProfile } from "@/lib/auth/profile";
|
||||
import { IssuesPanel } from "@/components/issues/issues-panel";
|
||||
|
||||
export default async function ManagerIssuesPage() {
|
||||
const supabase = await createClient();
|
||||
const ctx = await getCurrentProfile();
|
||||
|
||||
const { data: memberships } = await supabase
|
||||
.from("team_members")
|
||||
.select("teams(competitions(league_id, leagues(id, name)))")
|
||||
.eq("user_id", ctx!.user.id);
|
||||
|
||||
const leagueMap = new Map<string, string>();
|
||||
memberships?.forEach((m) => {
|
||||
const team = m.teams as {
|
||||
competitions: { leagues: { id: string; name: string } | null } | null;
|
||||
} | null;
|
||||
const league = team?.competitions?.leagues;
|
||||
if (league) leagueMap.set(league.id, league.name);
|
||||
});
|
||||
|
||||
const leagues = [...leagueMap.entries()].map(([id, name]) => ({ id, name }));
|
||||
|
||||
return <IssuesPanel leagues={leagues} asMaster={false} />;
|
||||
}
|
||||
11
app/(manager)/manager/leagues/page.tsx
Normal file
11
app/(manager)/manager/leagues/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ManagerCompetitionsTable } from "@/components/manager/manager-competitions-table";
|
||||
|
||||
export default function ManagerLeaguesPage() {
|
||||
return (
|
||||
<ManagerCompetitionsTable
|
||||
title="Leagues"
|
||||
description="Round-robin competitions you manage — points and goal difference"
|
||||
mode="league"
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
app/(manager)/manager/page.tsx
Normal file
5
app/(manager)/manager/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ManagerDashboardClient } from "@/components/manager/manager-dashboard-client";
|
||||
|
||||
export default function ManagerDashboardPage() {
|
||||
return <ManagerDashboardClient />;
|
||||
}
|
||||
96
app/(manager)/manager/rules/page.tsx
Normal file
96
app/(manager)/manager/rules/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { getCurrentProfile } from "@/lib/auth/profile";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import Link from "next/link";
|
||||
import { parseLeagueRules, defaultLeagueRules } from "@/lib/rules/schema";
|
||||
|
||||
export default async function ManagerRulesPage() {
|
||||
const supabase = await createClient();
|
||||
const ctx = await getCurrentProfile();
|
||||
|
||||
const { data: memberships } = await supabase
|
||||
.from("team_members")
|
||||
.select("teams(competition_id, competitions(league_id, name, leagues(name)))")
|
||||
.eq("user_id", ctx!.user.id)
|
||||
.eq("role", "manager");
|
||||
|
||||
const leagueIds = new Set<string>();
|
||||
const leagueNames = new Map<string, string>();
|
||||
|
||||
memberships?.forEach((m) => {
|
||||
const team = m.teams as {
|
||||
competitions: { league_id: string; leagues: { name: string } | null } | null;
|
||||
} | null;
|
||||
const leagueId = team?.competitions?.league_id;
|
||||
const name = team?.competitions?.leagues?.name;
|
||||
if (leagueId) {
|
||||
leagueIds.add(leagueId);
|
||||
if (name) leagueNames.set(leagueId, name);
|
||||
}
|
||||
});
|
||||
|
||||
const rulesBlocks = await Promise.all(
|
||||
[...leagueIds].map(async (leagueId) => {
|
||||
const { data: latest } = await supabase
|
||||
.from("league_rules")
|
||||
.select("rules, version")
|
||||
.eq("league_id", leagueId)
|
||||
.order("version", { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
const rules = latest?.rules
|
||||
? parseLeagueRules(latest.rules)
|
||||
: defaultLeagueRules;
|
||||
return { leagueId, name: leagueNames.get(leagueId) ?? "League", rules, version: latest?.version ?? 0 };
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Rules"
|
||||
description="Scoring and format for leagues you participate in"
|
||||
/>
|
||||
{rulesBlocks.length === 0 ? (
|
||||
<GlassCard>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
No league rules available yet. You need to be assigned as a team manager.
|
||||
</p>
|
||||
</GlassCard>
|
||||
) : (
|
||||
rulesBlocks.map((block) => (
|
||||
<GlassCard
|
||||
key={block.leagueId}
|
||||
title={`${block.name} · v${block.version}`}
|
||||
>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[var(--color-muted)]">Win points</dt>
|
||||
<dd className="font-medium">{block.rules.points_win}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-muted)]">Draw points</dt>
|
||||
<dd className="font-medium">{block.rules.points_draw}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-muted)]">Format</dt>
|
||||
<dd className="font-medium capitalize">{block.rules.round_robin_format}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-muted)]">Auto-qualify (CL)</dt>
|
||||
<dd className="font-medium">{block.rules.auto_qualify_count}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Link
|
||||
href={`/leagues/${block.leagueId}/rules`}
|
||||
className="mt-4 inline-block text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
View full rules page
|
||||
</Link>
|
||||
</GlassCard>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/(master)/layout.tsx
Normal file
21
app/(master)/layout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { requirePortalRole } from "@/lib/auth/profile";
|
||||
import { MasterShell } from "@/components/layout/MasterShell";
|
||||
|
||||
export default async function MasterLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const gate = await requirePortalRole("league_master");
|
||||
if (!gate.ok) {
|
||||
redirect(gate.reason === "auth" ? "/login/master" : "/login/manager");
|
||||
}
|
||||
|
||||
const user = {
|
||||
name: gate.profile?.display_name ?? gate.user.email?.split("@")[0] ?? "Master",
|
||||
email: gate.user.email ?? "",
|
||||
};
|
||||
|
||||
return <MasterShell user={user}>{children}</MasterShell>;
|
||||
}
|
||||
11
app/(master)/master/calendar/page.tsx
Normal file
11
app/(master)/master/calendar/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MatchCalendar } from "@/components/calendar/match-calendar";
|
||||
|
||||
export default function MasterCalendarPage() {
|
||||
return (
|
||||
<MatchCalendar
|
||||
apiPath="/api/master/calendar"
|
||||
title="Fixture calendar"
|
||||
description="All matches in leagues you manage — schedule and track from one retro grid."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
app/(master)/master/issues/page.tsx
Normal file
11
app/(master)/master/issues/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { IssuesPanel } from "@/components/issues/issues-panel";
|
||||
|
||||
export default async function MasterIssuesPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: leagues } = await supabase.from("leagues").select("id, name");
|
||||
|
||||
return (
|
||||
<IssuesPanel leagues={leagues ?? []} asMaster />
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
|
||||
export default function NewCompetitionPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.leagueId as string;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="New competition" description="Add a league season or cup" />
|
||||
<GlassCard>
|
||||
<form
|
||||
className="space-y-4 max-w-md"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
const fd = new FormData(e.currentTarget);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const comp = (await api.leagues.createCompetition(leagueId, {
|
||||
name: fd.get("name") as string,
|
||||
tournament_mode: fd.get("tournament_mode") as "league" | "cup",
|
||||
})) as { id: string };
|
||||
router.push(`/leagues/${leagueId}/competitions/${comp.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input name="name" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mode</Label>
|
||||
<select
|
||||
name="tournament_mode"
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
<option value="league">League</option>
|
||||
<option value="cup">Cup</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<Button type="submit" disabled={loading}>
|
||||
Create competition
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
app/(master)/master/leagues/[leagueId]/page.tsx
Normal file
65
app/(master)/master/leagues/[leagueId]/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default async function MasterLeagueDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ leagueId: string }>;
|
||||
}) {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: league } = await supabase
|
||||
.from("leagues")
|
||||
.select("*, competitions(*)")
|
||||
.eq("id", leagueId)
|
||||
.single();
|
||||
|
||||
if (!league) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={league.name}
|
||||
description={league.description ?? "League administration"}
|
||||
actions={
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/leagues/${leagueId}/rules`}>Edit rules</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<GlassCard title="Competitions">
|
||||
<ul className="space-y-2">
|
||||
{league.competitions?.map(
|
||||
(c: { id: string; name: string; status: string; tournament_mode: string }) => (
|
||||
<li key={c.id}>
|
||||
<Link
|
||||
href={`/leagues/${leagueId}/competitions/${c.id}`}
|
||||
className="flex items-center justify-between rounded-lg border border-white/10 px-4 py-3 hover:bg-white/5"
|
||||
>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
<span className="text-xs capitalize text-[var(--color-muted)]">
|
||||
{c.tournament_mode} · {c.status}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
{(!league.competitions || league.competitions.length === 0) && (
|
||||
<p className="text-sm text-[var(--color-muted)]">No competitions yet</p>
|
||||
)}
|
||||
</ul>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href={`/master/leagues/${leagueId}/competitions/new`}>
|
||||
Add competition
|
||||
</Link>
|
||||
</Button>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/(master)/master/leagues/page.tsx
Normal file
18
app/(master)/master/leagues/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { getCurrentProfile } from "@/lib/auth/profile";
|
||||
import { listLeaguesForMaster } from "@/lib/services/leagues";
|
||||
import { MasterLeaguesClient } from "@/components/master/master-leagues-client";
|
||||
|
||||
export default async function MasterLeaguesPage() {
|
||||
const supabase = await createClient();
|
||||
const ctx = await getCurrentProfile();
|
||||
const isGlobal = ctx?.profile?.portal_role === "league_master";
|
||||
|
||||
const leagues = await listLeaguesForMaster(
|
||||
supabase,
|
||||
ctx!.user.id,
|
||||
!!isGlobal
|
||||
);
|
||||
|
||||
return <MasterLeaguesClient initialLeagues={leagues} />;
|
||||
}
|
||||
103
app/(master)/master/page.tsx
Normal file
103
app/(master)/master/page.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Trophy, Users, Inbox, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { MasterAssignPanel } from "@/components/master/master-assign-panel";
|
||||
|
||||
export default async function MasterDashboardPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const [{ count: leagues }, { count: players }, { data: openIssues }] =
|
||||
await Promise.all([
|
||||
supabase.from("leagues").select("*", { count: "exact", head: true }),
|
||||
supabase.from("players").select("*", { count: "exact", head: true }),
|
||||
supabase
|
||||
.from("support_issues")
|
||||
.select("id, subject, status, created_at, leagues(name)")
|
||||
.eq("status", "open")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="League Master Dashboard"
|
||||
description="Overview of leagues, players, and open issues"
|
||||
actions={
|
||||
<Link
|
||||
href="/master/leagues"
|
||||
className="inline-flex h-10 items-center rounded-lg bg-cyan-500 px-4 text-sm font-medium text-slate-950 hover:bg-cyan-400"
|
||||
>
|
||||
Manage leagues
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
title="Leagues"
|
||||
value={leagues ?? 0}
|
||||
subtitle="Total tournaments"
|
||||
icon={Trophy}
|
||||
accent="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
title="Players"
|
||||
value={players ?? 0}
|
||||
subtitle="Global registry"
|
||||
icon={Users}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Open issues"
|
||||
value={openIssues?.length ?? 0}
|
||||
subtitle="Needs attention"
|
||||
icon={Inbox}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
title="Portal"
|
||||
value="Master"
|
||||
subtitle="Full admin access"
|
||||
icon={Shield}
|
||||
accent="pink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<MasterAssignPanel />
|
||||
|
||||
<GlassCard title="Recent issues">
|
||||
<ul className="space-y-3">
|
||||
{openIssues?.map((issue) => {
|
||||
const league = issue.leagues as { name: string } | null;
|
||||
return (
|
||||
<li
|
||||
key={issue.id}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="font-medium">{issue.subject}</p>
|
||||
<p className="text-xs text-[var(--color-muted)]">
|
||||
{league?.name} · {new Date(issue.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{(!openIssues || openIssues.length === 0) && (
|
||||
<p className="text-sm text-[var(--color-muted)]">No open issues</p>
|
||||
)}
|
||||
</ul>
|
||||
<Link
|
||||
href="/master/issues"
|
||||
className="mt-4 inline-block text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
View all issues
|
||||
</Link>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/(master)/master/players/page.tsx
Normal file
21
app/(master)/master/players/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { PlayersRegistry } from "@/components/players/players-registry";
|
||||
|
||||
export default async function MasterPlayersPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: players } = await supabase
|
||||
.from("players")
|
||||
.select("*")
|
||||
.order("display_name");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Player registry"
|
||||
description="Global players used for rosters and transfers"
|
||||
/>
|
||||
<PlayersRegistry initialPlayers={players ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
app/api/auth/signup/route.ts
Normal file
126
app/api/auth/signup/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient, hasAdminClient } from "@/lib/supabase/admin";
|
||||
import { formatSignupError } from "@/lib/supabase/auth-errors";
|
||||
import type { PortalRole } from "@/lib/auth/roles";
|
||||
|
||||
type Body = {
|
||||
email?: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
portalRole?: PortalRole;
|
||||
};
|
||||
|
||||
function parsePortalRole(value: unknown): PortalRole {
|
||||
return value === "league_master" ? "league_master" : "manager";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!hasAdminClient()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
"Server signup is not configured. Add SUPABASE_SERVICE_ROLE_KEY to .env.local or use client signup.",
|
||||
useClient: true,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: Body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid JSON body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const email = body.email?.trim().toLowerCase();
|
||||
const password = body.password;
|
||||
const displayName = body.displayName?.trim() || email?.split("@")[0] || "User";
|
||||
const portalRole = parsePortalRole(body.portalRole);
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Email and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Password must be at least 6 characters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const autoConfirm =
|
||||
process.env.SUPABASE_AUTO_CONFIRM_EMAIL === "true" ||
|
||||
process.env.NODE_ENV === "development";
|
||||
|
||||
try {
|
||||
const admin = createAdminClient();
|
||||
|
||||
const { data: created, error: createError } =
|
||||
await admin.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: autoConfirm,
|
||||
user_metadata: {
|
||||
display_name: displayName,
|
||||
portal_role: portalRole,
|
||||
},
|
||||
});
|
||||
|
||||
if (createError) {
|
||||
const message = formatSignupError(createError.message);
|
||||
const status = createError.status === 422 ? 422 : 500;
|
||||
return NextResponse.json({ success: false, error: message }, { status });
|
||||
}
|
||||
|
||||
const userId = created.user?.id;
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "User was not created" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { error: profileError } = await admin.from("profiles").upsert(
|
||||
{
|
||||
id: userId,
|
||||
display_name: displayName,
|
||||
portal_role: portalRole,
|
||||
},
|
||||
{ onConflict: "id" }
|
||||
);
|
||||
|
||||
if (profileError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: formatSignupError(
|
||||
profileError.message.includes("portal_role")
|
||||
? "Database error saving new user"
|
||||
: profileError.message
|
||||
),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
userId,
|
||||
needsEmailConfirmation: !autoConfirm,
|
||||
portalRole,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Signup failed";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: formatSignupError(message) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/api/competitions/[competitionId]/activate/route.ts
Normal file
19
app/api/competitions/[competitionId]/activate/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
await leagues.activateCompetition(supabase, competitionId);
|
||||
return apiSuccess({ activated: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
32
app/api/competitions/[competitionId]/dashboard/route.ts
Normal file
32
app/api/competitions/[competitionId]/dashboard/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { ApiError, apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as teams from "@/lib/services/teams";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const membership = await teams.getManagerTeam(
|
||||
supabase,
|
||||
user.id,
|
||||
competitionId
|
||||
);
|
||||
if (!membership) {
|
||||
throw new ApiError(404, "You are not a manager in this competition");
|
||||
}
|
||||
const teamId = membership.team_id;
|
||||
const dashboard = await teams.getTeamDashboard(
|
||||
supabase,
|
||||
teamId,
|
||||
competitionId
|
||||
);
|
||||
return apiSuccess({ team: membership.teams, ...dashboard });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
24
app/api/competitions/[competitionId]/fixtures/route.ts
Normal file
24
app/api/competitions/[competitionId]/fixtures/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const comp = await leagues.getCompetition(supabase, competitionId);
|
||||
const count = await leagues.generateFixtures(
|
||||
supabase,
|
||||
competitionId,
|
||||
comp.tournament_mode as "league" | "cup"
|
||||
);
|
||||
return apiSuccess({ matchesCreated: count });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
19
app/api/competitions/[competitionId]/matches/route.ts
Normal file
19
app/api/competitions/[competitionId]/matches/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as matches from "@/lib/services/matches";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await matches.listMatches(supabase, competitionId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
24
app/api/competitions/[competitionId]/roster/route.ts
Normal file
24
app/api/competitions/[competitionId]/roster/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as players from "@/lib/services/players";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{ teamId: string; playerId: string }>(request);
|
||||
await players.addToRoster(supabase, user.id, {
|
||||
teamId: body.teamId,
|
||||
competitionId,
|
||||
playerId: body.playerId,
|
||||
});
|
||||
return apiSuccess({ added: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
19
app/api/competitions/[competitionId]/route.ts
Normal file
19
app/api/competitions/[competitionId]/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await leagues.getCompetition(supabase, competitionId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
19
app/api/competitions/[competitionId]/standings/route.ts
Normal file
19
app/api/competitions/[competitionId]/standings/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await leagues.getStandings(supabase, competitionId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
39
app/api/competitions/[competitionId]/teams/route.ts
Normal file
39
app/api/competitions/[competitionId]/teams/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as teams from "@/lib/services/teams";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await teams.listTeams(supabase, competitionId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
name: string;
|
||||
nickname?: string;
|
||||
icon?: string;
|
||||
}>(request);
|
||||
const data = await teams.createTeam(supabase, competitionId, body);
|
||||
return apiSuccess(data, 201);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
42
app/api/competitions/[competitionId]/transfers/route.ts
Normal file
42
app/api/competitions/[competitionId]/transfers/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as players from "@/lib/services/players";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await players.listTransfers(supabase, competitionId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ competitionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { competitionId } = await params;
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
playerId: string;
|
||||
fromTeamId: string;
|
||||
toTeamId: string;
|
||||
}>(request);
|
||||
await players.registerTransfer(supabase, user.id, {
|
||||
competitionId,
|
||||
...body,
|
||||
});
|
||||
return apiSuccess({ transferred: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
31
app/api/health/route.ts
Normal file
31
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { getSupabaseEnv } from "@/lib/supabase/env";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
let urlOk = false;
|
||||
try {
|
||||
const { url } = getSupabaseEnv();
|
||||
urlOk = url.includes(".supabase.co");
|
||||
} catch (e) {
|
||||
return apiSuccess({
|
||||
status: "misconfigured",
|
||||
supabase: false,
|
||||
message: e instanceof Error ? e.message : "Invalid env",
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("leagues").select("id").limit(1);
|
||||
|
||||
return apiSuccess({
|
||||
status: error ? "degraded" : "ok",
|
||||
supabase: !error,
|
||||
dbError: error?.message,
|
||||
urlOk,
|
||||
});
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
43
app/api/issues/route.ts
Normal file
43
app/api/issues/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as issues from "@/lib/services/issues";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const asMaster = searchParams.get("as") === "master";
|
||||
const data = await issues.listIssuesForUser(
|
||||
supabase,
|
||||
user.id,
|
||||
asMaster
|
||||
);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
leagueId: string;
|
||||
competitionId?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}>(request);
|
||||
const data = await issues.createIssue(supabase, user.id, {
|
||||
leagueId: body.leagueId,
|
||||
competitionId: body.competitionId,
|
||||
subject: body.subject,
|
||||
body: body.body,
|
||||
});
|
||||
return apiSuccess(data, 201);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
24
app/api/leagues/[leagueId]/competitions/route.ts
Normal file
24
app/api/leagues/[leagueId]/competitions/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ leagueId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
name: string;
|
||||
tournament_mode: "league" | "cup";
|
||||
timezone?: string;
|
||||
}>(request);
|
||||
const data = await leagues.createCompetition(supabase, user.id, leagueId, body);
|
||||
return apiSuccess(data, 201);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
44
app/api/leagues/[leagueId]/masters/route.ts
Normal file
44
app/api/leagues/[leagueId]/masters/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { ApiError, apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as masters from "@/lib/services/masters";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ leagueId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await masters.listLeagueMasters(supabase, leagueId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ leagueId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{ email: string }>(request);
|
||||
const targetId = await masters.getUserIdByEmail(supabase, body.email);
|
||||
if (!targetId) {
|
||||
throw new ApiError(404, "No user found with that email");
|
||||
}
|
||||
await masters.assignLeagueMaster(
|
||||
supabase,
|
||||
leagueId,
|
||||
targetId,
|
||||
user.id
|
||||
);
|
||||
return apiSuccess({ assigned: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
34
app/api/leagues/[leagueId]/route.ts
Normal file
34
app/api/leagues/[leagueId]/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ leagueId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await leagues.getLeague(supabase, leagueId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ leagueId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
await leagues.deleteLeague(supabase, leagueId);
|
||||
return apiSuccess({ deleted: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
20
app/api/leagues/[leagueId]/rules/route.ts
Normal file
20
app/api/leagues/[leagueId]/rules/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ leagueId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { leagueId } = await params;
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{ rules: object }>(request);
|
||||
await leagues.saveLeagueRules(supabase, user.id, leagueId, body.rules);
|
||||
return apiSuccess({ saved: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
30
app/api/leagues/route.ts
Normal file
30
app/api/leagues/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as leagues from "@/lib/services/leagues";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await leagues.listLeagues(supabase);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{ name: string; description?: string }>(request);
|
||||
if (!body.name?.trim()) {
|
||||
return apiError(new Error("name is required"));
|
||||
}
|
||||
const data = await leagues.createLeague(supabase, user.id, body);
|
||||
return apiSuccess(data, 201);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
21
app/api/manager/calendar/route.ts
Normal file
21
app/api/manager/calendar/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import { getManagerCalendarMatches } from "@/lib/services/calendar";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
if (!from || !to) {
|
||||
return apiError(new Error("from and to query params required (ISO dates)"));
|
||||
}
|
||||
const data = await getManagerCalendarMatches(supabase, user.id, from, to);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
23
app/api/manager/competitions/route.ts
Normal file
23
app/api/manager/competitions/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as manager from "@/lib/services/manager";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const mode = new URL(request.url).searchParams.get("mode") as
|
||||
| "league"
|
||||
| "cup"
|
||||
| null;
|
||||
const data = await manager.getManagerCompetitions(
|
||||
supabase,
|
||||
user.id,
|
||||
mode ?? undefined
|
||||
);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
15
app/api/manager/dashboard/route.ts
Normal file
15
app/api/manager/dashboard/route.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as manager from "@/lib/services/manager";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const data = await manager.getManagerDashboard(supabase, user.id);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
32
app/api/master/calendar/route.ts
Normal file
32
app/api/master/calendar/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import { getMasterCalendarMatches } from "@/lib/services/calendar";
|
||||
import { resolvePortalRole } from "@/lib/auth/resolve-portal-role";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const { role } = await resolvePortalRole(supabase, user);
|
||||
if (role !== "league_master") {
|
||||
return apiError(new Error("League master access required"));
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
if (!from || !to) {
|
||||
return apiError(new Error("from and to query params required"));
|
||||
}
|
||||
const data = await getMasterCalendarMatches(
|
||||
supabase,
|
||||
user.id,
|
||||
false,
|
||||
from,
|
||||
to
|
||||
);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
58
app/api/matches/[matchId]/results/route.ts
Normal file
58
app/api/matches/[matchId]/results/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { ApiError, apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as matches from "@/lib/services/matches";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ matchId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { matchId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
action: "submit" | "approve" | "set";
|
||||
teamId?: string;
|
||||
homeScore?: number;
|
||||
awayScore?: number;
|
||||
note?: string;
|
||||
}>(request);
|
||||
|
||||
switch (body.action) {
|
||||
case "submit":
|
||||
if (body.teamId == null || body.homeScore == null || body.awayScore == null) {
|
||||
throw new ApiError(400, "teamId, homeScore, awayScore required");
|
||||
}
|
||||
await matches.submitResult(
|
||||
supabase,
|
||||
matchId,
|
||||
body.teamId,
|
||||
body.homeScore,
|
||||
body.awayScore
|
||||
);
|
||||
break;
|
||||
case "approve":
|
||||
await matches.approveResult(supabase, matchId);
|
||||
break;
|
||||
case "set":
|
||||
if (body.homeScore == null || body.awayScore == null) {
|
||||
throw new ApiError(400, "homeScore, awayScore required");
|
||||
}
|
||||
await matches.setResultByManager(
|
||||
supabase,
|
||||
matchId,
|
||||
body.homeScore,
|
||||
body.awayScore,
|
||||
body.note
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new ApiError(400, "action must be submit, approve, or set");
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
19
app/api/matches/[matchId]/route.ts
Normal file
19
app/api/matches/[matchId]/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess } from "@/lib/api/errors";
|
||||
import * as matches from "@/lib/services/matches";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ matchId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { matchId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await matches.getMatchDetails(supabase, matchId);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
34
app/api/matches/[matchId]/schedule/route.ts
Normal file
34
app/api/matches/[matchId]/schedule/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { ApiError, apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as matches from "@/lib/services/matches";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ matchId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { matchId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
action: "propose" | "sign";
|
||||
scheduledAt?: string;
|
||||
teamId?: string;
|
||||
}>(request);
|
||||
|
||||
if (body.action === "propose") {
|
||||
if (!body.scheduledAt) throw new ApiError(400, "scheduledAt required");
|
||||
await matches.proposeSchedule(supabase, matchId, body.scheduledAt);
|
||||
} else if (body.action === "sign") {
|
||||
if (!body.teamId) throw new ApiError(400, "teamId required");
|
||||
await matches.signSchedule(supabase, matchId, body.teamId);
|
||||
} else {
|
||||
throw new ApiError(400, "action must be propose or sign");
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
20
app/api/players/[playerId]/route.ts
Normal file
20
app/api/players/[playerId]/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as players from "@/lib/services/players";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ playerId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { playerId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const body = await parseJson<{ status: "active" | "inactive" }>(request);
|
||||
await players.updatePlayerStatus(supabase, playerId, body.status);
|
||||
return apiSuccess({ updated: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
29
app/api/players/route.ts
Normal file
29
app/api/players/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as players from "@/lib/services/players";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const data = await players.listPlayers(supabase);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const user = await requireUser(supabase);
|
||||
const body = await parseJson<{ display_name: string; external_id?: string }>(
|
||||
request
|
||||
);
|
||||
const data = await players.createPlayer(supabase, user.id, body);
|
||||
return apiSuccess(data, 201);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
22
app/api/teams/[teamId]/availability/route.ts
Normal file
22
app/api/teams/[teamId]/availability/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as teams from "@/lib/services/teams";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
windows: { day_of_week: number; start_time?: string; end_time?: string }[];
|
||||
}>(request);
|
||||
await teams.setAvailability(supabase, teamId, body.windows ?? []);
|
||||
return apiSuccess({ saved: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
40
app/api/teams/[teamId]/route.ts
Normal file
40
app/api/teams/[teamId]/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireUser } from "@/lib/api/auth";
|
||||
import { apiError, apiSuccess, parseJson } from "@/lib/api/errors";
|
||||
import * as teams from "@/lib/services/teams";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
const body = await parseJson<{
|
||||
home_stadium_name?: string;
|
||||
logo_path?: string;
|
||||
nickname?: string;
|
||||
icon?: string;
|
||||
}>(request);
|
||||
const data = await teams.updateTeam(supabase, teamId, body);
|
||||
return apiSuccess(data);
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId } = await params;
|
||||
const supabase = await createClient();
|
||||
await requireUser(supabase);
|
||||
await teams.deleteTeam(supabase, teamId);
|
||||
return apiSuccess({ deleted: true });
|
||||
} catch (e) {
|
||||
return apiError(e);
|
||||
}
|
||||
}
|
||||
21
app/auth/callback/route.ts
Normal file
21
app/auth/callback/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const next = searchParams.get("next") ?? "/";
|
||||
|
||||
if (code) {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (error) {
|
||||
const errUrl = new URL("/reset-password", origin);
|
||||
errUrl.searchParams.set("error", error.message);
|
||||
return NextResponse.redirect(errUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const safeNext = next.startsWith("/") ? next : "/";
|
||||
return NextResponse.redirect(`${origin}${safeNext}`);
|
||||
}
|
||||
155
app/globals.css
Normal file
155
app/globals.css
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-neon: var(--neon);
|
||||
--color-neon-muted: var(--neon-muted);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--font-sans: var(--font-barlow), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: var(--font-barlow-condensed), var(--font-barlow), sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--background: #030303;
|
||||
--foreground: #f4f4f5;
|
||||
--card: #0a0a0c;
|
||||
--card-foreground: #f4f4f5;
|
||||
--popover: #0c0c0f;
|
||||
--popover-foreground: #f4f4f5;
|
||||
--primary: #c8ff4a;
|
||||
--primary-foreground: #050505;
|
||||
--secondary: #141416;
|
||||
--secondary-foreground: #e4e4e7;
|
||||
--muted: #18181b;
|
||||
--muted-foreground: #8b8b96;
|
||||
--accent: #1a1a1f;
|
||||
--accent-foreground: #c8ff4a;
|
||||
--destructive: #ff4d6d;
|
||||
--border: #252528;
|
||||
--input: #1c1c20;
|
||||
--ring: #c8ff4a;
|
||||
--neon: #c8ff4a;
|
||||
--neon-muted: #7cb342;
|
||||
--chart-1: #c8ff4a;
|
||||
--chart-2: #a855f7;
|
||||
--chart-3: #ff9124;
|
||||
--chart-4: #22d3ee;
|
||||
--chart-5: #f472b6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Retro grid + scanline atmosphere */
|
||||
.retro-grid {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.retro-grid::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in oklab, var(--neon) 4%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in oklab, var(--neon) 4%, transparent) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 80% 70% at 50% 0%, black 20%, transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.retro-grid::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: radial-gradient(
|
||||
ellipse 120% 80% at 50% -20%,
|
||||
color-mix(in oklab, var(--neon) 8%, transparent),
|
||||
transparent 55%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.retro-card {
|
||||
background: color-mix(in oklab, var(--card) 92%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in oklab, var(--neon) 6%, transparent) inset,
|
||||
0 8px 32px -12px rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.retro-card-glow {
|
||||
border-color: color-mix(in oklab, var(--neon) 35%, var(--border));
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in oklab, var(--neon) 12%, transparent) inset,
|
||||
0 0 24px -6px color-mix(in oklab, var(--neon) 25%, transparent);
|
||||
}
|
||||
|
||||
.neon-text {
|
||||
color: var(--neon);
|
||||
text-shadow: 0 0 20px color-mix(in oklab, var(--neon) 45%, transparent);
|
||||
}
|
||||
|
||||
.neon-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 9999px;
|
||||
background: var(--neon);
|
||||
box-shadow: 0 0 8px var(--neon);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.glass-card-highlight {
|
||||
background: var(--card);
|
||||
border: 1px solid color-mix(in oklab, var(--neon) 35%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
37
app/layout.tsx
Normal file
37
app/layout.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Barlow, Barlow_Condensed } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const barlow = Barlow({
|
||||
variable: "--font-barlow",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const barlowCondensed = Barlow_Condensed({
|
||||
variable: "--font-barlow-condensed",
|
||||
subsets: ["latin"],
|
||||
weight: ["600", "700", "800", "900"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Yaltopia FIFA — Tournament System",
|
||||
description: "FIFA tournament holding system by Yaltopia Tech",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${barlow.variable} ${barlowCondensed.variable} font-sans antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
73
app/page.tsx
Normal file
73
app/page.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import Link from "next/link";
|
||||
import { Trophy, ArrowRight, CalendarDays } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { YaltopiaFooter } from "@/components/layout/YaltopiaFooter";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="retro-grid flex min-h-screen flex-col items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary shadow-[0_0_32px_-8px_var(--neon)]">
|
||||
<Trophy className="h-7 w-7 text-primary-foreground" />
|
||||
</div>
|
||||
<p className="font-display text-[10px] font-bold uppercase tracking-[0.25em] text-neon">
|
||||
Yaltopia · FIFA
|
||||
</p>
|
||||
<h1 className="font-display mt-2 text-4xl font-black uppercase tracking-tight">
|
||||
Tournament OS
|
||||
</h1>
|
||||
<p className="mt-3 max-w-sm text-sm text-muted-foreground">
|
||||
Retro-grade fixtures, standings, and squad tools — built for team
|
||||
managers who want the hype without the clutter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="retro-card border-border/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-display text-lg font-bold uppercase tracking-wide">
|
||||
Team Manager
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to view leagues, cups, your match calendar, and submit
|
||||
issues.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button asChild variant="neon" className="w-full" size="lg">
|
||||
<Link href="/login/manager">
|
||||
Sign in
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
New manager?{" "}
|
||||
<Link
|
||||
href="/signup/manager"
|
||||
className="font-medium text-neon underline-offset-4 hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3.5 w-3.5 text-neon-muted" />
|
||||
<span>Match calendar inside the manager portal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full max-w-lg">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components.json
Normal file
18
components.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
||||
146
components/auth/forgot-password-form.tsx
Normal file
146
components/auth/forgot-password-form.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { formatAuthNetworkError } from "@/lib/supabase/env";
|
||||
import { formatAuthError, isAuthRateLimitError } from "@/lib/supabase/auth-errors";
|
||||
import {
|
||||
formatCooldownSeconds,
|
||||
getResetEmailCooldownRemainingMs,
|
||||
RESET_EMAIL_COOLDOWN_MS,
|
||||
startResetEmailCooldown,
|
||||
} from "@/lib/auth/reset-email-cooldown";
|
||||
import type { PortalRole } from "@/lib/auth/roles";
|
||||
import { loginPathForRole } from "@/lib/auth/roles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function resetRedirectUrl(portal: PortalRole) {
|
||||
const origin =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const next = `/reset-password?portal=${portal}`;
|
||||
return `${origin}/auth/callback?next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
export function ForgotPasswordForm({ portal }: { portal: PortalRole }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [cooldownMs, setCooldownMs] = useState(0);
|
||||
|
||||
const loginHref = loginPathForRole(portal);
|
||||
const portalLabel = portal === "league_master" ? "League Master" : "Team Manager";
|
||||
|
||||
useEffect(() => {
|
||||
setCooldownMs(getResetEmailCooldownRemainingMs());
|
||||
const id = window.setInterval(() => {
|
||||
const remaining = getResetEmailCooldownRemainingMs();
|
||||
setCooldownMs(remaining);
|
||||
if (remaining <= 0) window.clearInterval(id);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [sent, error]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const remaining = getResetEmailCooldownRemainingMs();
|
||||
if (remaining > 0) {
|
||||
setError(
|
||||
`Please wait ${formatCooldownSeconds(remaining)} seconds before requesting another email.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
|
||||
email.trim(),
|
||||
{ redirectTo: resetRedirectUrl(portal) }
|
||||
);
|
||||
if (resetError) {
|
||||
setError(formatAuthError(resetError));
|
||||
if (isAuthRateLimitError(resetError)) {
|
||||
startResetEmailCooldown();
|
||||
setCooldownMs(RESET_EMAIL_COOLDOWN_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
startResetEmailCooldown();
|
||||
setCooldownMs(RESET_EMAIL_COOLDOWN_MS);
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(formatAuthNetworkError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const submitDisabled = loading || cooldownMs > 0;
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If an account exists for{" "}
|
||||
<strong className="text-foreground">{email}</strong>, we sent a reset
|
||||
link. Check your inbox and spam folder.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The link opens a page to set a new password, then signs you into the{" "}
|
||||
{portalLabel} portal.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Did not get it? Wait at least an hour (Supabase email limits) before
|
||||
trying again, or use Authentication → Users in the Supabase dashboard.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href={loginHref}>Back to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
{cooldownMs > 0 && !error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can request another email in{" "}
|
||||
{formatCooldownSeconds(cooldownMs)}s.
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={submitDisabled}>
|
||||
{loading
|
||||
? "Sending…"
|
||||
: cooldownMs > 0
|
||||
? `Wait ${formatCooldownSeconds(cooldownMs)}s`
|
||||
: "Send reset link"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href={loginHref} className="hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
157
components/auth/login-form.tsx
Normal file
157
components/auth/login-form.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { formatAuthNetworkError } from "@/lib/supabase/env";
|
||||
import type { PortalRole } from "@/lib/auth/roles";
|
||||
import { PORTAL_ROUTES } from "@/lib/auth/roles";
|
||||
import {
|
||||
ensureUserProfile,
|
||||
isPortalRoleSchemaError,
|
||||
resolvePortalRole,
|
||||
} from "@/lib/auth/resolve-portal-role";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function LoginForm({ expectedRole }: { expectedRole: PortalRole }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: authData, error: authError } =
|
||||
await supabase.auth.signInWithPassword({ email, password });
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = authData.user?.id;
|
||||
if (!userId) {
|
||||
setError("Sign in failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
setError("Sign in failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const { role, profileError } = await resolvePortalRole(supabase, user);
|
||||
|
||||
if (role !== expectedRole) {
|
||||
await supabase.auth.signOut();
|
||||
setError(
|
||||
expectedRole === "manager"
|
||||
? "This account is not a Team Manager. Use the League Master sign-in URL if you are an admin."
|
||||
: "This account is not a League Master. Use the manager sign-in page."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileError && isPortalRoleSchemaError(profileError)) {
|
||||
setError(
|
||||
"Database is missing portal_role. Run: npm run db:push — then try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileError) {
|
||||
await ensureUserProfile(supabase, user, expectedRole);
|
||||
}
|
||||
|
||||
router.push(PORTAL_ROUTES[expectedRole]);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(formatAuthNetworkError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<p className="text-center text-sm">
|
||||
<Link
|
||||
href={
|
||||
expectedRole === "league_master"
|
||||
? "/forgot-password/master"
|
||||
: "/forgot-password/manager"
|
||||
}
|
||||
className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="retro-grid flex min-h-screen flex-col items-center justify-center px-4">
|
||||
<div className="retro-card w-full max-w-md p-8">
|
||||
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
|
||||
Yaltopia FIFA
|
||||
</p>
|
||||
<h1 className="font-display mt-1 text-2xl font-black uppercase tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
<div className="mt-6">{children}</div>
|
||||
{footer && <div className="mt-6">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
components/auth/reset-password-form.tsx
Normal file
137
components/auth/reset-password-form.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { formatAuthNetworkError } from "@/lib/supabase/env";
|
||||
import {
|
||||
type PortalRole,
|
||||
PORTAL_ROUTES,
|
||||
loginPathForRole,
|
||||
} from "@/lib/auth/roles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function parsePortal(value: string | null): PortalRole {
|
||||
return value === "league_master" ? "league_master" : "manager";
|
||||
}
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const portal = parsePortal(searchParams.get("portal"));
|
||||
const urlError = searchParams.get("error");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(urlError);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const loginHref = loginPathForRole(portal);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
});
|
||||
if (updateError) {
|
||||
setError(updateError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (user) {
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("portal_role")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
const role = (profile?.portal_role as PortalRole) ?? portal;
|
||||
if (role !== portal) {
|
||||
setError(
|
||||
`This account is a ${role === "league_master" ? "League Master" : "Team Manager"} account. Use the correct reset link or sign in at the other portal.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDone(true);
|
||||
setTimeout(() => {
|
||||
router.push(PORTAL_ROUTES[portal]);
|
||||
router.refresh();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(formatAuthNetworkError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-sm text-emerald-400">Password updated successfully.</p>
|
||||
<p className="text-xs text-muted-foreground">Redirecting to your dashboard…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm">Confirm password</Label>
|
||||
<PasswordInput
|
||||
id="confirm"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Saving…" : "Set new password"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href={loginHref} className="hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
221
components/auth/signup-form.tsx
Normal file
221
components/auth/signup-form.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { formatAuthNetworkError } from "@/lib/supabase/env";
|
||||
import { formatAuthError, formatSignupError } from "@/lib/supabase/auth-errors";
|
||||
import { ensureUserProfile } from "@/lib/auth/resolve-portal-role";
|
||||
import type { PortalRole } from "@/lib/auth/roles";
|
||||
import { loginPathForRole, PORTAL_ROUTES } from "@/lib/auth/roles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
async function signupViaApi(
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
portalRole: PortalRole
|
||||
) {
|
||||
const res = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, displayName, portalRole }),
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
useClient?: boolean;
|
||||
needsEmailConfirmation?: boolean;
|
||||
};
|
||||
return { ok: res.ok && json.success, status: res.status, ...json };
|
||||
}
|
||||
|
||||
export function SignupForm({
|
||||
portalRole,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
portalRole: PortalRole;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loginHref = loginPathForRole(portalRole);
|
||||
const portalLabel =
|
||||
portalRole === "league_master" ? "League Master" : "Team Manager";
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedEmail = email.trim();
|
||||
const api = await signupViaApi(
|
||||
trimmedEmail,
|
||||
password,
|
||||
displayName,
|
||||
portalRole
|
||||
);
|
||||
|
||||
if (api.ok) {
|
||||
if (api.needsEmailConfirmation) {
|
||||
setError(
|
||||
`Confirm your email, then sign in at ${portalLabel} login.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: trimmedEmail,
|
||||
password,
|
||||
});
|
||||
if (signInError) {
|
||||
setError(
|
||||
`Account created. Sign in at ${loginHref} (${signInError.message})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.push(PORTAL_ROUTES[portalRole]);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (api.status !== 503 && !api.useClient) {
|
||||
setError(formatSignupError(api.error ?? "Signup failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data, error: authError } = await supabase.auth.signUp({
|
||||
email: trimmedEmail,
|
||||
password,
|
||||
options: {
|
||||
data: { display_name: displayName, portal_role: portalRole },
|
||||
},
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
setError(formatAuthError(authError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.user && !data.session) {
|
||||
setError(
|
||||
`Confirm your email, then sign in at ${portalLabel} login.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
const sync = await ensureUserProfile(
|
||||
supabase,
|
||||
data.user,
|
||||
portalRole,
|
||||
displayName
|
||||
);
|
||||
if (sync.error === "schema") {
|
||||
setError(
|
||||
"Account created but database needs an update. Run: npm run db:push — then sign in."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!sync.ok && sync.error) {
|
||||
setError(formatSignupError(sync.error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
router.push(PORTAL_ROUTES[portalRole]);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(formatAuthNetworkError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-grid flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<Card className="retro-card w-full max-w-md border-border/80">
|
||||
<CardHeader>
|
||||
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
|
||||
Yaltopia FIFA
|
||||
</p>
|
||||
<CardTitle className="font-display text-xl font-black uppercase tracking-wide">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Display name</Label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password</Label>
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="whitespace-pre-line text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
<Button type="submit" variant="neon" className="w-full" disabled={loading}>
|
||||
{loading ? "Creating account…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Link href={loginHref} className="text-neon hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
{portalRole === "manager" && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/" className="hover:underline">
|
||||
Home
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
components/calendar/match-calendar.tsx
Normal file
324
components/calendar/match-calendar.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { CalendarMatch } from "@/lib/services/calendar";
|
||||
import {
|
||||
addMonths,
|
||||
buildMonthGrid,
|
||||
endOfMonth,
|
||||
formatMonthYear,
|
||||
matchDisplayDate,
|
||||
parseDateKey,
|
||||
sameDay,
|
||||
startOfMonth,
|
||||
toDateKey,
|
||||
} from "@/lib/calendar/utils";
|
||||
import { apiFetch } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
|
||||
function matchWhen(m: CalendarMatch) {
|
||||
return m.scheduled_at ?? m.proposed_scheduled_at;
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === "completed" || status === "played") return "default" as const;
|
||||
if (status === "scheduled" || status === "confirmed") return "secondary" as const;
|
||||
return "outline" as const;
|
||||
}
|
||||
|
||||
export function MatchCalendar({
|
||||
apiPath,
|
||||
title = "Match calendar",
|
||||
description = "Plan around fixtures — days with matches glow on the grid.",
|
||||
}: {
|
||||
apiPath: "/api/manager/calendar" | "/api/master/calendar";
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||||
const [selected, setSelected] = useState(() => toDateKey(new Date()));
|
||||
const [matches, setMatches] = useState<CalendarMatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const from = startOfMonth(month).toISOString();
|
||||
const to = endOfMonth(month).toISOString();
|
||||
const data = await apiFetch<CalendarMatch[]>(
|
||||
`${apiPath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
|
||||
);
|
||||
setMatches(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load matches");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiPath, month]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const byDate = useMemo(() => {
|
||||
const map = new Map<string, CalendarMatch[]>();
|
||||
for (const m of matches) {
|
||||
const when = matchWhen(m);
|
||||
if (!when) continue;
|
||||
const key = toDateKey(new Date(when));
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(m);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
}, [matches]);
|
||||
|
||||
const unscheduled = useMemo(
|
||||
() => matches.filter((m) => !matchWhen(m)),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const grid = useMemo(() => buildMonthGrid(month), [month]);
|
||||
const selectedMatches = byDate.get(selected) ?? [];
|
||||
const selectedDate = parseDateKey(selected);
|
||||
const today = new Date();
|
||||
|
||||
const monthMatchCount = useMemo(() => {
|
||||
let n = 0;
|
||||
for (const [, list] of byDate) n += list.length;
|
||||
return n;
|
||||
}, [byDate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
|
||||
Schedule
|
||||
</p>
|
||||
<h1 className="font-display text-3xl font-black uppercase tracking-tight md:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
|
||||
<div className="retro-card p-4 md:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-5 w-5 text-neon" />
|
||||
<h2 className="font-display text-xl font-bold uppercase tracking-wide">
|
||||
{formatMonthYear(month)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full border-border"
|
||||
onClick={() => setMonth((m) => addMonths(m, -1))}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => {
|
||||
const now = startOfMonth(new Date());
|
||||
setMonth(now);
|
||||
setSelected(toDateKey(new Date()));
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full border-border"
|
||||
onClick={() => setMonth((m) => addMonths(m, 1))}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-neon" />
|
||||
Loading fixtures…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<p className="py-8 text-center text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{WEEKDAYS.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="py-1 text-center font-display text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{grid.map((day) => {
|
||||
const key = toDateKey(day);
|
||||
const inMonth = day.getMonth() === month.getMonth();
|
||||
const isSelected = key === selected;
|
||||
const isToday = sameDay(day, today);
|
||||
const dayMatches = byDate.get(key) ?? [];
|
||||
const hasMatches = dayMatches.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setSelected(key)}
|
||||
className={cn(
|
||||
"group relative flex min-h-[52px] flex-col items-center justify-start rounded-xl border px-1 py-2 text-sm transition-all md:min-h-[64px]",
|
||||
inMonth ? "border-border/80 bg-secondary/30" : "border-transparent bg-transparent opacity-35",
|
||||
isSelected && "retro-card-glow border-neon/40 bg-neon/10",
|
||||
!isSelected && hasMatches && "hover:border-neon/30 hover:bg-neon/5",
|
||||
!isSelected && !hasMatches && inMonth && "hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"font-display text-base font-bold tabular-nums",
|
||||
isToday && "neon-text",
|
||||
isSelected && !isToday && "text-foreground"
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
{hasMatches && (
|
||||
<div className="mt-1 flex flex-wrap justify-center gap-0.5">
|
||||
{dayMatches.slice(0, 3).map((m) => (
|
||||
<span key={m.id} className="neon-dot" />
|
||||
))}
|
||||
{dayMatches.length > 3 && (
|
||||
<span className="text-[9px] text-neon-muted">
|
||||
+{dayMatches.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
<span className="neon-text font-semibold">{monthMatchCount}</span>{" "}
|
||||
scheduled in {formatMonthYear(month)}
|
||||
{unscheduled.length > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-foreground">{unscheduled.length}</span> awaiting
|
||||
date
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="retro-card retro-card-glow p-4 md:p-5">
|
||||
<p className="font-display text-[10px] font-bold uppercase tracking-[0.15em] text-neon">
|
||||
{selectedDate.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<h3 className="font-display mt-1 text-2xl font-black uppercase">
|
||||
{selectedMatches.length === 0 ? "No matches" : `${selectedMatches.length} match${selectedMatches.length > 1 ? "es" : ""}`}
|
||||
</h3>
|
||||
|
||||
<ul className="mt-4 space-y-3">
|
||||
{selectedMatches.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">
|
||||
Pick a glowing day or schedule a fixture from your league.
|
||||
</li>
|
||||
)}
|
||||
{selectedMatches.map((m) => (
|
||||
<MatchCard key={m.id} match={m} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{unscheduled.length > 0 && (
|
||||
<div className="retro-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="font-display text-sm font-bold uppercase tracking-wide">
|
||||
Date TBD
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{unscheduled.map((m) => (
|
||||
<MatchCard key={m.id} match={m} compact />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchCard({ match, compact }: { match: CalendarMatch; compact?: boolean }) {
|
||||
const when = matchWhen(match);
|
||||
const href = `/leagues/${match.league_id}/competitions/${match.competition_id}/matches/${match.id}`;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"block rounded-xl border border-border/80 bg-background/60 p-3 transition-colors hover:border-neon/40 hover:bg-neon/5",
|
||||
compact && "p-2.5"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={statusVariant(match.status)} className="text-[10px] uppercase">
|
||||
{match.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{!compact && (
|
||||
<span className="text-[10px] text-muted-foreground">{match.league_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={cn("mt-1 font-semibold", compact ? "text-sm" : "text-base")}>
|
||||
{match.home_name}{" "}
|
||||
<span className="text-muted-foreground font-normal">vs</span> {match.away_name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{match.competition_name}</p>
|
||||
<p className="mt-1 text-xs text-neon-muted">{matchDisplayDate(when)}</p>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
242
components/competitions/competition-draft-panel.tsx
Normal file
242
components/competitions/competition-draft-panel.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TeamIconPicker } from "@/components/teams/team-icon-picker";
|
||||
import { TeamIcon } from "@/components/teams/TeamIcon";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export type DraftTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname: string | null;
|
||||
icon: string | null;
|
||||
logo_path: string | null;
|
||||
};
|
||||
|
||||
export function CompetitionDraftPanel({
|
||||
competitionId,
|
||||
initialTeams,
|
||||
}: {
|
||||
competitionId: string;
|
||||
initialTeams: DraftTeam[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [teams, setTeams] = useState<DraftTeam[]>(initialTeams);
|
||||
const [name, setName] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [icon, setIcon] = useState("shield");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<
|
||||
| { type: "activate" }
|
||||
| { type: "fixtures" }
|
||||
| { type: "delete-team"; team: DraftTeam }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const refreshTeams = useCallback(async () => {
|
||||
const list = (await api.competitions.listTeams(competitionId)) as DraftTeam[];
|
||||
setTeams(list);
|
||||
}, [competitionId]);
|
||||
|
||||
async function run(fn: () => Promise<void>, refresh = true) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
if (refresh) {
|
||||
await refreshTeams();
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setConfirmAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddTeam(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = (await api.competitions.createTeam(competitionId, {
|
||||
name: trimmed,
|
||||
nickname: nickname.trim() || undefined,
|
||||
icon,
|
||||
})) as DraftTeam;
|
||||
setTeams((prev) => [...prev, created]);
|
||||
setName("");
|
||||
setNickname("");
|
||||
setIcon("shield");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add team");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading}
|
||||
onClick={() => setConfirmAction({ type: "activate" })}
|
||||
>
|
||||
Activate competition
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => setConfirmAction({ type: "fixtures" })}
|
||||
>
|
||||
Generate fixtures
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GlassCard title="Add team">
|
||||
<form onSubmit={handleAddTeam} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="team_name">Team name</Label>
|
||||
<Input
|
||||
id="team_name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="FC Example"
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="team_nickname">Nickname</Label>
|
||||
<Input
|
||||
id="team_nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="The Blues"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Team icon</Label>
|
||||
<TeamIconPicker value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Adding…" : "Add team"}
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title={`Teams (${teams.length})`}>
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
No teams yet. Add your first team above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-[var(--color-muted)]">
|
||||
<th className="pb-3 pr-4">Team</th>
|
||||
<th className="pb-3 pr-4">Nickname</th>
|
||||
<th className="pb-3 w-16" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((t) => (
|
||||
<tr key={t.id} className="border-b border-white/5">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<TeamIcon
|
||||
icon={t.icon}
|
||||
className="h-5 w-5 text-cyan-400"
|
||||
/>
|
||||
{t.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-[var(--color-muted)]">
|
||||
{t.nickname || "—"}
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
setConfirmAction({ type: "delete-team", team: t })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-400" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmAction?.type === "activate"}
|
||||
onOpenChange={(o) => !o && setConfirmAction(null)}
|
||||
title="Activate competition?"
|
||||
description="This snapshots league rules and opens the season. Teams cannot be added casually after activation without master access."
|
||||
confirmLabel="Activate"
|
||||
loading={loading}
|
||||
variant="primary"
|
||||
onConfirm={() =>
|
||||
run(() => api.competitions.activate(competitionId), true)
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmAction?.type === "fixtures"}
|
||||
onOpenChange={(o) => !o && setConfirmAction(null)}
|
||||
title="Generate fixtures?"
|
||||
description="This creates all matches for every team. Make sure your team list is final."
|
||||
confirmLabel="Generate"
|
||||
loading={loading}
|
||||
variant="primary"
|
||||
onConfirm={() =>
|
||||
run(() => api.competitions.generateFixtures(competitionId), true)
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmAction?.type === "delete-team"}
|
||||
onOpenChange={(o) => !o && setConfirmAction(null)}
|
||||
title="Remove team?"
|
||||
description={`Delete "${confirmAction?.type === "delete-team" ? confirmAction.team.name : ""}" from this competition? This cannot be undone.`}
|
||||
confirmLabel="Delete team"
|
||||
loading={loading}
|
||||
onConfirm={() => {
|
||||
if (confirmAction?.type !== "delete-team") return;
|
||||
const id = confirmAction.team.id;
|
||||
return run(async () => {
|
||||
await api.teams.delete(id);
|
||||
setTeams((prev) => prev.filter((t) => t.id !== id));
|
||||
}, true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
components/dashboard/page-header.tsx
Normal file
52
components/dashboard/page-header.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
tabs?: { id: string; label: string }[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-black uppercase tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
value={activeTab ?? tabs[0].id}
|
||||
onValueChange={onTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((t) => (
|
||||
<TabsTrigger key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
components/dashboard/stat-card.tsx
Normal file
55
components/dashboard/stat-card.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
trend,
|
||||
trendLabel,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: LucideIcon;
|
||||
trend?: "up" | "down" | "neutral";
|
||||
trendLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<div className="rounded-md border border-border bg-muted/50 p-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tracking-tight">{value}</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{trend && trendLabel && (
|
||||
<Badge
|
||||
variant={
|
||||
trend === "up"
|
||||
? "success"
|
||||
: trend === "down"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{trend === "up" ? "+" : trend === "down" ? "−" : ""}
|
||||
{trendLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{subtitle && (
|
||||
<span className={cn("text-xs text-muted-foreground")}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
154
components/issues/issues-panel.tsx
Normal file
154
components/issues/issues-panel.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { GlassCard } from "@/components/ui/glass-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
||||
type Issue = {
|
||||
id: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
status: string;
|
||||
master_reply: string | null;
|
||||
created_at: string;
|
||||
leagues: { name: string } | null;
|
||||
};
|
||||
|
||||
export function IssuesPanel({
|
||||
leagues,
|
||||
asMaster,
|
||||
}: {
|
||||
leagues: { id: string; name: string }[];
|
||||
asMaster: boolean;
|
||||
}) {
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [pending, setPending] = useState<FormData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.issues.list(asMaster).then((d) => setIssues(d as Issue[]));
|
||||
}, [asMaster]);
|
||||
|
||||
async function submit(fd: FormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.issues.create({
|
||||
leagueId: fd.get("league_id") as string,
|
||||
subject: fd.get("subject") as string,
|
||||
body: fd.get("body") as string,
|
||||
});
|
||||
const updated = (await api.issues.list(asMaster)) as Issue[];
|
||||
setIssues(updated);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowConfirm(false);
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Issues"
|
||||
description={
|
||||
asMaster
|
||||
? "Messages from team managers"
|
||||
: "Send problems to your league master"
|
||||
}
|
||||
/>
|
||||
|
||||
{!asMaster && (
|
||||
<GlassCard title="New issue">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (leagues.length === 0) return;
|
||||
setPending(new FormData(e.currentTarget));
|
||||
setShowConfirm(true);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label>League</Label>
|
||||
<select
|
||||
name="league_id"
|
||||
required
|
||||
className="mt-1 flex h-10 w-full rounded-lg border border-white/15 bg-white/5 px-3 text-sm"
|
||||
>
|
||||
{leagues.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Subject</Label>
|
||||
<Input name="subject" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Details</Label>
|
||||
<textarea
|
||||
name="body"
|
||||
required
|
||||
rows={4}
|
||||
className="mt-1 flex w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || leagues.length === 0}>
|
||||
Submit to league master
|
||||
</Button>
|
||||
</form>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
<GlassCard title={asMaster ? "All issues" : "Your issues"}>
|
||||
<ul className="space-y-3">
|
||||
{issues.map((issue) => (
|
||||
<li
|
||||
key={issue.id}
|
||||
className="rounded-lg border border-white/10 p-3 text-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-2">
|
||||
<p className="font-medium">{issue.subject}</p>
|
||||
<span className="text-xs capitalize text-[var(--color-muted)]">
|
||||
{issue.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-cyan-400/70">
|
||||
{issue.leagues?.name}
|
||||
</p>
|
||||
<p className="mt-2 text-[var(--color-muted)]">{issue.body}</p>
|
||||
{issue.master_reply && (
|
||||
<p className="mt-2 rounded-lg bg-white/5 p-2 text-foreground">
|
||||
<span className="text-xs text-[var(--color-muted)]">Reply: </span>
|
||||
{issue.master_reply}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{issues.length === 0 && (
|
||||
<p className="text-[var(--color-muted)]">No issues</p>
|
||||
)}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showConfirm}
|
||||
onOpenChange={setShowConfirm}
|
||||
title="Send issue to league master?"
|
||||
description="Your message will be visible to league masters for this league."
|
||||
confirmLabel="Send"
|
||||
variant="primary"
|
||||
loading={loading}
|
||||
onConfirm={() => pending && submit(pending)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
components/layout/AppShell.tsx
Normal file
16
components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Sidebar } from "./Sidebar";
|
||||
import { YaltopiaFooter } from "./YaltopiaFooter";
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
components/layout/DashboardShell.tsx
Normal file
156
components/layout/DashboardShell.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { YaltopiaFooter } from "./YaltopiaFooter";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
Search,
|
||||
Settings,
|
||||
Trophy,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export type NavItem = { href: string; label: string; icon: LucideIcon };
|
||||
|
||||
export function DashboardShell({
|
||||
brand,
|
||||
navItems,
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
brand: string;
|
||||
navItems: NavItem[];
|
||||
user: { name: string; email: string };
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const initials = user.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
async function signOut() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-grid flex min-h-screen bg-background">
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-border/80 bg-card/80 backdrop-blur-md md:flex">
|
||||
<div className="flex h-14 items-center gap-2 border-b border-border/80 px-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary shadow-[0_0_20px_-4px_var(--neon)]">
|
||||
<Trophy className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-display truncate text-sm font-bold uppercase tracking-wide">
|
||||
Yaltopia FIFA
|
||||
</p>
|
||||
<p className="truncate text-[10px] uppercase tracking-wider text-neon-muted">
|
||||
{brand}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 p-3">
|
||||
<p className="font-display px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Menu
|
||||
</p>
|
||||
{navItems.map(({ href, label, icon: Icon }) => {
|
||||
const active =
|
||||
pathname === href ||
|
||||
(href !== "/" && pathname.startsWith(href + "/"));
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all",
|
||||
active
|
||||
? "border border-neon/25 bg-neon/10 text-foreground shadow-[inset_3px_0_0_0_var(--neon)]"
|
||||
: "text-muted-foreground hover:bg-secondary/80 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
active ? "text-neon" : "opacity-70"
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border/80 p-3">
|
||||
<div className="flex items-center gap-3 rounded-xl px-2 py-2">
|
||||
<Avatar className="h-9 w-9 border border-border">
|
||||
<AvatarFallback className="bg-secondary text-xs font-bold">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 w-full justify-start text-muted-foreground"
|
||||
onClick={() => void signOut()}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-border/80 bg-background/80 px-4 backdrop-blur-md lg:px-6">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search…"
|
||||
className="h-9 rounded-full border-border bg-muted/40 pl-9"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="rounded-full text-muted-foreground">
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="rounded-full text-muted-foreground">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="mx-1 h-6 w-px bg-border" aria-hidden />
|
||||
<Avatar className="h-8 w-8 border border-border md:hidden">
|
||||
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 lg:p-6">{children}</main>
|
||||
|
||||
<div className="border-t border-border/80 px-6 py-3">
|
||||
<YaltopiaFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
components/layout/ManagerShell.tsx
Normal file
36
components/layout/ManagerShell.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Trophy,
|
||||
Medal,
|
||||
BookOpen,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { DashboardShell } from "./DashboardShell";
|
||||
|
||||
const nav = [
|
||||
{ href: "/manager", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/manager/calendar", label: "Calendar", icon: CalendarDays },
|
||||
{ href: "/manager/leagues", label: "Leagues", icon: Trophy },
|
||||
{ href: "/manager/cups", label: "Cups", icon: Medal },
|
||||
{ href: "/manager/rules", label: "Rules", icon: BookOpen },
|
||||
{ href: "/manager/faq", label: "FAQ", icon: HelpCircle },
|
||||
{ href: "/manager/issues", label: "Issues", icon: MessageSquare },
|
||||
];
|
||||
|
||||
export function ManagerShell({
|
||||
children,
|
||||
user,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
user: { name: string; email: string };
|
||||
}) {
|
||||
return (
|
||||
<DashboardShell brand="Team Manager" navItems={nav} user={user}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
32
components/layout/MasterShell.tsx
Normal file
32
components/layout/MasterShell.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Trophy,
|
||||
Users,
|
||||
Inbox,
|
||||
CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { DashboardShell } from "./DashboardShell";
|
||||
|
||||
const nav = [
|
||||
{ href: "/master", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/master/calendar", label: "Calendar", icon: CalendarDays },
|
||||
{ href: "/master/leagues", label: "Leagues", icon: Trophy },
|
||||
{ href: "/master/players", label: "Players", icon: Users },
|
||||
{ href: "/master/issues", label: "Issues", icon: Inbox },
|
||||
];
|
||||
|
||||
export function MasterShell({
|
||||
children,
|
||||
user,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
user: { name: string; email: string };
|
||||
}) {
|
||||
return (
|
||||
<DashboardShell brand="League Master" navItems={nav} user={user}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user