v28
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
|
||||
import { Box, Heading, Input, Text, VStack, HStack } from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
@@ -21,35 +21,60 @@ import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { BiLock } from "react-icons/bi";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { BiUser } from "react-icons/bi";
|
||||
import { authService } from "@/lib/api/auth/service";
|
||||
|
||||
const schema = yup.object({
|
||||
/* ────────────────────────── Schemas ────────────────────────── */
|
||||
|
||||
const loginSchema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().min(6).required(),
|
||||
});
|
||||
|
||||
type LoginForm = yup.InferType<typeof schema>;
|
||||
const registerSchema = yup.object({
|
||||
name: yup.string().required(),
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
type LoginForm = yup.InferType<typeof loginSchema>;
|
||||
type RegisterForm = yup.InferType<typeof registerSchema>;
|
||||
|
||||
/* ────────────────────────── Props ────────────────────────── */
|
||||
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/* ────────────────────────── Component ────────────────────────── */
|
||||
|
||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
const t = useTranslations();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: yupResolver(schema),
|
||||
/* ── Login form ── */
|
||||
const loginForm = useForm<LoginForm>({
|
||||
resolver: yupResolver(loginSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: LoginForm) => {
|
||||
/* ── Register form ── */
|
||||
const registerForm = useForm<RegisterForm>({
|
||||
resolver: yupResolver(registerSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
/* ── Switch mode ── */
|
||||
const switchMode = (newMode: "login" | "register") => {
|
||||
setMode(newMode);
|
||||
loginForm.reset();
|
||||
registerForm.reset();
|
||||
};
|
||||
|
||||
/* ── Handle login ── */
|
||||
const onLogin = async (formData: LoginForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await signIn("credentials", {
|
||||
@@ -64,12 +89,49 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
|
||||
onOpenChange(false);
|
||||
toaster.success({
|
||||
title: t("auth.login-success") || "Login successful!",
|
||||
title: t("auth.login-success") || "Giriş başarılı!",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Login failed!",
|
||||
title: (error as Error).message || "Giriş yapılamadı!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Handle register ── */
|
||||
const onRegister = async (formData: RegisterForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await authService.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
firstName: formData.name,
|
||||
lastName: "",
|
||||
});
|
||||
|
||||
// Auto-login after successful registration
|
||||
const res = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
toaster.success({
|
||||
title: t("auth.register-success") || "Kayıt başarılı!",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Kayıt yapılamadı!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -78,75 +140,198 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
||||
<DialogRoot
|
||||
open={open}
|
||||
onOpenChange={(e) => {
|
||||
onOpenChange(e.open);
|
||||
if (!e.open) {
|
||||
// Reset to login when closing
|
||||
setMode("login");
|
||||
loginForm.reset();
|
||||
registerForm.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Heading size="lg" color="primary.500">
|
||||
{t("auth.sign-in")}
|
||||
{mode === "login" ? t("auth.sign-in") : t("auth.sign-up")}
|
||||
</Heading>
|
||||
</DialogTitle>
|
||||
<DialogCloseTrigger />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
{...register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{/* ────── Tab Switcher ────── */}
|
||||
<HStack
|
||||
gap={0}
|
||||
mb={5}
|
||||
borderWidth="1px"
|
||||
borderColor={{ base: "gray.200", _dark: "gray.700" }}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Button
|
||||
flex={1}
|
||||
size="sm"
|
||||
variant={mode === "login" ? "solid" : "ghost"}
|
||||
colorPalette={mode === "login" ? "primary" : "gray"}
|
||||
borderRadius="0"
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
size="sm"
|
||||
variant={mode === "register" ? "solid" : "ghost"}
|
||||
colorPalette={mode === "register" ? "primary" : "gray"}
|
||||
borderRadius="0"
|
||||
onClick={() => switchMode("register")}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Field
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
{/* ────── LOGIN FORM ────── */}
|
||||
{mode === "login" && (
|
||||
<Box as="form" onSubmit={loginForm.handleSubmit(onLogin)}>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
label={t("email")}
|
||||
errorText={loginForm.formState.errors.email?.message}
|
||||
invalid={!!loginForm.formState.errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
{...loginForm.register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("password")}
|
||||
errorText={loginForm.formState.errors.password?.message}
|
||||
invalid={!!loginForm.formState.errors.password}
|
||||
>
|
||||
<PasswordInput
|
||||
rootProps={{ w: "full" }}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
{...register("password")}
|
||||
{...loginForm.register("password")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
color="white"
|
||||
_hover={{ bg: "primary.500" }}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
color="white"
|
||||
_hover={{ bg: "primary.500" }}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("auth.dont-have-account")}{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
style={{
|
||||
color: "var(--chakra-colors-primary-500)",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("auth.dont-have-account")}{" "}
|
||||
<Text
|
||||
as="span"
|
||||
color="primary.500"
|
||||
fontWeight="bold"
|
||||
cursor="pointer"
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
onClick={() => switchMode("register")}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</Text>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ────── REGISTER FORM ────── */}
|
||||
{mode === "register" && (
|
||||
<Box as="form" onSubmit={registerForm.handleSubmit(onRegister)}>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
label={t("name")}
|
||||
errorText={registerForm.formState.errors.name?.message}
|
||||
invalid={!!registerForm.formState.errors.name}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("name")}
|
||||
{...registerForm.register("name")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("email")}
|
||||
errorText={registerForm.formState.errors.email?.message}
|
||||
invalid={!!registerForm.formState.errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
{...registerForm.register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("password")}
|
||||
errorText={registerForm.formState.errors.password?.message}
|
||||
invalid={!!registerForm.formState.errors.password}
|
||||
>
|
||||
<PasswordInput
|
||||
rootProps={{ w: "full" }}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
{...registerForm.register("password")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
color="white"
|
||||
_hover={{ bg: "primary.500" }}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("auth.already-have-an-account")}{" "}
|
||||
<Text
|
||||
as="span"
|
||||
color="primary.500"
|
||||
fontWeight="bold"
|
||||
cursor="pointer"
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Text>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import {
|
||||
LuBadgeAlert,
|
||||
LuBarChart3,
|
||||
LuChartBar,
|
||||
LuCircleHelp,
|
||||
LuDatabase,
|
||||
LuTrendingUp,
|
||||
@@ -182,7 +182,7 @@ export default function FrequencyPanel() {
|
||||
<VStack align="stretch" gap={2} mb={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuBarChart3} color="purple.500" />
|
||||
<Icon as={LuChartBar} color="purple.500" />
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{t("match-count-label")}
|
||||
</Text>
|
||||
|
||||
@@ -37,8 +37,10 @@ import type {
|
||||
MatchPickDto,
|
||||
MatchPredictionDto,
|
||||
SignalTier,
|
||||
V27EngineDto,
|
||||
} from "@/lib/api/predictions/types";
|
||||
import type { SportType } from "@/lib/api/matches/types";
|
||||
import V28OddsBandPanel from "@/components/matches/v28-odds-band-panel";
|
||||
|
||||
interface PredictionCardProps {
|
||||
prediction: MatchPredictionDto;
|
||||
@@ -1087,6 +1089,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
info={uiText("market-board-info", "Modelin her markette gordugu olasilik dagilimi.")}
|
||||
/>
|
||||
|
||||
{prediction.v27_engine ? (
|
||||
<V28OddsBandPanel engine={prediction.v27_engine as V27EngineDto} />
|
||||
) : null}
|
||||
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||
<Card.Body gap={4}>
|
||||
<SectionTitle
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Grid,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
LuBadgeCheck,
|
||||
LuCircleHelp,
|
||||
LuRectangleVertical,
|
||||
LuShieldAlert,
|
||||
LuTarget,
|
||||
LuTrendingUp,
|
||||
} from "react-icons/lu";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||
import type {
|
||||
HtftComboKey,
|
||||
OddsBandCardsDto,
|
||||
OddsBandHtftComboDto,
|
||||
TripleValueEntryDto,
|
||||
V27EngineDto,
|
||||
} from "@/lib/api/predictions/types";
|
||||
|
||||
// ──────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────
|
||||
|
||||
function pct(v: number, d = 0): string {
|
||||
if (!v && v !== 0) return "-";
|
||||
return `${(v * 100).toFixed(d)}%`;
|
||||
}
|
||||
|
||||
function edgeStr(edge: number): string {
|
||||
const sign = edge > 0 ? "+" : "";
|
||||
return `${sign}${(edge * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const TRIPLE_VALUE_LABELS: Record<string, string> = {
|
||||
home: "MS Ev",
|
||||
away: "MS Dep",
|
||||
ou25_over: "ÜST 2.5",
|
||||
btts_yes: "KG Var",
|
||||
ou15_over: "ÜST 1.5",
|
||||
ou35_over: "ÜST 3.5",
|
||||
dc_1x: "ÇŞ 1X",
|
||||
dc_x2: "ÇŞ X2",
|
||||
dc_12: "ÇŞ 12",
|
||||
ht_home: "İY Ev",
|
||||
ht_away: "İY Dep",
|
||||
ht_ou05_over: "İY ÜST 0.5",
|
||||
ht_ou15_over: "İY ÜST 1.5",
|
||||
oe_odd: "Tek",
|
||||
cards_over: "Kart ÜST",
|
||||
htft_11: "İY/MS 1/1",
|
||||
htft_1x: "İY/MS 1/X",
|
||||
htft_12: "İY/MS 1/2",
|
||||
htft_x1: "İY/MS X/1",
|
||||
htft_xx: "İY/MS X/X",
|
||||
htft_x2: "İY/MS X/2",
|
||||
htft_21: "İY/MS 2/1",
|
||||
htft_2x: "İY/MS 2/X",
|
||||
htft_22: "İY/MS 2/2",
|
||||
};
|
||||
|
||||
const HTFT_DISPLAY: Record<HtftComboKey, string> = {
|
||||
"11": "1/1",
|
||||
"1x": "1/X",
|
||||
"12": "1/2",
|
||||
x1: "X/1",
|
||||
xx: "X/X",
|
||||
x2: "X/2",
|
||||
"21": "2/1",
|
||||
"2x": "2/X",
|
||||
"22": "2/2",
|
||||
};
|
||||
|
||||
const HTFT_ROWS: HtftComboKey[][] = [
|
||||
["11", "1x", "12"],
|
||||
["x1", "xx", "x2"],
|
||||
["21", "2x", "22"],
|
||||
];
|
||||
|
||||
function TooltipIcon({ content }: { content: string }) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={content}
|
||||
showArrow
|
||||
positioning={{ placement: "top" }}
|
||||
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Bilgi"
|
||||
variant="ghost"
|
||||
size="2xs"
|
||||
colorPalette="gray"
|
||||
>
|
||||
<LuCircleHelp />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
icon,
|
||||
title,
|
||||
info,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
info?: string;
|
||||
}) {
|
||||
return (
|
||||
<HStack justify="space-between" w="full">
|
||||
<HStack gap={2}>
|
||||
<Icon as={icon} boxSize={4.5} color="fg.muted" />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
{info ? <TooltipIcon content={info} /> : null}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────
|
||||
// Triple Value Card
|
||||
// ──────────────────────────────────────
|
||||
|
||||
function TripleValueCard({
|
||||
label,
|
||||
entry,
|
||||
}: {
|
||||
label: string;
|
||||
entry: TripleValueEntryDto;
|
||||
}) {
|
||||
const isValue = entry.is_value;
|
||||
const hasSample = entry.band_sample >= 5;
|
||||
|
||||
const cardBg = useColorModeValue(
|
||||
isValue ? "green.50" : "gray.50",
|
||||
isValue ? "green.950" : "whiteAlpha.50",
|
||||
);
|
||||
const borderCol = useColorModeValue(
|
||||
isValue ? "green.300" : "gray.200",
|
||||
isValue ? "green.700" : "gray.700",
|
||||
);
|
||||
const edgeColor = entry.edge > 0.03 ? "green.500" : entry.edge < -0.03 ? "red.400" : "fg.muted";
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderCol}
|
||||
borderRadius="xl"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{isValue && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="3px"
|
||||
bgGradient="to-r"
|
||||
gradientFrom="green.400"
|
||||
gradientTo="teal.400"
|
||||
/>
|
||||
)}
|
||||
<VStack align="stretch" gap={1.5}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="xs" fontWeight="semibold" color="fg.muted">
|
||||
{label}
|
||||
</Text>
|
||||
{isValue ? (
|
||||
<Badge
|
||||
colorPalette="green"
|
||||
variant="solid"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
DEĞER
|
||||
</Badge>
|
||||
) : hasSample ? (
|
||||
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||||
PAS
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
colorPalette="gray"
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
YETERSİZ
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Text fontSize="xl" fontWeight="bold" color={edgeColor}>
|
||||
{edgeStr(entry.edge)}
|
||||
</Text>
|
||||
|
||||
<HStack gap={2} flexWrap="wrap">
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
Band: {pct(entry.band_rate, 1)}
|
||||
</Text>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
Oran: {pct(entry.implied_prob, 1)}
|
||||
</Text>
|
||||
{entry.confirmations !== undefined && (
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
Onay: {entry.confirmations}/2
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{entry.band_sample} maç
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────
|
||||
// Cards Section
|
||||
// ──────────────────────────────────────
|
||||
|
||||
function ProgressBar({
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
color: string;
|
||||
}) {
|
||||
const trackBg = useColorModeValue("gray.100", "gray.700");
|
||||
const w = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||||
return (
|
||||
<Box
|
||||
h="10px"
|
||||
w="full"
|
||||
bg={trackBg}
|
||||
borderRadius="full"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
h="full"
|
||||
w={`${w}%`}
|
||||
bg={color}
|
||||
borderRadius="full"
|
||||
transition="width 0.4s ease"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CardsSection({ cards }: { cards: OddsBandCardsDto }) {
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const hasData = cards.sample >= 3;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<HStack gap={2} mb={2}>
|
||||
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
Kart Analizi
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Yetersiz veri — henüz yeterli maç örneği bulunamadı.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const overPct = cards.combined_over_rate * 100;
|
||||
const overColor =
|
||||
overPct >= 65 ? "red.400" : overPct >= 50 ? "orange.400" : "green.400";
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
Kart Analizi
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||||
{cards.sample} maç
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack align="stretch" gap={3}>
|
||||
{/* Referee profile */}
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
Hakem Profili
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="semibold">
|
||||
Ort: {cards.referee_avg.toFixed(1)} kart
|
||||
</Text>
|
||||
</HStack>
|
||||
<ProgressBar
|
||||
value={cards.referee_over_rate * 100}
|
||||
max={100}
|
||||
color="purple.400"
|
||||
/>
|
||||
<HStack justify="space-between" mt={0.5}>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
Üst oranı: {pct(cards.referee_over_rate, 0)}
|
||||
</Text>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{cards.referee_sample} maç
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Team profile */}
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
Takım Profili
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="semibold">
|
||||
Ort: {cards.team_avg.toFixed(1)} kart
|
||||
</Text>
|
||||
</HStack>
|
||||
<ProgressBar
|
||||
value={cards.team_over_rate * 100}
|
||||
max={100}
|
||||
color="blue.400"
|
||||
/>
|
||||
<HStack justify="space-between" mt={0.5}>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
Üst oranı: {pct(cards.team_over_rate, 0)}
|
||||
</Text>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{cards.team_sample} maç
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Combined */}
|
||||
<Box
|
||||
p={3}
|
||||
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" gap={0}>
|
||||
<Text fontSize="xs" fontWeight="semibold">
|
||||
Kombine ÜST Oranı
|
||||
</Text>
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
%60 Hakem + %40 Takım ağırlıklı
|
||||
</Text>
|
||||
</VStack>
|
||||
<Text fontSize="2xl" fontWeight="bold" color={overColor}>
|
||||
{overPct.toFixed(0)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────
|
||||
// HTFT 3x3 Grid
|
||||
// ──────────────────────────────────────
|
||||
|
||||
function HtftGrid({
|
||||
htft,
|
||||
}: {
|
||||
htft: Record<HtftComboKey, OddsBandHtftComboDto>;
|
||||
}) {
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
|
||||
// Find the max rate for highlighting
|
||||
let maxRate = 0;
|
||||
let maxKey: HtftComboKey = "11";
|
||||
let totalSample = 0;
|
||||
for (const [key, val] of Object.entries(htft) as [
|
||||
HtftComboKey,
|
||||
OddsBandHtftComboDto,
|
||||
][]) {
|
||||
if (val.rate > maxRate) {
|
||||
maxRate = val.rate;
|
||||
maxKey = key;
|
||||
}
|
||||
totalSample = Math.max(totalSample, val.sample);
|
||||
}
|
||||
|
||||
const getCellColor = (rate: number, isMax: boolean) => {
|
||||
if (isMax) return { bg: "green.500", text: "white" };
|
||||
if (rate >= 0.2) return { bg: "green.100", text: "green.800" };
|
||||
if (rate >= 0.12) return { bg: "yellow.100", text: "yellow.800" };
|
||||
if (rate >= 0.06) return { bg: "orange.50", text: "orange.700" };
|
||||
return { bg: "gray.50", text: "gray.500" };
|
||||
};
|
||||
|
||||
const getCellColorDark = (rate: number, isMax: boolean) => {
|
||||
if (isMax) return { bg: "green.600", text: "white" };
|
||||
if (rate >= 0.2) return { bg: "green.900", text: "green.200" };
|
||||
if (rate >= 0.12) return { bg: "yellow.900", text: "yellow.200" };
|
||||
if (rate >= 0.06) return { bg: "orange.900", text: "orange.200" };
|
||||
return { bg: "whiteAlpha.50", text: "gray.500" };
|
||||
};
|
||||
|
||||
const lightMode = useColorModeValue(true, false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuTarget} boxSize={4} color="teal.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
İY/MS Kombinasyonları
|
||||
</Text>
|
||||
</HStack>
|
||||
<TooltipIcon content="İlk yarı sonucu ve maç sonucu kombinasyonlarının tarihsel oran bandındaki gerçekleşme oranları." />
|
||||
</HStack>
|
||||
|
||||
{/* Column headers */}
|
||||
<Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}>
|
||||
<Box />
|
||||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||
MS 1
|
||||
</Text>
|
||||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||
MS X
|
||||
</Text>
|
||||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||||
MS 2
|
||||
</Text>
|
||||
</Grid>
|
||||
|
||||
{/* Grid rows */}
|
||||
{HTFT_ROWS.map((row, rowIdx) => {
|
||||
const rowLabels = ["İY 1", "İY X", "İY 2"];
|
||||
return (
|
||||
<Grid
|
||||
key={rowIdx}
|
||||
templateColumns="50px repeat(3, 1fr)"
|
||||
gap={1.5}
|
||||
mb={1.5}
|
||||
>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Text fontSize="2xs" fontWeight="bold" color="fg.muted">
|
||||
{rowLabels[rowIdx]}
|
||||
</Text>
|
||||
</Box>
|
||||
{row.map((comboKey) => {
|
||||
const data = htft[comboKey] || { rate: 0, sample: 0 };
|
||||
const isMax = comboKey === maxKey && maxRate > 0.05;
|
||||
const colors = lightMode
|
||||
? getCellColor(data.rate, isMax)
|
||||
: getCellColorDark(data.rate, isMax);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={comboKey}
|
||||
py={2.5}
|
||||
px={2}
|
||||
bg={colors.bg}
|
||||
borderRadius="lg"
|
||||
textAlign="center"
|
||||
position="relative"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: "scale(1.04)" }}
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={colors.text}
|
||||
mb={0.5}
|
||||
>
|
||||
{HTFT_DISPLAY[comboKey]}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="extrabold"
|
||||
color={colors.text}
|
||||
>
|
||||
{pct(data.rate, 0)}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="2xs"
|
||||
color={isMax ? "whiteAlpha.800" : "fg.muted"}
|
||||
>
|
||||
{data.sample} maç
|
||||
</Text>
|
||||
{isMax && (
|
||||
<Icon
|
||||
as={LuBadgeCheck}
|
||||
position="absolute"
|
||||
top={1}
|
||||
right={1}
|
||||
boxSize={3.5}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Best combo callout */}
|
||||
{maxRate > 0.05 && (
|
||||
<Box
|
||||
mt={2}
|
||||
p={2.5}
|
||||
bg={useColorModeValue("green.50", "green.950")}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuTrendingUp} boxSize={4} color="green.500" />
|
||||
<Text fontSize="xs" fontWeight="semibold">
|
||||
En güçlü:{" "}
|
||||
<Text as="span" color="green.500">
|
||||
{HTFT_DISPLAY[maxKey]} ({pct(maxRate, 0)})
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────
|
||||
// Main Panel Export
|
||||
// ──────────────────────────────────────
|
||||
|
||||
interface V28OddsBandPanelProps {
|
||||
engine: V27EngineDto;
|
||||
}
|
||||
|
||||
export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
|
||||
const tripleValue = engine.triple_value;
|
||||
const cards = engine.odds_band?.cards as OddsBandCardsDto | undefined;
|
||||
const htft = engine.odds_band?.htft as
|
||||
| Record<HtftComboKey, OddsBandHtftComboDto>
|
||||
| undefined;
|
||||
|
||||
// Filter out HTFT triple-value entries from the main grid (shown in HTFT section)
|
||||
const mainValueEntries = Object.entries(tripleValue || {}).filter(
|
||||
([key]) => !key.startsWith("htft_"),
|
||||
);
|
||||
|
||||
// Separate value hits from non-hits for priority ordering
|
||||
const valueHits = mainValueEntries.filter(([, e]) => e.is_value);
|
||||
const valueNon = mainValueEntries.filter(([, e]) => !e.is_value);
|
||||
const orderedEntries = [...valueHits, ...valueNon];
|
||||
|
||||
const hasTriple = orderedEntries.length > 0;
|
||||
const hasCards = cards && cards.sample >= 1;
|
||||
const hasHtft = htft && Object.values(htft).some((v) => v.sample > 0);
|
||||
|
||||
if (!hasTriple && !hasCards && !hasHtft) return null;
|
||||
|
||||
return (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||
<Card.Body gap={5}>
|
||||
<SectionTitle
|
||||
icon={LuShieldAlert}
|
||||
title="V28 Oran Bandı Analizi"
|
||||
info="Geçmiş maçlarda benzer oranlarda gerçekleşen sonuçların istatistiksel analizi. Triple Value, Kart Profili ve İY/MS kombinasyonlarını içerir."
|
||||
/>
|
||||
|
||||
{/* Engine version badge */}
|
||||
<HStack>
|
||||
<Badge colorPalette="purple" variant="subtle" borderRadius="full" fontSize="2xs">
|
||||
{engine.version}
|
||||
</Badge>
|
||||
{engine.consensus && (
|
||||
<Badge
|
||||
colorPalette={engine.consensus === "AGREE" ? "green" : "orange"}
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
fontSize="2xs"
|
||||
>
|
||||
{engine.consensus === "AGREE" ? "Motorlar Uyumlu" : "Motorlar Farklı"}
|
||||
</Badge>
|
||||
)}
|
||||
{valueHits.length > 0 && (
|
||||
<Badge colorPalette="green" variant="outline" borderRadius="full" fontSize="2xs">
|
||||
{valueHits.length} Değer Sinyali
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Triple Value Grid */}
|
||||
{hasTriple && (
|
||||
<Box>
|
||||
<HStack mb={3} gap={2}>
|
||||
<Icon as={LuTarget} boxSize={4} color="blue.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
Değer Tespiti (Triple Value)
|
||||
</Text>
|
||||
<TooltipIcon content="Model olasılığı, oran bandı istatistiği ve piyasa oranı karşılaştırması. Edge pozitifse model avantaj görüyor demektir." />
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, xl: 4 }} gap={2.5}>
|
||||
{orderedEntries.map(([key, entry]) => (
|
||||
<TripleValueCard
|
||||
key={key}
|
||||
label={TRIPLE_VALUE_LABELS[key] || key}
|
||||
entry={entry}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Cards + HTFT side by side on large screens */}
|
||||
{(hasCards || hasHtft) && (
|
||||
<Grid
|
||||
templateColumns={{ base: "1fr", xl: hasCards && hasHtft ? "1fr 1fr" : "1fr" }}
|
||||
gap={4}
|
||||
>
|
||||
{hasCards && <CardsSection cards={cards} />}
|
||||
{hasHtft && <HtftGrid htft={htft} />}
|
||||
</Grid>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
@@ -12,15 +12,19 @@ import {
|
||||
Spinner,
|
||||
Button,
|
||||
Card,
|
||||
Table,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { SlideUp, FadeIn } from "@/components/motion";
|
||||
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
|
||||
import { LuArrowLeft, LuCalendar, LuTrophy } from "react-icons/lu";
|
||||
import { LuArrowLeft, LuCalendar, LuTrophy, LuChevronDown } from "react-icons/lu";
|
||||
import type { MatchResponseDto } from "@/lib/api/matches/types";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// Utility Functions
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
function getMatchTimestamp(match: MatchResponseDto): number {
|
||||
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
|
||||
@@ -46,53 +50,118 @@ function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["
|
||||
}
|
||||
|
||||
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
|
||||
return String(team?.logo || team?.logoUrl || fallback || "");
|
||||
return String(team?.logo || (team as Record<string, unknown> | undefined)?.logoUrl || fallback || "");
|
||||
}
|
||||
|
||||
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();
|
||||
const month = date.getMonth() + 1; // 1-indexed
|
||||
|
||||
if (month >= 8) {
|
||||
return `${year}-${year + 1}`;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// Main Component
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
export default function TeamDetailContent() {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const teamId = params.id as string;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
|
||||
const { data: matchesData, isLoading: matchesLoading } = useTeamMatches(teamId, { limit: 30 });
|
||||
const {
|
||||
data: matchesResponse,
|
||||
isLoading: matchesLoading,
|
||||
isFetching: matchesFetching,
|
||||
} = useTeamMatches(teamId, { page: currentPage, limit: 20 });
|
||||
|
||||
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?.data;
|
||||
const matches: MatchResponseDto[] = matchesData?.data ?? [];
|
||||
|
||||
if (teamLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
||||
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<LuArrowLeft /> Geri
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
// Separate past and upcoming matches
|
||||
const isFinished = (m: MatchResponseDto) => isMatchFinished(m);
|
||||
const pastMatches = useMemo(
|
||||
() => matches.filter((m) => isMatchFinished(m)),
|
||||
[matches]
|
||||
);
|
||||
const upcomingMatches = useMemo(
|
||||
() => matches.filter((m) => !isMatchFinished(m)),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const pastMatches = matches.filter((m: MatchResponseDto) => isFinished(m));
|
||||
const upcomingMatches = matches.filter((m: MatchResponseDto) => !isFinished(m));
|
||||
// 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]);
|
||||
|
||||
const getStatusBadge = (match: MatchResponseDto) => {
|
||||
if (isMatchLive(match))
|
||||
@@ -114,6 +183,25 @@ export default function TeamDetailContent() {
|
||||
);
|
||||
};
|
||||
|
||||
if (teamLoading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
|
||||
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<LuArrowLeft /> Geri
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
@@ -127,10 +215,10 @@ export default function TeamDetailContent() {
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Body>
|
||||
<HStack gap={6} justify="center" align="center">
|
||||
{team.logo ? (
|
||||
{(team as Record<string, unknown>).logo ? (
|
||||
<Image
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
src={String((team as Record<string, unknown>).logo)}
|
||||
alt={String((team as Record<string, unknown>).name)}
|
||||
boxSize="80px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
@@ -143,23 +231,23 @@ export default function TeamDetailContent() {
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
|
||||
{team.name?.charAt(0) || "T"}
|
||||
{String((team as Record<string, unknown>).name || "T").charAt(0)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<VStack gap={1} align="start">
|
||||
<Heading as="h1" size="xl">
|
||||
{team.name}
|
||||
{String((team as Record<string, unknown>).name)}
|
||||
</Heading>
|
||||
{team.country && (
|
||||
{Boolean((team as Record<string, unknown>).country) && (
|
||||
<Text fontSize="md" color="fg.muted">
|
||||
🌍 {team.country}
|
||||
🌍 {String((team as Record<string, unknown>).country)}
|
||||
</Text>
|
||||
)}
|
||||
<HStack gap={4} mt={1}>
|
||||
<Badge colorPalette="blue" variant="subtle">
|
||||
<LuTrophy style={{ width: 12, height: 12 }} />
|
||||
{matches.length} Maç
|
||||
{totalMatches} Maç
|
||||
</Badge>
|
||||
<Badge colorPalette="green" variant="subtle">
|
||||
<LuCalendar style={{ width: 12, height: 12 }} />
|
||||
@@ -194,23 +282,65 @@ export default function TeamDetailContent() {
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
{/* Past Matches */}
|
||||
{/* Past Matches — Season Grouped */}
|
||||
<FadeIn>
|
||||
<Box>
|
||||
<Heading as="h2" size="lg" mb={4}>
|
||||
📊 Geçmiş Maçlar
|
||||
</Heading>
|
||||
{matchesLoading ? (
|
||||
<Flex align="center" justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||
<Heading as="h2" size="lg">
|
||||
📊 Geçmiş Maçlar
|
||||
</Heading>
|
||||
{/* Pagination Info */}
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
Sayfa {currentPage}/{totalPages} • Toplam {totalMatches} maç
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Season Tabs */}
|
||||
{seasonKeys.length > 0 && (
|
||||
<HStack gap={2} mb={4} flexWrap="wrap">
|
||||
{seasonKeys.map((season) => {
|
||||
const isActive = season === displaySeason;
|
||||
const count = seasonGroups.get(season)?.length ?? 0;
|
||||
return (
|
||||
<Button
|
||||
key={season}
|
||||
size="sm"
|
||||
variant={isActive ? "solid" : "outline"}
|
||||
bg={isActive ? seasonActiveBg : seasonInactiveBg}
|
||||
color={isActive ? "white" : undefined}
|
||||
borderRadius="full"
|
||||
fontWeight={isActive ? "700" : "500"}
|
||||
fontSize="xs"
|
||||
px={4}
|
||||
onClick={() => setActiveSeason(season)}
|
||||
_hover={{
|
||||
transform: "translateY(-1px)",
|
||||
shadow: "sm",
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
🏆 {season} ({count})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{matchesLoading || matchesFetching ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="md" color="primary.500" />
|
||||
</Flex>
|
||||
) : pastMatches.length === 0 ? (
|
||||
) : displayMatches.length === 0 && pastMatches.length === 0 ? (
|
||||
<Text color="fg.muted" textAlign="center" py={8}>
|
||||
Geçmiş maç bulunamadı
|
||||
Bu sayfada geçmiş maç bulunamadı
|
||||
</Text>
|
||||
) : displayMatches.length === 0 ? (
|
||||
<Text color="fg.muted" textAlign="center" py={8}>
|
||||
Bu sezonda maç bulunamadı
|
||||
</Text>
|
||||
) : (
|
||||
<VStack gap={2} align="stretch">
|
||||
{pastMatches.map((match: MatchResponseDto) => (
|
||||
{displayMatches.map((match: MatchResponseDto) => (
|
||||
<MatchRow
|
||||
key={match.id}
|
||||
match={match}
|
||||
@@ -222,6 +352,63 @@ export default function TeamDetailContent() {
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="center" gap={3} mt={6} align="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage <= 1}
|
||||
borderRadius="full"
|
||||
>
|
||||
← Önceki
|
||||
</Button>
|
||||
<HStack gap={1}>
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
// Show pages around current page
|
||||
let pageNum: number;
|
||||
if (totalPages <= 7) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 4) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 3) {
|
||||
pageNum = totalPages - 6 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 3 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={pageNum === currentPage ? "solid" : "ghost"}
|
||||
bg={pageNum === currentPage ? seasonActiveBg : undefined}
|
||||
color={pageNum === currentPage ? "white" : undefined}
|
||||
borderRadius="full"
|
||||
minW="36px"
|
||||
onClick={() => {
|
||||
setCurrentPage(pageNum);
|
||||
setActiveSeason(null);
|
||||
}}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage >= totalPages}
|
||||
borderRadius="full"
|
||||
>
|
||||
Sonraki →
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</FadeIn>
|
||||
</Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
TeamSearchParams,
|
||||
HeadToHeadParams,
|
||||
TeamMatchesParams,
|
||||
PaginatedMatchesResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ const getTeamById = (id: string) => {
|
||||
};
|
||||
|
||||
const getTeamMatches = (id: string, params?: TeamMatchesParams) => {
|
||||
return apiRequest<ApiResponse<MatchResponseDto[]>>({
|
||||
return apiRequest<PaginatedMatchesResponse>({
|
||||
url: `/leagues/teams/${id}/matches`,
|
||||
client: "core",
|
||||
method: "get",
|
||||
|
||||
@@ -21,9 +21,18 @@ export interface HeadToHeadParams {
|
||||
}
|
||||
|
||||
export interface TeamMatchesParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedMatchesResponse {
|
||||
data: import("@/lib/api/matches/types").MatchResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Response DTOs
|
||||
// ========================
|
||||
|
||||
@@ -141,6 +141,93 @@ export interface MarketBoardEntryDto {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// V28 Odds-Band Engine DTOs
|
||||
// ========================
|
||||
|
||||
export interface OddsBandEntryDto {
|
||||
win_rate?: number;
|
||||
draw_rate?: number;
|
||||
lose_rate?: number;
|
||||
over_rate?: number;
|
||||
under_rate?: number;
|
||||
yes_rate?: number;
|
||||
no_rate?: number;
|
||||
odd_rate?: number;
|
||||
even_rate?: number;
|
||||
"1x_rate"?: number;
|
||||
"x2_rate"?: number;
|
||||
"12_rate"?: number;
|
||||
"1x_sample"?: number;
|
||||
"x2_sample"?: number;
|
||||
"12_sample"?: number;
|
||||
sample: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OddsBandCardsDto {
|
||||
referee_avg: number;
|
||||
referee_over_rate: number;
|
||||
referee_sample: number;
|
||||
team_avg: number;
|
||||
team_over_rate: number;
|
||||
team_sample: number;
|
||||
combined_over_rate: number;
|
||||
sample: number;
|
||||
}
|
||||
|
||||
export interface OddsBandHtftComboDto {
|
||||
rate: number;
|
||||
sample: number;
|
||||
}
|
||||
|
||||
export interface TripleValueEntryDto {
|
||||
v27_prob?: number;
|
||||
band_rate: number;
|
||||
implied_prob: number;
|
||||
combined_prob?: number;
|
||||
edge: number;
|
||||
band_sample: number;
|
||||
confirmations?: number;
|
||||
is_value: boolean;
|
||||
}
|
||||
|
||||
export type HtftComboKey =
|
||||
| "11" | "1x" | "12"
|
||||
| "x1" | "xx" | "x2"
|
||||
| "21" | "2x" | "22";
|
||||
|
||||
export interface V27EngineDto {
|
||||
version: string;
|
||||
approach?: string;
|
||||
consensus?: "AGREE" | "DISAGREE";
|
||||
predictions?: Record<string, Record<string, number>>;
|
||||
divergence?: Record<string, Record<string, number>>;
|
||||
value_edge?: Record<string, Record<string, unknown>>;
|
||||
odds_band?: {
|
||||
ms_home?: OddsBandEntryDto;
|
||||
ms_away?: OddsBandEntryDto;
|
||||
ou25?: OddsBandEntryDto;
|
||||
ou15?: OddsBandEntryDto;
|
||||
ou35?: OddsBandEntryDto;
|
||||
btts?: OddsBandEntryDto;
|
||||
dc?: OddsBandEntryDto;
|
||||
ht_home?: OddsBandEntryDto;
|
||||
ht_away?: OddsBandEntryDto;
|
||||
ht_ou05?: OddsBandEntryDto;
|
||||
ht_ou15?: OddsBandEntryDto;
|
||||
oe?: OddsBandEntryDto;
|
||||
cards?: OddsBandCardsDto;
|
||||
htft?: Record<HtftComboKey, OddsBandHtftComboDto>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
triple_value?: Record<string, TripleValueEntryDto>;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Main Prediction DTOs
|
||||
// ========================
|
||||
|
||||
export interface MatchPredictionDto {
|
||||
model_version: string;
|
||||
match_info: MatchInfoDto;
|
||||
@@ -157,6 +244,7 @@ export interface MatchPredictionDto {
|
||||
market_board: Record<string, MarketBoardEntryDto>;
|
||||
reasoning_factors: string[];
|
||||
ai_commentary?: string | null;
|
||||
v27_engine?: V27EngineDto;
|
||||
}
|
||||
|
||||
export interface ValueBetDto {
|
||||
|
||||
Reference in New Issue
Block a user