This commit is contained in:
2026-04-24 00:27:14 +03:00
parent 4bf0ab52f9
commit 30592394ef
9 changed files with 246 additions and 86 deletions
+11 -3
View File
@@ -19,7 +19,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react"; import { useState, useEffect } from "react";
import { MdMail } from "react-icons/md"; import { MdMail } from "react-icons/md";
import { BiUser } from "react-icons/bi"; import { BiUser } from "react-icons/bi";
import { authService } from "@/lib/api/auth/service"; import { authService } from "@/lib/api/auth/service";
@@ -45,15 +45,23 @@ type RegisterForm = yup.InferType<typeof registerSchema>;
interface LoginModalProps { interface LoginModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
initialMode?: "login" | "register";
} }
/* ────────────────────────── Component ────────────────────────── */ /* ────────────────────────── Component ────────────────────────── */
export function LoginModal({ open, onOpenChange }: LoginModalProps) { export function LoginModal({ open, onOpenChange, initialMode = "login" }: LoginModalProps) {
const t = useTranslations(); const t = useTranslations();
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">(initialMode);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Update mode when modal opens
useEffect(() => {
if (open) {
setMode(initialMode);
}
}, [open, initialMode]);
/* ── Login form ── */ /* ── Login form ── */
const loginForm = useForm<LoginForm>({ const loginForm = useForm<LoginForm>({
resolver: yupResolver(loginSchema), resolver: yupResolver(loginSchema),
+52 -23
View File
@@ -47,6 +47,7 @@ export default function Header() {
const t = useTranslations(); const t = useTranslations();
const [isSticky, setIsSticky] = useState(false); const [isSticky, setIsSticky] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">("login");
const router = useRouter(); const router = useRouter();
const { data: session, status } = useSession(); const { data: session, status } = useSession();
@@ -63,10 +64,15 @@ export default function Header() {
const handleLogout = async () => { const handleLogout = async () => {
await signOut({ redirect: false }); await signOut({ redirect: false });
if (authConfig.isAuthRequired) { if (authConfig.isAuthRequired) {
router.replace("/signin"); router.replace("/home");
} }
}; };
const openAuthModal = (mode: "login" | "register") => {
setLoginModalMode(mode);
setLoginModalOpen(true);
};
// Desktop auth section // Desktop auth section
const renderAuthSection = () => { const renderAuthSection = () => {
if (isLoading) return <Skeleton boxSize="10" rounded="full" />; if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
@@ -97,16 +103,27 @@ export default function Header() {
} }
return ( return (
<Button <HStack gap={2}>
variant="solid" <Button
colorPalette="primary" variant="outline"
size="sm" colorPalette="gray"
borderRadius="full" size="sm"
onClick={() => setLoginModalOpen(true)} borderRadius="full"
> onClick={() => openAuthModal("register")}
<LuLogIn /> >
{t("auth.sign-in")} {t("auth.sign-up")}
</Button> </Button>
<Button
variant="solid"
colorPalette="primary"
size="sm"
borderRadius="full"
onClick={() => openAuthModal("login")}
>
<LuLogIn />
{t("auth.sign-in")}
</Button>
</HStack>
); );
}; };
@@ -150,17 +167,29 @@ export default function Header() {
} }
return ( return (
<Button <VStack gap={2} w="full">
variant="solid" <Button
colorPalette="primary" variant="outline"
size="sm" colorPalette="gray"
width="full" size="sm"
borderRadius="full" width="full"
onClick={() => setLoginModalOpen(true)} borderRadius="full"
> onClick={() => openAuthModal("register")}
<LuLogIn /> >
{t("auth.sign-in")} {t("auth.sign-up")}
</Button> </Button>
<Button
variant="solid"
colorPalette="primary"
size="sm"
width="full"
borderRadius="full"
onClick={() => openAuthModal("login")}
>
<LuLogIn />
{t("auth.sign-in")}
</Button>
</VStack>
); );
}; };
@@ -296,7 +325,7 @@ export default function Header() {
</Box> </Box>
{/* Login Modal */} {/* Login Modal */}
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} /> <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} initialMode={loginModalMode} />
</> </>
); );
} }
+143
View File
@@ -0,0 +1,143 @@
"use client";
import {
Box,
Card,
Flex,
HStack,
Text,
VStack,
Badge,
SimpleGrid,
Icon,
} from "@chakra-ui/react";
import { useColorModeValue } from "@/components/ui/color-mode";
import { LuUsers, LuUser } from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
import type { MatchPredictionDto } from "@/lib/api/predictions/types";
interface LineupsCardProps {
match: MatchResponseDto;
prediction?: MatchPredictionDto | null;
}
export default function LineupsCard({ match, prediction }: LineupsCardProps) {
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const headerBg = useColorModeValue("gray.50", "whiteAlpha.50");
const homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
const awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
if (homeLineups.length === 0 && awayLineups.length === 0) {
return null;
}
// Determine if it's confirmed or probable
const source = prediction?.data_quality?.lineup_source;
const isConfirmed = source === "confirmed_live";
const title = isConfirmed ? "İlk 11" : "Muhtemel Kadro";
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
<Card.Body>
<Flex justify="space-between" align="center" mb={4}>
<HStack gap={2}>
<Icon as={LuUsers} boxSize={5} color="fg.muted" />
<Text fontSize="lg" fontWeight="semibold">
{title}
</Text>
</HStack>
<Badge
colorPalette={isConfirmed ? "green" : "orange"}
variant="subtle"
>
{isConfirmed ? "Onaylı" : "Muhtemel"}
</Badge>
</Flex>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={6}>
{/* Home Team Lineup */}
<Box>
<Flex
bg={headerBg}
p={3}
borderRadius="md"
align="center"
justify="center"
mb={3}
>
<Text fontWeight="bold">{match.homeTeamName}</Text>
</Flex>
<VStack align="stretch" gap={2}>
{homeLineups.map((p, idx) => (
<HStack
key={p.player?.id || idx}
p={2}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
>
<Icon as={LuUser} color="fg.muted" />
{p.shirtNumber && (
<Text fontSize="xs" fontWeight="bold" w="20px">
{p.shirtNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium">
{p.player?.name || "Bilinmiyor"}
</Text>
{p.position && (
<Badge ml="auto" size="sm" variant="surface">
{p.position}
</Badge>
)}
</HStack>
))}
</VStack>
</Box>
{/* Away Team Lineup */}
<Box>
<Flex
bg={headerBg}
p={3}
borderRadius="md"
align="center"
justify="center"
mb={3}
>
<Text fontWeight="bold">{match.awayTeamName}</Text>
</Flex>
<VStack align="stretch" gap={2}>
{awayLineups.map((p, idx) => (
<HStack
key={p.player?.id || idx}
p={2}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
>
<Icon as={LuUser} color="fg.muted" />
{p.shirtNumber && (
<Text fontSize="xs" fontWeight="bold" w="20px">
{p.shirtNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium">
{p.player?.name || "Bilinmiyor"}
</Text>
{p.position && (
<Badge ml="auto" size="sm" variant="surface">
{p.position}
</Badge>
)}
</HStack>
))}
</VStack>
</Box>
</SimpleGrid>
</Card.Body>
</Card.Root>
);
}
@@ -21,6 +21,7 @@ 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 { LuArrowLeft, LuRefreshCw } from "react-icons/lu"; import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
export default function MatchDetailContent() { export default function MatchDetailContent() {
@@ -237,6 +238,9 @@ export default function MatchDetailContent() {
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
{/* Lineups Section */}
<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}>
+19 -56
View File
@@ -57,11 +57,6 @@ function getLeagueLabel(match: MatchResponseDto): string {
return String(match.leagueName || match.league?.name || ""); return String(match.leagueName || match.league?.name || "");
} }
/**
* Football season logic: AugJun
* If month >= August (8) → season starts this year: "YYYY-(YYYY+1)"
* If month < August → season started last year: "(YYYY-1)-YYYY"
*/
function getSeasonFromTimestamp(timestampMs: number): string { function getSeasonFromTimestamp(timestampMs: number): string {
const date = new Date(timestampMs); const date = new Date(timestampMs);
const year = date.getFullYear(); const year = date.getFullYear();
@@ -73,28 +68,12 @@ function getSeasonFromTimestamp(timestampMs: number): string {
return `${year - 1}-${year}`; return `${year - 1}-${year}`;
} }
/** const SEASONS = (() => {
* Group matches by season string, returning a Map ordered by newest season first. const currentYear = new Date().getFullYear();
*/ const currentMonth = new Date().getMonth() + 1;
function groupMatchesBySeason(matches: MatchResponseDto[]): Map<string, MatchResponseDto[]> { const startYear = currentMonth >= 8 ? currentYear : currentYear - 1;
const groups = new Map<string, MatchResponseDto[]>(); return Array.from({ length: 5 }, (_, i) => `${startYear - i}-${startYear - i + 1}`);
})();
for (const match of matches) {
const ts = getMatchTimestamp(match);
const season = ts ? getSeasonFromTimestamp(ts) : "Bilinmiyor";
if (!groups.has(season)) {
groups.set(season, []);
}
groups.get(season)!.push(match);
}
// Sort by season key descending (newest first)
const sorted = new Map(
[...groups.entries()].sort((a, b) => b[0].localeCompare(a[0]))
);
return sorted;
}
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// Main Component // Main Component
@@ -107,13 +86,14 @@ export default function TeamDetailContent() {
const teamId = params.id as string; const teamId = params.id as string;
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [activeSeason, setActiveSeason] = useState<string>(SEASONS[0]);
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId); const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
const { const {
data: matchesResponse, data: matchesResponse,
isLoading: matchesLoading, isLoading: matchesLoading,
isFetching: matchesFetching, isFetching: matchesFetching,
} = useTeamMatches(teamId, { page: currentPage, limit: 20 }); } = useTeamMatches(teamId, { page: currentPage, limit: 20, season: activeSeason });
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700"); const borderColor = useColorModeValue("gray.100", "gray.700");
@@ -136,30 +116,16 @@ export default function TeamDetailContent() {
[matches] [matches]
); );
// Group past matches by season
const seasonGroups = useMemo(
() => groupMatchesBySeason(pastMatches),
[pastMatches]
);
const seasonKeys = useMemo(() => [...seasonGroups.keys()], [seasonGroups]);
// Active season selection
const [activeSeason, setActiveSeason] = useState<string | null>(null);
const displaySeason = activeSeason ?? seasonKeys[0] ?? null;
const displayMatches = displaySeason ? seasonGroups.get(displaySeason) ?? [] : [];
// Pagination handlers // Pagination handlers
const handleNextPage = useCallback(() => { const handleNextPage = useCallback(() => {
if (currentPage < totalPages) { if (currentPage < totalPages) {
setCurrentPage((p) => p + 1); setCurrentPage((p) => p + 1);
setActiveSeason(null); // Reset season on page change
} }
}, [currentPage, totalPages]); }, [currentPage, totalPages]);
const handlePrevPage = useCallback(() => { const handlePrevPage = useCallback(() => {
if (currentPage > 1) { if (currentPage > 1) {
setCurrentPage((p) => p - 1); setCurrentPage((p) => p - 1);
setActiveSeason(null);
} }
}, [currentPage]); }, [currentPage]);
@@ -296,11 +262,10 @@ export default function TeamDetailContent() {
</Flex> </Flex>
{/* Season Tabs */} {/* Season Tabs */}
{seasonKeys.length > 0 && ( {SEASONS.length > 0 && (
<HStack gap={2} mb={4} flexWrap="wrap"> <HStack gap={2} mb={4} flexWrap="wrap">
{seasonKeys.map((season) => { {SEASONS.map((season) => {
const isActive = season === displaySeason; const isActive = season === activeSeason;
const count = seasonGroups.get(season)?.length ?? 0;
return ( return (
<Button <Button
key={season} key={season}
@@ -312,14 +277,17 @@ export default function TeamDetailContent() {
fontWeight={isActive ? "700" : "500"} fontWeight={isActive ? "700" : "500"}
fontSize="xs" fontSize="xs"
px={4} px={4}
onClick={() => setActiveSeason(season)} onClick={() => {
setActiveSeason(season);
setCurrentPage(1);
}}
_hover={{ _hover={{
transform: "translateY(-1px)", transform: "translateY(-1px)",
shadow: "sm", shadow: "sm",
}} }}
transition="all 0.2s" transition="all 0.2s"
> >
🏆 {season} ({count}) 🏆 {season}
</Button> </Button>
); );
})} })}
@@ -330,17 +298,13 @@ export default function TeamDetailContent() {
<Flex justify="center" py={8}> <Flex justify="center" py={8}>
<Spinner size="md" color="primary.500" /> <Spinner size="md" color="primary.500" />
</Flex> </Flex>
) : displayMatches.length === 0 && pastMatches.length === 0 ? ( ) : pastMatches.length === 0 ? (
<Text color="fg.muted" textAlign="center" py={8}> <Text color="fg.muted" textAlign="center" py={8}>
Bu sayfada geçmiş maç bulunamadı Bu sezonda geçmiş maç bulunamadı
</Text>
) : displayMatches.length === 0 ? (
<Text color="fg.muted" textAlign="center" py={8}>
Bu sezonda maç bulunamadı
</Text> </Text>
) : ( ) : (
<VStack gap={2} align="stretch"> <VStack gap={2} align="stretch">
{displayMatches.map((match: MatchResponseDto) => ( {pastMatches.map((match: MatchResponseDto) => (
<MatchRow <MatchRow
key={match.id} key={match.id}
match={match} match={match}
@@ -390,7 +354,6 @@ export default function TeamDetailContent() {
minW="36px" minW="36px"
onClick={() => { onClick={() => {
setCurrentPage(pageNum); setCurrentPage(pageNum);
setActiveSeason(null);
}} }}
> >
{pageNum} {pageNum}
+1
View File
@@ -23,6 +23,7 @@ export interface HeadToHeadParams {
export interface TeamMatchesParams { export interface TeamMatchesParams {
page?: number; page?: number;
limit?: number; limit?: number;
season?: string;
} }
export interface PaginatedMatchesResponse { export interface PaginatedMatchesResponse {
+16
View File
@@ -90,6 +90,22 @@ export interface MatchResponseDto {
country?: { name: string; flag?: string }; country?: { name: string; flag?: string };
[key: string]: unknown; [key: string]: unknown;
}; };
lineups?: {
home: Array<{
player?: { name: string; id: string; [key: string]: unknown };
position?: string | null;
shirtNumber?: number | null;
isStarting?: boolean;
[key: string]: unknown;
}>;
away: Array<{
player?: { name: string; id: string; [key: string]: unknown };
position?: string | null;
shirtNumber?: number | null;
isStarting?: boolean;
[key: string]: unknown;
}>;
};
[key: string]: unknown; [key: string]: unknown;
} }
-4
View File
@@ -111,10 +111,6 @@ export const authOptions: NextAuthOptions = {
return session; return session;
}, },
}, },
pages: {
signIn: "/signin",
error: "/signin",
},
session: { strategy: "jwt" }, session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
}; };
View File