"use client"; import { Badge, Box, Card, Flex, Grid, HStack, Icon, IconButton, Separator, SimpleGrid, Text, VStack, } from "@chakra-ui/react"; import { useMessages, useTranslations } from "next-intl"; import { LuBadgeCheck, LuBrain, LuChartColumn, LuChartNoAxesCombined, LuCircleHelp, LuFlame, LuGauge, LuShieldAlert, LuSparkles, LuTarget, LuTriangleAlert, LuTrendingUp, } from "react-icons/lu"; import { useColorModeValue } from "@/components/ui/color-mode"; import { Tooltip } from "@/components/ui/overlays/tooltip"; import type { MarketBoardEntryDto, MatchBetSummaryItemDto, MatchPickDto, MatchPredictionDto, SignalTier, V27EngineDto, } from "@/lib/api/predictions/types"; import type { SportType } from "@/lib/api/matches/types"; import V28OddsBandPanel from "@/components/matches/v28-odds-band-panel"; interface PredictionCardProps { prediction: MatchPredictionDto; } function formatReasonFallback(reason: string): string { if (reason.startsWith("risk:")) return formatReasonFallback(reason.slice(5)); const evMatch = reason.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/); if (evMatch) return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; const negMatch = reason.match(/^negative_model_edge_([+\-][\d.]+)$/); if (negMatch) return `Model avantajı negatif (${negMatch[1]})`; const thresholdMatch = reason.match(/^below_market_edge_threshold_([+\-]?[\d.]+)$/); if (thresholdMatch) return `Piyasa avantaj eşiğinin altında (${thresholdMatch[1]})`; if (reason === "confidence_interval_too_wide") return "Güven aralığı fazla geniş."; if (reason === "confidence_band_low") return "Güven bandı düşük."; if (reason === "draw_probability_elevated") return "Beraberlik olasılığı yükselmiş görünüyor."; if (reason === "balanced_match_risk") return "Maç dengeli görünüyor, sürpriz riski var."; if (reason === "high_total_goal_volatility") return "Yüksek gol temposu sürpriz riskini artırıyor."; if (reason === "mutual_goal_pressure") return "İki takım da gol tehdidi üretiyor."; if (reason === "late_goal_swing_risk") return "Geç gol veya skor kırılması riski yüksek."; if (reason === "live_match_open_state") return "Canlı maç tamamen açık oyuna dönmüş durumda."; if (reason === "live_match_active_state") return "Canlı maç beklenenden daha hareketli ilerliyor."; if (reason === "live_state_impossible_market") return "Canlı maç durumu bu marketi geçersiz kılıyor."; if (reason === "live_score_exceeds_under_line") return "Canlı skor, alt seçeneğinin üst sınırına çok yaklaştı veya geçti."; if (reason === "score_model_conflicts_with_under_pick") return "Skor ve xG modeli bu alt seçeneğiyle çelişiyor."; if (reason === "score_model_conflicts_with_over_pick") return "Skor ve xG modeli bu üst seçeneğiyle çelişiyor."; if (reason === "market_stack_conflict_over25") return "2.5 üst sinyali bu marketle çelişiyor."; if (reason === "market_stack_conflict_btts") return "KG Var sinyali bu marketle çelişiyor."; if (reason === "live_total_goals_close_to_line") return "Canlı toplam gol sayısı bu çizgiye fazla yaklaştı."; if (reason === "score_model_conflicts_with_btts_no") return "Skor ve xG modeli KG Yok seçeneğiyle çelişiyor."; if (reason === "score_model_conflicts_with_draw_pick") return "Skor modeli beraberlik seçeneğini desteklemiyor."; if (reason === "score_model_conflicts_with_home_pick") return "Skor modeli ev sahibi seçeneğini desteklemiyor."; if (reason === "score_model_conflicts_with_away_pick") return "Skor modeli deplasman seçeneğini desteklemiyor."; if (/^[a-z0-9_]+$/i.test(reason)) { return reason.replace(/_/g, " ").replace(/^\w/, (char) => char.toUpperCase()); } return reason; } function formatPercent(value?: number, digits = 0): string { if (value === undefined || value === null || Number.isNaN(value)) return "-"; return `${value.toFixed(digits)}%`; } function formatProbability(value?: number, digits = 1): string { if (value === undefined || value === null || Number.isNaN(value)) return "-"; return `${(value * 100).toFixed(digits)}%`; } function formatOdds(value?: number | null): string { if (!value || value <= 1.01) return "-"; return value.toFixed(2); } function formatUnits(value?: number): string { if (!value || value <= 0) return "-"; return `${value.toFixed(1)}u`; } function getRiskPalette(level: string) { switch (level.toUpperCase()) { case "LOW": return "green"; case "MEDIUM": return "yellow"; case "HIGH": return "orange"; case "EXTREME": return "red"; default: return "gray"; } } function getQualityPalette(label: string) { switch (label.toUpperCase()) { case "HIGH": return "green"; case "MEDIUM": return "yellow"; case "LOW": return "red"; default: return "gray"; } } function getConfidenceBandPalette(band?: string) { switch ((band || "").toUpperCase()) { case "HIGH": return "green"; case "MEDIUM": return "yellow"; case "LOW": return "red"; default: return "gray"; } } function getConfidenceBandLabel(band?: string) { switch ((band || "").toUpperCase()) { case "HIGH": return "Yüksek"; case "MEDIUM": return "Orta"; case "LOW": return "Düşük"; default: return "Belirsiz"; } } function getLineupSourceLabel(source?: string): string { if (source === "confirmed_live") return "Onayli ilk 11"; if (source === "probable_xi") return "Muhtemel ilk 11"; return source ? formatReasonFallback(source) : "Bilinmiyor"; } function formatInterval( interval?: { lower?: number; upper?: number }, digits = 0, ): string { if ( !interval || interval.lower === undefined || interval.upper === undefined || Number.isNaN(interval.lower) || Number.isNaN(interval.upper) ) { return "-"; } return `${interval.lower.toFixed(digits)}-${interval.upper.toFixed(digits)}%`; } function getPredictionReasonText( reason: string, reasonMessages?: Record, ): string { if ( reasonMessages && Object.prototype.hasOwnProperty.call(reasonMessages, reason) ) { return reasonMessages[reason]; } return formatReasonFallback(reason); } function getMarketLabel( market: string, marketLabels?: Record, ): string { if (marketLabels && Object.prototype.hasOwnProperty.call(marketLabels, market)) { return marketLabels[market]; } const fallbackLabels: Record = { ML: "Moneyline", MS: "Maç Sonucu", DC: "Çifte Şans", TOTAL: "Toplam Sayı", SPREAD: "Handikap", OU15: "Toplam Gol 1.5", OU25: "Toplam Gol 2.5", OU35: "Toplam Gol 3.5", BTTS: "Karşılıklı Gol", HT: "İlk Yarı Sonucu", HT_OU05: "İlk Yarı 0.5 Gol", HT_OU15: "İlk Yarı 1.5 Gol", HTFT: "İlk Yarı / Maç Sonu", "HT/FT": "İlk Yarı / Maç Sonu", OE: "Tek / Çift", CARDS: "Kartlar 4.5", HCAP: "Handikap Sonucu", }; if (fallbackLabels[market]) { return fallbackLabels[market]; } return market; } const MARKET_ORDER = [ "ML", "TOTAL", "SPREAD", "MS", "DC", "OU15", "OU25", "OU35", "BTTS", "HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP", ]; function getPredictionSport(prediction: MatchPredictionDto): SportType { const explicitSport = prediction.match_info?.sport; if (explicitSport === "basketball" || explicitSport === "football") { return explicitSport; } if ( prediction.model_version?.toLowerCase().includes("basketball") || Object.keys(prediction.market_board || {}).some((market) => ["ML", "TOTAL", "SPREAD"].includes(market), ) ) { return "basketball"; } return "football"; } const SIGNAL_TIER_ORDER: SignalTier[] = ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"]; function getSignalTierPalette(tier?: SignalTier) { switch (tier) { case "CORE": return "green"; case "VALUE": return "blue"; case "LEAN": return "orange"; case "LONGSHOT": return "pink"; default: return "gray"; } } function getSignalTierLabel(tier?: SignalTier) { switch (tier) { case "CORE": return "Çekirdek"; case "VALUE": return "Değer"; case "LEAN": return "Yorum"; case "LONGSHOT": return "Sürpriz"; default: return "Pas"; } } function TooltipIcon({ content }: { content: string }) { return ( ); } function SectionTitle({ icon, title, info, }: { icon: React.ElementType; title: string; info?: string; }) { return ( {title} {info ? : null} ); } 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 ( {label} {helper ? : null} {value} ); } function Bar({ value, color, trackBg, height = "8px", }: { value: number; color: string; trackBg: string; height?: string; }) { return ( ); } function ReasonList({ items, resolveReason, }: { items?: string[]; resolveReason: (reason: string) => string; }) { if (!items?.length) return null; return ( {items.map((item, index) => ( {resolveReason(item)} ))} ); } function ProbabilitySplit({ modelProb, impliedProb, }: { modelProb: number; impliedProb: number; }) { const trackBg = useColorModeValue("gray.100", "gray.700"); if (!impliedProb || impliedProb <= 0) return null; return ( Model {formatProbability(modelProb, 0)} Piyasa {formatProbability(impliedProb, 0)} ); } function PickCard({ pick, stakeFallback, title, resolveReason, palette, marketLabels, labels, }: { pick: MatchPickDto; stakeFallback?: number; title: string; resolveReason: (reason: string) => string; palette: string; marketLabels?: Record; labels: { confidence: string; odds: string; recommendedStake: string; playScore: string; playability: string; }; }) { const bg = useColorModeValue(`${palette}.50`, `${palette}.950`); const borderColor = useColorModeValue(`${palette}.200`, `${palette}.800`); const trackBg = useColorModeValue("gray.100", "gray.700"); const intervalWarningBg = useColorModeValue("orange.50", "orange.950"); const intervalWarningBorder = useColorModeValue("orange.200", "orange.800"); const confidenceBandPalette = getConfidenceBandPalette( pick.confidence_interval?.band, ); return ( {title} {pick.pick} {getMarketLabel(pick.market, marketLabels)} {pick.bet_grade} {getSignalTierLabel(pick.signal_tier)} {getConfidenceBandLabel(pick.confidence_interval?.band)} 0 ? "green" : "red"} variant="subtle"> EV {pick.ev_edge > 0 ? "+" : ""} {formatPercent(pick.ev_edge * 100, 1)} {labels.playability} {formatPercent(pick.play_score, 1)} 0 ? "green.400" : "orange.400"} trackBg={trackBg} /> {pick.confidence_interval && !pick.confidence_interval.threshold_met ? ( Guven araligi genis. Sinyal olsa bile tek basina oynanmasi onerilmez. ) : null} ); } function SummaryTable({ items, marketLabels, title, info, }: { items: MatchBetSummaryItemDto[]; marketLabels?: Record; title: string; info: string; }) { const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); const highlightBg = useColorModeValue("green.50", "green.950"); if (!items.length) return null; return ( {items .slice() .sort((left, right) => { const leftIndex = SIGNAL_TIER_ORDER.indexOf(left.signal_tier || "PASS"); const rightIndex = SIGNAL_TIER_ORDER.indexOf(right.signal_tier || "PASS"); if (leftIndex !== rightIndex) return leftIndex - rightIndex; return right.calibrated_confidence - left.calibrated_confidence; }) .map((item) => ( {item.bet_grade} {getSignalTierLabel(item.signal_tier)} {getMarketLabel(item.market, marketLabels)} {item.pick} {formatOdds(item.odds)} 0 ? "green.500" : "red.500"} fontWeight="semibold"> {item.ev_edge > 0 ? "+" : ""} {formatPercent(item.ev_edge * 100, 1)} {formatPercent(item.calibrated_confidence, 0)} {getConfidenceBandLabel(item.confidence_interval?.band)} {formatUnits(item.stake_units)} ))} ); } function MarketBoardSection({ marketBoard, betSummary, marketLabels, title, info, }: { marketBoard?: Record; betSummary?: MatchBetSummaryItemDto[]; marketLabels?: Record; title: string; info: string; }) { const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); const trackBg = useColorModeValue("gray.100", "gray.700"); const innerBg = useColorModeValue("gray.50", "whiteAlpha.50"); if (!marketBoard || !Object.keys(marketBoard).length) return null; const summaryByMarket = new Map((betSummary || []).map((item) => [item.market, item])); const orderedEntries = Object.entries(marketBoard).sort(([left], [right]) => { const leftIndex = MARKET_ORDER.indexOf(left); const rightIndex = MARKET_ORDER.indexOf(right); const safeLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex; const safeRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex; return safeLeft - safeRight; }); return ( {orderedEntries.map(([market, entry]) => { if (!entry?.probs) return null; const summary = summaryByMarket.get(market); const interval = summary?.confidence_interval || entry.confidence_interval; return ( {market} {getMarketLabel(market, marketLabels)} {summary ? ( {summary.playable ? "Oynanabilir" : "Riskli"} ) : null} {summary?.signal_tier ? ( {getSignalTierLabel(summary.signal_tier)} ) : null} {summary?.bet_grade ? {summary.bet_grade} : null} {entry.pick ? ( {entry.pick} ({formatPercent(entry.confidence, 0)}) ) : null} {interval ? ( Guven araligi: {formatInterval(interval)} ) : null} {Object.entries(entry.probs).map(([outcome, probability]) => ( {outcome.toUpperCase()} {formatProbability(probability, 1)} ))} ); })} ); } function ScoreCard({ prediction, sport, }: { prediction: MatchPredictionDto; sport: SportType; }) { const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); const subBg = useColorModeValue("gray.50", "whiteAlpha.50"); const isBasketball = sport === "basketball"; return ( {prediction.scenario_top5.map((scenario) => ( {scenario.score} {formatProbability(scenario.prob, 1)} ))} ); } export default function PredictionCard({ prediction }: PredictionCardProps) { const t = useTranslations("predictions"); const messages = useMessages() as { predictions?: { "prediction-reasons"?: Record; "market-labels"?: Record; ui?: Record; }; }; const marketLabels = messages.predictions?.["market-labels"]; const ui = messages.predictions?.ui; const uiText = (key: string, fallback: string) => ui?.[key] || fallback; const resolveReason = (reason: string) => getPredictionReasonText(reason, messages.predictions?.["prediction-reasons"]); const pageBg = useColorModeValue("gray.50", "gray.900"); const cardBg = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); const riskPalette = getRiskPalette(prediction.risk.level); const qualityPalette = getQualityPalette(prediction.data_quality.label); const recommendedPick = prediction.main_pick; const mainBandPalette = getConfidenceBandPalette( prediction.bet_advice.confidence_band, ); const sport = getPredictionSport(prediction); const isBasketball = sport === "basketball"; const engineItems = [ { key: "team", icon: LuGauge, label: isBasketball ? "Takim Formu" : "Takim Gucu", value: prediction.engine_breakdown.team, color: "blue.400", }, { key: "player", icon: LuSparkles, label: isBasketball ? "Kadro Etkisi" : "Oyuncu Etkisi", value: prediction.engine_breakdown.player, color: "green.400", }, { key: "odds", icon: LuTrendingUp, label: "Oran Analizi", value: prediction.engine_breakdown.odds, color: "orange.400" }, { key: "referee", icon: LuShieldAlert, label: isBasketball ? "Yardimci Sinyaller" : "Hakem Etkisi", value: prediction.engine_breakdown.referee, color: "purple.400", }, ]; return ( {recommendedPick ? ( {uiText("main-recommendation", "Ana Oneri")} {recommendedPick.pick} {getMarketLabel(recommendedPick.market, marketLabels)} {uiText("best-market-copy", "marketinde en guclu secim.")} {getConfidenceBandLabel(prediction.bet_advice.confidence_band)} {recommendedPick.confidence_interval ? ( {formatInterval(recommendedPick.confidence_interval)} ) : null} 0 ? "+" : ""}${formatPercent(recommendedPick.ev_edge * 100, 1)}`} helper={uiText( "edge-info", "Edge, model olasiligi ile piyasa olasiligi arasindaki farktir. Pozitifse model bu orani avantajli buluyor demektir.", )} accent={recommendedPick.ev_edge > 0 ? "green.500" : "red.500"} /> {uiText("quick-read", "Hizli yorum")} ) : null} {prediction.risk.is_surprise_risk || prediction.risk.warnings?.length ? ( Risk Yorumu {prediction.risk.surprise_comment || (prediction.risk.surprise_type ? `${resolveReason(prediction.risk.surprise_type)}` : "Model bu maçta ekstra dikkat istiyor.")} {prediction.risk.surprise_score !== undefined ? ( Sürpriz skoru: {formatPercent(prediction.risk.surprise_score, 0)} ) : null} ) : null} {engineItems.map((item) => ( {item.label} +{item.value.toFixed(1)} ))} {recommendedPick ? ( ) : null} {prediction.supporting_picks?.length ? ( {prediction.supporting_picks.map((pick) => ( 0 ? "blue" : "orange"} marketLabels={marketLabels} labels={{ confidence: uiText("confidence-label", "Guven"), odds: uiText("odds-label", "Oran"), recommendedStake: uiText("stake-label-short", "Stake"), playScore: uiText("play-score-label", "Play Score"), playability: uiText("playability-label", "Oynanabilirlik"), }} /> ))} ) : null} {prediction.v27_engine ? ( ) : null} {prediction.bet_advice.playable ? "OYNA" : "OYNAMA"} {getConfidenceBandLabel(prediction.bet_advice.confidence_band)} {getSignalTierLabel(prediction.bet_advice.signal_tier)} {resolveReason(prediction.bet_advice.reason)} {uiText("recommended-stake-inline", "Onerilen miktar")}: {formatUnits(prediction.bet_advice.suggested_stake_units)} ); }