This commit is contained in:
+1
-1
@@ -48,4 +48,4 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"description": "Generated by Frontend CLI"
|
"description": "Generated by Frontend CLI"
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
import { SlideUp, FadeIn } from "@/components/motion";
|
import { SlideUp, FadeIn } from "@/components/motion";
|
||||||
import { useMatchDetails } from "@/lib/api/matches/use-hooks";
|
import { useMatchDetails, useOddsMovement } from "@/lib/api/matches/use-hooks";
|
||||||
import { usePrediction, useAiCommentary } from "@/lib/api/predictions/use-hooks";
|
import { usePrediction, useAiCommentary } from "@/lib/api/predictions/use-hooks";
|
||||||
import { useGetMe } from "@/lib/api/users/use-hooks";
|
import { useGetMe } from "@/lib/api/users/use-hooks";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -170,6 +170,8 @@ export default function MatchDetailContent() {
|
|||||||
|
|
||||||
const match = matchData?.data as MatchResponseDto | undefined;
|
const match = matchData?.data as MatchResponseDto | undefined;
|
||||||
const prediction = predictionData?.data;
|
const prediction = predictionData?.data;
|
||||||
|
const { data: movementData } = useOddsMovement(matchId);
|
||||||
|
const oddsMovement = movementData?.data;
|
||||||
|
|
||||||
if (matchLoading) return <MatchDetailSkeleton />;
|
if (matchLoading) return <MatchDetailSkeleton />;
|
||||||
|
|
||||||
@@ -830,7 +832,7 @@ export default function MatchDetailContent() {
|
|||||||
{/* ══════════════════════════════════════════════ */}
|
{/* ══════════════════════════════════════════════ */}
|
||||||
{match.odds && Object.keys(match.odds).length > 0 && show("odds") && (
|
{match.odds && Object.keys(match.odds).length > 0 && show("odds") && (
|
||||||
<Box mt={6}>
|
<Box mt={6}>
|
||||||
<OddsCard odds={match.odds} />
|
<OddsCard odds={match.odds} prediction={prediction} movement={oddsMovement} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,110 +1,163 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Box, SimpleGrid, Text, Card, VStack, Flex } from "@chakra-ui/react";
|
||||||
Box,
|
|
||||||
SimpleGrid,
|
|
||||||
Text,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
VStack,
|
|
||||||
Flex,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||||
|
import type { MatchPredictionDto } from "@/lib/api/predictions/types";
|
||||||
|
|
||||||
|
// movement: per market (Turkish name) -> per selection -> {open, close}
|
||||||
|
export type OddsMovement = Record<
|
||||||
|
string,
|
||||||
|
Record<string, { open: number; close: number }>
|
||||||
|
>;
|
||||||
|
|
||||||
interface OddsCardProps {
|
interface OddsCardProps {
|
||||||
odds?: Record<string, Record<string, { odd: string }>>;
|
odds?: Record<string, Record<string, { odd: string }>>;
|
||||||
|
prediction?: MatchPredictionDto;
|
||||||
|
movement?: OddsMovement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turkish odds-market name -> prediction market_board code
|
||||||
|
const MARKET_CODE: Record<string, string> = {
|
||||||
|
"Maç Sonucu": "MS",
|
||||||
|
"1. Yarı Sonucu": "HT",
|
||||||
|
"İlk Yarı Sonucu": "HT",
|
||||||
|
"Karşılıklı Gol": "BTTS",
|
||||||
|
"Çifte Şans": "DC",
|
||||||
|
"1,5 Alt/Üst": "OU15",
|
||||||
|
"1.5 Alt/Üst": "OU15",
|
||||||
|
"2,5 Alt/Üst": "OU25",
|
||||||
|
"2.5 Alt/Üst": "OU25",
|
||||||
|
"3,5 Alt/Üst": "OU35",
|
||||||
|
"3.5 Alt/Üst": "OU35",
|
||||||
|
};
|
||||||
|
|
||||||
|
// odds selection label -> market_board probs key
|
||||||
|
function probKey(code: string, sel: string): string | null {
|
||||||
|
if (code === "MS" || code === "HT") {
|
||||||
|
return ["1", "X", "2"].includes(sel) ? sel : null;
|
||||||
|
}
|
||||||
|
if (code.startsWith("OU")) {
|
||||||
|
if (sel === "Üst" || sel === "Ust") return "over";
|
||||||
|
if (sel === "Alt") return "under";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (code === "BTTS") {
|
||||||
|
if (sel === "Var") return "yes";
|
||||||
|
if (sel === "Yok") return "no";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (code === "DC") {
|
||||||
|
return { "1-X": "1X", "X-2": "X2", "1-2": "12" }[sel] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarketBlockProps {
|
interface MarketBlockProps {
|
||||||
title: string;
|
title: string;
|
||||||
selections: Record<string, { odd: string }>;
|
selections: Record<string, { odd: string }>;
|
||||||
|
prediction?: MatchPredictionDto;
|
||||||
|
movement?: OddsMovement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarketBlock({ title, selections }: MarketBlockProps) {
|
function MarketBlock({ title, selections, prediction, movement }: MarketBlockProps) {
|
||||||
const bg = useColorModeValue("gray.50", "gray.800");
|
const bg = useColorModeValue("gray.50", "gray.800");
|
||||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||||
const selectionBg = useColorModeValue("white", "gray.700");
|
const selectionBg = useColorModeValue("white", "gray.700");
|
||||||
|
|
||||||
// Sort selections based on common market patterns
|
const code = MARKET_CODE[title];
|
||||||
const sortedKeys = Object.keys(selections).sort((a, b) => {
|
const probs = code ? prediction?.market_board?.[code]?.probs : undefined;
|
||||||
// MS: 1, X, 2
|
const moveMkt = movement?.[title];
|
||||||
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b)) {
|
|
||||||
const order = ["1", "X", "2"];
|
const order = (a: string, b: string) => {
|
||||||
return order.indexOf(a) - order.indexOf(b);
|
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b))
|
||||||
}
|
return ["1", "X", "2"].indexOf(a) - ["1", "X", "2"].indexOf(b);
|
||||||
// Alt/Üst: Alt, Üst
|
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b))
|
||||||
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b)) {
|
return a === "Alt" ? -1 : 1;
|
||||||
return a === "Alt" ? -1 : 1; // Alt first
|
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b))
|
||||||
}
|
return a === "Var" ? -1 : 1;
|
||||||
// KG: Var, Yok
|
|
||||||
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b)) {
|
|
||||||
return a === "Var" ? -1 : 1; // Var first
|
|
||||||
}
|
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
};
|
||||||
|
const sortedKeys = Object.keys(selections).sort(order);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box bg={bg} borderWidth="1px" borderColor={borderColor} borderRadius="md" overflow="hidden">
|
||||||
bg={bg}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={borderColor}
|
|
||||||
borderRadius="md"
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
<Box px={3} py={1.5} borderBottomWidth="1px" borderColor={borderColor}>
|
<Box px={3} py={1.5} borderBottomWidth="1px" borderColor={borderColor}>
|
||||||
<Text
|
<Text fontSize="xs" fontWeight="bold" color="fg.muted" textTransform="uppercase">
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="bold"
|
|
||||||
color="fg.muted"
|
|
||||||
textTransform="uppercase"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Flex p={2} gap={2} wrap="wrap">
|
<Flex p={2} gap={2} wrap="wrap">
|
||||||
{sortedKeys.map((key) => (
|
{sortedKeys.map((key) => {
|
||||||
<Flex
|
const pk = code ? probKey(code, key) : null;
|
||||||
key={key}
|
const prob = pk && probs ? probs[pk] : undefined; // 0..1
|
||||||
direction="column"
|
const mv = moveMkt?.[key];
|
||||||
align="center"
|
let movePct: number | null = null;
|
||||||
justify="center"
|
if (mv && mv.open > 0) movePct = ((mv.close - mv.open) / mv.open) * 100;
|
||||||
bg={selectionBg}
|
|
||||||
borderWidth="1px"
|
return (
|
||||||
borderColor={borderColor}
|
<Flex
|
||||||
borderRadius="sm"
|
key={key}
|
||||||
minW="50px"
|
direction="column"
|
||||||
py={1}
|
align="center"
|
||||||
px={2}
|
justify="center"
|
||||||
flex={1}
|
bg={selectionBg}
|
||||||
>
|
borderWidth="1px"
|
||||||
<Text fontSize="xs" color="fg.muted" mb={0.5}>
|
borderColor={borderColor}
|
||||||
{key}
|
borderRadius="sm"
|
||||||
</Text>
|
minW="64px"
|
||||||
<Text fontSize="sm" fontWeight="bold" color="primary.500">
|
py={1}
|
||||||
{Number(selections[key].odd).toFixed(2)}
|
px={2}
|
||||||
</Text>
|
flex={1}
|
||||||
</Flex>
|
>
|
||||||
))}
|
<Text fontSize="xs" color="fg.muted" mb={0.5}>
|
||||||
|
{key}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="primary.500">
|
||||||
|
{Number(selections[key].odd).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
{/* Model hit probability (calibrated) */}
|
||||||
|
{prob !== undefined && (
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="semibold"
|
||||||
|
color={prob >= 0.5 ? "green.500" : "fg.muted"}
|
||||||
|
>
|
||||||
|
%{Math.round(prob * 100)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{/* Opening→closing total movement */}
|
||||||
|
{movePct !== null && Math.abs(movePct) >= 0.5 && (
|
||||||
|
<Text
|
||||||
|
fontSize="10px"
|
||||||
|
color={movePct < 0 ? "red.400" : "blue.400"}
|
||||||
|
title={`Açılış ${mv!.open.toFixed(2)} → Kapanış ${mv!.close.toFixed(2)}`}
|
||||||
|
>
|
||||||
|
{movePct < 0 ? "↓" : "↑"}
|
||||||
|
{Math.abs(movePct).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OddsCard({ odds }: OddsCardProps) {
|
export default function OddsCard({ odds, prediction, movement }: OddsCardProps) {
|
||||||
const cardBg = useColorModeValue("white", "gray.900");
|
const cardBg = useColorModeValue("white", "gray.900");
|
||||||
const borderColor = useColorModeValue("gray.100", "gray.800");
|
const borderColor = useColorModeValue("gray.100", "gray.800");
|
||||||
|
|
||||||
if (!odds || Object.keys(odds).length === 0) {
|
if (!odds || Object.keys(odds).length === 0) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define priority markets to show at the top
|
|
||||||
const PRIORITY_MARKETS = [
|
const PRIORITY_MARKETS = [
|
||||||
"Maç Sonucu",
|
"Maç Sonucu",
|
||||||
"2.5 Alt/Üst",
|
"2.5 Alt/Üst",
|
||||||
|
"2,5 Alt/Üst",
|
||||||
"Karşılıklı Gol",
|
"Karşılıklı Gol",
|
||||||
"İlk Yarı Sonucu",
|
"İlk Yarı Sonucu",
|
||||||
"1. Yarı Sonucu",
|
"1. Yarı Sonucu",
|
||||||
|
"Çifte Şans",
|
||||||
"Kart",
|
"Kart",
|
||||||
"Korner",
|
"Korner",
|
||||||
];
|
];
|
||||||
@@ -117,26 +170,33 @@ export default function OddsCard({ odds }: OddsCardProps) {
|
|||||||
(k) => !PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
(k) => !PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group similar markets if needed, but simple list for now
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<VStack align="stretch" gap={4}>
|
<VStack align="stretch" gap={4}>
|
||||||
<Text fontSize="lg" fontWeight="bold">
|
<Flex justify="space-between" align="baseline" wrap="wrap" gap={1}>
|
||||||
Canlı İddaa Oranları
|
<Text fontSize="lg" fontWeight="bold">
|
||||||
</Text>
|
Canlı İddaa Oranları
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
%= modelin tutma olasılığı (kalibre) · ↓↑ = açılış→kapanış hareketi
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{/* Priority Markets Grid */}
|
|
||||||
{priorityKeys.length > 0 && (
|
{priorityKeys.length > 0 && (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||||
{priorityKeys.map((key) => (
|
{priorityKeys.map((key) => (
|
||||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
<MarketBlock
|
||||||
|
key={key}
|
||||||
|
title={key}
|
||||||
|
selections={odds[key]}
|
||||||
|
prediction={prediction}
|
||||||
|
movement={movement}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Other Markets - Show ALL */}
|
|
||||||
{otherKeys.length > 0 && (
|
{otherKeys.length > 0 && (
|
||||||
<SimpleGrid
|
<SimpleGrid
|
||||||
columns={{ base: 1, md: 2, lg: 3 }}
|
columns={{ base: 1, md: 2, lg: 3 }}
|
||||||
@@ -144,7 +204,13 @@ export default function OddsCard({ odds }: OddsCardProps) {
|
|||||||
mt={priorityKeys.length > 0 ? 2 : 0}
|
mt={priorityKeys.length > 0 ? 2 : 0}
|
||||||
>
|
>
|
||||||
{otherKeys.map((key) => (
|
{otherKeys.map((key) => (
|
||||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
<MarketBlock
|
||||||
|
key={key}
|
||||||
|
title={key}
|
||||||
|
selections={odds[key]}
|
||||||
|
prediction={prediction}
|
||||||
|
movement={movement}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,9 +48,23 @@ const getActiveLeagues = (sport?: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OddsMovementDto = Record<
|
||||||
|
string,
|
||||||
|
Record<string, { open: number; close: number }>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const getOddsMovement = (id: string) => {
|
||||||
|
return apiRequest<ApiResponse<OddsMovementDto>>({
|
||||||
|
url: `/matches/${id}/odds-movement`,
|
||||||
|
client: "core",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const matchesService = {
|
export const matchesService = {
|
||||||
listMatches,
|
listMatches,
|
||||||
getMatchDetails,
|
getMatchDetails,
|
||||||
queryMatches,
|
queryMatches,
|
||||||
getActiveLeagues,
|
getActiveLeagues,
|
||||||
|
getOddsMovement,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const MatchesQueryKeys = {
|
|||||||
list: (params?: MatchListParams) =>
|
list: (params?: MatchListParams) =>
|
||||||
[...MatchesQueryKeys.all, "list", params] as const,
|
[...MatchesQueryKeys.all, "list", params] as const,
|
||||||
detail: (id: string) => [...MatchesQueryKeys.all, "detail", id] as const,
|
detail: (id: string) => [...MatchesQueryKeys.all, "detail", id] as const,
|
||||||
|
oddsMovement: (id: string) =>
|
||||||
|
[...MatchesQueryKeys.all, "oddsMovement", id] as const,
|
||||||
activeLeagues: (sport?: string) =>
|
activeLeagues: (sport?: string) =>
|
||||||
[...MatchesQueryKeys.all, "activeLeagues", sport] as const,
|
[...MatchesQueryKeys.all, "activeLeagues", sport] as const,
|
||||||
};
|
};
|
||||||
@@ -26,6 +28,14 @@ export const useMatchDetails = (id: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useOddsMovement = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: MatchesQueryKeys.oddsMovement(id),
|
||||||
|
queryFn: () => matchesService.getOddsMovement(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useQueryMatches = () => {
|
export const useQueryMatches = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (queryDto: MatchQueryDto) =>
|
mutationFn: (queryDto: MatchQueryDto) =>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user