This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export { default as MatchCard } from "./match-card";
|
||||
export { default as MatchList } from "./match-list";
|
||||
export { default as SportFilter } from "./sport-filter";
|
||||
export { default as LeagueSidebar } from "./league-sidebar";
|
||||
export { default as PredictionCard } from "./prediction-card";
|
||||
export { default as MatchDetailContent } from "./match-detail-content";
|
||||
export { default as MatchesContent } from "./matches-content";
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { Box, VStack, Text, Badge, Flex, Image } from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import type { ActiveLeagueDto } from "@/lib/api/matches/types";
|
||||
|
||||
interface LeagueSidebarProps {
|
||||
leagues: ActiveLeagueDto[];
|
||||
selectedLeagueId: string | null;
|
||||
onSelect: (leagueId: string | null) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function LeagueSidebar({
|
||||
leagues,
|
||||
selectedLeagueId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: LeagueSidebarProps) {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
const bg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
const activeBg = useColorModeValue("primary.50", "primary.900");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
p={4}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
h="40px"
|
||||
w="100%"
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box px={4} py={3} borderBottomWidth="1px" borderColor={borderColor}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wide"
|
||||
color="fg.muted"
|
||||
>
|
||||
{t("active-leagues")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* All Leagues Option */}
|
||||
<Box
|
||||
px={4}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
bg={selectedLeagueId === null ? activeBg : "transparent"}
|
||||
_hover={{ bg: selectedLeagueId === null ? activeBg : hoverBg }}
|
||||
onClick={() => onSelect(null)}
|
||||
transition="background 0.15s"
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={selectedLeagueId === null ? "bold" : "medium"}
|
||||
color={selectedLeagueId === null ? "primary.fg" : "fg"}
|
||||
>
|
||||
{t("all-leagues")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* League List */}
|
||||
<VStack gap={0} align="stretch" maxH="60vh" overflowY="auto">
|
||||
{leagues.map((league) => {
|
||||
const isActive = selectedLeagueId === league.id;
|
||||
return (
|
||||
<Box
|
||||
key={league.id}
|
||||
px={4}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
bg={isActive ? activeBg : "transparent"}
|
||||
_hover={{ bg: isActive ? activeBg : hoverBg }}
|
||||
onClick={() => onSelect(league.id)}
|
||||
transition="background 0.15s"
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex align="center" gap={2} minW={0} flex={1}>
|
||||
{league.countryFlag && (
|
||||
<Image
|
||||
src={league.countryFlag}
|
||||
alt={league.countryName || ""}
|
||||
boxSize="16px"
|
||||
objectFit="contain"
|
||||
flexShrink={0}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={isActive ? "bold" : "medium"}
|
||||
color={isActive ? "primary.fg" : "fg"}
|
||||
truncate
|
||||
>
|
||||
{league.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={1.5} flexShrink={0}>
|
||||
{league.liveCount > 0 && (
|
||||
<Badge
|
||||
colorPalette="red"
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
px={1.5}
|
||||
>
|
||||
{league.liveCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
colorPalette="gray"
|
||||
variant="subtle"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
px={1.5}
|
||||
>
|
||||
{league.matchCount}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Badge,
|
||||
HStack,
|
||||
VStack,
|
||||
Image,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { slideUpVariants } from "@/components/motion";
|
||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
|
||||
interface MatchCardProps {
|
||||
match: MatchResponseDto;
|
||||
}
|
||||
|
||||
const MotionBox = motion.create(Box);
|
||||
|
||||
export default function MatchCard({ match }: MatchCardProps) {
|
||||
const t = useTranslations("matches");
|
||||
const router = useRouter();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const cardBorder = useColorModeValue("gray.100", "gray.700");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.750");
|
||||
const hoverBorder = useColorModeValue("primary.200", "primary.500");
|
||||
|
||||
const isLive = match.status === "LIVE";
|
||||
const isFinished = match.status === "Finished";
|
||||
|
||||
const statusColor = isLive ? "red" : isFinished ? "gray" : "green";
|
||||
const statusText = isLive
|
||||
? t("live")
|
||||
: isFinished
|
||||
? t("finished")
|
||||
: t("not-started");
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/matches/${match.id}`);
|
||||
};
|
||||
|
||||
// Date handling from timestamp (mstUtc)
|
||||
const matchDate = new Date(match.mstUtc);
|
||||
|
||||
return (
|
||||
<MotionBox
|
||||
variants={slideUpVariants}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={handleClick}
|
||||
transition={{ duration: 0.25 }}
|
||||
_hover={{
|
||||
bg: hoverBg,
|
||||
borderColor: hoverBorder,
|
||||
transform: "translateY(-3px)",
|
||||
shadow: "xl",
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Badge
|
||||
colorPalette={statusColor}
|
||||
variant="subtle"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{isLive && (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-block"
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
bg="red.500"
|
||||
mr={1.5}
|
||||
animation="pulse 1.5s ease-in-out infinite"
|
||||
/>
|
||||
)}
|
||||
{statusText}
|
||||
</Badge>
|
||||
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{matchDate.toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Teams */}
|
||||
<HStack gap={3} justify="space-between">
|
||||
{/* Home Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.homeTeamLogo ? (
|
||||
<Image
|
||||
src={match.homeTeamLogo}
|
||||
alt={match.homeTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.homeTeamName?.charAt(0) || "H"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.homeTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Score or VS */}
|
||||
<VStack gap={0} flexShrink={0}>
|
||||
{(isLive || isFinished) &&
|
||||
match.scoreHome !== undefined &&
|
||||
match.scoreAway !== undefined ? (
|
||||
<HStack gap={2}>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreHome}
|
||||
</Text>
|
||||
<Text fontSize="lg" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.scoreAway}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text fontSize="md" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<VStack gap={1} flex={1} align="center" minW={0}>
|
||||
{match.awayTeamLogo ? (
|
||||
<Image
|
||||
src={match.awayTeamLogo}
|
||||
alt={match.awayTeamName}
|
||||
boxSize="40px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="40px"
|
||||
bg="primary.subtle"
|
||||
borderRadius="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
|
||||
{match.awayTeamName?.charAt(0) || "A"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
textAlign="center"
|
||||
truncate
|
||||
maxW="100%"
|
||||
>
|
||||
{match.awayTeamName}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* League Info */}
|
||||
{(match.leagueName || match.countryName) && (
|
||||
<Flex
|
||||
mt={3}
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor={cardBorder}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={1.5}
|
||||
>
|
||||
{/* Flag handling if available in flat response, otherwise skip or pass from parent */}
|
||||
<Text fontSize="xs" color="fg.muted" truncate>
|
||||
{match.countryName && `${match.countryName} • `}
|
||||
{match.leagueName}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</MotionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Image,
|
||||
Spinner,
|
||||
Button,
|
||||
Card,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { useMatchDetails } from "@/lib/api/matches/use-hooks";
|
||||
import { usePrediction } from "@/lib/api/predictions/use-hooks";
|
||||
import PredictionCard from "@/components/matches/prediction-card";
|
||||
import OddsCard from "@/components/matches/odds-card";
|
||||
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
|
||||
|
||||
export default function MatchDetailContent() {
|
||||
const t = useTranslations("matches");
|
||||
const tPred = useTranslations("predictions");
|
||||
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: refetchPrediction,
|
||||
} = usePrediction(matchId);
|
||||
|
||||
const headerBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const match = matchData?.data;
|
||||
const prediction = predictionData?.data;
|
||||
|
||||
if (matchLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const isLive = match.status === "LIVE";
|
||||
const isFinished = match.status === "Finished";
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
mb={4}
|
||||
onClick={() => router.back()}
|
||||
gap={1.5}
|
||||
>
|
||||
<LuArrowLeft />
|
||||
{tCommon("back")}
|
||||
</Button>
|
||||
{/* Match Header */}
|
||||
<Card.Root
|
||||
bg={headerBg}
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
mb={6}
|
||||
>
|
||||
<Card.Body>
|
||||
{/* League Info */}
|
||||
{match.league && (
|
||||
<Flex justify="center" align="center" gap={2} mb={4}>
|
||||
{match.league.country?.flag && (
|
||||
<Image
|
||||
src={match.league.country.flag}
|
||||
alt={match.league.country.name || ""}
|
||||
boxSize="18px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Teams & Score */}
|
||||
<HStack gap={6} 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="64px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="64px"
|
||||
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="md" fontWeight="bold" textAlign="center">
|
||||
{match.homeTeam?.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("home-team")}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Score */}
|
||||
<VStack gap={1} flexShrink={0}>
|
||||
{match.score && (isLive || isFinished) ? (
|
||||
<HStack gap={3}>
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.home}
|
||||
</Text>
|
||||
<Text fontSize="2xl" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.away}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text fontSize="xl" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{new Date(match.mstUtc).toLocaleDateString("tr-TR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Away Team */}
|
||||
<VStack gap={2} flex={1} align="center">
|
||||
{match.awayTeam?.logo ? (
|
||||
<Image
|
||||
src={match.awayTeam.logo}
|
||||
alt={match.awayTeam.name}
|
||||
boxSize="64px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
boxSize="64px"
|
||||
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="md" fontWeight="bold" textAlign="center">
|
||||
{match.awayTeam?.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{t("away-team")}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Prediction Section */}
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<Heading as="h2" size="lg">
|
||||
{tPred("title")}
|
||||
</Heading>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchPrediction()}
|
||||
gap={1.5}
|
||||
>
|
||||
<LuRefreshCw />
|
||||
{tCommon("refresh")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{predLoading ? (
|
||||
<Flex justify="center" py={10}>
|
||||
<Spinner size="md" color="primary.500" />
|
||||
</Flex>
|
||||
) : prediction ? (
|
||||
<PredictionCard prediction={prediction} />
|
||||
) : (
|
||||
<Card.Root borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<Flex justify="center" align="center" py={8}>
|
||||
<Text color="fg.muted">{tPred("no-predictions")}</Text>
|
||||
</Flex>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Odds Section */}
|
||||
{match.odds && Object.keys(match.odds).length > 0 && (
|
||||
<OddsCard odds={match.odds} />
|
||||
)}
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import MatchCard from "./match-card";
|
||||
import type {
|
||||
LeagueWithMatchesDto,
|
||||
MatchResponseDto,
|
||||
} from "@/lib/api/matches/types";
|
||||
|
||||
// ========================
|
||||
// Match Card Skeleton — realistic loading placeholder
|
||||
// ========================
|
||||
|
||||
function MatchCardSkeleton() {
|
||||
const bg = useColorModeValue("white", "gray.800");
|
||||
const border = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={border}
|
||||
borderRadius="xl"
|
||||
p={4}
|
||||
className="animate-shimmer"
|
||||
>
|
||||
{/* Status + Date */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Skeleton borderRadius="full" height="20px" width="60px" />
|
||||
<Skeleton borderRadius="md" height="14px" width="80px" />
|
||||
</Flex>
|
||||
|
||||
{/* Teams */}
|
||||
<HStack gap={3} justify="space-between">
|
||||
{/* Home */}
|
||||
<VStack gap={1.5} flex={1} align="center">
|
||||
<Skeleton boxSize="40px" borderRadius="full" />
|
||||
<Skeleton height="14px" width="70px" />
|
||||
</VStack>
|
||||
|
||||
{/* VS / Score */}
|
||||
<Skeleton height="24px" width="30px" borderRadius="md" />
|
||||
|
||||
{/* Away */}
|
||||
<VStack gap={1.5} flex={1} align="center">
|
||||
<Skeleton boxSize="40px" borderRadius="full" />
|
||||
<Skeleton height="14px" width="70px" />
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* League */}
|
||||
<Flex mt={3} pt={2} borderTopWidth="1px" borderColor={border} justify="center">
|
||||
<Skeleton height="12px" width="120px" />
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton grid for loading state */
|
||||
function MatchListSkeleton() {
|
||||
return (
|
||||
<Box>
|
||||
{/* Fake league header */}
|
||||
<Skeleton height="44px" borderRadius="xl" mb={3} />
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
md: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
}}
|
||||
gap={3}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<MatchCardSkeleton key={i} />
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface MatchListProps {
|
||||
leagues?: LeagueWithMatchesDto[];
|
||||
flatMatches?: MatchResponseDto[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MatchList — renders matches grouped by league, or flat if only flatMatches is provided.
|
||||
*/
|
||||
export default function MatchList({
|
||||
leagues,
|
||||
flatMatches,
|
||||
isLoading,
|
||||
}: MatchListProps) {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
const leagueHeaderBg = useColorModeValue("gray.50", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
|
||||
if (isLoading) {
|
||||
return <MatchListSkeleton />;
|
||||
}
|
||||
|
||||
// Flat mode — no league grouping
|
||||
if (flatMatches) {
|
||||
if (flatMatches.length === 0) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={16}>
|
||||
<Text color="fg.muted" fontSize="md">
|
||||
{t("no-matches")}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StaggerContainer>
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
md: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
}}
|
||||
gap={4}
|
||||
>
|
||||
{flatMatches.map((match) => (
|
||||
<StaggerItem key={match.id}>
|
||||
<MatchCard match={match} />
|
||||
</StaggerItem>
|
||||
))}
|
||||
</Grid>
|
||||
</StaggerContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Grouped mode — grouped by league
|
||||
if (!leagues || leagues.length === 0) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={16}>
|
||||
<Text color="fg.muted" fontSize="md">
|
||||
{t("no-matches")}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StaggerContainer>
|
||||
{leagues.map((league) => (
|
||||
<StaggerItem key={league.id}>
|
||||
<Box mb={6}>
|
||||
{/* League Header */}
|
||||
<Flex
|
||||
align="center"
|
||||
gap={2}
|
||||
px={4}
|
||||
py={2.5}
|
||||
bg={leagueHeaderBg}
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
mb={3}
|
||||
>
|
||||
{league.country?.flagUrl && (
|
||||
<Image
|
||||
src={league.country.flagUrl}
|
||||
alt={league.country.name || ""}
|
||||
boxSize="20px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{league.country?.name && `${league.country.name} • `}
|
||||
{league.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.muted" ml="auto">
|
||||
{league.matches.length} {t("title").toLowerCase()}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Match Grid */}
|
||||
<Grid
|
||||
templateColumns={{
|
||||
base: "1fr",
|
||||
md: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
}}
|
||||
gap={3}
|
||||
>
|
||||
{league.matches.map((match) => (
|
||||
<MatchCard key={match.id} match={match} />
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Heading } from "@chakra-ui/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { SportFilter, LeagueSidebar, MatchList } from "@/components/matches";
|
||||
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
|
||||
import { useMatchStore } from "@/lib/stores/match-store";
|
||||
|
||||
export default function MatchesContent() {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
const sport = useMatchStore((s) => s.sport);
|
||||
const leagueFilter = useMatchStore((s) => s.leagueFilter);
|
||||
const setSport = useMatchStore((s) => s.setSport);
|
||||
const setLeague = useMatchStore((s) => s.setLeague);
|
||||
|
||||
// Fetch active leagues for sidebar
|
||||
const { data: leaguesData, isLoading: leaguesLoading } =
|
||||
useActiveLeagues(sport);
|
||||
const leagues = leaguesData?.data ?? [];
|
||||
|
||||
// Query matches grouped by league
|
||||
const queryMatches = useQueryMatches();
|
||||
|
||||
// Trigger query on sport/league change
|
||||
const { data: matchesData, isPending: matchesLoading } = (() => {
|
||||
// We use the queryMatches mutation for initial data
|
||||
// but for the UI we want a reactive approach.
|
||||
// Let's use the standard list with league filter
|
||||
return {
|
||||
data: queryMatches.data,
|
||||
isPending: queryMatches.isPending,
|
||||
};
|
||||
})();
|
||||
|
||||
// Auto-trigger query when sport or league changes
|
||||
const handleSportChange = (newSport: typeof sport) => {
|
||||
setSport(newSport);
|
||||
queryMatches.mutate({
|
||||
sport: newSport,
|
||||
leagueId: undefined,
|
||||
limit: 100,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeagueChange = (leagueId: string | null) => {
|
||||
setLeague(leagueId);
|
||||
queryMatches.mutate({
|
||||
sport,
|
||||
leagueId: leagueId || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!queryMatches.data && !queryMatches.isPending) {
|
||||
queryMatches.mutate({
|
||||
sport,
|
||||
leagueId: leagueFilter || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const matchLeagues = matchesData?.data ?? [];
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
{/* Page Header */}
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mb={6}
|
||||
flexWrap="wrap"
|
||||
gap={3}
|
||||
>
|
||||
<Heading as="h1" size="xl" fontWeight="bold">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<SportFilter value={sport} onChange={handleSportChange} />
|
||||
</Flex>
|
||||
|
||||
{/* Main Content */}
|
||||
<Flex
|
||||
gap={6}
|
||||
align="flex-start"
|
||||
direction={{ base: "column", lg: "row" }}
|
||||
>
|
||||
{/* League Sidebar (Desktop only) */}
|
||||
<Box
|
||||
display={{ base: "none", lg: "block" }}
|
||||
w="260px"
|
||||
flexShrink={0}
|
||||
position="sticky"
|
||||
top="80px"
|
||||
>
|
||||
<LeagueSidebar
|
||||
leagues={leagues}
|
||||
selectedLeagueId={leagueFilter}
|
||||
onSelect={handleLeagueChange}
|
||||
isLoading={leaguesLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Match List */}
|
||||
<Box flex={1} minW={0}>
|
||||
<MatchList
|
||||
leagues={matchLeagues}
|
||||
isLoading={queryMatches.isPending}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
VStack,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
|
||||
interface OddsCardProps {
|
||||
odds?: Record<string, Record<string, { odd: string }>>;
|
||||
}
|
||||
|
||||
interface MarketBlockProps {
|
||||
title: string;
|
||||
selections: Record<string, { odd: string }>;
|
||||
}
|
||||
|
||||
function MarketBlock({ title, selections }: MarketBlockProps) {
|
||||
const bg = useColorModeValue("gray.50", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const selectionBg = useColorModeValue("white", "gray.700");
|
||||
|
||||
// Sort selections based on common market patterns
|
||||
const sortedKeys = Object.keys(selections).sort((a, b) => {
|
||||
// MS: 1, X, 2
|
||||
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b)) {
|
||||
const order = ["1", "X", "2"];
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
}
|
||||
// Alt/Üst: Alt, Üst
|
||||
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b)) {
|
||||
return a === "Alt" ? -1 : 1; // Alt first
|
||||
}
|
||||
// KG: Var, Yok
|
||||
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b)) {
|
||||
return a === "Var" ? -1 : 1; // Var first
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box px={3} py={1.5} borderBottomWidth="1px" borderColor={borderColor}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="fg.muted"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex p={2} gap={2} wrap="wrap">
|
||||
{sortedKeys.map((key) => (
|
||||
<Flex
|
||||
key={key}
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg={selectionBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="sm"
|
||||
minW="50px"
|
||||
py={1}
|
||||
px={2}
|
||||
flex={1}
|
||||
>
|
||||
<Text fontSize="xs" color="fg.muted" mb={0.5}>
|
||||
{key}
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="bold" color="primary.500">
|
||||
{Number(selections[key].odd).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OddsCard({ odds }: OddsCardProps) {
|
||||
const cardBg = useColorModeValue("white", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.800");
|
||||
|
||||
if (!odds || Object.keys(odds).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Define priority markets to show at the top
|
||||
const PRIORITY_MARKETS = [
|
||||
"Maç Sonucu",
|
||||
"2.5 Alt/Üst",
|
||||
"Karşılıklı Gol",
|
||||
"İlk Yarı Sonucu",
|
||||
"1. Yarı Sonucu",
|
||||
"Kart",
|
||||
"Korner",
|
||||
];
|
||||
|
||||
const marketKeys = Object.keys(odds);
|
||||
const priorityKeys = marketKeys.filter((k) =>
|
||||
PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
||||
);
|
||||
const otherKeys = marketKeys.filter(
|
||||
(k) => !PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
||||
);
|
||||
|
||||
// Group similar markets if needed, but simple list for now
|
||||
|
||||
return (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Body>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
Canlı İddaa Oranları
|
||||
</Text>
|
||||
|
||||
{/* Priority Markets Grid */}
|
||||
{priorityKeys.length > 0 && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
{priorityKeys.map((key) => (
|
||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Other Markets - Show ALL */}
|
||||
{otherKeys.length > 0 && (
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
gap={4}
|
||||
mt={priorityKeys.length > 0 ? 2 : 0}
|
||||
>
|
||||
{otherKeys.map((key) => (
|
||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { HStack, Button } from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LuCircleDot } from "react-icons/lu";
|
||||
import { MdSportsSoccer, MdSportsBasketball } from "react-icons/md";
|
||||
import type { SportType } from "@/lib/api/matches/types";
|
||||
|
||||
interface SportFilterProps {
|
||||
value: SportType;
|
||||
onChange: (sport: SportType) => void;
|
||||
}
|
||||
|
||||
const SPORT_OPTIONS: { value: SportType; icon: React.ReactNode }[] = [
|
||||
{ value: "football", icon: <MdSportsSoccer /> },
|
||||
{ value: "basketball", icon: <MdSportsBasketball /> },
|
||||
];
|
||||
|
||||
export default function SportFilter({ value, onChange }: SportFilterProps) {
|
||||
const t = useTranslations("matches");
|
||||
|
||||
return (
|
||||
<HStack gap={2}>
|
||||
{SPORT_OPTIONS.map((sport) => {
|
||||
const isActive = value === sport.value;
|
||||
return (
|
||||
<Button
|
||||
key={sport.value}
|
||||
onClick={() => onChange(sport.value)}
|
||||
variant={isActive ? "solid" : "outline"}
|
||||
colorPalette={isActive ? "primary" : "gray"}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
gap={1.5}
|
||||
>
|
||||
{sport.icon}
|
||||
{t(sport.value)}
|
||||
{isActive && <LuCircleDot size={12} />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user