Compare commits
8 Commits
10e521e382
...
v26-shadow
| Author | SHA1 | Date | |
|---|---|---|---|
| ab5864df2f | |||
| bff5ea7b5f | |||
| 14159911f0 | |||
| 96b9653a7e | |||
| 30592394ef | |||
| c450661cf8 | |||
| 4bf0ab52f9 | |||
| 105c10699f |
@@ -1,3 +1,5 @@
|
||||
node_modules
|
||||
|
||||
.next
|
||||
|
||||
.env.local
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --webpack --experimental-https -p 3001",
|
||||
"dev": "next dev -p 6195",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import AdminContent from "@/components/admin/admin-content";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { authOptions } from "@/lib/auth/auth-options";
|
||||
import { isAdminRole } from "@/lib/auth/roles";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
@@ -1,124 +1,5 @@
|
||||
import { authService } from "@/lib/api/auth/service";
|
||||
import { normalizeRoles } from "@/lib/auth/roles";
|
||||
import { authOptions } from "@/lib/auth/auth-options";
|
||||
import NextAuth from "next-auth";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import type { Session, User } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
function randomToken() {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
console.log("Starting authorization with:", {
|
||||
email: credentials?.email,
|
||||
});
|
||||
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error("Email ve şifre gereklidir.");
|
||||
}
|
||||
|
||||
// Eğer mock mod aktifse backend'e gitme
|
||||
if (isMockMode) {
|
||||
console.log("Mock mode active, bypassing backend");
|
||||
return {
|
||||
id: credentials.email,
|
||||
name: credentials.email.split("@")[0],
|
||||
email: credentials.email,
|
||||
accessToken: randomToken(),
|
||||
refreshToken: randomToken(),
|
||||
};
|
||||
}
|
||||
|
||||
// Normal mod: backend'e istek at
|
||||
console.log("Sending login request to backend...");
|
||||
const res = await authService.login({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Backend response received:",
|
||||
JSON.stringify(res, null, 2),
|
||||
);
|
||||
|
||||
const response = res;
|
||||
|
||||
// Backend returns ApiResponse<TokenResponseDto>
|
||||
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
||||
if (!res.success || !response?.data?.accessToken) {
|
||||
console.error("Login failed or no access token in response");
|
||||
throw new Error(response?.message || "Giriş başarısız");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data;
|
||||
const normalizedRoles = normalizeRoles(user.roles);
|
||||
|
||||
console.log("Login successful, creating user session object");
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.firstName
|
||||
? `${user.firstName} ${user.lastName || ""}`.trim()
|
||||
: user.email.split("@")[0],
|
||||
email: user.email,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
roles: normalizedRoles,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Authorize error detailed:", error);
|
||||
const err = error as Error & {
|
||||
response?: { data: unknown; status: number };
|
||||
};
|
||||
if (err.response) {
|
||||
console.error("Error response data:", err.response.data);
|
||||
console.error("Error response status:", err.response.status);
|
||||
}
|
||||
throw new Error(
|
||||
err.message || "An error occurred during authentication",
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }: { token: JWT; user?: User }) {
|
||||
if (user) {
|
||||
token.accessToken = user.accessToken;
|
||||
token.refreshToken = user.refreshToken;
|
||||
token.id = user.id;
|
||||
token.roles = normalizeRoles(user.roles);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: JWT }) {
|
||||
session.user.id = token.id;
|
||||
session.user.roles = normalizeRoles(token.roles);
|
||||
session.accessToken = token.accessToken;
|
||||
session.refreshToken = token.refreshToken;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/signin",
|
||||
error: "/signin",
|
||||
},
|
||||
session: { strategy: "jwt" },
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { BiUser } from "react-icons/bi";
|
||||
import { authService } from "@/lib/api/auth/service";
|
||||
@@ -45,15 +45,23 @@ type RegisterForm = yup.InferType<typeof registerSchema>;
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialMode?: "login" | "register";
|
||||
}
|
||||
|
||||
/* ────────────────────────── Component ────────────────────────── */
|
||||
|
||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
export function LoginModal({ open, onOpenChange, initialMode = "login" }: LoginModalProps) {
|
||||
const t = useTranslations();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [mode, setMode] = useState<"login" | "register">(initialMode);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Update mode when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode(initialMode);
|
||||
}
|
||||
}, [open, initialMode]);
|
||||
|
||||
/* ── Login form ── */
|
||||
const loginForm = useForm<LoginForm>({
|
||||
resolver: yupResolver(loginSchema),
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function Header() {
|
||||
const t = useTranslations();
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const [loginModalMode, setLoginModalMode] = useState<"login" | "register">("login");
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
@@ -63,10 +64,15 @@ export default function Header() {
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
if (authConfig.isAuthRequired) {
|
||||
router.replace("/signin");
|
||||
router.replace("/home");
|
||||
}
|
||||
};
|
||||
|
||||
const openAuthModal = (mode: "login" | "register") => {
|
||||
setLoginModalMode(mode);
|
||||
setLoginModalOpen(true);
|
||||
};
|
||||
|
||||
// Desktop auth section
|
||||
const renderAuthSection = () => {
|
||||
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
|
||||
@@ -97,16 +103,27 @@ export default function Header() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorPalette="gray"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
onClick={() => openAuthModal("register")}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</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 (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
width="full"
|
||||
borderRadius="full"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
<VStack gap={2} w="full">
|
||||
<Button
|
||||
variant="outline"
|
||||
colorPalette="gray"
|
||||
size="sm"
|
||||
width="full"
|
||||
borderRadius="full"
|
||||
onClick={() => openAuthModal("register")}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</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>
|
||||
|
||||
{/* Login Modal */}
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} initialMode={loginModalMode} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"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, LuInfo, LuShieldCheck, LuClock } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineup source metadata used for title, badge, and informational banners.
|
||||
*/
|
||||
function getLineupSourceMeta(source?: string) {
|
||||
switch (source) {
|
||||
case "confirmed_live":
|
||||
return {
|
||||
title: "Resmi İlk 11",
|
||||
badge: "Onaylı Kadro",
|
||||
badgeColor: "green" as const,
|
||||
icon: LuShieldCheck,
|
||||
description: "Kadro resmi olarak onaylandı.",
|
||||
};
|
||||
case "confirmed_participation":
|
||||
return {
|
||||
title: "Onaylı Kadro",
|
||||
badge: "Onaylı",
|
||||
badgeColor: "green" as const,
|
||||
icon: LuShieldCheck,
|
||||
description: "Kadro maç katılım verilerinden alındı.",
|
||||
};
|
||||
case "probable_xi":
|
||||
return {
|
||||
title: "Muhtemel Kadro",
|
||||
badge: "Muhtemel",
|
||||
badgeColor: "orange" as const,
|
||||
icon: LuUsers,
|
||||
description:
|
||||
"Son maçlardaki ilk 11 verilerine dayalı muhtemel kadro. AI analizi bu kadro üzerinden yapılmaktadır.",
|
||||
};
|
||||
case "none":
|
||||
default:
|
||||
return {
|
||||
title: "Kadro Bilgisi",
|
||||
badge: "Kadro Bekleniyor",
|
||||
badgeColor: "gray" as const,
|
||||
icon: LuClock,
|
||||
description:
|
||||
"Kadro henüz açıklanmadı. AI analizi, takımların genel güç dengesi ve istatistiklerine dayalı olarak üretilmiştir.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 infoBg = useColorModeValue("blue.50", "whiteAlpha.100");
|
||||
const infoBorder = useColorModeValue("blue.200", "blue.800");
|
||||
|
||||
let homeLineups = match.lineups?.home?.filter((p) => p.isStarting) || [];
|
||||
let awayLineups = match.lineups?.away?.filter((p) => p.isStarting) || [];
|
||||
|
||||
// Determine lineup source from prediction data quality
|
||||
const source = prediction?.data_quality?.lineup_source;
|
||||
const meta = getLineupSourceMeta(source);
|
||||
|
||||
// Fallback: If no starting players are marked, but we have players, treat them as probable XI
|
||||
if (homeLineups.length === 0 && match.lineups?.home && match.lineups.home.length > 0) {
|
||||
homeLineups = match.lineups.home.slice(0, 11);
|
||||
}
|
||||
if (awayLineups.length === 0 && match.lineups?.away && match.lineups.away.length > 0) {
|
||||
awayLineups = match.lineups.away.slice(0, 11);
|
||||
}
|
||||
|
||||
const hasLineups = homeLineups.length > 0 || awayLineups.length > 0;
|
||||
|
||||
return (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Body>
|
||||
{/* ── Header ────────────────────────────────── */}
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<HStack gap={2}>
|
||||
<Icon as={meta.icon} boxSize={5} color="fg.muted" />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{meta.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorPalette={meta.badgeColor}
|
||||
variant="subtle"
|
||||
>
|
||||
{meta.badge}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* ── Info Banner ───────────────────────────── */}
|
||||
{source !== "confirmed_live" && (
|
||||
<Flex
|
||||
bg={infoBg}
|
||||
borderWidth="1px"
|
||||
borderColor={infoBorder}
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
mb={4}
|
||||
align="center"
|
||||
gap={2}
|
||||
>
|
||||
<Icon as={LuInfo} color="blue.500" flexShrink={0} />
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{meta.description}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* ── Lineups Grid ─────────────────────────── */}
|
||||
{hasLineups ? (
|
||||
<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}
|
||||
gap={2}
|
||||
>
|
||||
<Text fontWeight="bold">{match.homeTeamName}</Text>
|
||||
<Badge size="sm" variant="outline" colorPalette="blue">
|
||||
Ev Sahibi
|
||||
</Badge>
|
||||
</Flex>
|
||||
{homeLineups.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Flex
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="column"
|
||||
gap={1}
|
||||
>
|
||||
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||
Kadro henüz belli değil
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.subtle">
|
||||
Maç saatine yakın güncellenecek
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Away Team Lineup */}
|
||||
<Box>
|
||||
<Flex
|
||||
bg={headerBg}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
align="center"
|
||||
justify="center"
|
||||
mb={3}
|
||||
gap={2}
|
||||
>
|
||||
<Text fontWeight="bold">{match.awayTeamName}</Text>
|
||||
<Badge size="sm" variant="outline" colorPalette="red">
|
||||
Deplasman
|
||||
</Badge>
|
||||
</Flex>
|
||||
{awayLineups.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Flex
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="column"
|
||||
gap={1}
|
||||
>
|
||||
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
|
||||
Kadro henüz belli değil
|
||||
</Text>
|
||||
<Text fontSize="xs" color="fg.subtle">
|
||||
Maç saatine yakın güncellenecek
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
/* ── Empty State: No lineups at all ─────── */
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
py={8}
|
||||
gap={3}
|
||||
>
|
||||
<Icon as={LuClock} boxSize={8} color="fg.subtle" />
|
||||
<VStack gap={1}>
|
||||
<Text fontWeight="semibold" color="fg.muted">
|
||||
Kadro Henüz Açıklanmadı
|
||||
</Text>
|
||||
<Text fontSize="sm" color="fg.subtle" textAlign="center" maxW="sm">
|
||||
{match.homeTeamName} ve {match.awayTeamName} kadroları maç saatine
|
||||
yakın güncellenecektir. AI analizi, takım istatistikleri ve güç
|
||||
dengesi üzerinden yapılmaktadır.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
</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 PredictionCard from "@/components/matches/prediction-card";
|
||||
import OddsCard from "@/components/matches/odds-card";
|
||||
import LineupsCard from "@/components/matches/lineups-card";
|
||||
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
|
||||
|
||||
export default function MatchDetailContent() {
|
||||
@@ -237,6 +238,9 @@ export default function MatchDetailContent() {
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Lineups Section */}
|
||||
<LineupsCard match={match} prediction={prediction} />
|
||||
|
||||
{/* Prediction Section */}
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
|
||||
@@ -57,11 +57,6 @@ function getLeagueLabel(match: MatchResponseDto): string {
|
||||
return String(match.leagueName || match.league?.name || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Football season logic: Aug–Jun
|
||||
* 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 {
|
||||
const date = new Date(timestampMs);
|
||||
const year = date.getFullYear();
|
||||
@@ -73,28 +68,12 @@ function getSeasonFromTimestamp(timestampMs: number): string {
|
||||
return `${year - 1}-${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group matches by season string, returning a Map ordered by newest season first.
|
||||
*/
|
||||
function groupMatchesBySeason(matches: MatchResponseDto[]): Map<string, MatchResponseDto[]> {
|
||||
const groups = new Map<string, MatchResponseDto[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
const SEASONS = (() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const startYear = currentMonth >= 8 ? currentYear : currentYear - 1;
|
||||
return Array.from({ length: 5 }, (_, i) => `${startYear - i}-${startYear - i + 1}`);
|
||||
})();
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// Main Component
|
||||
@@ -107,24 +86,30 @@ export default function TeamDetailContent() {
|
||||
|
||||
const teamId = params.id as string;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeSeason, setActiveSeason] = useState<string>(SEASONS[0]);
|
||||
|
||||
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
||||
const {
|
||||
data: matchesResponse,
|
||||
isLoading: matchesLoading,
|
||||
isFetching: matchesFetching,
|
||||
} = useTeamMatches(teamId, { page: currentPage, limit: 20 });
|
||||
} = useTeamMatches(teamId, { page: currentPage, limit: 20, season: activeSeason });
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
const seasonActiveBg = useColorModeValue("primary.500", "primary.400");
|
||||
const seasonInactiveBg = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
const team = (teamData as Record<string, unknown> | undefined)?.data as Record<string, unknown> | undefined;
|
||||
const paginationData = matchesResponse;
|
||||
const matches: MatchResponseDto[] = paginationData?.data ?? [];
|
||||
const totalPages = paginationData?.totalPages ?? 1;
|
||||
const totalMatches = paginationData?.total ?? 0;
|
||||
// Backend ResponseInterceptor wraps all responses in { success, status, message, data }
|
||||
const teamWrapper = teamData as Record<string, unknown> | undefined;
|
||||
const team = teamWrapper?.data as Record<string, unknown> | undefined;
|
||||
|
||||
// matchesResponse = { success, status, message, data: { data: [...], total, page, limit, totalPages } }
|
||||
const paginationWrapper = matchesResponse as Record<string, unknown> | undefined;
|
||||
const paginationData = paginationWrapper?.data as Record<string, unknown> | undefined;
|
||||
const matches: MatchResponseDto[] = (Array.isArray(paginationData?.data) ? paginationData.data : paginationData?.data ? [] : []) as MatchResponseDto[];
|
||||
const totalPages = (paginationData?.totalPages as number) ?? 1;
|
||||
const totalMatches = (paginationData?.total as number) ?? 0;
|
||||
|
||||
// Separate past and upcoming matches
|
||||
const pastMatches = useMemo(
|
||||
@@ -136,30 +121,16 @@ export default function TeamDetailContent() {
|
||||
[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
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage((p) => p + 1);
|
||||
setActiveSeason(null); // Reset season on page change
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage((p) => p - 1);
|
||||
setActiveSeason(null);
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
@@ -296,11 +267,10 @@ export default function TeamDetailContent() {
|
||||
</Flex>
|
||||
|
||||
{/* Season Tabs */}
|
||||
{seasonKeys.length > 0 && (
|
||||
{SEASONS.length > 0 && (
|
||||
<HStack gap={2} mb={4} flexWrap="wrap">
|
||||
{seasonKeys.map((season) => {
|
||||
const isActive = season === displaySeason;
|
||||
const count = seasonGroups.get(season)?.length ?? 0;
|
||||
{SEASONS.map((season) => {
|
||||
const isActive = season === activeSeason;
|
||||
return (
|
||||
<Button
|
||||
key={season}
|
||||
@@ -312,14 +282,17 @@ export default function TeamDetailContent() {
|
||||
fontWeight={isActive ? "700" : "500"}
|
||||
fontSize="xs"
|
||||
px={4}
|
||||
onClick={() => setActiveSeason(season)}
|
||||
onClick={() => {
|
||||
setActiveSeason(season);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
_hover={{
|
||||
transform: "translateY(-1px)",
|
||||
shadow: "sm",
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
🏆 {season} ({count})
|
||||
🏆 {season}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -330,17 +303,13 @@ export default function TeamDetailContent() {
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="md" color="primary.500" />
|
||||
</Flex>
|
||||
) : displayMatches.length === 0 && pastMatches.length === 0 ? (
|
||||
) : pastMatches.length === 0 ? (
|
||||
<Text color="fg.muted" textAlign="center" py={8}>
|
||||
Bu sayfada geçmiş maç bulunamadı
|
||||
</Text>
|
||||
) : displayMatches.length === 0 ? (
|
||||
<Text color="fg.muted" textAlign="center" py={8}>
|
||||
Bu sezonda maç bulunamadı
|
||||
Bu sezonda geçmiş maç bulunamadı
|
||||
</Text>
|
||||
) : (
|
||||
<VStack gap={2} align="stretch">
|
||||
{displayMatches.map((match: MatchResponseDto) => (
|
||||
{pastMatches.map((match: MatchResponseDto) => (
|
||||
<MatchRow
|
||||
key={match.id}
|
||||
match={match}
|
||||
@@ -390,7 +359,6 @@ export default function TeamDetailContent() {
|
||||
minW="36px"
|
||||
onClick={() => {
|
||||
setCurrentPage(pageNum);
|
||||
setActiveSeason(null);
|
||||
}}
|
||||
>
|
||||
{pageNum}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface HeadToHeadParams {
|
||||
export interface TeamMatchesParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
season?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedMatchesResponse {
|
||||
|
||||
@@ -90,6 +90,22 @@ export interface MatchResponseDto {
|
||||
country?: { name: string; flag?: string };
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { authService } from "@/lib/api/auth/service";
|
||||
import { normalizeRoles } from "@/lib/auth/roles";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import type { Session, User } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
function randomToken() {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
console.log("Starting authorization with:", {
|
||||
email: credentials?.email,
|
||||
});
|
||||
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error("Email ve şifre gereklidir.");
|
||||
}
|
||||
|
||||
// Eğer mock mod aktifse backend'e gitme
|
||||
if (isMockMode) {
|
||||
console.log("Mock mode active, bypassing backend");
|
||||
return {
|
||||
id: credentials.email,
|
||||
name: credentials.email.split("@")[0],
|
||||
email: credentials.email,
|
||||
accessToken: randomToken(),
|
||||
refreshToken: randomToken(),
|
||||
};
|
||||
}
|
||||
|
||||
// Normal mod: backend'e istek at
|
||||
console.log("Sending login request to backend...");
|
||||
const res = await authService.login({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Backend response received:",
|
||||
JSON.stringify(res, null, 2),
|
||||
);
|
||||
|
||||
const response = res;
|
||||
|
||||
// Backend returns ApiResponse<TokenResponseDto>
|
||||
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
||||
if (!res.success || !response?.data?.accessToken) {
|
||||
console.error("Login failed or no access token in response");
|
||||
throw new Error(response?.message || "Giriş başarısız");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data;
|
||||
const normalizedRoles = normalizeRoles(user.roles);
|
||||
|
||||
console.log("Login successful, creating user session object");
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.firstName
|
||||
? `${user.firstName} ${user.lastName || ""}`.trim()
|
||||
: user.email.split("@")[0],
|
||||
email: user.email,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
roles: normalizedRoles,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Authorize error detailed:", error);
|
||||
const err = error as Error & {
|
||||
response?: { data: unknown; status: number };
|
||||
};
|
||||
if (err.response) {
|
||||
console.error("Error response data:", err.response.data);
|
||||
console.error("Error response status:", err.response.status);
|
||||
}
|
||||
throw new Error(
|
||||
err.message || "An error occurred during authentication",
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }: { token: JWT; user?: User }) {
|
||||
if (user) {
|
||||
token.accessToken = user.accessToken;
|
||||
token.refreshToken = user.refreshToken;
|
||||
token.id = user.id;
|
||||
token.roles = normalizeRoles(user.roles);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: JWT }) {
|
||||
session.user.id = token.id;
|
||||
session.user.roles = normalizeRoles(token.roles);
|
||||
session.accessToken = token.accessToken;
|
||||
session.refreshToken = token.refreshToken;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
session: { strategy: "jwt" },
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user