1130 lines
40 KiB
TypeScript
1130 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Text,
|
||
Heading,
|
||
Badge,
|
||
VStack,
|
||
HStack,
|
||
Image,
|
||
Button,
|
||
Card,
|
||
SimpleGrid,
|
||
Skeleton,
|
||
} from "@chakra-ui/react";
|
||
import { useTranslations } from "next-intl";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||
import { SlideUp, FadeIn } from "@/components/motion";
|
||
import { useMatchDetails } from "@/lib/api/matches/use-hooks";
|
||
import { usePrediction } from "@/lib/api/predictions/use-hooks";
|
||
import { useGetMe } from "@/lib/api/users/use-hooks";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { UsersQueryKeys } from "@/lib/api/users/use-hooks";
|
||
import PredictionCard from "@/components/matches/prediction-card";
|
||
import OddsCard from "@/components/matches/odds-card";
|
||
import LineupsCard from "@/components/matches/lineups-card";
|
||
import {
|
||
LuArrowLeft,
|
||
LuRefreshCw,
|
||
LuUser,
|
||
LuSparkles,
|
||
LuInfo,
|
||
LuChevronDown,
|
||
LuChevronUp,
|
||
LuCalendar,
|
||
LuArrowRight,
|
||
} from "react-icons/lu";
|
||
import type {
|
||
MatchResponseDto,
|
||
MatchEvent,
|
||
} from "@/lib/api/matches/types";
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Types
|
||
// ─────────────────────────────────────────────────
|
||
|
||
interface SidelinedPlayer {
|
||
playerName: string;
|
||
positionShort?: string;
|
||
description?: string;
|
||
type?: string;
|
||
matchesMissed?: number;
|
||
}
|
||
|
||
interface SidelinedTeam {
|
||
players?: SidelinedPlayer[];
|
||
}
|
||
|
||
interface SidelinedData {
|
||
homeTeam?: SidelinedTeam;
|
||
awayTeam?: SidelinedTeam;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Skeleton Loading
|
||
// ─────────────────────────────────────────────────
|
||
|
||
function MatchDetailSkeleton() {
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||
|
||
return (
|
||
<Box>
|
||
{/* Header skeleton */}
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={4} overflow="hidden">
|
||
<Box h="36px" bg={useColorModeValue("gray.50", "gray.900")} />
|
||
<Card.Body py={8}>
|
||
<Flex justify="space-between" align="center" gap={6}>
|
||
<VStack flex={1} align="center" gap={3}>
|
||
<Skeleton boxSize="72px" borderRadius="full" />
|
||
<Skeleton h="4" w="80px" />
|
||
</VStack>
|
||
<VStack gap={2} flexShrink={0}>
|
||
<Skeleton h="10" w="120px" borderRadius="lg" />
|
||
<Skeleton h="3" w="60px" />
|
||
</VStack>
|
||
<VStack flex={1} align="center" gap={3}>
|
||
<Skeleton boxSize="72px" borderRadius="full" />
|
||
<Skeleton h="4" w="80px" />
|
||
</VStack>
|
||
</Flex>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
{/* Tab bar skeleton */}
|
||
<Skeleton h="44px" borderRadius="xl" mb={6} />
|
||
|
||
{/* Content skeletons */}
|
||
{[1, 2].map((i) => (
|
||
<Card.Root key={i} bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={4}>
|
||
<Card.Body>
|
||
<Skeleton h="5" w="140px" mb={4} />
|
||
<VStack gap={3} align="stretch">
|
||
{[1, 2, 3, 4].map((j) => (
|
||
<Skeleton key={j} h="8" borderRadius="md" />
|
||
))}
|
||
</VStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Main Component
|
||
// ─────────────────────────────────────────────────
|
||
|
||
export default function MatchDetailContent() {
|
||
const t = useTranslations("matches");
|
||
const tPred = useTranslations("predictions");
|
||
const queryClient = useQueryClient();
|
||
const { data: meData } = useGetMe();
|
||
const usageLimit = meData?.data?.usageLimit;
|
||
const hasLimit = usageLimit
|
||
? usageLimit.maxAnalyses - usageLimit.analysisCount > 0
|
||
: true;
|
||
const tCommon = useTranslations("common");
|
||
const params = useParams();
|
||
const router = useRouter();
|
||
|
||
const matchId = params.id as string;
|
||
|
||
const { data: matchData, isLoading: matchLoading } = useMatchDetails(matchId);
|
||
const {
|
||
data: predictionData,
|
||
isLoading: predLoading,
|
||
refetch: refetchPredictionRaw,
|
||
isFetching: isPredFetching,
|
||
} = usePrediction(matchId);
|
||
|
||
const [officialsOpen, setOfficialsOpen] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<string>("all");
|
||
|
||
const refetchPrediction = async () => {
|
||
await refetchPredictionRaw();
|
||
queryClient.invalidateQueries({ queryKey: UsersQueryKeys.me() });
|
||
};
|
||
|
||
// Colors
|
||
const headerBg = useColorModeValue("white", "gray.800");
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||
const subtleBg = useColorModeValue("gray.50", "gray.900");
|
||
const injuryBg = useColorModeValue("red.50", "rgba(254,178,178,0.08)");
|
||
const injuryBorder = useColorModeValue("red.100", "red.800");
|
||
const tabBg = useColorModeValue("white", "gray.800");
|
||
const disclaimerBg = useColorModeValue("blue.50", "rgba(99,179,237,0.08)");
|
||
const disclaimerBorder = useColorModeValue("blue.100", "blue.800");
|
||
const timelineBg = useColorModeValue("gray.200", "gray.600");
|
||
|
||
const match = matchData?.data as MatchResponseDto | undefined;
|
||
const prediction = predictionData?.data;
|
||
|
||
if (matchLoading) return <MatchDetailSkeleton />;
|
||
|
||
if (!match) {
|
||
return (
|
||
<Flex justify="center" align="center" py={20} direction="column" gap={4}>
|
||
<Text color="fg.muted" fontSize="lg">
|
||
{t("no-matches")}
|
||
</Text>
|
||
<Button variant="outline" onClick={() => router.back()}>
|
||
<LuArrowLeft />
|
||
{tCommon("back")}
|
||
</Button>
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
// ── Derived state ──────────────────────────────
|
||
const isLive = match.status === "LIVE";
|
||
const isFinished =
|
||
match.status === "Finished" ||
|
||
match.status === "FT" ||
|
||
match.state === "postGame";
|
||
|
||
const winner = match.winner; // "home" | "away" | "draw" | undefined
|
||
const homeWon = winner === "home";
|
||
const awayWon = winner === "away";
|
||
|
||
const matchEvents = (match.events || match.playerEvents || [])
|
||
.slice()
|
||
.sort((a, b) => (parseInt(a.timeMinute) || 0) - (parseInt(b.timeMinute) || 0));
|
||
|
||
const homeStats = match.stats?.home;
|
||
const awayStats = match.stats?.away;
|
||
const hasStats =
|
||
homeStats != null &&
|
||
awayStats != null &&
|
||
(homeStats.possessionPercentage != null || homeStats.totalShots != null);
|
||
|
||
const officials = match.officials || [];
|
||
const mainReferee = officials.find((o) => o.roleId === 1);
|
||
const otherOfficials = officials.filter((o) => o.roleId !== 1);
|
||
|
||
const sidelined = (match.sidelined || {}) as SidelinedData;
|
||
const hasSidelined =
|
||
!isFinished &&
|
||
((sidelined.homeTeam?.players?.length || 0) > 0 ||
|
||
(sidelined.awayTeam?.players?.length || 0) > 0);
|
||
|
||
const show = (key: string) => activeTab === "all" || activeTab === key;
|
||
|
||
const tabs = [
|
||
{ key: "all", label: t("all-matches", { defaultValue: "Hepsi" }) },
|
||
...(matchEvents.length > 0 ? [{ key: "events", label: t("match-events") }] : []),
|
||
...(hasStats ? [{ key: "stats", label: t("statistics") }] : []),
|
||
{ key: "lineups", label: t("lineups") },
|
||
{ key: "prediction", label: tPred("title") },
|
||
...((match.odds && Object.keys(match.odds).length > 0) ? [{ key: "odds", label: t("odds") }] : []),
|
||
];
|
||
|
||
return (
|
||
<SlideUp>
|
||
<Box>
|
||
{/* Back Button */}
|
||
<Button variant="ghost" size="sm" mb={4} onClick={() => router.back()} gap={1.5}>
|
||
<LuArrowLeft />
|
||
{tCommon("back")}
|
||
</Button>
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 1. MATCH HEADER */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
<Card.Root bg={headerBg} borderColor={borderColor} borderRadius="xl" mb={4} overflow="hidden">
|
||
{/* League banner */}
|
||
{match.league && (
|
||
<Box bg={subtleBg} px={4} py={2.5} borderBottomWidth="1px" borderColor={borderColor}>
|
||
<Flex justify="center" align="center" gap={2}>
|
||
{match.league.country?.flag && (
|
||
<Image
|
||
src={match.league.country.flag}
|
||
alt={match.league.country.name || ""}
|
||
boxSize="18px"
|
||
objectFit="contain"
|
||
borderRadius="sm"
|
||
/>
|
||
)}
|
||
<Text fontSize="sm" fontWeight="semibold" color="fg.muted">
|
||
{match.league.country?.name && `${match.league.country.name} · `}
|
||
{match.league.name}
|
||
</Text>
|
||
<Badge
|
||
colorPalette={isLive ? "red" : isFinished ? "gray" : "green"}
|
||
variant="subtle"
|
||
fontSize="xs"
|
||
borderRadius="full"
|
||
>
|
||
{isLive && (
|
||
<Box
|
||
as="span"
|
||
display="inline-block"
|
||
w="6px"
|
||
h="6px"
|
||
borderRadius="full"
|
||
bg="red.500"
|
||
mr={1}
|
||
animation="pulse 1.5s ease-in-out infinite"
|
||
/>
|
||
)}
|
||
{isLive ? t("live") : isFinished ? t("finished") : t("not-started")}
|
||
</Badge>
|
||
</Flex>
|
||
</Box>
|
||
)}
|
||
|
||
<Card.Body py={7}>
|
||
{/* Teams & Score */}
|
||
<HStack gap={4} justify="center" align="center">
|
||
{/* Home Team */}
|
||
<VStack gap={2} flex={1} align="center">
|
||
{match.homeTeam?.logo ? (
|
||
<Image
|
||
src={match.homeTeam.logo}
|
||
alt={match.homeTeam.name}
|
||
boxSize={{ base: "56px", md: "72px" }}
|
||
objectFit="contain"
|
||
opacity={isFinished && awayWon ? 0.55 : 1}
|
||
/>
|
||
) : (
|
||
<Flex
|
||
boxSize={{ base: "56px", md: "72px" }}
|
||
bg="primary.subtle"
|
||
borderRadius="full"
|
||
align="center"
|
||
justify="center"
|
||
>
|
||
<Text fontSize="2xl" fontWeight="bold" color="primary.fg">
|
||
{match.homeTeam?.name?.charAt(0) || "H"}
|
||
</Text>
|
||
</Flex>
|
||
)}
|
||
<Text
|
||
fontSize={{ base: "sm", md: "md" }}
|
||
fontWeight={homeWon ? "extrabold" : "bold"}
|
||
textAlign="center"
|
||
color={homeWon ? "fg" : "fg.muted"}
|
||
lineClamp={2}
|
||
>
|
||
{match.homeTeam?.name}
|
||
</Text>
|
||
<Badge variant="subtle" colorPalette="blue" fontSize="2xs">
|
||
{t("home-team")}
|
||
</Badge>
|
||
</VStack>
|
||
|
||
{/* Score */}
|
||
<VStack gap={1} flexShrink={0} align="center">
|
||
{match.score && (isLive || isFinished) ? (
|
||
<>
|
||
{/* FT badge */}
|
||
{isFinished && (
|
||
<Badge colorPalette="gray" variant="subtle" fontSize="xs" borderRadius="full" mb={1}>
|
||
FT
|
||
</Badge>
|
||
)}
|
||
<HStack gap={2}>
|
||
<Text
|
||
fontSize={{ base: "5xl", md: "6xl" }}
|
||
fontWeight="900"
|
||
lineHeight="1"
|
||
color={
|
||
isLive
|
||
? "red.500"
|
||
: homeWon
|
||
? "green.500"
|
||
: awayWon
|
||
? "fg.muted"
|
||
: "fg"
|
||
}
|
||
>
|
||
{match.score.home}
|
||
</Text>
|
||
<Text fontSize="2xl" color="fg.muted" fontWeight="light">
|
||
:
|
||
</Text>
|
||
<Text
|
||
fontSize={{ base: "5xl", md: "6xl" }}
|
||
fontWeight="900"
|
||
lineHeight="1"
|
||
color={
|
||
isLive
|
||
? "red.500"
|
||
: awayWon
|
||
? "green.500"
|
||
: homeWon
|
||
? "fg.muted"
|
||
: "fg"
|
||
}
|
||
>
|
||
{match.score.away}
|
||
</Text>
|
||
</HStack>
|
||
{match.score.htHome != null && match.score.htAway != null && (
|
||
<Text fontSize="xs" color="fg.muted">
|
||
({t("half-time")}: {match.score.htHome}–{match.score.htAway})
|
||
</Text>
|
||
)}
|
||
</>
|
||
) : (
|
||
<VStack gap={1}>
|
||
<Text fontSize="2xl" fontWeight="bold" color="fg.muted">
|
||
{t("vs")}
|
||
</Text>
|
||
</VStack>
|
||
)}
|
||
|
||
{/* Date */}
|
||
<HStack gap={1} mt={1}>
|
||
<LuCalendar size={11} />
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{new Date(match.mstUtc).toLocaleDateString("tr-TR", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}
|
||
</Text>
|
||
</HStack>
|
||
</VStack>
|
||
|
||
{/* Away Team */}
|
||
<VStack gap={2} flex={1} align="center">
|
||
{match.awayTeam?.logo ? (
|
||
<Image
|
||
src={match.awayTeam.logo}
|
||
alt={match.awayTeam.name}
|
||
boxSize={{ base: "56px", md: "72px" }}
|
||
objectFit="contain"
|
||
opacity={isFinished && homeWon ? 0.55 : 1}
|
||
/>
|
||
) : (
|
||
<Flex
|
||
boxSize={{ base: "56px", md: "72px" }}
|
||
bg="primary.subtle"
|
||
borderRadius="full"
|
||
align="center"
|
||
justify="center"
|
||
>
|
||
<Text fontSize="2xl" fontWeight="bold" color="primary.fg">
|
||
{match.awayTeam?.name?.charAt(0) || "A"}
|
||
</Text>
|
||
</Flex>
|
||
)}
|
||
<Text
|
||
fontSize={{ base: "sm", md: "md" }}
|
||
fontWeight={awayWon ? "extrabold" : "bold"}
|
||
textAlign="center"
|
||
color={awayWon ? "fg" : "fg.muted"}
|
||
lineClamp={2}
|
||
>
|
||
{match.awayTeam?.name}
|
||
</Text>
|
||
<Badge variant="subtle" colorPalette="orange" fontSize="2xs">
|
||
{t("away-team")}
|
||
</Badge>
|
||
</VStack>
|
||
</HStack>
|
||
|
||
{/* Referee + Officials (collapsible) */}
|
||
{(mainReferee || officials.length > 0) && (
|
||
<Box mt={4} pt={3} borderTopWidth="1px" borderColor={borderColor}>
|
||
<Flex
|
||
justify="center"
|
||
align="center"
|
||
gap={2}
|
||
cursor={otherOfficials.length > 0 ? "pointer" : "default"}
|
||
onClick={() => otherOfficials.length > 0 && setOfficialsOpen((o) => !o)}
|
||
_hover={otherOfficials.length > 0 ? { opacity: 0.75 } : {}}
|
||
>
|
||
<LuUser size={13} />
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{t("main-referee")}:{" "}
|
||
<Text as="span" fontWeight="semibold" color="fg">
|
||
{mainReferee?.name ?? t("referee")}
|
||
</Text>
|
||
</Text>
|
||
{otherOfficials.length > 0 && (
|
||
<Box color="fg.muted">
|
||
{officialsOpen ? <LuChevronUp size={13} /> : <LuChevronDown size={13} />}
|
||
</Box>
|
||
)}
|
||
</Flex>
|
||
|
||
{/* Other officials expanded */}
|
||
{officialsOpen && otherOfficials.length > 0 && (
|
||
<SimpleGrid columns={{ base: 1, sm: 2 }} gap={1.5} mt={3}>
|
||
{otherOfficials.map((official) => (
|
||
<HStack
|
||
key={official.id}
|
||
gap={2}
|
||
py={1.5}
|
||
px={3}
|
||
bg={subtleBg}
|
||
borderRadius="lg"
|
||
>
|
||
<LuUser size={11} />
|
||
<VStack gap={0} align="start">
|
||
<Text fontSize="xs" fontWeight="semibold">
|
||
{official.name}
|
||
</Text>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{officialRoleLabel(official.roleId, t)}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
))}
|
||
</SimpleGrid>
|
||
)}
|
||
</Box>
|
||
)}
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 2. TAB FILTER BAR */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
<Box
|
||
position="sticky"
|
||
top={0}
|
||
zIndex={10}
|
||
bg={tabBg}
|
||
borderBottomWidth="1px"
|
||
borderColor={borderColor}
|
||
mb={5}
|
||
mx={-4}
|
||
px={4}
|
||
py={2}
|
||
>
|
||
<Flex gap={1} overflowX="auto" css={{ "&::-webkit-scrollbar": { display: "none" } }}>
|
||
{tabs.map((tab) => {
|
||
const isActive = activeTab === tab.key;
|
||
return (
|
||
<Button
|
||
key={tab.key}
|
||
size="xs"
|
||
variant={isActive ? "solid" : "ghost"}
|
||
colorPalette={isActive ? "primary" : undefined}
|
||
borderRadius="full"
|
||
px={3}
|
||
py={1}
|
||
fontSize="xs"
|
||
fontWeight={isActive ? "bold" : "medium"}
|
||
whiteSpace="nowrap"
|
||
flexShrink={0}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
_hover={isActive ? {} : { bg: subtleBg }}
|
||
>
|
||
{tab.label}
|
||
</Button>
|
||
);
|
||
})}
|
||
</Flex>
|
||
</Box>
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 3. INJURIES (only pre-match) */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{hasSidelined && show("all") && (
|
||
<FadeIn>
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={5}>
|
||
<Card.Body>
|
||
<Heading as="h2" size="md" mb={4}>
|
||
🏥 {t("sidelined")}
|
||
</Heading>
|
||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||
<SidelinedColumn
|
||
team={sidelined.homeTeam}
|
||
teamName={match.homeTeam?.name || ""}
|
||
teamLogo={match.homeTeam?.logo}
|
||
injuryBg={injuryBg}
|
||
injuryBorder={injuryBorder}
|
||
t={t}
|
||
/>
|
||
<SidelinedColumn
|
||
team={sidelined.awayTeam}
|
||
teamName={match.awayTeam?.name || ""}
|
||
teamLogo={match.awayTeam?.logo}
|
||
injuryBg={injuryBg}
|
||
injuryBorder={injuryBorder}
|
||
t={t}
|
||
/>
|
||
</SimpleGrid>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
</FadeIn>
|
||
)}
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 4. MATCH EVENTS TIMELINE */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{matchEvents.length > 0 && show("events") && (
|
||
<FadeIn>
|
||
<Card.Root
|
||
bg={cardBg}
|
||
borderColor={borderColor}
|
||
borderRadius="xl"
|
||
mb={5}
|
||
>
|
||
<Card.Body>
|
||
<Heading as="h2" size="md" mb={5}>
|
||
{t("match-events")}
|
||
</Heading>
|
||
|
||
{/* Team name headers */}
|
||
<Flex mb={2}>
|
||
<Text fontSize="xs" fontWeight="bold" color="blue.500" flex={1}>
|
||
{match.homeTeam?.name}
|
||
</Text>
|
||
<Box w="48px" />
|
||
<Text fontSize="xs" fontWeight="bold" color="orange.500" flex={1} textAlign="right">
|
||
{match.awayTeam?.name}
|
||
</Text>
|
||
</Flex>
|
||
|
||
{/* Events */}
|
||
<Box position="relative">
|
||
{/* Center vertical line */}
|
||
<Box
|
||
position="absolute"
|
||
left="50%"
|
||
top={0}
|
||
bottom={0}
|
||
w="1px"
|
||
bg={timelineBg}
|
||
transform="translateX(-50%)"
|
||
zIndex={0}
|
||
/>
|
||
|
||
<VStack gap={0} align="stretch" position="relative">
|
||
{matchEvents.map((event) => (
|
||
<TimelineEventRow
|
||
key={event.id}
|
||
event={event}
|
||
homeTeamId={match.homeTeamId || match.homeTeam?.id}
|
||
t={t}
|
||
borderColor={borderColor}
|
||
subtleBg={subtleBg}
|
||
timelineBg={timelineBg}
|
||
/>
|
||
))}
|
||
</VStack>
|
||
</Box>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
</FadeIn>
|
||
)}
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 5. TEAM STATISTICS */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{hasStats && show("stats") && (
|
||
<FadeIn>
|
||
<Card.Root
|
||
bg={cardBg}
|
||
borderColor={borderColor}
|
||
borderRadius="xl"
|
||
mb={5}
|
||
>
|
||
<Card.Body>
|
||
{/* Header with team names */}
|
||
<Flex justify="space-between" align="center" mb={5}>
|
||
<Text fontSize="xs" fontWeight="bold" color="blue.500">
|
||
{match.homeTeam?.name}
|
||
</Text>
|
||
<Heading as="h2" size="md">
|
||
{t("statistics")}
|
||
</Heading>
|
||
<Text fontSize="xs" fontWeight="bold" color="orange.500">
|
||
{match.awayTeam?.name}
|
||
</Text>
|
||
</Flex>
|
||
|
||
<VStack gap={5} align="stretch">
|
||
{homeStats!.possessionPercentage != null && (
|
||
<StatBar
|
||
label={t("possession")}
|
||
homeVal={homeStats!.possessionPercentage!}
|
||
awayVal={awayStats!.possessionPercentage!}
|
||
suffix="%"
|
||
/>
|
||
)}
|
||
{homeStats!.totalShots != null && (
|
||
<StatBar
|
||
label={t("total-shots")}
|
||
homeVal={homeStats!.totalShots!}
|
||
awayVal={awayStats!.totalShots!}
|
||
/>
|
||
)}
|
||
{homeStats!.shotsOnTarget != null && (
|
||
<StatBar
|
||
label={t("shots-on-target")}
|
||
homeVal={homeStats!.shotsOnTarget!}
|
||
awayVal={awayStats!.shotsOnTarget!}
|
||
/>
|
||
)}
|
||
{homeStats!.shotsOffTarget != null && (
|
||
<StatBar
|
||
label={t("shots-off-target")}
|
||
homeVal={homeStats!.shotsOffTarget!}
|
||
awayVal={awayStats!.shotsOffTarget!}
|
||
/>
|
||
)}
|
||
{homeStats!.totalPasses != null && (
|
||
<StatBar
|
||
label={t("total-passes")}
|
||
homeVal={homeStats!.totalPasses!}
|
||
awayVal={awayStats!.totalPasses!}
|
||
/>
|
||
)}
|
||
{homeStats!.corners != null && (
|
||
<StatBar
|
||
label={t("corners")}
|
||
homeVal={homeStats!.corners!}
|
||
awayVal={awayStats!.corners!}
|
||
/>
|
||
)}
|
||
{homeStats!.fouls != null && (
|
||
<StatBar
|
||
label={t("fouls")}
|
||
homeVal={homeStats!.fouls!}
|
||
awayVal={awayStats!.fouls!}
|
||
/>
|
||
)}
|
||
{homeStats!.offsides != null && (
|
||
<StatBar
|
||
label={t("offsides")}
|
||
homeVal={homeStats!.offsides!}
|
||
awayVal={awayStats!.offsides!}
|
||
/>
|
||
)}
|
||
</VStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
</FadeIn>
|
||
)}
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 6. LINEUPS */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{show("lineups") && (
|
||
<LineupsCard match={match} prediction={prediction} />
|
||
)}
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 7. PREDICTION */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{show("prediction") && <Box>
|
||
<Flex justify="space-between" align="center" mb={3}>
|
||
<Heading as="h2" size="lg">
|
||
{tPred("title")}
|
||
</Heading>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => refetchPrediction()}
|
||
disabled={!hasLimit}
|
||
gap={1.5}
|
||
>
|
||
<LuRefreshCw />
|
||
{tCommon("refresh")}
|
||
</Button>
|
||
</Flex>
|
||
|
||
{/* Pre-match disclaimer */}
|
||
<Flex
|
||
align="center"
|
||
gap={2}
|
||
bg={disclaimerBg}
|
||
borderWidth="1px"
|
||
borderColor={disclaimerBorder}
|
||
borderRadius="lg"
|
||
px={3}
|
||
py={2}
|
||
mb={4}
|
||
>
|
||
<LuInfo size={14} style={{ flexShrink: 0 }} />
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{tPred("pre-match-disclaimer")}
|
||
</Text>
|
||
</Flex>
|
||
|
||
{predLoading || isPredFetching ? (
|
||
<VStack gap={3} align="stretch">
|
||
<Skeleton h="120px" borderRadius="xl" />
|
||
<Skeleton h="80px" borderRadius="xl" />
|
||
<Skeleton h="80px" borderRadius="xl" />
|
||
</VStack>
|
||
) : prediction ? (
|
||
<PredictionCard prediction={prediction} />
|
||
) : (
|
||
<Card.Root borderColor={borderColor} borderRadius="xl">
|
||
<Card.Body>
|
||
<Flex direction="column" justify="center" align="center" py={8} gap={4}>
|
||
<Text color="fg.muted">
|
||
{tPred("no-predictions")}
|
||
</Text>
|
||
<Button
|
||
colorPalette="primary"
|
||
onClick={() => refetchPrediction()}
|
||
disabled={!hasLimit}
|
||
loading={isPredFetching}
|
||
>
|
||
<LuSparkles />
|
||
{tPred("generate")}
|
||
</Button>
|
||
{!hasLimit && (
|
||
<Text fontSize="sm" color="red.500">
|
||
{tCommon("limits.out_of_analysis")}
|
||
</Text>
|
||
)}
|
||
</Flex>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
)}
|
||
</Box>}
|
||
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{/* 8. ODDS */}
|
||
{/* ══════════════════════════════════════════════ */}
|
||
{match.odds && Object.keys(match.odds).length > 0 && show("odds") && (
|
||
<Box mt={6}>
|
||
<OddsCard odds={match.odds} />
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</SlideUp>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Timeline Event Row
|
||
// ─────────────────────────────────────────────────
|
||
|
||
interface TimelineEventRowProps {
|
||
event: MatchEvent;
|
||
homeTeamId?: string;
|
||
t: ReturnType<typeof useTranslations>;
|
||
borderColor: string;
|
||
subtleBg: string;
|
||
timelineBg: string;
|
||
}
|
||
|
||
function TimelineEventRow({
|
||
event,
|
||
homeTeamId,
|
||
t,
|
||
subtleBg,
|
||
}: TimelineEventRowProps) {
|
||
const isHome = event.teamId === homeTeamId || event.position === "home";
|
||
const isGoal = event.eventType === "goal";
|
||
const isCard = event.eventType === "card";
|
||
const isSub = event.eventType === "substitute";
|
||
|
||
const icon = (() => {
|
||
if (isGoal) return "⚽";
|
||
if (isCard && event.eventSubtype === "yc") return "🟨";
|
||
if (isCard && (event.eventSubtype === "rc" || event.eventSubtype === "y2c")) return "🟥";
|
||
if (isSub) return "🔄";
|
||
return "•";
|
||
})();
|
||
|
||
const mainText = (() => {
|
||
if (isGoal) {
|
||
const penalty = event.eventSubtype === "penalty-goal" ? ` (${t("penalty")})` : "";
|
||
return `${event.player?.name || ""}${penalty}`;
|
||
}
|
||
if (isSub) return event.player?.name || "";
|
||
return event.player?.name || "";
|
||
})();
|
||
|
||
const subText = (() => {
|
||
if (isGoal && event.assistPlayer)
|
||
return `${t("assist")}: ${event.assistPlayer.name}`;
|
||
if (isSub && event.substitutedOut)
|
||
return `↓ ${event.substitutedOut.name}`;
|
||
if (isCard) return event.eventSubtype === "yc" ? t("yellow-card") : t("red-card");
|
||
return null;
|
||
})();
|
||
|
||
return (
|
||
<Flex align="center" py={2.5} gap={0}>
|
||
{/* Home side */}
|
||
<Box flex={1} pr={3}>
|
||
{isHome && (
|
||
<Flex direction="column" align="flex-end" gap={0.5}>
|
||
<HStack gap={1.5} justify="flex-end">
|
||
<VStack gap={0} align="end">
|
||
<Text fontSize="sm" fontWeight={isGoal ? "bold" : "medium"} textAlign="right">
|
||
{mainText}
|
||
</Text>
|
||
{subText && (
|
||
<Text fontSize="2xs" color="fg.muted" textAlign="right">
|
||
{subText}
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
<Text fontSize="lg">{icon}</Text>
|
||
</HStack>
|
||
{isGoal && event.scoreAfter && (
|
||
<Badge
|
||
variant="solid"
|
||
colorPalette="green"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
alignSelf="flex-end"
|
||
>
|
||
{event.scoreAfter}
|
||
</Badge>
|
||
)}
|
||
</Flex>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Center minute bubble */}
|
||
<Flex
|
||
w="48px"
|
||
flexShrink={0}
|
||
justify="center"
|
||
align="center"
|
||
zIndex={1}
|
||
>
|
||
<Box
|
||
bg={subtleBg}
|
||
borderRadius="full"
|
||
px={1.5}
|
||
py={0.5}
|
||
minW="36px"
|
||
textAlign="center"
|
||
>
|
||
<Text fontSize="2xs" fontWeight="bold" color="fg.muted">
|
||
{event.timeMinute}'
|
||
</Text>
|
||
</Box>
|
||
</Flex>
|
||
|
||
{/* Away side */}
|
||
<Box flex={1} pl={3}>
|
||
{!isHome && (
|
||
<Flex direction="column" align="flex-start" gap={0.5}>
|
||
<HStack gap={1.5} justify="flex-start">
|
||
<Text fontSize="lg">{icon}</Text>
|
||
<VStack gap={0} align="start">
|
||
<Text fontSize="sm" fontWeight={isGoal ? "bold" : "medium"}>
|
||
{mainText}
|
||
</Text>
|
||
{subText && (
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{subText}
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</HStack>
|
||
{isGoal && event.scoreAfter && (
|
||
<Badge
|
||
variant="solid"
|
||
colorPalette="green"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
>
|
||
{event.scoreAfter}
|
||
</Badge>
|
||
)}
|
||
</Flex>
|
||
)}
|
||
</Box>
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Stat Bar
|
||
// ─────────────────────────────────────────────────
|
||
|
||
interface StatBarProps {
|
||
label: string;
|
||
homeVal: number;
|
||
awayVal: number;
|
||
suffix?: string;
|
||
}
|
||
|
||
function StatBar({ label, homeVal, awayVal, suffix = "" }: StatBarProps) {
|
||
const total = homeVal + awayVal || 1;
|
||
const homePct = (homeVal / total) * 100;
|
||
const awayPct = (awayVal / total) * 100;
|
||
const homeBarBg = useColorModeValue("blue.400", "blue.500");
|
||
const awayBarBg = useColorModeValue("orange.400", "orange.500");
|
||
const trackBg = useColorModeValue("gray.100", "gray.700");
|
||
const homeWins = homeVal > awayVal;
|
||
const awayWins = awayVal > homeVal;
|
||
|
||
return (
|
||
<Box>
|
||
<Flex justify="space-between" align="center" mb={1.5}>
|
||
<HStack gap={1}>
|
||
<Text
|
||
fontSize="sm"
|
||
fontWeight={homeWins ? "bold" : "medium"}
|
||
color={homeWins ? "blue.500" : "fg.muted"}
|
||
>
|
||
{homeVal}
|
||
{suffix}
|
||
</Text>
|
||
{homeWins && <LuArrowRight size={11} color="var(--chakra-colors-blue-400)" />}
|
||
</HStack>
|
||
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
|
||
{label}
|
||
</Text>
|
||
<HStack gap={1}>
|
||
{awayWins && (
|
||
<Box transform="rotate(180deg)">
|
||
<LuArrowRight size={11} color="var(--chakra-colors-orange-400)" />
|
||
</Box>
|
||
)}
|
||
<Text
|
||
fontSize="sm"
|
||
fontWeight={awayWins ? "bold" : "medium"}
|
||
color={awayWins ? "orange.500" : "fg.muted"}
|
||
>
|
||
{awayVal}
|
||
{suffix}
|
||
</Text>
|
||
</HStack>
|
||
</Flex>
|
||
<Flex h="7px" borderRadius="full" overflow="hidden" bg={trackBg}>
|
||
<Box
|
||
bg={homeBarBg}
|
||
w={`${homePct}%`}
|
||
borderLeftRadius="full"
|
||
transition="width 0.6s ease"
|
||
/>
|
||
<Box
|
||
bg={awayBarBg}
|
||
w={`${awayPct}%`}
|
||
borderRightRadius="full"
|
||
transition="width 0.6s ease"
|
||
/>
|
||
</Flex>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Sidelined Column
|
||
// ─────────────────────────────────────────────────
|
||
|
||
interface SidelinedColumnProps {
|
||
team?: SidelinedTeam;
|
||
teamName: string;
|
||
teamLogo?: string;
|
||
injuryBg: string;
|
||
injuryBorder: string;
|
||
t: ReturnType<typeof useTranslations>;
|
||
}
|
||
|
||
function SidelinedColumn({
|
||
team,
|
||
teamName,
|
||
teamLogo,
|
||
injuryBg,
|
||
injuryBorder,
|
||
t,
|
||
}: SidelinedColumnProps) {
|
||
const players = team?.players || [];
|
||
|
||
if (players.length === 0) {
|
||
return (
|
||
<Box>
|
||
<HStack gap={2} mb={3}>
|
||
{teamLogo && (
|
||
<Image src={teamLogo} alt={teamName} boxSize="20px" objectFit="contain" />
|
||
)}
|
||
<Text fontSize="sm" fontWeight="bold">{teamName}</Text>
|
||
</HStack>
|
||
<Text fontSize="xs" color="fg.muted" fontStyle="italic">
|
||
{t("no-sidelined")}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box>
|
||
<HStack gap={2} mb={3}>
|
||
{teamLogo && (
|
||
<Image src={teamLogo} alt={teamName} boxSize="20px" objectFit="contain" />
|
||
)}
|
||
<Text fontSize="sm" fontWeight="bold">{teamName}</Text>
|
||
<Badge colorPalette="red" variant="subtle" fontSize="2xs" borderRadius="full">
|
||
{players.length}
|
||
</Badge>
|
||
</HStack>
|
||
<VStack gap={2} align="stretch">
|
||
{players.map((player, idx) => (
|
||
<Box
|
||
key={`${player.playerName}-${idx}`}
|
||
bg={injuryBg}
|
||
borderWidth="1px"
|
||
borderColor={injuryBorder}
|
||
borderRadius="lg"
|
||
px={3}
|
||
py={2}
|
||
>
|
||
<Flex justify="space-between" align="center">
|
||
<VStack gap={0} align="start">
|
||
<Text fontSize="sm" fontWeight="semibold">
|
||
{player.playerName}
|
||
</Text>
|
||
<HStack gap={1.5}>
|
||
{player.positionShort && (
|
||
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||
{player.positionShort}
|
||
</Badge>
|
||
)}
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{player.description ||
|
||
(player.type === "injury"
|
||
? t("injury")
|
||
: player.type === "suspended"
|
||
? t("suspended")
|
||
: t("other-reason"))}
|
||
</Text>
|
||
</HStack>
|
||
</VStack>
|
||
{player.matchesMissed !== undefined && player.matchesMissed > 0 && (
|
||
<Badge colorPalette="red" variant="subtle" fontSize="2xs">
|
||
{player.matchesMissed} {t("matches-missed")}
|
||
</Badge>
|
||
)}
|
||
</Flex>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────
|
||
// Official Role Label
|
||
// ─────────────────────────────────────────────────
|
||
|
||
function officialRoleLabel(
|
||
roleId: number,
|
||
t: ReturnType<typeof useTranslations>,
|
||
): string {
|
||
switch (roleId) {
|
||
case 1: return t("main-referee");
|
||
case 2: return t("assistant-referee");
|
||
case 3: return t("fourth-official");
|
||
case 5: return t("var-referee");
|
||
case 6: return t("avar-referee");
|
||
default: return t("referee");
|
||
}
|
||
}
|