Files
iddaai-fe/src/components/matches/prediction-card.tsx
T
2026-04-23 22:23:35 +03:00

1135 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Badge,
Box,
Card,
Flex,
Grid,
HStack,
Icon,
IconButton,
Separator,
SimpleGrid,
Text,
VStack,
} from "@chakra-ui/react";
import { useMessages, useTranslations } from "next-intl";
import {
LuBadgeCheck,
LuBrain,
LuChartColumn,
LuChartNoAxesCombined,
LuCircleHelp,
LuFlame,
LuGauge,
LuShieldAlert,
LuSparkles,
LuTarget,
LuTriangleAlert,
LuTrendingUp,
} from "react-icons/lu";
import { useColorModeValue } from "@/components/ui/color-mode";
import { Tooltip } from "@/components/ui/overlays/tooltip";
import type {
MarketBoardEntryDto,
MatchBetSummaryItemDto,
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;
}
function formatReasonFallback(reason: string): string {
if (reason.startsWith("risk:")) return formatReasonFallback(reason.slice(5));
const evMatch = reason.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/);
if (evMatch) return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
const negMatch = reason.match(/^negative_model_edge_([+\-][\d.]+)$/);
if (negMatch) return `Model avantajı negatif (${negMatch[1]})`;
const thresholdMatch = reason.match(/^below_market_edge_threshold_([+\-]?[\d.]+)$/);
if (thresholdMatch) return `Piyasa avantaj eşiğinin altında (${thresholdMatch[1]})`;
if (reason === "confidence_interval_too_wide") return "Güven aralığı fazla geniş.";
if (reason === "confidence_band_low") return "Güven bandı düşük.";
if (reason === "draw_probability_elevated") return "Beraberlik olasılığı yükselmiş görünüyor.";
if (reason === "balanced_match_risk") return "Maç dengeli görünüyor, sürpriz riski var.";
if (reason === "high_total_goal_volatility") return "Yüksek gol temposu sürpriz riskini artırıyor.";
if (reason === "mutual_goal_pressure") return "İki takım da gol tehdidi üretiyor.";
if (reason === "late_goal_swing_risk") return "Geç gol veya skor kırılması riski yüksek.";
if (reason === "live_match_open_state") return "Canlı maç tamamen açık oyuna dönmüş durumda.";
if (reason === "live_match_active_state") return "Canlı maç beklenenden daha hareketli ilerliyor.";
if (reason === "live_state_impossible_market") return "Canlı maç durumu bu marketi geçersiz kılıyor.";
if (reason === "live_score_exceeds_under_line") return "Canlı skor, alt seçeneğinin üst sınırına çok yaklaştı veya geçti.";
if (reason === "score_model_conflicts_with_under_pick") return "Skor ve xG modeli bu alt seçeneğiyle çelişiyor.";
if (reason === "score_model_conflicts_with_over_pick") return "Skor ve xG modeli bu üst seçeneğiyle çelişiyor.";
if (reason === "market_stack_conflict_over25") return "2.5 üst sinyali bu marketle çelişiyor.";
if (reason === "market_stack_conflict_btts") return "KG Var sinyali bu marketle çelişiyor.";
if (reason === "live_total_goals_close_to_line") return "Canlı toplam gol sayısı bu çizgiye fazla yaklaştı.";
if (reason === "score_model_conflicts_with_btts_no") return "Skor ve xG modeli KG Yok seçeneğiyle çelişiyor.";
if (reason === "score_model_conflicts_with_draw_pick") return "Skor modeli beraberlik seçeneğini desteklemiyor.";
if (reason === "score_model_conflicts_with_home_pick") return "Skor modeli ev sahibi seçeneğini desteklemiyor.";
if (reason === "score_model_conflicts_with_away_pick") return "Skor modeli deplasman seçeneğini desteklemiyor.";
if (/^[a-z0-9_]+$/i.test(reason)) {
return reason.replace(/_/g, " ").replace(/^\w/, (char) => char.toUpperCase());
}
return reason;
}
function formatPercent(value?: number, digits = 0): string {
if (value === undefined || value === null || Number.isNaN(value)) return "-";
return `${value.toFixed(digits)}%`;
}
function formatProbability(value?: number, digits = 1): string {
if (value === undefined || value === null || Number.isNaN(value)) return "-";
return `${(value * 100).toFixed(digits)}%`;
}
function formatOdds(value?: number | null): string {
if (!value || value <= 1.01) return "-";
return value.toFixed(2);
}
function formatUnits(value?: number): string {
if (!value || value <= 0) return "-";
return `${value.toFixed(1)}u`;
}
function getRiskPalette(level: string) {
switch (level.toUpperCase()) {
case "LOW":
return "green";
case "MEDIUM":
return "yellow";
case "HIGH":
return "orange";
case "EXTREME":
return "red";
default:
return "gray";
}
}
function getQualityPalette(label: string) {
switch (label.toUpperCase()) {
case "HIGH":
return "green";
case "MEDIUM":
return "yellow";
case "LOW":
return "red";
default:
return "gray";
}
}
function getConfidenceBandPalette(band?: string) {
switch ((band || "").toUpperCase()) {
case "HIGH":
return "green";
case "MEDIUM":
return "yellow";
case "LOW":
return "red";
default:
return "gray";
}
}
function getConfidenceBandLabel(band?: string) {
switch ((band || "").toUpperCase()) {
case "HIGH":
return "Yüksek";
case "MEDIUM":
return "Orta";
case "LOW":
return "Düşük";
default:
return "Belirsiz";
}
}
function getLineupSourceLabel(source?: string): string {
if (source === "confirmed_live") return "Onayli ilk 11";
if (source === "probable_xi") return "Muhtemel ilk 11";
return source ? formatReasonFallback(source) : "Bilinmiyor";
}
function formatInterval(
interval?: { lower?: number; upper?: number },
digits = 0,
): string {
if (
!interval ||
interval.lower === undefined ||
interval.upper === undefined ||
Number.isNaN(interval.lower) ||
Number.isNaN(interval.upper)
) {
return "-";
}
return `${interval.lower.toFixed(digits)}-${interval.upper.toFixed(digits)}%`;
}
function getPredictionReasonText(
reason: string,
reasonMessages?: Record<string, string>,
): string {
if (
reasonMessages &&
Object.prototype.hasOwnProperty.call(reasonMessages, reason)
) {
return reasonMessages[reason];
}
return formatReasonFallback(reason);
}
function getMarketLabel(
market: string,
marketLabels?: Record<string, string>,
): string {
if (marketLabels && Object.prototype.hasOwnProperty.call(marketLabels, market)) {
return marketLabels[market];
}
const fallbackLabels: Record<string, string> = {
ML: "Moneyline",
MS: "Maç Sonucu",
DC: "Çifte Şans",
TOTAL: "Toplam Sayı",
SPREAD: "Handikap",
OU15: "Toplam Gol 1.5",
OU25: "Toplam Gol 2.5",
OU35: "Toplam Gol 3.5",
BTTS: "Karşılıklı Gol",
HT: "İlk Yarı Sonucu",
HT_OU05: "İlk Yarı 0.5 Gol",
HT_OU15: "İlk Yarı 1.5 Gol",
HTFT: "İlk Yarı / Maç Sonu",
"HT/FT": "İlk Yarı / Maç Sonu",
OE: "Tek / Çift",
CARDS: "Kartlar 4.5",
HCAP: "Handikap Sonucu",
};
if (fallbackLabels[market]) {
return fallbackLabels[market];
}
return market;
}
const MARKET_ORDER = [
"ML",
"TOTAL",
"SPREAD",
"MS",
"DC",
"OU15",
"OU25",
"OU35",
"BTTS",
"HT",
"HT_OU05",
"HT_OU15",
"HTFT",
"OE",
"CARDS",
"HCAP",
];
function getPredictionSport(prediction: MatchPredictionDto): SportType {
const explicitSport = prediction.match_info?.sport;
if (explicitSport === "basketball" || explicitSport === "football") {
return explicitSport;
}
if (
prediction.model_version?.toLowerCase().includes("basketball") ||
Object.keys(prediction.market_board || {}).some((market) =>
["ML", "TOTAL", "SPREAD"].includes(market),
)
) {
return "basketball";
}
return "football";
}
const SIGNAL_TIER_ORDER: SignalTier[] = ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"];
function getSignalTierPalette(tier?: SignalTier) {
switch (tier) {
case "CORE":
return "green";
case "VALUE":
return "blue";
case "LEAN":
return "orange";
case "LONGSHOT":
return "pink";
default:
return "gray";
}
}
function getSignalTierLabel(tier?: SignalTier) {
switch (tier) {
case "CORE":
return "Çekirdek";
case "VALUE":
return "Değer";
case "LEAN":
return "Yorum";
case "LONGSHOT":
return "Sürpriz";
default:
return "Pas";
}
}
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>
);
}
function MetricTile({
label,
value,
helper,
accent,
}: {
label: string;
value: string;
helper?: string;
accent?: string;
}) {
const bg = useColorModeValue("gray.50", "whiteAlpha.50");
const borderColor = useColorModeValue("gray.200", "gray.700");
return (
<Box p={3.5} bg={bg} borderWidth="1px" borderColor={borderColor} borderRadius="xl">
<HStack justify="space-between" mb={1.5}>
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
{label}
</Text>
{helper ? <TooltipIcon content={helper} /> : null}
</HStack>
<Text fontSize="xl" fontWeight="bold" color={accent}>
{value}
</Text>
</Box>
);
}
function Bar({
value,
color,
trackBg,
height = "8px",
}: {
value: number;
color: string;
trackBg: string;
height?: string;
}) {
return (
<Box h={height} w="full" bg={trackBg} borderRadius="full" overflow="hidden">
<Box h="full" w={`${Math.max(0, Math.min(100, value))}%`} bg={color} borderRadius="full" />
</Box>
);
}
function ReasonList({
items,
resolveReason,
}: {
items?: string[];
resolveReason: (reason: string) => string;
}) {
if (!items?.length) return null;
return (
<VStack align="stretch" gap={1.5}>
{items.map((item, index) => (
<HStack key={`${item}-${index}`} align="start" gap={2}>
<Box mt="7px" boxSize="5px" bg="primary.400" borderRadius="full" />
<Text fontSize="sm" color="fg.muted" lineHeight="tall">
{resolveReason(item)}
</Text>
</HStack>
))}
</VStack>
);
}
function ProbabilitySplit({
modelProb,
impliedProb,
}: {
modelProb: number;
impliedProb: number;
}) {
const trackBg = useColorModeValue("gray.100", "gray.700");
if (!impliedProb || impliedProb <= 0) return null;
return (
<VStack align="stretch" gap={2}>
<Flex justify="space-between">
<Text fontSize="xs" color="blue.600" fontWeight="semibold">
Model {formatProbability(modelProb, 0)}
</Text>
<Text fontSize="xs" color="orange.500" fontWeight="semibold">
Piyasa {formatProbability(impliedProb, 0)}
</Text>
</Flex>
<Box position="relative">
<Bar value={modelProb * 100} color="blue.400" trackBg={trackBg} />
<Box
position="absolute"
top="-2px"
left={`calc(${Math.min(impliedProb * 100, 100)}% - 1px)`}
h="12px"
w="2px"
bg="orange.500"
borderRadius="full"
/>
</Box>
</VStack>
);
}
function PickCard({
pick,
stakeFallback,
title,
resolveReason,
palette,
marketLabels,
labels,
}: {
pick: MatchPickDto;
stakeFallback?: number;
title: string;
resolveReason: (reason: string) => string;
palette: string;
marketLabels?: Record<string, string>;
labels: {
confidence: string;
odds: string;
recommendedStake: string;
playScore: string;
playability: string;
};
}) {
const bg = useColorModeValue(`${palette}.50`, `${palette}.950`);
const borderColor = useColorModeValue(`${palette}.200`, `${palette}.800`);
const trackBg = useColorModeValue("gray.100", "gray.700");
const intervalWarningBg = useColorModeValue("orange.50", "orange.950");
const intervalWarningBorder = useColorModeValue("orange.200", "orange.800");
const confidenceBandPalette = getConfidenceBandPalette(
pick.confidence_interval?.band,
);
return (
<Card.Root bg={bg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<Flex justify="space-between" align={{ base: "start", md: "center" }} direction={{ base: "column", md: "row" }} gap={3}>
<VStack align="start" gap={2}>
<Badge colorPalette={palette} variant="solid" borderRadius="full">
{title}
</Badge>
<Text fontSize="2xl" fontWeight="bold">
{pick.pick}
</Text>
<HStack gap={2} flexWrap="wrap">
<Badge variant="subtle">{getMarketLabel(pick.market, marketLabels)}</Badge>
<Badge colorPalette={pick.playable ? "green" : "gray"} variant="subtle">
{pick.bet_grade}
</Badge>
<Badge colorPalette={getSignalTierPalette(pick.signal_tier)} variant="subtle">
{getSignalTierLabel(pick.signal_tier)}
</Badge>
<Badge colorPalette={confidenceBandPalette} variant="subtle">
{getConfidenceBandLabel(pick.confidence_interval?.band)}
</Badge>
<Badge colorPalette={pick.ev_edge > 0 ? "green" : "red"} variant="subtle">
EV {pick.ev_edge > 0 ? "+" : ""}
{formatPercent(pick.ev_edge * 100, 1)}
</Badge>
</HStack>
</VStack>
<SimpleGrid columns={2} gap={3} minW={{ base: "full", md: "320px" }}>
<MetricTile label={labels.confidence} value={formatPercent(pick.calibrated_confidence, 0)} />
<MetricTile label={labels.odds} value={formatOdds(pick.odds)} />
<MetricTile
label={labels.recommendedStake}
value={formatUnits(pick.stake_units || stakeFallback)}
/>
<MetricTile label={labels.playScore} value={formatPercent(pick.play_score, 0)} />
<MetricTile label="Guven Araligi" value={formatInterval(pick.confidence_interval)} />
<MetricTile
label="Band"
value={getConfidenceBandLabel(pick.confidence_interval?.band)}
accent={`${confidenceBandPalette}.500`}
/>
</SimpleGrid>
</Flex>
<ProbabilitySplit modelProb={pick.probability} impliedProb={pick.implied_prob} />
<Box>
<HStack justify="space-between" mb={1.5}>
<Text fontSize="sm" fontWeight="semibold">
{labels.playability}
</Text>
<Text fontSize="sm" color="fg.muted">
{formatPercent(pick.play_score, 1)}
</Text>
</HStack>
<Bar
value={pick.play_score}
color={pick.ev_edge > 0 ? "green.400" : "orange.400"}
trackBg={trackBg}
/>
</Box>
<ReasonList items={pick.decision_reasons} resolveReason={resolveReason} />
{pick.confidence_interval && !pick.confidence_interval.threshold_met ? (
<Box
p={3}
borderRadius="xl"
bg={intervalWarningBg}
borderWidth="1px"
borderColor={intervalWarningBorder}
>
<Text fontSize="sm" color="fg.muted">
Guven araligi genis. Sinyal olsa bile tek basina oynanmasi onerilmez.
</Text>
</Box>
) : null}
</Card.Body>
</Card.Root>
);
}
function SummaryTable({
items,
marketLabels,
title,
info,
}: {
items: MatchBetSummaryItemDto[];
marketLabels?: Record<string, string>;
title: string;
info: string;
}) {
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.200", "gray.700");
const highlightBg = useColorModeValue("green.50", "green.950");
if (!items.length) return null;
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<SectionTitle icon={LuChartColumn} title={title} info={info} />
<VStack align="stretch" gap={2}>
{items
.slice()
.sort((left, right) => {
const leftIndex = SIGNAL_TIER_ORDER.indexOf(left.signal_tier || "PASS");
const rightIndex = SIGNAL_TIER_ORDER.indexOf(right.signal_tier || "PASS");
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
return right.calibrated_confidence - left.calibrated_confidence;
})
.map((item) => (
<Flex
key={`${item.market}-${item.pick}`}
justify="space-between"
align={{ base: "start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
px={3}
py={3}
borderRadius="xl"
bg={item.playable ? highlightBg : "transparent"}
borderWidth="1px"
borderColor={item.playable ? "green.200" : borderColor}
>
<HStack gap={2} flexWrap="wrap">
<Badge colorPalette={item.playable ? "green" : "gray"} variant="subtle">
{item.bet_grade}
</Badge>
<Badge colorPalette={getSignalTierPalette(item.signal_tier)} variant="subtle">
{getSignalTierLabel(item.signal_tier)}
</Badge>
<Text fontWeight="semibold">{getMarketLabel(item.market, marketLabels)}</Text>
<Text color="fg.muted">{item.pick}</Text>
</HStack>
<HStack gap={5} fontSize="sm">
<Text minW="48px">{formatOdds(item.odds)}</Text>
<Text minW="68px" color={item.ev_edge > 0 ? "green.500" : "red.500"} fontWeight="semibold">
{item.ev_edge > 0 ? "+" : ""}
{formatPercent(item.ev_edge * 100, 1)}
</Text>
<Text minW="48px">{formatPercent(item.calibrated_confidence, 0)}</Text>
<Badge colorPalette={getConfidenceBandPalette(item.confidence_interval?.band)} variant="subtle">
{getConfidenceBandLabel(item.confidence_interval?.band)}
</Badge>
<Badge variant="surface">{formatUnits(item.stake_units)}</Badge>
</HStack>
</Flex>
))}
</VStack>
</Card.Body>
</Card.Root>
);
}
function MarketBoardSection({
marketBoard,
betSummary,
marketLabels,
title,
info,
}: {
marketBoard?: Record<string, MarketBoardEntryDto>;
betSummary?: MatchBetSummaryItemDto[];
marketLabels?: Record<string, string>;
title: string;
info: string;
}) {
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.200", "gray.700");
const trackBg = useColorModeValue("gray.100", "gray.700");
const innerBg = useColorModeValue("gray.50", "whiteAlpha.50");
if (!marketBoard || !Object.keys(marketBoard).length) return null;
const summaryByMarket = new Map((betSummary || []).map((item) => [item.market, item]));
const orderedEntries = Object.entries(marketBoard).sort(([left], [right]) => {
const leftIndex = MARKET_ORDER.indexOf(left);
const rightIndex = MARKET_ORDER.indexOf(right);
const safeLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
const safeRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
return safeLeft - safeRight;
});
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<SectionTitle
icon={LuChartNoAxesCombined}
title={title}
info={info}
/>
<SimpleGrid columns={{ base: 1, xl: 2 }} gap={4}>
{orderedEntries.map(([market, entry]) => {
if (!entry?.probs) return null;
const summary = summaryByMarket.get(market);
const interval =
summary?.confidence_interval || entry.confidence_interval;
return (
<Box
key={market}
p={4}
bg={innerBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
>
<Flex justify="space-between" align="start" mb={3}>
<VStack align="start" gap={1}>
<Text fontWeight="semibold">{market}</Text>
<Text fontSize="sm" color="fg.muted">
{getMarketLabel(market, marketLabels)}
</Text>
<HStack gap={2} flexWrap="wrap">
{summary ? (
<Badge colorPalette={summary.playable ? "green" : "gray"} variant="subtle">
{summary.playable ? "Oynanabilir" : "Riskli"}
</Badge>
) : null}
{summary?.signal_tier ? (
<Badge colorPalette={getSignalTierPalette(summary.signal_tier)} variant="subtle">
{getSignalTierLabel(summary.signal_tier)}
</Badge>
) : null}
{summary?.bet_grade ? <Badge variant="outline">{summary.bet_grade}</Badge> : null}
</HStack>
</VStack>
{entry.pick ? (
<Badge
colorPalette={getConfidenceBandPalette(entry.confidence_band || interval?.band)}
variant="subtle"
borderRadius="full"
>
{entry.pick} ({formatPercent(entry.confidence, 0)})
</Badge>
) : null}
</Flex>
<SimpleGrid columns={3} gap={2} mb={3}>
<MetricTile
label="Tutma Olasiligi"
value={formatPercent(entry.confidence, 0)}
accent="green.500"
/>
<MetricTile
label="Kalibre Guven"
value={summary ? formatPercent(summary.calibrated_confidence, 0) : "-"}
accent={summary?.playable ? "green.500" : "orange.500"}
/>
<MetricTile
label="Oran"
value={summary ? formatOdds(summary.odds) : "-"}
/>
</SimpleGrid>
{interval ? (
<Text fontSize="xs" color="fg.muted" mb={3}>
Guven araligi: {formatInterval(interval)}
</Text>
) : null}
<VStack align="stretch" gap={2.5}>
{Object.entries(entry.probs).map(([outcome, probability]) => (
<Box key={`${market}-${outcome}`}>
<Flex justify="space-between" mb={1}>
<Text fontSize="sm" color="fg.muted">
{outcome.toUpperCase()}
</Text>
<Text fontSize="sm" fontWeight="semibold">
{formatProbability(probability, 1)}
</Text>
</Flex>
<Bar
value={probability * 100}
color={
entry.pick === outcome ||
entry.pick?.toUpperCase() === outcome.toUpperCase()
? "green.400"
: "blue.400"
}
trackBg={trackBg}
height="6px"
/>
</Box>
))}
</VStack>
</Box>
);
})}
</SimpleGrid>
</Card.Body>
</Card.Root>
);
}
function ScoreCard({
prediction,
sport,
}: {
prediction: MatchPredictionDto;
sport: SportType;
}) {
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.200", "gray.700");
const subBg = useColorModeValue("gray.50", "whiteAlpha.50");
const isBasketball = sport === "basketball";
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<SectionTitle
icon={LuTarget}
title={isBasketball ? "Sayi Senaryosu" : "Skor Senaryosu"}
info={
isBasketball
? "Beklenen sayi dagilimi ve en olasi mac senaryolari."
: "Beklenen skor ve en olasi senaryolar."
}
/>
<SimpleGrid columns={{ base: 1, md: 3 }} gap={3}>
<MetricTile
label={isBasketball ? "Mac Sonu Sayi" : "Mac Sonu"}
value={prediction.score_prediction.ft}
/>
<MetricTile
label={isBasketball ? "Ilk Yari Sayi" : "Ilk Yari"}
value={prediction.score_prediction.ht}
/>
<MetricTile
label={isBasketball ? "Beklenen Toplam Sayi" : "Toplam xG"}
value={prediction.score_prediction.xg_total.toFixed(2)}
/>
</SimpleGrid>
<SimpleGrid columns={{ base: 2, md: 5 }} gap={2}>
{prediction.scenario_top5.map((scenario) => (
<Box key={`${scenario.score}-${scenario.prob}`} p={3} bg={subBg} borderWidth="1px" borderColor={borderColor} borderRadius="xl">
<Text fontSize="lg" fontWeight="bold">
{scenario.score}
</Text>
<Text fontSize="sm" color="fg.muted">
{formatProbability(scenario.prob, 1)}
</Text>
</Box>
))}
</SimpleGrid>
</Card.Body>
</Card.Root>
);
}
export default function PredictionCard({ prediction }: PredictionCardProps) {
const t = useTranslations("predictions");
const messages = useMessages() as {
predictions?: {
"prediction-reasons"?: Record<string, string>;
"market-labels"?: Record<string, string>;
ui?: Record<string, string>;
};
};
const marketLabels = messages.predictions?.["market-labels"];
const ui = messages.predictions?.ui;
const uiText = (key: string, fallback: string) => ui?.[key] || fallback;
const resolveReason = (reason: string) =>
getPredictionReasonText(reason, messages.predictions?.["prediction-reasons"]);
const pageBg = useColorModeValue("gray.50", "gray.900");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.200", "gray.700");
const riskPalette = getRiskPalette(prediction.risk.level);
const qualityPalette = getQualityPalette(prediction.data_quality.label);
const recommendedPick = prediction.main_pick;
const mainBandPalette = getConfidenceBandPalette(
prediction.bet_advice.confidence_band,
);
const sport = getPredictionSport(prediction);
const isBasketball = sport === "basketball";
const engineItems = [
{
key: "team",
icon: LuGauge,
label: isBasketball ? "Takim Formu" : "Takim Gucu",
value: prediction.engine_breakdown.team,
color: "blue.400",
},
{
key: "player",
icon: LuSparkles,
label: isBasketball ? "Kadro Etkisi" : "Oyuncu Etkisi",
value: prediction.engine_breakdown.player,
color: "green.400",
},
{ key: "odds", icon: LuTrendingUp, label: "Oran Analizi", value: prediction.engine_breakdown.odds, color: "orange.400" },
{
key: "referee",
icon: LuShieldAlert,
label: isBasketball ? "Yardimci Sinyaller" : "Hakem Etkisi",
value: prediction.engine_breakdown.referee,
color: "purple.400",
},
];
return (
<VStack align="stretch" gap={5}>
<Card.Root bg={pageBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={5}>
<SectionTitle
icon={LuBrain}
title={uiText("summary-title", "Tahmin Ozeti")}
info={uiText(
"summary-info",
"Kullanicinin once neyi oynayacagini, sonra nedenini anlamasi icin sade ozet.",
)}
/>
{recommendedPick ? (
<Grid templateColumns={{ base: "1fr", xl: "1.4fr 1fr" }} gap={4}>
<Box p={4} bg={useColorModeValue("green.50", "green.950")} borderWidth="1px" borderColor={useColorModeValue("green.200", "green.800")} borderRadius="2xl">
<HStack justify="space-between" align="start" mb={4}>
<VStack align="start" gap={2}>
<Badge colorPalette="green" variant="solid" borderRadius="full">
{uiText("main-recommendation", "Ana Oneri")}
</Badge>
<Text fontSize="2xl" fontWeight="bold">
{recommendedPick.pick}
</Text>
<Text fontSize="sm" color="fg.muted">
{getMarketLabel(recommendedPick.market, marketLabels)} {uiText("best-market-copy", "marketinde en guclu secim.")}
</Text>
<HStack gap={2} flexWrap="wrap">
<Badge colorPalette={mainBandPalette} variant="subtle">
{getConfidenceBandLabel(prediction.bet_advice.confidence_band)}
</Badge>
{recommendedPick.confidence_interval ? (
<Badge variant="outline">
{formatInterval(recommendedPick.confidence_interval)}
</Badge>
) : null}
</HStack>
</VStack>
<Icon as={LuBadgeCheck} boxSize={8} color="green.500" />
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
<MetricTile label={uiText("confidence-label", "Guven")} value={formatPercent(recommendedPick.calibrated_confidence, 0)} />
<MetricTile label={uiText("odds-label", "Oran")} value={formatOdds(recommendedPick.odds)} />
<MetricTile label="Guven Araligi" value={formatInterval(recommendedPick.confidence_interval)} />
<MetricTile
label={uiText("edge-label", "Beklenen Avantaj (Edge)")}
value={`${recommendedPick.ev_edge > 0 ? "+" : ""}${formatPercent(recommendedPick.ev_edge * 100, 1)}`}
helper={uiText(
"edge-info",
"Edge, model olasiligi ile piyasa olasiligi arasindaki farktir. Pozitifse model bu orani avantajli buluyor demektir.",
)}
accent={recommendedPick.ev_edge > 0 ? "green.500" : "red.500"}
/>
<MetricTile
label={uiText("stake-label", "Onerilen Miktar (Stake)")}
value={formatUnits(
recommendedPick.stake_units ||
prediction.bet_advice.suggested_stake_units,
)}
helper={uiText(
"stake-info",
"Stake, bu bahis icin onerilen bahis birimidir. 2.0u demek, kendi bankroll planinizdaki 2 birimlik bahis anlamina gelir.",
)}
/>
</SimpleGrid>
</Box>
<Box p={4} bg={cardBg} borderWidth="1px" borderColor={borderColor} borderRadius="2xl">
<Text fontSize="sm" fontWeight="semibold" mb={3}>
{uiText("quick-read", "Hizli yorum")}
</Text>
<ReasonList
items={[
...prediction.reasoning_factors.slice(0, 2),
prediction.risk.surprise_type || "",
].filter(Boolean)}
resolveReason={resolveReason}
/>
</Box>
</Grid>
) : null}
<SimpleGrid columns={{ base: 1, md: 2, xl: 4 }} gap={3}>
<MetricTile
label="Veri Kalitesi"
value={formatPercent(prediction.data_quality.score * 100, 0)}
helper="Kadro, oran ve mac verisinin ne kadar guvenilir oldugu."
accent={`${qualityPalette}.500`}
/>
<MetricTile
label={t("risk-level")}
value={`${prediction.risk.level} (${prediction.risk.score}/100)`}
helper="Surpriz ihtimali ve belirsizlik seviyesi."
accent={`${riskPalette}.500`}
/>
<MetricTile
label={uiText("lineup-source", "Lineup Kaynagi")}
value={getLineupSourceLabel(prediction.data_quality.lineup_source)}
/>
<MetricTile label={uiText("model-label", "Model")} value={prediction.model_version} />
</SimpleGrid>
{prediction.risk.is_surprise_risk || prediction.risk.warnings?.length ? (
<Box
p={4}
bg={useColorModeValue("orange.50", "orange.950")}
borderWidth="1px"
borderColor={useColorModeValue("orange.200", "orange.800")}
borderRadius="2xl"
>
<HStack align="start" gap={3}>
<Icon as={LuTriangleAlert} boxSize={5} color="orange.500" mt={0.5} />
<VStack align="start" gap={1.5}>
<Text fontWeight="semibold">Risk Yorumu</Text>
<Text fontSize="sm" color="fg.muted">
{prediction.risk.surprise_comment ||
(prediction.risk.surprise_type
? `${resolveReason(prediction.risk.surprise_type)}`
: "Model bu maçta ekstra dikkat istiyor.")}
</Text>
{prediction.risk.surprise_score !== undefined ? (
<Text fontSize="sm" fontWeight="semibold" color="orange.600">
Sürpriz skoru: {formatPercent(prediction.risk.surprise_score, 0)}
</Text>
) : null}
<ReasonList
items={[
...(prediction.risk.surprise_reasons || []),
...prediction.risk.warnings,
]}
resolveReason={resolveReason}
/>
</VStack>
</HStack>
</Box>
) : null}
</Card.Body>
</Card.Root>
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<SectionTitle
icon={LuChartColumn}
title={t("engine-breakdown-title")}
info={uiText("engine-info", "Tahmini en cok hangi bilesenlerin etkiledigini gosterir.")}
/>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
{engineItems.map((item) => (
<Box key={item.key} p={4} bg={useColorModeValue("gray.50", "whiteAlpha.50")} borderWidth="1px" borderColor={borderColor} borderRadius="xl">
<HStack justify="space-between" mb={2}>
<HStack gap={2}>
<Icon as={item.icon} boxSize={4} color={item.color} />
<Text fontSize="sm" fontWeight="semibold">
{item.label}
</Text>
</HStack>
<Text fontSize="sm" fontWeight="bold">
+{item.value.toFixed(1)}
</Text>
</HStack>
<Bar value={Math.min(item.value, 100)} color={item.color} trackBg={useColorModeValue("gray.100", "gray.700")} />
</Box>
))}
</SimpleGrid>
</Card.Body>
</Card.Root>
{recommendedPick ? (
<PickCard
pick={recommendedPick}
title={uiText("best-single-pick", "En iyi tekli secim")}
resolveReason={resolveReason}
palette="green"
stakeFallback={prediction.bet_advice.suggested_stake_units}
marketLabels={marketLabels}
labels={{
confidence: uiText("confidence-label", "Guven"),
odds: uiText("odds-label", "Oran"),
recommendedStake: uiText("stake-label-short", "Stake"),
playScore: uiText("play-score-label", "Play Score"),
playability: uiText("playability-label", "Oynanabilirlik"),
}}
/>
) : null}
{prediction.supporting_picks?.length ? (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<SectionTitle
icon={LuFlame}
title={uiText("alternative-markets", "Alternatif Marketler")}
info={uiText("alternative-markets-info", "Ana tahmin disindaki secenekler.")}
/>
<SimpleGrid columns={{ base: 1, xl: 2 }} gap={4}>
{prediction.supporting_picks.map((pick) => (
<PickCard
key={`${pick.market}-${pick.pick}`}
pick={pick}
title={pick.playable ? uiText("alternative", "Alternatif") : uiText("pass-market", "PASS market")}
resolveReason={resolveReason}
palette={pick.ev_edge > 0 ? "blue" : "orange"}
marketLabels={marketLabels}
labels={{
confidence: uiText("confidence-label", "Guven"),
odds: uiText("odds-label", "Oran"),
recommendedStake: uiText("stake-label-short", "Stake"),
playScore: uiText("play-score-label", "Play Score"),
playability: uiText("playability-label", "Oynanabilirlik"),
}}
/>
))}
</SimpleGrid>
</Card.Body>
</Card.Root>
) : null}
<SummaryTable
items={prediction.bet_summary || []}
marketLabels={marketLabels}
title={uiText("all-markets-title", "Tum Marketler")}
info={uiText("all-markets-info", "Butun secenekleri tek tabloda karsilastir.")}
/>
<ScoreCard prediction={prediction} sport={sport} />
<MarketBoardSection
marketBoard={prediction.market_board}
betSummary={prediction.bet_summary || []}
marketLabels={marketLabels}
title={t("market-board")}
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
icon={LuSparkles}
title={t("bet-advice")}
info={uiText("bet-advice-info", "Modelin nihai aksiyon onerisi.")}
/>
<HStack justify="space-between" align={{ base: "start", md: "center" }} flexDir={{ base: "column", md: "row" }} gap={3}>
<HStack gap={3}>
<Badge colorPalette={prediction.bet_advice.playable ? "green" : "red"} variant="solid" borderRadius="full" fontSize="sm" px={3} py={1}>
{prediction.bet_advice.playable ? "OYNA" : "OYNAMA"}
</Badge>
<Badge colorPalette={mainBandPalette} variant="subtle" borderRadius="full" fontSize="sm" px={3} py={1}>
{getConfidenceBandLabel(prediction.bet_advice.confidence_band)}
</Badge>
<Badge
colorPalette={getSignalTierPalette(prediction.bet_advice.signal_tier)}
variant="subtle"
borderRadius="full"
fontSize="sm"
px={3}
py={1}
>
{getSignalTierLabel(prediction.bet_advice.signal_tier)}
</Badge>
<Text color="fg.muted">{resolveReason(prediction.bet_advice.reason)}</Text>
</HStack>
<Badge variant="surface" fontSize="sm" px={3} py={1}>
{uiText("recommended-stake-inline", "Onerilen miktar")}: {formatUnits(prediction.bet_advice.suggested_stake_units)}
</Badge>
</HStack>
<Separator />
<SectionTitle icon={LuBrain} title={t("reasoning")} info="Modelin bu maci neden bu sekilde okudugunun ust seviye ozeti." />
<ReasonList items={prediction.reasoning_factors} resolveReason={resolveReason} />
</Card.Body>
</Card.Root>
</VStack>
);
}