This commit is contained in:
2026-04-23 22:23:35 +03:00
parent 4896323e04
commit 9e04ca5627
13 changed files with 1286 additions and 114 deletions
+249 -64
View File
@@ -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>
+2 -2
View File
@@ -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>
);
}
+231 -44
View File
@@ -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: 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 {
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>
+2 -1
View File
@@ -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",
+9
View File
@@ -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
// ========================
+88
View File
@@ -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 {