Files
iddaai-fe/src/components/matches/prediction-card.tsx
T
fahricansecer e59f4b4e72
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m30s
Update prediction-card.tsx
2026-06-02 17:54:47 +03:00

1940 lines
65 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;
}
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>
);
}