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