Yaltopia-FIFA/components/calendar/match-calendar.tsx
Kirubel-Kibru-Yaltopia 89440985f1
Some checks failed
Deploy to Cloudflare Workers / deploy (push) Has been cancelled
x
2026-05-24 21:46:10 +03:00

325 lines
12 KiB
TypeScript

"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>
);
}