This commit is contained in:
@@ -19,7 +19,7 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
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 { useGetMe } from "@/lib/api/users/use-hooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -170,6 +170,8 @@ export default function MatchDetailContent() {
|
||||
|
||||
const match = matchData?.data as MatchResponseDto | undefined;
|
||||
const prediction = predictionData?.data;
|
||||
const { data: movementData } = useOddsMovement(matchId);
|
||||
const oddsMovement = movementData?.data;
|
||||
|
||||
if (matchLoading) return <MatchDetailSkeleton />;
|
||||
|
||||
@@ -830,7 +832,7 @@ export default function MatchDetailContent() {
|
||||
{/* ══════════════════════════════════════════════ */}
|
||||
{match.odds && Object.keys(match.odds).length > 0 && show("odds") && (
|
||||
<Box mt={6}>
|
||||
<OddsCard odds={match.odds} />
|
||||
<OddsCard odds={match.odds} prediction={prediction} movement={oddsMovement} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,110 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
VStack,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { Box, SimpleGrid, Text, Card, VStack, Flex } from "@chakra-ui/react";
|
||||
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 {
|
||||
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 {
|
||||
title: 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 borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const selectionBg = useColorModeValue("white", "gray.700");
|
||||
|
||||
// Sort selections based on common market patterns
|
||||
const sortedKeys = Object.keys(selections).sort((a, b) => {
|
||||
// MS: 1, X, 2
|
||||
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b)) {
|
||||
const order = ["1", "X", "2"];
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
}
|
||||
// Alt/Üst: Alt, Üst
|
||||
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b)) {
|
||||
return a === "Alt" ? -1 : 1; // Alt first
|
||||
}
|
||||
// KG: Var, Yok
|
||||
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b)) {
|
||||
return a === "Var" ? -1 : 1; // Var first
|
||||
}
|
||||
const code = MARKET_CODE[title];
|
||||
const probs = code ? prediction?.market_board?.[code]?.probs : undefined;
|
||||
const moveMkt = movement?.[title];
|
||||
|
||||
const order = (a: string, b: string) => {
|
||||
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b))
|
||||
return ["1", "X", "2"].indexOf(a) - ["1", "X", "2"].indexOf(b);
|
||||
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b))
|
||||
return a === "Alt" ? -1 : 1;
|
||||
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b))
|
||||
return a === "Var" ? -1 : 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
};
|
||||
const sortedKeys = Object.keys(selections).sort(order);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box bg={bg} borderWidth="1px" borderColor={borderColor} borderRadius="md" overflow="hidden">
|
||||
<Box px={3} py={1.5} borderBottomWidth="1px" borderColor={borderColor}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="fg.muted"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="bold" color="fg.muted" textTransform="uppercase">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex p={2} gap={2} wrap="wrap">
|
||||
{sortedKeys.map((key) => (
|
||||
<Flex
|
||||
key={key}
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg={selectionBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="sm"
|
||||
minW="50px"
|
||||
py={1}
|
||||
px={2}
|
||||
flex={1}
|
||||
>
|
||||
<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>
|
||||
</Flex>
|
||||
))}
|
||||
{sortedKeys.map((key) => {
|
||||
const pk = code ? probKey(code, key) : null;
|
||||
const prob = pk && probs ? probs[pk] : undefined; // 0..1
|
||||
const mv = moveMkt?.[key];
|
||||
let movePct: number | null = null;
|
||||
if (mv && mv.open > 0) movePct = ((mv.close - mv.open) / mv.open) * 100;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={key}
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg={selectionBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="sm"
|
||||
minW="64px"
|
||||
py={1}
|
||||
px={2}
|
||||
flex={1}
|
||||
>
|
||||
<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>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OddsCard({ odds }: OddsCardProps) {
|
||||
export default function OddsCard({ odds, prediction, movement }: OddsCardProps) {
|
||||
const cardBg = useColorModeValue("white", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.800");
|
||||
|
||||
if (!odds || Object.keys(odds).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!odds || Object.keys(odds).length === 0) return null;
|
||||
|
||||
// Define priority markets to show at the top
|
||||
const PRIORITY_MARKETS = [
|
||||
"Maç Sonucu",
|
||||
"2.5 Alt/Üst",
|
||||
"2,5 Alt/Üst",
|
||||
"Karşılıklı Gol",
|
||||
"İlk Yarı Sonucu",
|
||||
"1. Yarı Sonucu",
|
||||
"Çifte Şans",
|
||||
"Kart",
|
||||
"Korner",
|
||||
];
|
||||
@@ -117,26 +170,33 @@ export default function OddsCard({ odds }: OddsCardProps) {
|
||||
(k) => !PRIORITY_MARKETS.some((pm) => k.includes(pm)),
|
||||
);
|
||||
|
||||
// Group similar markets if needed, but simple list for now
|
||||
|
||||
return (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
|
||||
<Card.Body>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
Canlı İddaa Oranları
|
||||
</Text>
|
||||
<Flex justify="space-between" align="baseline" wrap="wrap" gap={1}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
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 && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
{priorityKeys.map((key) => (
|
||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
||||
<MarketBlock
|
||||
key={key}
|
||||
title={key}
|
||||
selections={odds[key]}
|
||||
prediction={prediction}
|
||||
movement={movement}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Other Markets - Show ALL */}
|
||||
{otherKeys.length > 0 && (
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
@@ -144,7 +204,13 @@ export default function OddsCard({ odds }: OddsCardProps) {
|
||||
mt={priorityKeys.length > 0 ? 2 : 0}
|
||||
>
|
||||
{otherKeys.map((key) => (
|
||||
<MarketBlock key={key} title={key} selections={odds[key]} />
|
||||
<MarketBlock
|
||||
key={key}
|
||||
title={key}
|
||||
selections={odds[key]}
|
||||
prediction={prediction}
|
||||
movement={movement}
|
||||
/>
|
||||
))}
|
||||
</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 = {
|
||||
listMatches,
|
||||
getMatchDetails,
|
||||
queryMatches,
|
||||
getActiveLeagues,
|
||||
getOddsMovement,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ export const MatchesQueryKeys = {
|
||||
list: (params?: MatchListParams) =>
|
||||
[...MatchesQueryKeys.all, "list", params] as const,
|
||||
detail: (id: string) => [...MatchesQueryKeys.all, "detail", id] as const,
|
||||
oddsMovement: (id: string) =>
|
||||
[...MatchesQueryKeys.all, "oddsMovement", id] as const,
|
||||
activeLeagues: (sport?: string) =>
|
||||
[...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 = () => {
|
||||
return useMutation({
|
||||
mutationFn: (queryDto: MatchQueryDto) =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user