main
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 3m37s

This commit is contained in:
2026-05-05 01:04:50 +03:00
parent f72857a3b2
commit e3cc6702dd
6 changed files with 571 additions and 212 deletions
+91 -2
View File
@@ -36,7 +36,9 @@
"logging-in": "Signing in...", "logging-in": "Signing in...",
"registering": "Creating account...", "registering": "Creating account...",
"login-success": "Login successful!", "login-success": "Login successful!",
"register-success": "Registration successful!" "register-success": "Registration successful!",
"login-required-title": "Login Required",
"login-required-message": "Please sign in or create an account to view match analysis."
}, },
"all-right-reserved": "All rights reserved.", "all-right-reserved": "All rights reserved.",
"privacy-policy": "Privacy Policy", "privacy-policy": "Privacy Policy",
@@ -121,7 +123,22 @@
"recent-matches": "Recent Matches", "recent-matches": "Recent Matches",
"home-team": "Home", "home-team": "Home",
"away-team": "Away", "away-team": "Away",
"vs": "vs" "vs": "vs",
"referee": "Referee",
"sidelined": "Injuries & Absences",
"injury": "Injury",
"suspended": "Suspended",
"other-reason": "Other",
"matches-missed": "Matches Missed",
"position": "Position",
"no-sidelined": "No injury information available",
"match-events": "Match Events",
"goal": "Goal",
"yellow-card": "Yellow Card",
"red-card": "Red Card",
"substitution": "Substitution",
"starters": "Starting XI",
"substitutes": "Substitutes"
}, },
"predictions": { "predictions": {
@@ -517,5 +534,77 @@
"items-per-page": "Items per page", "items-per-page": "Items per page",
"showing": "Showing", "showing": "Showing",
"results": "results" "results": "results"
},
"seo": {
"global": {
"title": "iddaai.com | AI-Powered Betting Predictions",
"description": "iddaai.com offers AI-powered betting predictions, detailed match analysis, and data-driven coupon building.",
"keywords": "betting, betting predictions, AI betting, match analysis, sure bets, football statistics"
},
"home": {
"title": "Home",
"description": "AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons."
},
"h2h": {
"title": "Head to Head Comparison",
"description": "Compare football teams head to head. In-depth statistics, past matches, and predictions."
},
"analysis": {
"title": "Multi-Match Analysis",
"description": "Analyze multiple matches at once. Detailed statistics and AI-driven strategies."
},
"leagues": {
"title": "Leagues & Teams",
"description": "Explore football and basketball leagues, countries, and team statistics worldwide."
},
"admin": {
"title": "Admin Panel",
"description": "Admin panel for managing users and system settings."
},
"matches": {
"title": "Matches & Fixtures",
"description": "View upcoming matches, live scores, and past fixtures with AI predictions."
},
"about": {
"title": "About Us",
"description": "Learn more about iddaai.com, our AI technology, and how we deliver betting insights."
},
"dashboard": {
"title": "Dashboard",
"description": "Your personalized dashboard for betting stats, predictions, and account overview."
},
"profile": {
"title": "My Profile",
"description": "Manage your user profile, subscription, and account settings."
},
"spor-toto": {
"title": "Spor Toto Predictions",
"description": "AI-powered Spor Toto predictions. Build coupons with conservative, balanced, or aggressive strategies."
},
"coupon-builder": {
"title": "AI Coupon Builder",
"description": "Automatically generate optimized betting coupons using advanced AI and statistical models."
},
"teams": {
"title": "Team Statistics",
"description": "Detailed statistics, form analysis, and predictive models for football teams."
},
"coupon-history": {
"title": "Coupon History",
"description": "Review your past betting coupons and track your performance."
},
"predictions": {
"title": "Betting Predictions",
"description": "Daily AI betting predictions, value odds, and high-confidence match tips."
},
"signup": {
"title": "Sign Up",
"description": "Create your iddaai.com account to access AI-powered betting predictions."
},
"signin": {
"title": "Sign In",
"description": "Sign in to your iddaai.com account to access AI predictions and tools."
}
} }
} }
+19 -2
View File
@@ -36,7 +36,9 @@
"logging-in": "Giriş yapılıyor...", "logging-in": "Giriş yapılıyor...",
"registering": "Hesap oluşturuluyor...", "registering": "Hesap oluşturuluyor...",
"login-success": "Giriş başarılı!", "login-success": "Giriş başarılı!",
"register-success": "Kayıt başarılı!" "register-success": "Kayıt başarılı!",
"login-required-title": "Giriş Yapmanız Gerekiyor",
"login-required-message": "Maç analizlerini görüntülemek için lütfen giriş yapın veya hesap oluşturun."
}, },
"all-right-reserved": "Tüm hakları saklıdır.", "all-right-reserved": "Tüm hakları saklıdır.",
"privacy-policy": "Gizlilik Politikası", "privacy-policy": "Gizlilik Politikası",
@@ -117,7 +119,22 @@
"recent-matches": "Son Maçlar", "recent-matches": "Son Maçlar",
"home-team": "Ev Sahibi", "home-team": "Ev Sahibi",
"away-team": "Deplasman", "away-team": "Deplasman",
"vs": "vs" "vs": "vs",
"referee": "Hakem",
"sidelined": "Sakatlık & Eksikler",
"injury": "Sakatlık",
"suspended": "Cezalı",
"other-reason": "Diğer",
"matches-missed": "Kaçırılan Maç",
"position": "Pozisyon",
"no-sidelined": "Eksik oyuncu bilgisi yok",
"match-events": "Maç Olayları",
"goal": "Gol",
"yellow-card": "Sarı Kart",
"red-card": "Kırmızı Kart",
"substitution": "Oyuncu Değişikliği",
"starters": "İlk 11",
"substitutes": "Yedekler"
}, },
"predictions": { "predictions": {
"title": "Tahminler", "title": "Tahminler",
+206 -171
View File
@@ -11,10 +11,13 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { slideUpVariants } from "@/components/motion"; import { slideUpVariants } from "@/components/motion";
import type { MatchResponseDto } from "@/lib/api/matches/types"; import type { MatchResponseDto } from "@/lib/api/matches/types";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { useState } from "react";
import { LoginModal } from "@/components/auth/login-modal";
interface MatchCardProps { interface MatchCardProps {
match: MatchResponseDto; match: MatchResponseDto;
@@ -24,7 +27,10 @@ const MotionBox = motion.create(Box);
export default function MatchCard({ match }: MatchCardProps) { export default function MatchCard({ match }: MatchCardProps) {
const t = useTranslations("matches"); const t = useTranslations("matches");
const tAuth = useTranslations("auth");
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const [loginModalOpen, setLoginModalOpen] = useState(false);
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
const cardBorder = useColorModeValue("gray.100", "gray.700"); const cardBorder = useColorModeValue("gray.100", "gray.700");
@@ -42,6 +48,10 @@ export default function MatchCard({ match }: MatchCardProps) {
: t("not-started"); : t("not-started");
const handleClick = () => { const handleClick = () => {
if (!session) {
setLoginModalOpen(true);
return;
}
router.push(`/matches/${match.id}`); router.push(`/matches/${match.id}`);
}; };
@@ -49,180 +59,205 @@ export default function MatchCard({ match }: MatchCardProps) {
const matchDate = new Date(match.mstUtc); const matchDate = new Date(match.mstUtc);
return ( return (
<MotionBox <>
variants={slideUpVariants} <MotionBox
bg={cardBg} variants={slideUpVariants}
borderWidth="1px" bg={cardBg}
borderColor={cardBorder} borderWidth="1px"
borderRadius="xl" borderColor={cardBorder}
p={4} borderRadius="xl"
cursor="pointer" p={4}
onClick={handleClick} cursor="pointer"
transition={{ duration: 0.25 }} onClick={handleClick}
_hover={{ transition={{ duration: 0.25 }}
bg: hoverBg, _hover={{
borderColor: hoverBorder, bg: hoverBg,
transform: "translateY(-3px)", borderColor: hoverBorder,
shadow: "xl", transform: "translateY(-3px)",
}} shadow: "xl",
role="button" }}
tabIndex={0} role="button"
aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`} tabIndex={0}
> aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`}
{/* Status Badge */} >
<Flex justify="space-between" align="center" mb={3}> {/* Status Badge */}
<Badge <Flex justify="space-between" align="center" mb={3}>
colorPalette={statusColor} <Badge
variant="subtle" colorPalette={statusColor}
px={2} variant="subtle"
py={0.5} px={2}
borderRadius="full" py={0.5}
fontSize="xs" borderRadius="full"
fontWeight="bold" 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} {isLive && (
</Text> <Box
</VStack> 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>
{/* Score or VS */} <Text fontSize="xs" color="fg.muted">
<VStack gap={0} flexShrink={0}> {matchDate.toLocaleDateString("tr-TR", {
{(isLive || isFinished) && day: "2-digit",
match.scoreHome !== undefined && month: "short",
match.scoreAway !== undefined ? ( hour: "2-digit",
<HStack gap={2}> minute: "2-digit",
<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> </Text>
</Flex> </Flex>
)}
</MotionBox> {/* 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>
)}
{/* Auth hint for unauthenticated users */}
{!session && (
<Flex
mt={2}
pt={2}
borderTopWidth="1px"
borderColor={cardBorder}
justify="center"
align="center"
>
<Text fontSize="xs" color="orange.500" fontWeight="semibold">
🔒 {tAuth("login-required-title")}
</Text>
</Flex>
)}
</MotionBox>
{/* Login Modal — shown when unauthenticated user clicks a match */}
<LoginModal
open={loginModalOpen}
onOpenChange={setLoginModalOpen}
initialMode="login"
/>
</>
); );
} }
+222 -25
View File
@@ -12,17 +12,50 @@ import {
Spinner, Spinner,
Button, Button,
Card, Card,
Grid,
SimpleGrid,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion"; import { SlideUp, FadeIn } from "@/components/motion";
import { useMatchDetails } from "@/lib/api/matches/use-hooks"; import { useMatchDetails } from "@/lib/api/matches/use-hooks";
import { usePrediction } from "@/lib/api/predictions/use-hooks"; import { usePrediction } from "@/lib/api/predictions/use-hooks";
import PredictionCard from "@/components/matches/prediction-card"; import PredictionCard from "@/components/matches/prediction-card";
import OddsCard from "@/components/matches/odds-card"; import OddsCard from "@/components/matches/odds-card";
import LineupsCard from "@/components/matches/lineups-card"; import LineupsCard from "@/components/matches/lineups-card";
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu"; import { LuArrowLeft, LuRefreshCw, LuShield, LuFlag, LuUser } from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
// ─────────────────────────────────────────────────
// Type helpers for sidelined data
// ─────────────────────────────────────────────────
interface SidelinedPlayer {
playerName: string;
position?: string;
positionShort?: string;
description?: string;
type?: string;
matchesMissed?: number;
reasonIcon?: string;
}
interface SidelinedTeam {
teamId?: string;
teamName?: string;
totalSidelined?: number;
players?: SidelinedPlayer[];
}
interface SidelinedData {
homeTeam?: SidelinedTeam;
awayTeam?: SidelinedTeam;
}
// ─────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────
export default function MatchDetailContent() { export default function MatchDetailContent() {
const t = useTranslations("matches"); const t = useTranslations("matches");
@@ -41,9 +74,13 @@ export default function MatchDetailContent() {
} = usePrediction(matchId); } = usePrediction(matchId);
const headerBg = useColorModeValue("white", "gray.800"); const headerBg = useColorModeValue("white", "gray.800");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700"); 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 match = matchData?.data; const match = matchData?.data as MatchResponseDto | undefined;
const prediction = predictionData?.data; const prediction = predictionData?.data;
if (matchLoading) { if (matchLoading) {
@@ -70,6 +107,10 @@ export default function MatchDetailContent() {
const isLive = match.status === "LIVE"; const isLive = match.status === "LIVE";
const isFinished = match.status === "Finished"; const isFinished = match.status === "Finished";
const sidelined = (match.sidelined || {}) as SidelinedData;
const hasSidelined =
(sidelined.homeTeam?.players?.length || 0) > 0 ||
(sidelined.awayTeam?.players?.length || 0) > 0;
return ( return (
<SlideUp> <SlideUp>
@@ -85,26 +126,32 @@ export default function MatchDetailContent() {
<LuArrowLeft /> <LuArrowLeft />
{tCommon("back")} {tCommon("back")}
</Button> </Button>
{/* Match Header */}
{/* ═══════════════════════════════════════════ */}
{/* Match Header Card */}
{/* ═══════════════════════════════════════════ */}
<Card.Root <Card.Root
bg={headerBg} bg={headerBg}
borderColor={borderColor} borderColor={borderColor}
borderRadius="xl" borderRadius="xl"
mb={6} mb={6}
overflow="hidden"
> >
<Card.Body> {/* League Banner */}
{/* League Info */} {match.league && (
{match.league && ( <Box bg={subtleBg} px={4} py={2.5} borderBottomWidth="1px" borderColor={borderColor}>
<Flex justify="center" align="center" gap={2} mb={4}> <Flex justify="center" align="center" gap={2}>
{match.league.country?.flag && ( {match.league.country?.flag && (
<Image <Image
src={match.league.country.flag} src={match.league.country.flag}
alt={match.league.country.name || ""} alt={match.league.country.name || ""}
boxSize="18px" boxSize="18px"
objectFit="contain" objectFit="contain"
borderRadius="sm"
/> />
)} )}
<Text fontSize="sm" color="fg.muted" fontWeight="medium"> <Text fontSize="sm" fontWeight="semibold" color="fg.muted">
{match.league.country?.name && `${match.league.country.name}`}
{match.league.name} {match.league.name}
</Text> </Text>
<Badge <Badge
@@ -132,8 +179,10 @@ export default function MatchDetailContent() {
: t("not-started")} : t("not-started")}
</Badge> </Badge>
</Flex> </Flex>
)} </Box>
)}
<Card.Body py={6}>
{/* Teams & Score */} {/* Teams & Score */}
<HStack gap={6} justify="center" align="center"> <HStack gap={6} justify="center" align="center">
{/* Home Team */} {/* Home Team */}
@@ -142,12 +191,12 @@ export default function MatchDetailContent() {
<Image <Image
src={match.homeTeam.logo} src={match.homeTeam.logo}
alt={match.homeTeam.name} alt={match.homeTeam.name}
boxSize="64px" boxSize="72px"
objectFit="contain" objectFit="contain"
/> />
) : ( ) : (
<Flex <Flex
boxSize="64px" boxSize="72px"
bg="primary.subtle" bg="primary.subtle"
borderRadius="full" borderRadius="full"
align="center" align="center"
@@ -161,9 +210,9 @@ export default function MatchDetailContent() {
<Text fontSize="md" fontWeight="bold" textAlign="center"> <Text fontSize="md" fontWeight="bold" textAlign="center">
{match.homeTeam?.name} {match.homeTeam?.name}
</Text> </Text>
<Text fontSize="xs" color="fg.muted"> <Badge variant="subtle" colorPalette="blue" fontSize="2xs">
{t("home-team")} {t("home-team")}
</Text> </Badge>
</VStack> </VStack>
{/* Score */} {/* Score */}
@@ -171,18 +220,20 @@ export default function MatchDetailContent() {
{match.score && (isLive || isFinished) ? ( {match.score && (isLive || isFinished) ? (
<HStack gap={3}> <HStack gap={3}>
<Text <Text
fontSize="4xl" fontSize="5xl"
fontWeight="900" fontWeight="900"
lineHeight="1"
color={isLive ? "red.500" : "fg"} color={isLive ? "red.500" : "fg"}
> >
{match.score.home} {match.score.home}
</Text> </Text>
<Text fontSize="2xl" color="fg.muted"> <Text fontSize="2xl" color="fg.muted" fontWeight="300">
- :
</Text> </Text>
<Text <Text
fontSize="4xl" fontSize="5xl"
fontWeight="900" fontWeight="900"
lineHeight="1"
color={isLive ? "red.500" : "fg"} color={isLive ? "red.500" : "fg"}
> >
{match.score.away} {match.score.away}
@@ -211,12 +262,12 @@ export default function MatchDetailContent() {
<Image <Image
src={match.awayTeam.logo} src={match.awayTeam.logo}
alt={match.awayTeam.name} alt={match.awayTeam.name}
boxSize="64px" boxSize="72px"
objectFit="contain" objectFit="contain"
/> />
) : ( ) : (
<Flex <Flex
boxSize="64px" boxSize="72px"
bg="primary.subtle" bg="primary.subtle"
borderRadius="full" borderRadius="full"
align="center" align="center"
@@ -230,18 +281,77 @@ export default function MatchDetailContent() {
<Text fontSize="md" fontWeight="bold" textAlign="center"> <Text fontSize="md" fontWeight="bold" textAlign="center">
{match.awayTeam?.name} {match.awayTeam?.name}
</Text> </Text>
<Text fontSize="xs" color="fg.muted"> <Badge variant="subtle" colorPalette="orange" fontSize="2xs">
{t("away-team")} {t("away-team")}
</Text> </Badge>
</VStack> </VStack>
</HStack> </HStack>
{/* Referee Info */}
{match.refereeName && (
<Flex
justify="center"
align="center"
gap={1.5}
mt={4}
pt={3}
borderTopWidth="1px"
borderColor={borderColor}
>
<LuUser size={14} />
<Text fontSize="xs" color="fg.muted">
{t("referee")}: <Text as="span" fontWeight="semibold" color="fg">{match.refereeName}</Text>
</Text>
</Flex>
)}
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
{/* Lineups Section */} {/* ═══════════════════════════════════════════ */}
{/* Sidelined / Injuries Card */}
{/* ═══════════════════════════════════════════ */}
{hasSidelined && (
<FadeIn>
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
<Card.Body>
<Heading as="h2" size="md" mb={4}>
🏥 {t("sidelined")}
</Heading>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
{/* Home Team Sidelined */}
<SidelinedColumn
team={sidelined.homeTeam}
teamName={match.homeTeam?.name || ""}
teamLogo={match.homeTeam?.logo}
injuryBg={injuryBg}
injuryBorder={injuryBorder}
t={t}
/>
{/* Away Team Sidelined */}
<SidelinedColumn
team={sidelined.awayTeam}
teamName={match.awayTeam?.name || ""}
teamLogo={match.awayTeam?.logo}
injuryBg={injuryBg}
injuryBorder={injuryBorder}
t={t}
/>
</SimpleGrid>
</Card.Body>
</Card.Root>
</FadeIn>
)}
{/* ═══════════════════════════════════════════ */}
{/* Lineups Section */}
{/* ═══════════════════════════════════════════ */}
<LineupsCard match={match} prediction={prediction} /> <LineupsCard match={match} prediction={prediction} />
{/* Prediction Section */} {/* ═══════════════════════════════════════════ */}
{/* Prediction Section */}
{/* ═══════════════════════════════════════════ */}
<Box> <Box>
<Flex justify="space-between" align="center" mb={4}> <Flex justify="space-between" align="center" mb={4}>
<Heading as="h2" size="lg"> <Heading as="h2" size="lg">
@@ -275,7 +385,9 @@ export default function MatchDetailContent() {
)} )}
</Box> </Box>
{/* Odds Section */} {/* ═══════════════════════════════════════════ */}
{/* Odds Section */}
{/* ═══════════════════════════════════════════ */}
{match.odds && Object.keys(match.odds).length > 0 && ( {match.odds && Object.keys(match.odds).length > 0 && (
<OddsCard odds={match.odds} /> <OddsCard odds={match.odds} />
)} )}
@@ -283,3 +395,88 @@ export default function MatchDetailContent() {
</SlideUp> </SlideUp>
); );
} }
// ─────────────────────────────────────────────────
// Sidelined Column Sub-Component
// ─────────────────────────────────────────────────
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>
);
}
+15 -4
View File
@@ -15,12 +15,14 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useColorModeValue } from "@/components/ui/color-mode"; import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, FadeIn } from "@/components/motion"; import { SlideUp, FadeIn } from "@/components/motion";
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks"; import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
import { LuArrowLeft, LuCalendar, LuTrophy, LuChevronDown } from "react-icons/lu"; import { LuArrowLeft, LuCalendar, LuTrophy, LuChevronDown } from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types"; import type { MatchResponseDto } from "@/lib/api/matches/types";
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { LoginModal } from "@/components/auth/login-modal";
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// Utility Functions // Utility Functions
@@ -32,7 +34,7 @@ function getMatchTimestamp(match: MatchResponseDto): number {
} }
function getMatchStatus(match: MatchResponseDto): string { function getMatchStatus(match: MatchResponseDto): string {
return String(match.status || (match as Record<string, unknown>).state || "").toUpperCase(); return String(match.status || match.state || "").toUpperCase();
} }
function isMatchFinished(match: MatchResponseDto): boolean { function isMatchFinished(match: MatchResponseDto): boolean {
@@ -50,7 +52,7 @@ function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["
} }
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string { function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
return String(team?.logo || (team as Record<string, unknown> | undefined)?.logoUrl || fallback || ""); return String(team?.logo || fallback || "");
} }
function getLeagueLabel(match: MatchResponseDto): string { function getLeagueLabel(match: MatchResponseDto): string {
@@ -83,6 +85,8 @@ export default function TeamDetailContent() {
const t = useTranslations(); const t = useTranslations();
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const [loginModalOpen, setLoginModalOpen] = useState(false);
const teamId = params.id as string; const teamId = params.id as string;
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -245,7 +249,7 @@ export default function TeamDetailContent() {
cardBg={cardBg} cardBg={cardBg}
borderColor={borderColor} borderColor={borderColor}
statusBadge={getStatusBadge(match)} statusBadge={getStatusBadge(match)}
onClick={() => router.push(`/tr/matches/${match.id}`)} onClick={() => session ? router.push(`/matches/${match.id}`) : setLoginModalOpen(true)}
/> />
))} ))}
</VStack> </VStack>
@@ -316,7 +320,7 @@ export default function TeamDetailContent() {
cardBg={cardBg} cardBg={cardBg}
borderColor={borderColor} borderColor={borderColor}
statusBadge={getStatusBadge(match)} statusBadge={getStatusBadge(match)}
onClick={() => router.push(`/tr/matches/${match.id}`)} onClick={() => session ? router.push(`/matches/${match.id}`) : setLoginModalOpen(true)}
/> />
))} ))}
</VStack> </VStack>
@@ -379,6 +383,13 @@ export default function TeamDetailContent() {
)} )}
</Box> </Box>
</FadeIn> </FadeIn>
{/* Login Modal — shown when unauthenticated user clicks a match */}
<LoginModal
open={loginModalOpen}
onOpenChange={setLoginModalOpen}
initialMode="login"
/>
</Box> </Box>
</SlideUp> </SlideUp>
); );
+18 -8
View File
@@ -83,31 +83,41 @@ export interface MatchResponseDto {
odds?: Record<string, Record<string, { odd: string }>>; odds?: Record<string, Record<string, { odd: string }>>;
// Nested Objects (from Backend include) // Nested Objects (from Backend include)
homeTeam?: { name: string; logo?: string; [key: string]: unknown }; homeTeam?: { id?: string; name: string; logo?: string };
awayTeam?: { name: string; logo?: string; [key: string]: unknown }; awayTeam?: { id?: string; name: string; logo?: string };
league?: { league?: {
name: string; name: string;
country?: { name: string; flag?: string }; country?: { name: string; flag?: string };
[key: string]: unknown;
}; };
lineups?: { lineups?: {
home: Array<{ home: Array<{
player?: { name: string; id: string; [key: string]: unknown }; player?: { name: string; id: string };
position?: string | null; position?: string | null;
shirtNumber?: number | null; shirtNumber?: number | null;
isStarting?: boolean; isStarting?: boolean;
[key: string]: unknown; isProbable?: boolean;
lineupSource?: string;
projectionConfidence?: number;
}>; }>;
away: Array<{ away: Array<{
player?: { name: string; id: string; [key: string]: unknown }; player?: { name: string; id: string };
position?: string | null; position?: string | null;
shirtNumber?: number | null; shirtNumber?: number | null;
isStarting?: boolean; isStarting?: boolean;
[key: string]: unknown; isProbable?: boolean;
lineupSource?: string;
projectionConfidence?: number;
}>; }>;
}; };
[key: string]: unknown; // Match detail enrichments
refereeName?: string;
sidelined?: Record<string, unknown>;
events?: Array<Record<string, unknown>>;
// Additional fields from backend detail endpoint
lineupSource?: string;
stats?: Record<string, unknown>;
} }
export interface ActiveLeagueDto { export interface ActiveLeagueDto {