1940 lines
65 KiB
TypeScript
1940 lines
65 KiB
TypeScript
"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;
|
||
}
|
||
|
||
type PredictionUiMessages = Record<string, string>;
|
||
|
||
function getUiText(
|
||
ui: PredictionUiMessages | undefined,
|
||
key: string,
|
||
fallback: string,
|
||
): string {
|
||
return ui?.[key] || fallback;
|
||
}
|
||
|
||
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 `Teorik avantaj sinyali: 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 formatSignalScore(value?: number): string {
|
||
if (value === undefined || value === null || Number.isNaN(value)) return "-";
|
||
return `${Math.max(0, Math.min(100, value)).toFixed(0)}/100`;
|
||
}
|
||
|
||
function formatEdgeSignal(value?: number): string {
|
||
if (value === undefined || value === null || Number.isNaN(value)) return "-";
|
||
return `${value > 0 ? "+" : ""}${formatPercent(value * 100, 1)}`;
|
||
}
|
||
|
||
function getEdgePalette(value?: number): string {
|
||
if (value === undefined || value === null || Number.isNaN(value))
|
||
return "gray";
|
||
if (value <= 0) return "red";
|
||
if (value < 0.08) return "yellow";
|
||
if (value < 0.15) return "orange";
|
||
return "purple";
|
||
}
|
||
|
||
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 getEngineLabelPalette(label?: string): string {
|
||
switch ((label || "").toUpperCase()) {
|
||
case "YUKSEK":
|
||
return "green";
|
||
case "ORTA":
|
||
return "yellow";
|
||
case "DUSUK":
|
||
return "orange";
|
||
case "COK_DUSUK":
|
||
return "red";
|
||
default:
|
||
return "gray";
|
||
}
|
||
}
|
||
|
||
function getEngineLabelText(label?: string, ui?: PredictionUiMessages): string {
|
||
switch ((label || "").toUpperCase()) {
|
||
case "YUKSEK":
|
||
return getUiText(ui, "engine-label-high", "Yüksek");
|
||
case "ORTA":
|
||
return getUiText(ui, "engine-label-medium", "Orta");
|
||
case "DUSUK":
|
||
return getUiText(ui, "engine-label-low", "Düşük");
|
||
case "COK_DUSUK":
|
||
return getUiText(ui, "engine-label-very-low", "Çok Düşük");
|
||
default:
|
||
return label || "";
|
||
}
|
||
}
|
||
|
||
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, ui?: PredictionUiMessages) {
|
||
switch ((band || "").toUpperCase()) {
|
||
case "HIGH":
|
||
return getUiText(ui, "confidence-high", "Yüksek");
|
||
case "MEDIUM":
|
||
return getUiText(ui, "confidence-medium", "Orta");
|
||
case "LOW":
|
||
return getUiText(ui, "confidence-low", "Düşük");
|
||
default:
|
||
return getUiText(ui, "confidence-unknown", "Belirsiz");
|
||
}
|
||
}
|
||
|
||
function getLineupSourceLabel(
|
||
source?: string,
|
||
ui?: PredictionUiMessages,
|
||
): string {
|
||
if (source === "confirmed_live")
|
||
return getUiText(ui, "lineup-confirmed-live", "Onaylı ilk 11");
|
||
if (source === "probable_xi")
|
||
return getUiText(ui, "lineup-probable-xi", "Muhtemel ilk 11");
|
||
return source
|
||
? formatReasonFallback(source)
|
||
: getUiText(ui, "unknown", "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, ui?: PredictionUiMessages) {
|
||
switch (tier) {
|
||
case "CORE":
|
||
return getUiText(ui, "signal-tier-core", "Çekirdek");
|
||
case "VALUE":
|
||
return getUiText(ui, "signal-tier-value", "Değer");
|
||
case "LEAN":
|
||
return getUiText(ui, "signal-tier-lean", "Yorum");
|
||
case "LONGSHOT":
|
||
return getUiText(ui, "signal-tier-longshot", "Sürpriz");
|
||
default:
|
||
return getUiText(ui, "signal-tier-pass", "Pas");
|
||
}
|
||
}
|
||
|
||
function TooltipIcon({
|
||
content,
|
||
ariaLabel = "Bilgi",
|
||
}: {
|
||
content: string;
|
||
ariaLabel?: string;
|
||
}) {
|
||
return (
|
||
<Tooltip
|
||
content={content}
|
||
showArrow
|
||
positioning={{ placement: "top" }}
|
||
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||
>
|
||
<IconButton
|
||
aria-label={ariaLabel}
|
||
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,
|
||
labels,
|
||
}: {
|
||
modelProb: number;
|
||
impliedProb: number;
|
||
labels: {
|
||
model: string;
|
||
market: string;
|
||
};
|
||
}) {
|
||
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">
|
||
{labels.model} {formatProbability(modelProb, 0)}
|
||
</Text>
|
||
<Text fontSize="xs" color="orange.500" fontWeight="semibold">
|
||
{labels.market} {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,
|
||
ui,
|
||
}: {
|
||
pick: MatchPickDto;
|
||
stakeFallback?: number;
|
||
title: string;
|
||
resolveReason: (reason: string) => string;
|
||
palette: string;
|
||
marketLabels?: Record<string, string>;
|
||
ui?: PredictionUiMessages;
|
||
labels: {
|
||
confidence: string;
|
||
odds: string;
|
||
recommendedStake: string;
|
||
playScore: string;
|
||
playability: string;
|
||
confidenceInterval: string;
|
||
confidenceBand: string;
|
||
confidenceIntervalWarning: string;
|
||
theoreticalEdgeInline: string;
|
||
modelProbability: string;
|
||
marketProbability: 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, ui)}
|
||
</Badge>
|
||
<Badge colorPalette={confidenceBandPalette} variant="subtle">
|
||
{getConfidenceBandLabel(pick.confidence_interval?.band, ui)}
|
||
</Badge>
|
||
<Badge
|
||
colorPalette={getEdgePalette(pick.ev_edge)}
|
||
variant="subtle"
|
||
>
|
||
{labels.theoreticalEdgeInline} {formatEdgeSignal(pick.ev_edge)}
|
||
</Badge>
|
||
</HStack>
|
||
</VStack>
|
||
<SimpleGrid columns={2} gap={3} minW={{ base: "full", md: "320px" }}>
|
||
<MetricTile
|
||
label={labels.confidence}
|
||
value={formatPercent(pick.unified_score ?? 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={formatSignalScore(pick.play_score)}
|
||
/>
|
||
<MetricTile
|
||
label={labels.confidenceInterval}
|
||
value={formatInterval(pick.confidence_interval)}
|
||
/>
|
||
<MetricTile
|
||
label={labels.confidenceBand}
|
||
value={getConfidenceBandLabel(pick.confidence_interval?.band, ui)}
|
||
accent={`${confidenceBandPalette}.500`}
|
||
/>
|
||
</SimpleGrid>
|
||
</Flex>
|
||
<ProbabilitySplit
|
||
modelProb={pick.probability}
|
||
impliedProb={pick.implied_prob}
|
||
labels={{
|
||
model: labels.modelProbability,
|
||
market: labels.marketProbability,
|
||
}}
|
||
/>
|
||
<Box>
|
||
<HStack justify="space-between" mb={1.5}>
|
||
<Text fontSize="sm" fontWeight="semibold">
|
||
{labels.playability}
|
||
</Text>
|
||
<Text fontSize="sm" color="fg.muted">
|
||
{formatSignalScore(pick.play_score)}
|
||
</Text>
|
||
</HStack>
|
||
<Bar
|
||
value={pick.play_score}
|
||
color={pick.ev_edge > 0 ? "blue.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">
|
||
{labels.confidenceIntervalWarning}
|
||
</Text>
|
||
</Box>
|
||
) : null}
|
||
</Card.Body>
|
||
</Card.Root>
|
||
);
|
||
}
|
||
|
||
function SummaryTable({
|
||
items,
|
||
marketLabels,
|
||
title,
|
||
info,
|
||
ui,
|
||
}: {
|
||
items: MatchBetSummaryItemDto[];
|
||
marketLabels?: Record<string, string>;
|
||
title: string;
|
||
info: string;
|
||
ui?: PredictionUiMessages;
|
||
}) {
|
||
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.unified_score ?? right.calibrated_confidence) - (left.unified_score ?? 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, ui)}
|
||
</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="64px" color="fg.muted">
|
||
{formatPercent(
|
||
(item.model_probability ?? 0) * 100,
|
||
0,
|
||
)}{" "}
|
||
{getUiText(ui, "probability-short", "olasılık")}
|
||
</Text>
|
||
<Text
|
||
minW="96px"
|
||
color={`${getEdgePalette(item.ev_edge)}.500`}
|
||
fontWeight="semibold"
|
||
>
|
||
{formatEdgeSignal(item.ev_edge)}
|
||
</Text>
|
||
<Text minW="48px">
|
||
{formatPercent(item.unified_score ?? item.calibrated_confidence, 0)}
|
||
</Text>
|
||
<Badge
|
||
colorPalette={getConfidenceBandPalette(
|
||
item.confidence_interval?.band,
|
||
)}
|
||
variant="subtle"
|
||
>
|
||
{getConfidenceBandLabel(item.confidence_interval?.band, ui)}
|
||
</Badge>
|
||
<Badge variant="surface">
|
||
{formatUnits(item.stake_units)}
|
||
</Badge>
|
||
</HStack>
|
||
</Flex>
|
||
))}
|
||
{/* <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>
|
||
{item.is_underdog_reference ? (
|
||
<Badge colorPalette="gray" variant="outline" title="Underdog tarafının model olasılığı (bilgi amaçlı)">
|
||
Underdog ref.
|
||
</Badge>
|
||
) : null}
|
||
{item.betting_brain?.trap_market_flag ? (
|
||
<Badge colorPalette="red" variant="subtle" title={`Piyasa aşırı güveniyor (gap ${(item.betting_brain.trap_market_gap || 0) * 100 | 0}pp)`}>
|
||
Trap
|
||
</Badge>
|
||
) : null}
|
||
{item.betting_brain?.action === "WATCH_NO_VALUE" ? (
|
||
<Badge colorPalette="orange" variant="subtle" title="Model favoriyle hemfikir ama oran çok düşük">
|
||
No-value
|
||
</Badge>
|
||
) : null}
|
||
<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,
|
||
ui,
|
||
}: {
|
||
marketBoard?: Record<string, MarketBoardEntryDto>;
|
||
betSummary?: MatchBetSummaryItemDto[];
|
||
marketLabels?: Record<string, string>;
|
||
title: string;
|
||
info: string;
|
||
ui?: PredictionUiMessages;
|
||
}) {
|
||
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;
|
||
|
||
// Key by market:pick so each card resolves the summary row for the EXACT
|
||
// outcome it displays (graph pick). Keying by market alone collided on
|
||
// multi-row markets (MS 1/X/2) and surfaced the wrong odds + confidence.
|
||
const summaryByMarket = new Map(
|
||
(betSummary || []).map((item) => [`${item.market}:${item.pick}`, item]),
|
||
);
|
||
// Fallback: first row of a market, for cards whose pick has no exact row.
|
||
for (const item of betSummary || []) {
|
||
if (!summaryByMarket.has(item.market)) {
|
||
summaryByMarket.set(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}:${entry.pick}`) ??
|
||
summaryByMarket.get(market);
|
||
const interval =
|
||
summary?.confidence_interval || entry.confidence_interval;
|
||
// Hit probability == the dominant (green) bar in the graph below,
|
||
// so the headline never contradicts the distribution it sits on.
|
||
const pickProbPct =
|
||
Math.max(
|
||
0,
|
||
...Object.values(entry.probs).map((p) => Number(p) || 0),
|
||
) * 100;
|
||
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
|
||
? getUiText(ui, "playable", "Oynanabilir")
|
||
: getUiText(ui, "risky", "Riskli")}
|
||
</Badge>
|
||
) : null}
|
||
{summary?.signal_tier ? (
|
||
<Badge
|
||
colorPalette={getSignalTierPalette(
|
||
summary.signal_tier,
|
||
)}
|
||
variant="subtle"
|
||
>
|
||
{getSignalTierLabel(summary.signal_tier, ui)}
|
||
</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(pickProbPct, 0)})
|
||
</Badge>
|
||
) : null}
|
||
</Flex>
|
||
<SimpleGrid columns={3} gap={2} mb={3}>
|
||
<MetricTile
|
||
label={getUiText(ui, "hit-probability", "Tutma Olasılığı")}
|
||
value={formatPercent(pickProbPct, 0)}
|
||
accent="green.500"
|
||
/>
|
||
<MetricTile
|
||
label={getUiText(
|
||
ui,
|
||
"unified-confidence",
|
||
"Güven Skoru",
|
||
)}
|
||
value={
|
||
summary
|
||
? formatPercent(summary.unified_score ?? 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}>
|
||
{getUiText(ui, "confidence-interval", "Güven Aralığı")}:{" "}
|
||
{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={
|
||
(Number(probability) || 0) * 100 >= pickProbPct - 1e-6
|
||
? "green.400"
|
||
: "blue.400"
|
||
}
|
||
trackBg={trackBg}
|
||
height="6px"
|
||
/>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
</Box>
|
||
);
|
||
})}
|
||
</SimpleGrid>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
);
|
||
}
|
||
|
||
function ScoreCard({
|
||
prediction,
|
||
sport,
|
||
ui,
|
||
}: {
|
||
prediction: MatchPredictionDto;
|
||
sport: SportType;
|
||
ui?: PredictionUiMessages;
|
||
}) {
|
||
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
|
||
? getUiText(ui, "score-scenario-basketball", "Sayı Senaryosu")
|
||
: getUiText(ui, "score-scenario-football", "Skor Senaryosu")
|
||
}
|
||
info={
|
||
isBasketball
|
||
? getUiText(
|
||
ui,
|
||
"score-scenario-info-basketball",
|
||
"Beklenen sayı dağılımı ve en olası maç senaryoları.",
|
||
)
|
||
: getUiText(
|
||
ui,
|
||
"score-scenario-info-football",
|
||
"Beklenen skor ve en olası senaryolar.",
|
||
)
|
||
}
|
||
/>
|
||
<SimpleGrid columns={{ base: 1, md: 3 }} gap={3}>
|
||
<MetricTile
|
||
label={
|
||
isBasketball
|
||
? getUiText(ui, "full-time-basketball", "Maç Sonu Sayı")
|
||
: getUiText(ui, "full-time-football", "Maç Sonu")
|
||
}
|
||
value={prediction.score_prediction.ft}
|
||
/>
|
||
<MetricTile
|
||
label={
|
||
isBasketball
|
||
? getUiText(ui, "half-time-basketball", "İlk Yarı Sayı")
|
||
: getUiText(ui, "half-time-football", "İlk Yarı")
|
||
}
|
||
value={prediction.score_prediction.ht}
|
||
/>
|
||
<MetricTile
|
||
label={
|
||
isBasketball
|
||
? getUiText(
|
||
ui,
|
||
"expected-total-basketball",
|
||
"Beklenen Toplam Sayı",
|
||
)
|
||
: getUiText(ui, "expected-total-football", "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 liveBg = useColorModeValue("red.50", "red.950");
|
||
const liveBorderColor = useColorModeValue("red.300", "red.800");
|
||
const warningBg = useColorModeValue("yellow.50", "yellow.950");
|
||
const warningBorderColor = useColorModeValue("yellow.300", "yellow.800");
|
||
const orangeBg = useColorModeValue("orange.50", "orange.950");
|
||
const orangeBorderColor = useColorModeValue("orange.200", "orange.800");
|
||
const greenBg = useColorModeValue("green.50", "green.950");
|
||
const greenBorderColor = useColorModeValue("green.200", "green.800");
|
||
const statCardBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||
const trackBgColor = useColorModeValue("gray.100", "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,
|
||
);
|
||
|
||
// ── Maç Sonucu Tahmini (model'in EN OLASI sonucu) ──────────────────
|
||
// The value-bet hero below optimizes ROI (often a high-odds draw/underdog,
|
||
// ~27% hit) which users misread as a "bad prediction". This block instead
|
||
// shows what the model thinks will actually happen: the highest-probability
|
||
// 1X2 outcome (~55% hit, on par with the market). Sourced from market_board.MS.
|
||
const msBoard = prediction.market_board?.MS;
|
||
const matchResultPrediction = (() => {
|
||
const probs = msBoard?.probs;
|
||
if (!probs) return null;
|
||
const labelMap: Record<string, string> = {
|
||
"1": prediction.match_info?.home_team || uiText("home", "Ev Sahibi"),
|
||
X: uiText("draw", "Beraberlik"),
|
||
"2": prediction.match_info?.away_team || uiText("away", "Deplasman"),
|
||
};
|
||
let bestKey = "";
|
||
let bestProb = -1;
|
||
for (const [k, v] of Object.entries(probs)) {
|
||
const p = typeof v === "number" ? v : 0;
|
||
if (p > bestProb) {
|
||
bestProb = p;
|
||
bestKey = k;
|
||
}
|
||
}
|
||
if (!bestKey) return null;
|
||
return {
|
||
pick: bestKey,
|
||
label: labelMap[bestKey] ?? bestKey,
|
||
prob: bestProb,
|
||
all: probs as Record<string, number>,
|
||
};
|
||
})();
|
||
|
||
const sport = getPredictionSport(prediction);
|
||
const isBasketball = sport === "basketball";
|
||
|
||
const engineDetail = prediction.engine_breakdown.detail;
|
||
const engineItems = [
|
||
{
|
||
key: "team",
|
||
icon: LuGauge,
|
||
label: isBasketball
|
||
? uiText("engine-team-basketball", "Takım Formu")
|
||
: uiText("engine-team-football", "Takım Gücü"),
|
||
value: prediction.engine_breakdown.team,
|
||
color: "blue.400",
|
||
detail: engineDetail?.team,
|
||
},
|
||
{
|
||
key: "player",
|
||
icon: LuSparkles,
|
||
label: isBasketball
|
||
? uiText("engine-player-basketball", "Kadro Etkisi")
|
||
: uiText("engine-player-football", "Oyuncu Etkisi"),
|
||
value: prediction.engine_breakdown.player,
|
||
color: "green.400",
|
||
detail: engineDetail?.player,
|
||
},
|
||
{
|
||
key: "odds",
|
||
icon: LuTrendingUp,
|
||
label: uiText("engine-odds", "Oran Analizi"),
|
||
value: prediction.engine_breakdown.odds,
|
||
color: "orange.400",
|
||
detail: engineDetail?.odds,
|
||
},
|
||
{
|
||
key: "referee",
|
||
icon: LuShieldAlert,
|
||
label: isBasketball
|
||
? uiText("engine-referee-basketball", "Yardımcı Sinyaller")
|
||
: uiText("engine-referee-football", "Hakem Etkisi"),
|
||
value: prediction.engine_breakdown.referee,
|
||
color: "purple.400",
|
||
detail: engineDetail?.referee,
|
||
},
|
||
];
|
||
|
||
const liveScoreHome = prediction.match_info?.current_score_home;
|
||
const liveScoreAway = prediction.match_info?.current_score_away;
|
||
const isLive = Boolean(prediction.match_info?.is_live);
|
||
const isStale = Boolean(prediction.prediction_freshness?.is_stale_for_live);
|
||
const contradictions = prediction.match_commentary?.contradictions || [];
|
||
const pickCardLabels = {
|
||
confidence: uiText("confidence-label", "Güven"),
|
||
odds: uiText("odds-label", "Oran"),
|
||
recommendedStake: uiText("stake-label-short", "Stake"),
|
||
playScore: uiText("play-score-label", "Model Sinyali"),
|
||
playability: uiText("playability-label", "Model sinyali"),
|
||
confidenceInterval: uiText("confidence-interval", "Güven Aralığı"),
|
||
confidenceBand: uiText("confidence-band", "Band"),
|
||
confidenceIntervalWarning: uiText(
|
||
"confidence-interval-warning",
|
||
"Güven aralığı geniş. Sinyal olsa bile tek başına oynanması önerilmez.",
|
||
),
|
||
theoreticalEdgeInline: uiText("theoretical-edge-inline", "Teorik avantaj"),
|
||
modelProbability: uiText("model-probability-short", "Model"),
|
||
marketProbability: uiText("market-probability-short", "Piyasa"),
|
||
};
|
||
|
||
const leagueConfidence = prediction.match_info?.league_confidence;
|
||
const leagueConfStyles: Record<string, { color: string; label: string }> = {
|
||
high: {
|
||
color: "green",
|
||
label: uiText("league-conf-high", "Bu ligde model güçlü"),
|
||
},
|
||
medium: {
|
||
color: "yellow",
|
||
label: uiText("league-conf-medium", "Bu ligde model orta"),
|
||
},
|
||
low: {
|
||
color: "red",
|
||
label: uiText("league-conf-low", "Bu ligde model zayıf"),
|
||
},
|
||
};
|
||
const leagueConfMeta = leagueConfidence
|
||
? leagueConfStyles[leagueConfidence.label]
|
||
: null;
|
||
|
||
return (
|
||
<VStack align="stretch" gap={5}>
|
||
{leagueConfidence && leagueConfMeta ? (
|
||
<HStack
|
||
justify="space-between"
|
||
p={2.5}
|
||
px={3}
|
||
borderWidth="1px"
|
||
borderColor={`${leagueConfMeta.color}.300`}
|
||
bg={`${leagueConfMeta.color}.50`}
|
||
borderRadius="lg"
|
||
_dark={{ bg: `${leagueConfMeta.color}.950` }}
|
||
flexWrap="wrap"
|
||
gap={2}
|
||
>
|
||
<HStack gap={2}>
|
||
<Badge colorPalette={leagueConfMeta.color} variant="solid">
|
||
{leagueConfMeta.label}
|
||
</Badge>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{uiText("league-conf-basis", "geçmiş performans")}: ROI{" "}
|
||
{leagueConfidence.bet_roi > 0 ? "+" : ""}
|
||
{leagueConfidence.bet_roi}% · {leagueConfidence.bet_n}{" "}
|
||
{uiText("bets-short", "bahis")}
|
||
</Text>
|
||
</HStack>
|
||
</HStack>
|
||
) : null}
|
||
{isLive ? (
|
||
<Box
|
||
p={3}
|
||
bg={liveBg}
|
||
borderWidth="1px"
|
||
borderColor={liveBorderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<HStack justify="space-between" align="center">
|
||
<HStack gap={2}>
|
||
<Icon as={LuFlame} color="red.500" />
|
||
<Text fontWeight="bold" color="red.600">
|
||
🔴 {uiText("live", "CANLI")}
|
||
</Text>
|
||
{liveScoreHome != null && liveScoreAway != null ? (
|
||
<Text fontWeight="semibold">
|
||
{prediction.match_info.home_team} {liveScoreHome} -{" "}
|
||
{liveScoreAway} {prediction.match_info.away_team}
|
||
</Text>
|
||
) : null}
|
||
</HStack>
|
||
{isStale ? (
|
||
<Badge colorPalette="orange" variant="solid">
|
||
{uiText("pre-match-prediction", "Maç öncesi tahmin")}
|
||
</Badge>
|
||
) : null}
|
||
</HStack>
|
||
</Box>
|
||
) : null}
|
||
|
||
{contradictions.length ? (
|
||
<Box
|
||
p={3}
|
||
bg={warningBg}
|
||
borderWidth="1px"
|
||
borderColor={warningBorderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<HStack align="start" gap={2}>
|
||
<Icon as={LuTriangleAlert} color="yellow.600" mt={0.5} />
|
||
<VStack align="start" gap={1}>
|
||
<Text fontWeight="semibold">
|
||
{uiText("prediction-contradictions", "Tahmin Çelişkileri")}
|
||
</Text>
|
||
{contradictions.map((text, idx) => (
|
||
<Text key={idx} fontSize="sm" color="fg.muted">
|
||
• {text}
|
||
</Text>
|
||
))}
|
||
</VStack>
|
||
</HStack>
|
||
</Box>
|
||
) : null}
|
||
|
||
<Card.Root bg={pageBg} borderColor={borderColor} borderRadius="2xl">
|
||
<Card.Body gap={5}>
|
||
<SectionTitle
|
||
icon={LuBrain}
|
||
title={uiText("summary-title", "Tahmin Özeti")}
|
||
info={uiText(
|
||
"summary-info",
|
||
"Model sinyallerini ve belirsizlikleri sade şekilde gösterir.",
|
||
)}
|
||
/>
|
||
<Box
|
||
p={3}
|
||
bg={orangeBg}
|
||
borderWidth="1px"
|
||
borderColor={orangeBorderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<HStack align="start" gap={2}>
|
||
<Icon
|
||
as={LuShieldAlert}
|
||
boxSize={4.5}
|
||
color="orange.500"
|
||
mt={0.5}
|
||
/>
|
||
<Text fontSize="sm" color="fg.muted" lineHeight="tall">
|
||
{uiText(
|
||
"model-signal-disclaimer",
|
||
"Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.",
|
||
)}
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
|
||
{matchResultPrediction ? (
|
||
<Box
|
||
p={4}
|
||
bg={statCardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="2xl"
|
||
>
|
||
<HStack justify="space-between" align="start" mb={3}>
|
||
<VStack align="start" gap={1}>
|
||
<Badge colorPalette="blue" variant="subtle" borderRadius="full">
|
||
{uiText("match-result-prediction", "Maç Sonucu Tahmini")}
|
||
</Badge>
|
||
<Text fontSize="2xl" fontWeight="bold">
|
||
{matchResultPrediction.label}
|
||
</Text>
|
||
<Text fontSize="sm" color="fg.muted">
|
||
{uiText(
|
||
"match-result-copy",
|
||
"Modelin en olası gördüğü sonuç (kim kazanır).",
|
||
)}
|
||
</Text>
|
||
</VStack>
|
||
<VStack align="end" gap={0}>
|
||
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
|
||
{formatPercent(matchResultPrediction.prob * 100, 0)}
|
||
</Text>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{uiText("probability-short", "olasılık")}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
{/* 1X2 dağılımı */}
|
||
<VStack align="stretch" gap={2}>
|
||
{["1", "X", "2"].map((k) => {
|
||
const p = matchResultPrediction.all[k] ?? 0;
|
||
const lbl: Record<string, string> = {
|
||
"1":
|
||
prediction.match_info?.home_team ||
|
||
uiText("home", "Ev Sahibi"),
|
||
X: uiText("draw", "Beraberlik"),
|
||
"2":
|
||
prediction.match_info?.away_team ||
|
||
uiText("away", "Deplasman"),
|
||
};
|
||
const isTop = k === matchResultPrediction.pick;
|
||
return (
|
||
<Box key={k}>
|
||
<Flex justify="space-between" mb={1}>
|
||
<Text
|
||
fontSize="sm"
|
||
fontWeight={isTop ? "semibold" : "normal"}
|
||
color={isTop ? "blue.500" : "fg.muted"}
|
||
>
|
||
{lbl[k]}
|
||
</Text>
|
||
<Text fontSize="sm" fontWeight={isTop ? "semibold" : "normal"}>
|
||
{formatPercent(p * 100, 0)}
|
||
</Text>
|
||
</Flex>
|
||
<Bar
|
||
value={p * 100}
|
||
color={isTop ? "blue.400" : "gray.400"}
|
||
trackBg={trackBgColor}
|
||
height="6px"
|
||
/>
|
||
</Box>
|
||
);
|
||
})}
|
||
</VStack>
|
||
<Text fontSize="xs" color="fg.muted" mt={3}>
|
||
{uiText(
|
||
"match-result-vs-value",
|
||
"Bu en olası sonuçtur. Aşağıdaki “Değerli Bahis” ise orana göre en kârlı görülen seçimdir — ikisi farklı olabilir.",
|
||
)}
|
||
</Text>
|
||
</Box>
|
||
) : null}
|
||
|
||
{recommendedPick ? (
|
||
<Grid templateColumns={{ base: "1fr", xl: "1.4fr 1fr" }} gap={4}>
|
||
<Box
|
||
p={4}
|
||
bg={greenBg}
|
||
borderWidth="1px"
|
||
borderColor={greenBorderColor}
|
||
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", "Değerli Bahis")}
|
||
</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 güçlü seçim.")}
|
||
</Text>
|
||
<HStack gap={2} flexWrap="wrap">
|
||
<Badge colorPalette={mainBandPalette} variant="subtle">
|
||
{getConfidenceBandLabel(
|
||
prediction.bet_advice.confidence_band,
|
||
ui,
|
||
)}
|
||
</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", "Güven")}
|
||
value={formatPercent(
|
||
recommendedPick.unified_score ?? recommendedPick.calibrated_confidence,
|
||
0,
|
||
)}
|
||
/>
|
||
<MetricTile
|
||
label={uiText("odds-label", "Oran")}
|
||
value={formatOdds(recommendedPick.odds)}
|
||
/>
|
||
<MetricTile
|
||
label={uiText("confidence-interval", "Güven Aralığı")}
|
||
value={formatInterval(recommendedPick.confidence_interval)}
|
||
/>
|
||
<MetricTile
|
||
label={uiText("edge-label", "Teorik Avantaj")}
|
||
value={formatEdgeSignal(recommendedPick.ev_edge)}
|
||
helper={uiText(
|
||
"edge-info",
|
||
"Model olasılığı ile piyasa olasılığı arasındaki teorik farktır; tutma garantisi veya kesin kazanç beklentisi değildir.",
|
||
)}
|
||
accent={`${getEdgePalette(recommendedPick.ev_edge)}.500`}
|
||
/>
|
||
<MetricTile
|
||
label={uiText("stake-label", "Önerilen Miktar (Stake)")}
|
||
value={formatUnits(
|
||
recommendedPick.stake_units ||
|
||
prediction.bet_advice.suggested_stake_units,
|
||
)}
|
||
helper={uiText(
|
||
"stake-info",
|
||
"Stake, bu bahis için önerilen bahis birimidir. 2.0u, kendi bankroll planınızdaki 2 birimlik bahis anlamına gelir.",
|
||
)}
|
||
/>
|
||
</SimpleGrid>
|
||
</Box>
|
||
|
||
<Box
|
||
p={4}
|
||
bg={cardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="2xl"
|
||
>
|
||
<Text fontSize="sm" fontWeight="semibold" mb={3}>
|
||
{uiText("quick-read", "Hızlı 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={uiText("data-quality", "Veri Kalitesi")}
|
||
value={formatPercent(prediction.data_quality.score * 100, 0)}
|
||
helper={uiText(
|
||
"data-quality-info",
|
||
"Kadro, oran ve maç verisinin ne kadar güvenilir olduğu.",
|
||
)}
|
||
accent={`${qualityPalette}.500`}
|
||
/>
|
||
<MetricTile
|
||
label={t("risk-level")}
|
||
value={`${prediction.risk.level} (${prediction.risk.score}/100)`}
|
||
helper={uiText(
|
||
"risk-info",
|
||
"Sürpriz ihtimali ve belirsizlik seviyesi.",
|
||
)}
|
||
accent={`${riskPalette}.500`}
|
||
/>
|
||
<MetricTile
|
||
label={uiText("lineup-source", "Kadronun Kaynağı")}
|
||
value={getLineupSourceLabel(
|
||
prediction.data_quality.lineup_source,
|
||
ui,
|
||
)}
|
||
/>
|
||
<MetricTile
|
||
label={uiText("model-label", "Model")}
|
||
value={prediction.model_version}
|
||
/>
|
||
</SimpleGrid>
|
||
|
||
{prediction.risk.is_surprise_risk ||
|
||
prediction.risk.warnings?.length ? (
|
||
<Box
|
||
p={4}
|
||
bg={orangeBg}
|
||
borderWidth="1px"
|
||
borderColor={orangeBorderColor}
|
||
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">
|
||
{uiText("risk-commentary", "Risk Yorumu")}
|
||
</Text>
|
||
<Text fontSize="sm" color="fg.muted">
|
||
{prediction.risk.surprise_comment ||
|
||
(prediction.risk.surprise_type
|
||
? `${resolveReason(prediction.risk.surprise_type)}`
|
||
: uiText(
|
||
"risk-default-comment",
|
||
"Model bu maçta ekstra dikkat istiyor.",
|
||
))}
|
||
</Text>
|
||
{prediction.risk.surprise_score !== undefined ? (
|
||
<Text
|
||
fontSize="sm"
|
||
fontWeight="semibold"
|
||
color="orange.600"
|
||
>
|
||
{uiText("surprise-score", "Sürpriz skoru")}:{" "}
|
||
{formatPercent(prediction.risk.surprise_score, 0)}
|
||
</Text>
|
||
) : null}
|
||
{prediction.risk.surprise_breakdown?.length ? (
|
||
<VStack align="start" gap={1} mt={1}>
|
||
{prediction.risk.surprise_breakdown.map((entry) => (
|
||
<HStack key={entry.code} gap={2}>
|
||
<Badge
|
||
colorPalette={
|
||
entry.points >= 15
|
||
? "red"
|
||
: entry.points >= 8
|
||
? "orange"
|
||
: "yellow"
|
||
}
|
||
variant="subtle"
|
||
>
|
||
+{entry.points.toFixed(0)}
|
||
</Badge>
|
||
<Text fontSize="sm" color="fg.muted">
|
||
{entry.label}
|
||
</Text>
|
||
</HStack>
|
||
))}
|
||
</VStack>
|
||
) : (
|
||
<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 çok hangi bileşenlerin etkilediğini gösterir.",
|
||
)}
|
||
/>
|
||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||
{engineItems.map((item) => (
|
||
<Box
|
||
key={item.key}
|
||
p={4}
|
||
bg={statCardBg}
|
||
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>
|
||
<HStack gap={2}>
|
||
{item.detail?.label ? (
|
||
<Badge
|
||
colorPalette={getEngineLabelPalette(item.detail.label)}
|
||
variant="subtle"
|
||
>
|
||
{getEngineLabelText(item.detail.label, ui)}
|
||
</Badge>
|
||
) : null}
|
||
<Text fontSize="sm" fontWeight="bold">
|
||
+{item.value.toFixed(1)}
|
||
</Text>
|
||
</HStack>
|
||
</HStack>
|
||
<Bar
|
||
value={Math.min(item.value, 100)}
|
||
color={item.color}
|
||
trackBg={trackBgColor}
|
||
/>
|
||
{item.detail?.interpretation ? (
|
||
<Text fontSize="xs" color="fg.muted" mt={2}>
|
||
{item.detail.interpretation}
|
||
</Text>
|
||
) : null}
|
||
</Box>
|
||
))}
|
||
</SimpleGrid>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
{recommendedPick ? (
|
||
<PickCard
|
||
pick={recommendedPick}
|
||
title={uiText("best-single-pick", "En güçlü sinyal")}
|
||
resolveReason={resolveReason}
|
||
palette="green"
|
||
stakeFallback={prediction.bet_advice.suggested_stake_units}
|
||
marketLabels={marketLabels}
|
||
labels={pickCardLabels}
|
||
ui={ui}
|
||
/>
|
||
) : 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 dışındaki seçenekler.",
|
||
)}
|
||
/>
|
||
<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", "Elenen Market")
|
||
}
|
||
resolveReason={resolveReason}
|
||
palette={pick.ev_edge > 0 ? "blue" : "orange"}
|
||
marketLabels={marketLabels}
|
||
labels={pickCardLabels}
|
||
ui={ui}
|
||
/>
|
||
))}
|
||
</SimpleGrid>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
) : null}
|
||
|
||
<SummaryTable
|
||
items={prediction.bet_summary || []}
|
||
marketLabels={marketLabels}
|
||
title={uiText("all-markets-title", "Tüm Marketler")}
|
||
info={uiText(
|
||
"all-markets-info",
|
||
"Bütün seçenekleri tek tabloda karşılaştırır.",
|
||
)}
|
||
ui={ui}
|
||
/>
|
||
{prediction.match_commentary?.headline ||
|
||
prediction.match_commentary?.summary ? (
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||
<Card.Body gap={3}>
|
||
<SectionTitle
|
||
icon={LuBrain}
|
||
title={uiText("match-commentary-title", "Maç Yorumu")}
|
||
info={uiText(
|
||
"match-commentary-info",
|
||
"Modelin maç hakkındaki insan okunabilir özeti.",
|
||
)}
|
||
/>
|
||
{prediction.match_commentary.headline ? (
|
||
<Text fontSize="md" fontWeight="bold">
|
||
{prediction.match_commentary.headline}
|
||
</Text>
|
||
) : null}
|
||
{prediction.match_commentary.summary ? (
|
||
<Text fontSize="sm" color="fg.muted">
|
||
{prediction.match_commentary.summary}
|
||
</Text>
|
||
) : null}
|
||
{prediction.match_commentary.notes?.length ? (
|
||
<VStack align="start" gap={1}>
|
||
{prediction.match_commentary.notes.map((note, idx) => (
|
||
<Text key={idx} fontSize="sm">
|
||
• {note}
|
||
</Text>
|
||
))}
|
||
</VStack>
|
||
) : null}
|
||
</Card.Body>
|
||
</Card.Root>
|
||
) : null}
|
||
<ScoreCard prediction={prediction} sport={sport} ui={ui} />
|
||
<MarketBoardSection
|
||
marketBoard={prediction.market_board}
|
||
betSummary={prediction.bet_summary || []}
|
||
marketLabels={marketLabels}
|
||
title={t("market-board")}
|
||
info={uiText(
|
||
"market-board-info",
|
||
"Modelin her markette gördüğü olasılık dağılımı.",
|
||
)}
|
||
ui={ui}
|
||
/>
|
||
|
||
{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 önerisi.")}
|
||
/>
|
||
<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
|
||
? uiText("bet-advice-play", "OYNA")
|
||
: uiText("bet-advice-pass", "OYNAMA")}
|
||
</Badge>
|
||
<Badge
|
||
colorPalette={mainBandPalette}
|
||
variant="subtle"
|
||
borderRadius="full"
|
||
fontSize="sm"
|
||
px={3}
|
||
py={1}
|
||
>
|
||
{getConfidenceBandLabel(
|
||
prediction.bet_advice.confidence_band,
|
||
ui,
|
||
)}
|
||
</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, ui)}
|
||
</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", "Önerilen miktar")}:{" "}
|
||
{formatUnits(prediction.bet_advice.suggested_stake_units)}
|
||
</Badge>
|
||
</HStack>
|
||
<Separator />
|
||
<SectionTitle
|
||
icon={LuBrain}
|
||
title={t("reasoning")}
|
||
info={uiText(
|
||
"reasoning-info",
|
||
"Modelin bu maçı neden bu şekilde okuduğunun üst seviye özeti.",
|
||
)}
|
||
/>
|
||
<ReasonList
|
||
items={prediction.reasoning_factors}
|
||
resolveReason={resolveReason}
|
||
/>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
</VStack>
|
||
);
|
||
}
|