Files
iddaai-fe/src/components/matches/match-detail-content.tsx
T
fahricansecer e744a62fc2
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m24s
gg
2026-05-17 02:19:55 +03:00

1130 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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}&apos;
</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");
}
}